节点系统与 Three Shading Language:着色器如何从图结构生成
前置知识
- ›第 1-3 篇文章
- ›了解 DAG/图数据结构
- ›基本着色器编程概念(顶点/片元阶段、uniform、varying)
节点系统与 Three Shading Language:着色器如何从图结构生成
节点系统是现代 Three.js 中工程设计最为雄心勃勃的部分。旧的 WebGLRenderer 方案需要为每种材质、光照、效果组合维护独立的 GLSL 着色器模板,而新渲染器则将着色器构建为由 Node 对象组成的有向无环图(DAG)。NodeBuilder 通过三阶段流水线遍历这些图,生成 WGSL(用于 WebGPU)或 GLSL(用于 WebGL 回退)。在此之上,TSL(Three Shading Language)提供了流畅的 JavaScript API,让编写自定义着色器逻辑就像写数学公式,而非手动管理图数据结构。
Node 基类与类型系统
Node 继承自 EventDispatcher,是所有着色器图节点的基类。其构造函数接受一个 nodeType 字符串,表示该节点的输出类型——取值来自 src/nodes/core/constants.js 中定义的常量,包括 'float'、'vec2'、'vec3'、'vec4'、'mat4'、'bool' 等:
export const NodeType = {
BOOLEAN: 'bool',
INTEGER: 'int',
FLOAT: 'float',
VECTOR2: 'vec2',
VECTOR3: 'vec3',
VECTOR4: 'vec4',
MATRIX2: 'mat2',
MATRIX3: 'mat3',
MATRIX4: 'mat4'
};
三个生命周期属性控制节点 update() 方法的触发时机,定义于 NodeUpdateType:NONE(从不触发)、FRAME(每帧触发一次)、RENDER(每次渲染调用触发一次——一帧中可能因阴影、反射等包含多次渲染)、OBJECT(每个使用该节点的对象触发一次)。
构建过程分为三个阶段,在第 66 行中声明:
export const defaultBuildStages = [ 'setup', 'analyze', 'generate' ];
classDiagram
class Node {
+nodeType: string
+updateType: string
+version: number
+global: boolean
+setup(builder): Node
+analyze(builder): void
+generate(builder): string
+build(builder): string
}
class ConstNode {
+value: any
+generate(): string
}
class OperatorNode {
+op: string
+aNode: Node
+bNode: Node
}
class MathNode {
+method: string
+generate(): string
}
class MaterialNode {
+scope: string
}
Node <|-- ConstNode
Node <|-- OperatorNode
Node <|-- MathNode
Node <|-- MaterialNode
有一个细节值得注意:第 10 行的 _parentBuildStage 映射定义了阶段顺序:analyze 的父阶段是 setup,generate 的父阶段是 analyze。这确保了子节点在父节点引用它们之前,能在正确的阶段中得到处理。
节点分类:访问器、数学、光照、显示
src/nodes/ 目录按功能对节点进行了分类:
| 分类 | 目录 | 用途 | 示例 |
|---|---|---|---|
| 核心 | core/ |
基类与基础设施 | Node, NodeBuilder, ConstNode, StackNode |
| 访问器 | accessors/ |
读取场景/对象数据 | MaterialNode, CameraNode, PositionNode, NormalNode |
| 数学 | math/ |
数学运算 | MathNode, OperatorNode, CondNode |
| 光照 | lighting/ |
光照计算 | LightsNode, AnalyticLightNode, ShadowNode |
| 显示 | display/ |
输出后处理 | ToneMappingNode, ColorSpaceNode, ViewportDepthNode |
| 代码 | code/ |
原始着色器代码注入 | FunctionNode, ExpressionNode |
| TSL | tsl/ |
Three Shading Language 运行时 | TSLCore, TSLBase |
| 函数 | functions/ |
可复用着色器函数 | BRDF_GGX, getAlphaHashThreshold |
访问器节点负责将场景图中的数据传入着色器。MaterialNode 读取材质属性(颜色、粗糙度、金属度),CameraNode 读取相机 uniform(位置、投影矩阵),PositionNode 则提供各种坐标空间(局部、世界、视图)下的顶点位置。
数学节点执行具体运算。OperatorNode 处理二元运算符(+、−、×、÷),MathNode 处理函数(sin、cos、normalize、mix、smoothstep),CondNode 实现三元选择。
graph TD
subgraph "Accessor Nodes"
MC["materialColor"] --> MulOp
T["texture(map)"] --> MulOp
end
subgraph "Math Nodes"
MulOp["OperatorNode(*)"] --> AddOp["OperatorNode(+)"]
E["materialEmissive"] --> AddOp
end
subgraph "Display Nodes"
AddOp --> TM["ToneMappingNode"]
TM --> CS["ColorSpaceNode"]
CS --> Output["output"]
end
TSL:用于流畅着色器编写的运行时元编程
TSL 是面向开发者的 API,让构建节点图的过程就像在 JavaScript 中书写着色器数学公式。其核心魔法藏在 TSLCore.js 的 addMethodChaining() 函数中:
export function addMethodChaining( name, nodeElement ) {
if ( NodeElements.has( name ) ) {
warn( `TSL: Redefinition of method chaining '${ name }'.` );
return;
}
NodeElements.set( name, nodeElement );
if ( name !== 'assign' ) {
Node.prototype[ name ] = function ( ...params ) {
return this.isStackNode
? this.addToStack( nodeElement( ...params ) )
: nodeElement( this, ...params );
};
Node.prototype[ name + 'Assign' ] = function ( ...params ) {
return this.isStackNode
? this.assign( params[ 0 ], nodeElement( ...params ) )
: this.assign( nodeElement( this, ...params ) );
};
}
}
这个函数同时完成两件事:将节点工厂函数注册到 NodeElements Map 中,并修改 Node.prototype,将该方法添加为可链式调用的方法。当你写下 positionLocal.mul(2.0) 时,实际发生的是:
positionLocal是一个Node(具体为PositionNode).mul由addMethodChaining('mul', mulFunction)挂载到Node.prototype- 原型方法调用
mulFunction(this, 2.0),创建并返回一个新的OperatorNode('*', positionLocal, float(2.0))
这是纯粹的运行时元编程——没有编译步骤,没有 babel 插件。每个 TSL 函数调用都会构建一个节点并返回它,表达式因此可以自然地组合:
// 这段 JavaScript 表达式:
positionLocal.mul( 2.0 ).add( offset ).normalize()
// 会生成如下节点 DAG:
// MathNode('normalize',
// OperatorNode('+',
// OperatorNode('*', positionLocal, float(2.0)),
// offset
// )
// )
提示: TSL 表达式是惰性的——它们构建的是图结构,并不执行着色器代码。只有当 NodeBuilder 在材质编译期间遍历该图时,图才会被转化为着色器代码。这意味着你可以将 TSL 表达式存储为变量并反复复用。
NodeBuilder:DAG 到着色器的编译器
NodeBuilder 是将节点图编译为着色器源码的编译器基类。它接收 3D 对象、渲染器和解析器作为构造参数,并提供变量声明、uniform 绑定、varying 管理以及代码输出等基础能力。
三个构建阶段的工作方式如下:
-
Setup(建立):节点返回其子节点,建立 DAG 结构。节点在此阶段可能发生自我转换——例如,
MaterialNode在 setup 阶段可能会返回一个TextureNode(如果材质包含颜色贴图)。 -
Analyze(分析):构建器遍历 DAG,确定每个节点所属的着色器阶段(顶点或片元),检测需要转为 varying 的共享节点,并收集 uniform 声明。
-
Generate(生成):每个节点输出其着色器代码字符串。
OperatorNode生成(a * b),MathNode生成normalize(x),访问器节点生成 uniform 读取或属性查找代码。
flowchart LR
subgraph "Phase 1: Setup"
S1["Material slots → Node DAG"]
end
subgraph "Phase 2: Analyze"
S2["Stage assignment<br/>Varying detection<br/>Uniform collection"]
end
subgraph "Phase 3: Generate"
S3["Node → shader code string<br/>Variable declarations<br/>Code assembly"]
end
S1 --> S2 --> S3
S3 --> WGSL["WGSLNodeBuilder<br/>→ WGSL code"]
S3 --> GLSL["GLSLNodeBuilder<br/>→ GLSL code"]
NodeBuilder 有两个具体的子类实现:
WGSLNodeBuilder(约 2,523 行):为 WebGPU 生成 WGSLGLSLNodeBuilder(约 1,676 行):为 WebGL 回退生成 GLSL
两者接收来自同一材质的相同节点图——DAG 本身与后端无关,只有代码生成部分是后端特定的。
NodeMaterial:节点与材质的交汇点
NodeMaterial 继承自基础 Material 类(详见第 2 篇),并引入了槽位系统——一组可空的节点属性,用于控制材质管线的每个阶段:
| 槽位 | 用途 | 默认值 |
|---|---|---|
colorNode |
漫反射颜色 | 材质 color 属性 |
normalNode |
表面法线扰动 | 几何法线 |
opacityNode |
Alpha 值 | 材质透明度 |
emissiveNode |
自发光 | 材质自发光颜色 |
roughnessNode |
PBR 粗糙度 | 材质粗糙度 |
metalnessNode |
PBR 金属度 | 材质金属度 |
outputNode |
最终片元输出 | 计算后的光照结果 |
positionNode |
顶点位置覆盖 | 几何体位置 |
每个槽位接受任何能解析为期望类型的节点。设置 material.colorNode = texture(myTexture).mul(color(0xff0000)) 会用一个 TSL 表达式替换默认的颜色逻辑——该表达式对纹理采样后将其染红。
MeshStandardNodeMaterial 等具体子类会为 PBR 特定的槽位设置默认值,并额外提供粗糙度贴图、金属度贴图、法线贴图和环境贴图采样等槽位。
flowchart TB
subgraph "NodeMaterial Slots"
PN["positionNode"] --> VS["Vertex Shader"]
CN["colorNode"] --> FS["Fragment Shader"]
NN["normalNode"] --> FS
ON["opacityNode"] --> FS
EN["emissiveNode"] --> FS
RN["roughnessNode"] --> FS
MN["metalnessNode"] --> FS
end
VS --> Rasterizer["Rasterization"]
Rasterizer --> FS
FS --> OutputN["outputNode"] --> Final["Final Color"]
StandardNodeLibrary:向后兼容的桥梁
StandardNodeLibrary 是实现 WebGLRenderer 场景与新 WebGPURenderer 向后兼容的关键。它将每种经典材质和光源类型映射到对应的基于节点的实现:
this.addMaterial( MeshStandardNodeMaterial, 'MeshStandardMaterial' );
this.addMaterial( MeshPhysicalNodeMaterial, 'MeshPhysicalMaterial' );
this.addMaterial( MeshBasicNodeMaterial, 'MeshBasicMaterial' );
// ... 还有 10 种材质映射
this.addLight( PointLightNode, PointLight );
this.addLight( DirectionalLightNode, DirectionalLight );
this.addLight( SpotLightNode, SpotLight );
// ... 还有 6 种光源映射
this.addToneMapping( acesFilmicToneMapping, ACESFilmicToneMapping );
// ... 还有 5 种色调映射
当 WebGPURenderer 在场景中遇到一个完全不了解节点系统的 MeshStandardMaterial 时,NodeManager 会查询 StandardNodeLibrary,找到对应的 MeshStandardNodeMaterial,并透明地创建一个基于节点的等价材质。用户代码无需任何修改——库会自动处理转换。
提示: 如果你正在从 WebGLRenderer 迁移到 WebGPURenderer,无需重写材质。StandardNodeLibrary 会自动完成转换。但如果你想充分发挥节点系统的潜力(自定义着色器效果、compute shader、程序化几何体),建议直接使用
NodeMaterial和 TSL。
完整示例:从 TSL 表达式到着色器代码
让我们完整追踪一个简单的 TSL 表达式如何变成着色器代码。以下面这行为例:
material.colorNode = materialColor.mul( texture( map ) );
第一步:构建图结构。 materialColor 是一个读取材质 color 属性的 MaterialNode,texture(map) 创建一个 TextureNode,.mul() 创建一个 OperatorNode('*', materialColorNode, textureNode)。
第二步:Setup 阶段。 NodeBuilder 对根节点调用 setup()。OperatorNode 返回自身,并递归建立其子节点。TextureNode 可能会将 UV 访问器节点作为依赖项生成。
第三步:Analyze 阶段。 构建器判定纹理采样必须发生在片元阶段。若有输入来自顶点阶段,则将这些连接标记为 varying。
第四步:Generate 阶段。 每个节点输出代码。对于 WGSL,可能生成:
let nodeColor = nodeUniform.color * textureSample(nodeTexture, nodeSampler, nodeVarying_uv);
对于 GLSL,同一个图生成:
vec4 nodeColor = nodeUniform_color * texture(nodeTexture, nodeVarying_uv);
这种架构的强大之处在于:同一个语义图——"将材质颜色与纹理采样结果相乘"——能为任意目标语言生成正确代码,而所有底层的管线细节(uniform、sampler、varying、坐标变换)均由系统自动处理。
接下来
了解了渲染器和节点系统之后,接下来我们将聚焦于支撑基础设施:驱动所有变换和着色计算的数学库、相机与光源的层级结构、颜色管理,以及光源如何通过节点系统集成到基于着色器的光照计算中。