Read OSS

节点系统与 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() 方法的触发时机,定义于 NodeUpdateTypeNONE(从不触发)、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 的父阶段是 setupgenerate 的父阶段是 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.jsaddMethodChaining() 函数中:

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) 时,实际发生的是:

  1. positionLocal 是一个 Node(具体为 PositionNode
  2. .muladdMethodChaining('mul', mulFunction) 挂载到 Node.prototype
  3. 原型方法调用 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 管理以及代码输出等基础能力。

三个构建阶段的工作方式如下:

  1. Setup(建立):节点返回其子节点,建立 DAG 结构。节点在此阶段可能发生自我转换——例如,MaterialNode 在 setup 阶段可能会返回一个 TextureNode(如果材质包含颜色贴图)。

  2. Analyze(分析):构建器遍历 DAG,确定每个节点所属的着色器阶段(顶点或片元),检测需要转为 varying 的共享节点,并收集 uniform 声明。

  3. 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 生成 WGSL
  • GLSLNodeBuilder(约 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 属性的 MaterialNodetexture(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、坐标变换)均由系统自动处理。

接下来

了解了渲染器和节点系统之后,接下来我们将聚焦于支撑基础设施:驱动所有变换和着色计算的数学库、相机与光源的层级结构、颜色管理,以及光源如何通过节点系统集成到基于着色器的光照计算中。