three.js MeshToonNodeMaterial 深入解析:节点化卡通着色(Toon Shading)材质实战
在 three.js 的节点(Node)化渲染体系中,MeshToonNodeMaterial 是经典卡通着色材质 MeshToonMaterial 的 Node 版本,用于在基于节点的渲染管线中实现扁平、漫画风格的渐变着色。本文以 MeshToonNodeMaterial.html.md 的 API 文档为主体骨架,结合 MeshToonNodeMaterial.js 源码与其底层 ToonLightingModel.js 照明模型,以及官方示例 examples/webgpu_materials_toon.html,完整讲解它的构造函数、属性、默认值机制、光照模型替换原理,并给出可直接运行的自定义 gradientMap 卡通着色案例。读完你既能独立使用该材质,也能理解它在光照计算上与标准材质的本质差异。
一、MeshToonNodeMaterial 是什么:定位与使用场景
从源码中可以看到其类型定义:
class MeshToonNodeMaterial extends NodeMaterial {
static get type() {
return 'MeshToonNodeMaterial';
}
...
}
它继承自 NodeMaterial,属于 three.js 节点材质家族。与经典 MeshToonMaterial 不同,节点材质的着色逻辑不是被硬编码进一份固定的 GLSL 着色器,而是通过「节点(Node)图」在运行时被组合与编译,因此可以更灵活地接入 TSL(Three Shading Language)工作流。
在节点材质体系中,材料通常配对存在:
| 经典(内置)材质 | 节点版本 |
|---|---|
| MeshBasicMaterial | MeshBasicNodeMaterial |
| MeshLambertMaterial | MeshLambertNodeMaterial |
| MeshPhongMaterial | MeshPhongNodeMaterial |
| MeshStandardMaterial | MeshStandardNodeMaterial |
| MeshPhysicalMaterial | MeshPhysicalNodeMaterial |
| MeshToonMaterial | MeshToonNodeMaterial |
所有节点材质统一在 NodeMaterials.js 中被导出:
export { default as MeshToonNodeMaterial } from './MeshToonNodeMaterial.js';
而在 StandardNodeLibrary.js 中,它还被注册为对应经典材质字符串名的自动映射目标:
import MeshToonNodeMaterial from '../../../materials/nodes/MeshToonNodeMaterial.js';
...
this.addMaterial( MeshToonNodeMaterial, 'MeshToonMaterial' );
这意味在节点化管线中,指定经典 'MeshToonMaterial' 时渲染器可以自动找到并使用这个节点版本。MeshToonNodeMaterial 适合需要卡通/漫画风格扁平着色、需要自定义渐变分级(gradientMap)或希望在 TSL 中进一步改写光照结果的场景,它需要在 three.js 基于节点的渲染后端(官方示例使用 WebGPURenderer,构建产物见 build/three.webgpu.js)下工作。
二、构造函数与参数
new MeshToonNodeMaterial( parameters : Object )
源码中的构造实现非常简短:
constructor( parameters ) {
super();
this.isMeshToonNodeMaterial = true;
this.lights = true;
this.setDefaultValues( _defaultValues );
this.setValues( parameters );
}
参数与继承机制:parameters 是一个可选对象,其中可以包含材质外观相关的任一属性。与经典 MeshToonMaterial 相同,Color 类型的值可以用 Color#set 接受的任意方式传入。
值得强调的是第 6 行与 setDefaultValues 的组合:
const _defaultValues = /*@__PURE__*/ new MeshToonMaterial();
这是 Node 材质与经典材质共享参数的桥梁:MeshToonNodeMaterial 并不自己逐个声明 diffuse/gradient 等默认值,而是直接实例化一个经典 MeshToonMaterial 作为“默认值模板”,再交给基类 NodeMaterial 的 setDefaultValues() 复制。因此在构造参数、材质属性层面,MeshToonNodeMaterial 继承了 MeshToonMaterial 的全部外观参数集合(详见下文属性章节),这也是为何在官方示例中可以直接这样使用:
const material = new THREE.MeshToonNodeMaterial( {
color: diffuseColor,
gradientMap: gradientMap
} );
setDefaultValues() 之后调用 setValues( parameters ),保证用户显式传入的参数覆盖默认值。若完全不带参数构造,得到的就是一个默认的白色、无贴图、受光照影响的 toon 材质。
三、类属性详解
.isMeshToonNodeMaterial : boolean(只读)
类型判断标志,默认值为 true:
this.isMeshToonNodeMaterial = true;
用于在拿到任意材质对象时快速确认其是否为 MeshToonNodeMaterial。同类惯例在各节点材质中均有体现,例如 MeshToonMaterial.js 中对应经典版本也定义了 isMeshToonMaterial。
.lights : boolean
该属性默认值为 true,注释明确指出因为 toon 材质需要响应灯光:
this.lights = true;
它覆写(Override)了基类 NodeMaterial#lights。该开关与渲染器的光照节点紧密相关,在 NodeMaterial.js 的 setupLighting() 中可以看到它的实际消费逻辑:
const sceneLighting = this.lights === true && builder.renderer.lighting.enabled;
const lights = sceneLighting || this.lightsNode !== null;
...
if ( lightsNode && ( materialLightings.length > 0 || lightsNode.getScope().hasLights ) ) {
const lightingModel = this.setupLightingModel( builder ) || null;
outgoingLightNode = lightingContext( lightsNode, lightingModel, materialLightings, backdropNode, backdropAlphaNode );
}
简言之:只有 lights 为 true(且渲染器光照开启)时,材质才会走完整的光照模型计算分支,这也是 toon 材质能显示明暗分级的前提。如果你希望材质不受场景灯光影响而完全扁平,可以将其设为 false。
继承自 MeshToonMaterial 的可配置属性(默认值由 setDefaultValues 注入)
由于构造函数中通过 setDefaultValues( _defaultValues ) 引入了 MeshToonMaterial.js 的全部默认参数,MeshToonNodeMaterial 实例同样具备以下常用属性(完整字段表与说明可参见 MeshToonMaterial.html):
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
color |
Color | (1,1,1) |
材质漫反射颜色 |
gradientMap |
Texture | null |
卡通渐变图;使用时应将 minFilter/magFilter 设为 NearestFilter(该贴图为非颜色数据,须保持 colorSpace = NoColorSpace) |
map |
Texture | null |
颜色贴图(颜色数据,通常应设 SRGBColorSpace),与 color 相乘 |
alphaMap |
Texture | null |
灰度透明贴图,控制表面透明度 |
aoMap / aoMapIntensity |
Texture / number | null / 1 |
环境光遮蔽(需要第二套 UV) |
bumpMap / bumpScale |
Texture / number | null / 1 |
凹凸贴图(定义了 normalMap 时被忽略) |
normalMap / normalScale / normalMapType |
Texture / Vector2 / enum | null / (1,1) / TangentSpaceNormalMap |
法线贴图相关 |
displacementMap / displacementScale / displacementBias |
Texture / number / number | null / 1(注:见 MeshToonMaterial 默认)/ 0 |
顶点位移贴图 |
emissive / emissiveIntensity / emissiveMap |
Color / number / Texture | (0,0,0) / 1 / null |
自发光 |
lightMap / lightMapIntensity |
Texture / number | null / 1 |
烘焙光照贴图(需要第二套 UV) |
fog |
boolean | true |
是否受雾影响 |
wireframe 系列 |
boolean / 字符串 / number | false / 'round' / 1 |
线框渲染相关 |
注:
displacementScale的默认值与文档中列出的经典材质行为一致,详见 MeshToonMaterial.html 相应条目。AO、光照等贴图需要第二套 UV(如通过 Geometry 的 uv2)才能生效。
其中对卡通效果最关键的是 gradientMap:它决定明暗过渡被压缩成多少个离散梯度级。
四、核心方法:setupLightingModel()
.setupLightingModel() : ToonLightingModel
在基类 NodeMaterial.js 中,setupLightingModel() 被定义为一个空实现的“接口函数”,需要子类材质实现以确定自己的照明模型:
/**
* This method should be implemented by most derived materials
* since it defines the material's lighting model.
* @abstract
*/
setupLightingModel( /*builder*/ ) {
// Interface function.
}
而 MeshToonNodeMaterial 的唯一覆写实现非常简单:
setupLightingModel( /*builder*/ ) {
return new ToonLightingModel();
}
它将卡通渲染的“真正的算法”全部委托给了 ToonLightingModel。这个返回的照明模型对象会在上文 setupLighting() 中通过 lightingContext( lightsNode, lightingModel, ... ) 参与最终的出射光计算,lightingContext 会依次驱动该模型 direct() / indirect() 等钩子。因此对使用 MeshToonNodeMaterial 的开发者来说,理解 ToonLightingModel 的算法就等于理解了卡通着色何以“卡通”。
ToonLightingModel:卡通照明的源码级原理
ToonLightingModel 继承自通用 LightingModel(其官方文档见 ToonLightingModel.html)。核心函数是内部的 getGradientIrradiance:
const getGradientIrradiance = /*@__PURE__*/ Fn( ( { normal, lightDirection, builder } ) => {
// dotNL will be from -1.0 to 1.0
const dotNL = normal.dot( lightDirection );
const coord = vec2( dotNL.mul( 0.5 ).add( 0.5 ), 0.0 );
if ( builder.material.gradientMap ) {
const gradientMap = materialReference( 'gradientMap', 'texture' )
.context( { getUV: () => coord } );
return vec3( gradientMap.r );
} else {
const fw = coord.fwidth().mul( 0.5 );
return mix( vec3( 0.7 ), vec3( 1.0 ),
smoothstep( float( 0.7 ).sub( fw.x ), float( 0.7 ).add( fw.x ), coord.x ) );
}
} );
算法要点可以归纳为三步:
- 法线与光方向的点积被归一化到
[0,1],作为沿贴图 U 轴的采样坐标:coord.x = dotNL * 0.5 + 0.5。这就把“该点受光强弱”映射成了一个一维坐标。 - 若设置了 gradientMap:用该坐标去采样材质的
gradientMap(只取红色通道r),并把 UV 用.context({ getUV: () => coord })强制覆盖为上述动态坐标——所以 gradientMap 本质上是一张宽度为 N 的一维查找表,N 越大、明暗分级的层数越多。 - 若未设置 gradientMap:走一个内置的默认两段式近似——用
smoothstep在 0.7 阈值附近生成从 0.7 到 1.0 的硬过渡,配合fwidth做一点抗锯齿(AA)软化边缘,得到最小限度的漫画分色。
随后 direct() 将采样结果乘上灯光颜色,再乘以 Lambert 漫反射项累加到直接光:
direct( { lightDirection, lightColor, reflectedLight }, builder ) {
const irradiance = getGradientIrradiance( { normal: normalView, lightDirection, builder } ).mul( lightColor );
reflectedLight.directDiffuse.addAssign( irradiance.mul( BRDF_Lambert( { diffuseColor: diffuseColor.rgb } ) ) );
}
而 indirect() 则把环境/IBL 辐照度同样以 Lambert 方式累加并乘上环境光遮蔽:
indirect( builder ) {
const { ambientOcclusion, irradiance, reflectedLight } = builder.context;
reflectedLight.indirectDiffuse.addAssign( irradiance.mul( BRDF_Lambert( { diffuseColor } ) ) );
reflectedLight.indirectDiffuse.mulAssign( ambientOcclusion );
}
这正是“离散少数几档明暗(a small number of discrete shades)创造出漫画式平坦观感”的实现证据,与传统 PBR 材质的连续 dot(N,L) 光照曲线形成了鲜明对比。
五、实战:从源码到可运行示例
官方为节点化 toon 材质提供了完整示例 examples/webgpu_materials_toon.html,其截图见下方。
官方示例截图
在该示例中,材质通过 three/webgpu 入口导入并使用,全部材质均由 MeshToonNodeMaterial 构造:
import * as THREE from 'three/webgpu';
const gradientMap = new THREE.DataTexture( colors, colors.length, 1, THREE.RedFormat );
gradientMap.needsUpdate = true;
const material = new THREE.MeshToonNodeMaterial( {
color: diffuseColor,
gradientMap: gradientMap
} );
示例在场景中布置了大量渐变球体(每侧 5 个、共 5×5×5),分别沿 x 轴切换 gradientMap 的离散级数、沿 z 轴切换 diffuse 颜色,配合一个 TSL 的 toonOutlinePass( scene, camera )(见 ToonOutlinePassNode)得到描边+卡通填充的最终画面。
最小可运行示例
参照源码构造与照明模型,可以提炼出一个自包含的最小示例(核心逻辑见下方,完整场景搭建可对照官方示例源码):
import * as THREE from 'three/webgpu';
const renderer = new THREE.WebGPURenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 100 );
camera.position.set( 0, 0, 3 );
// 1) 构建 4 级卡通渐变查找表:数值从小到大 = 从暗到亮的分级台阶
const steps = 4;
const data = new Uint8Array( steps );
for ( let i = 0; i < steps; i ++ ) {
data[ i ] = Math.round( ( i / ( steps - 1 ) ) * 255 );
}
const gradientMap = new THREE.DataTexture( data, steps, 1, THREE.RedFormat );
// DataTexture 默认即 NearestFilter;若使用普通 Texture/CanvasTexture,必须手动设置:
// gradientMap.minFilter = THREE.NearestFilter;
// gradientMap.magFilter = THREE.NearestFilter;
gradientMap.needsUpdate = true;
// 2) 创建节点化 toon 材质
const material = new THREE.MeshToonNodeMaterial( {
color: 0xffaa33,
gradientMap: gradientMap
} );
// 3) 使用受光照的几何体
const mesh = new THREE.Mesh( new THREE.SphereGeometry( 1, 64, 32 ), material );
scene.add( mesh );
scene.add( new THREE.AmbientLight( 0xffffff, 0.5 ) );
const dirLight = new THREE.DirectionalLight( 0xffffff, 2 );
dirLight.position.set( 1, 1, 1 );
scene.add( dirLight );
renderer.setAnimationLoop( () => {
mesh.rotation.y += 0.01;
renderer.render( scene, camera );
} );
代码要点与注意事项:
- 材质属性直接复用经典参数:得益于
setDefaultValues( _defaultValues ),上面传给构造函数的是color/gradientMap这类属于MeshToonMaterial的参数,节点材质内部会在渲染时把material.gradientMap作为采样来源,与 ToonLightingModel.js 中if ( builder.material.gradientMap )的判断一一对应。 - gradientMap 必须关闭插值过滤:文档与源码注释都强调需要
NearestFilter(经典实现见 MeshToonMaterial.js)。THREE.DataTexture默认就使用 Nearest 滤波,因此示例中无需显式设置;若用普通纹理,则须手动将minFilter与magFilter都设为NearestFilter,否则相邻灰度会被线性插值糊成一团,分级感消失。 - colorSpace 处理:
gradientMap属于非颜色数据,应保持默认的NoColorSpace,不能设为SRGBColorSpace;而map/emissiveMap这类颜色贴图则须按 Texture#colorSpace 约定设置,常见值为SRGBColorSpace。 - 需要灯光:材质默认
lights = true,示例中同时放置了AmbientLight与DirectionalLight。没有灯光时,direct()分支贡献为零,模型会趋于纯黑剪影。 - 运行前提:该材质走节点化渲染路径,官方演示基于
WebGPURenderer与import * as THREE from 'three/webgpu',请使用支持 WebGPU 的浏览器环境(并可启用#enable-unsafe-webgpu等标志,视浏览器版本而定)。
调整 steps(渐变级数)、color 以及光照方向,即可直观观察“离散明暗带”的变化——这正是 MeshToonNodeMaterial 与 MeshToonMaterial 在观感上一脉相承、而在实现上彻底节点化的核心价值。
六、小结与延伸阅读
回顾全文,MeshToonNodeMaterial 的工程价值可以总结为三点:
- API 兼容性:通过
_defaultValues = new MeshToonMaterial()与setDefaultValues(),把经典 toon 材质的全部外观参数无缝迁移到节点世界,迁移成本极低; - 关注点分离:自身代码极其精简(源码仅 60 余行),只负责声明类型标志、
lights开关与setupLightingModel()覆写,复杂的卡通明暗计算完全下沉到 ToonLightingModel; - 可编程性:照明模型、渐变查找与 TSL 描述符的组合,使开发者可以在 examples/webgpu_materials_toon.html 的基础上自由替换 gradientMap、改写节点甚至自定义 LightingModel,实现更高阶的非写实渲染。
想继续深入,建议依次阅读:
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
