Skip to main content

09. TSL 入门与节点式着色

05 篇 末尾把 TSL 称作“革命”,但只给了一个 12 行的“波浪动画”片段,没有交代它为什么能解决 onBeforeCompile 的痛点、运行时到底发生了什么、和 GLSL 是什么关系。本篇把这些坑填上。

目标:读完能独立用 TSL 写出 PBR 派生材质、自定义 ShaderMaterial 派生材质,并理解它在 WebGPU / WebGL2 下的差异。

1. 为什么需要 TSL

先复盘 05 篇的 onBeforeCompile 写法,把“痛点”摆出来:

material.onBeforeCompile = (shader) => {
shader.uniforms.time = { value: 0 };
material.userData.shader = shader;

shader.vertexShader = shader.vertexShader.replace(
"#include <begin_vertex>",
`
#include <begin_vertex>
float height = sin(time + position.x * 2.0) * 0.5;
transformed.y += height;
`,
);

shader.vertexShader = "uniform float time;\n" + shader.vertexShader;
};

这段代码在生产里至少踩过这 4 个坑:

  1. 字符串黑客(String Hacking)replace 是无脑文本替换。一旦 Three.js 升级把 <begin_vertex> 改名或者把 transformed 改成 transformed_,整段逻辑静默失效,没有编译错误,阴影/动画突然没了。
  2. 没有类型:写错 transformedtransform,GLSL 编译器在某些场景会延后报错,或者直接把整个材质 fallback 成默认。
  3. 要手动同步 customDepthMaterial:阴影贴图走另一套材质,忘写 → 阴影是“直”的。
  4. 不能跨后端onBeforeCompile 注入的是 GLSL 字符串,WebGPU 不认。如果你想升级到 WebGPURenderer,所有特效都要重写成 WGSL。

TSL(Three.js Shading Language)的设计目标就是把以上 4 点全部干掉:

痛点TSL 的解法
字符串匹配直接赋值 material.positionNode = ...
类型TS 强类型 + 节点求值器在编译期校验
customDepthMaterial节点系统自动同步到深度 Pass
WebGPU/WebGL2 双跑同一份 TSL,编译期决定输出 WGSL 或 GLSL

MeshStandardNodeMaterialMeshStandardMaterial 不是并列的两套实现,而是同一份 PBR 着色逻辑的两种“接入方式”。前者接管了 vertexNode / positionNode / colorNode 等插槽。

2. 环境搭建

2.1 启用 WebGPURenderer

import * as THREE from 'three/webgpu';
import { Fn, vec3, sin, time, positionLocal } from 'three/tsl';

const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init(); // 注意:init() 是异步的
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

避坑:第一代教程里直接 new THREE.WebGPURenderer() 然后 renderer.render(scene, camera),在 r163+ 之后会没有任何报错但画布是黑的init() 必须 await,且要在挂载到 DOM 之前完成。

2.2 兼容 WebGL2 怎么办

只需要把导入源换一下:

import * as THREE from 'three';                  // WebGL2 fallback
// import * as THREE from 'three/webgpu'; // 强制 WebGPU

import { Fn, vec3, sin, time, positionLocal } from 'three/tsl';

three/tsl 这个入口从 r161 起就存在,里面的节点 API 两套渲染器通用。差别在于:

  • WebGPU 路径:TSL → WGSL,Compute 节点、Storage 节点可用
  • WebGL2 路径:TSL → GLSL,Compute 走 FBO 模拟,部分 Storage 相关节点不可用

本篇示例默认走 WebGPU,关键节点处会标注 WebGL2 限制。

3. 节点系统核心概念

3.1 节点(Node)是什么

TSL 里所有“值”——常量、属性、Uniform、运算符结果、函数——都是节点1vec3(0, 1, 0)positionLocal + vec3(0, h, 0) 没有任何类型差异:

import { vec3 } from 'three/tsl';

const a = vec3(1, 0, 0); // 常量节点
const b = positionLocal; // 属性节点
const c = a.add(b); // 加法节点

3.2 运算符重载

为了写起来像 GLSL,TSL 给 vec2/3/4 节点重载了常用运算符:

const result = positionLocal.mul(2.0).add(vec3(0, time, 0));
// 等价于
const result = positionLocal * 2.0 + vec3(0, time, 0);

避坑:重载只在 节点之间 生效。positionLocal.x + 1.0 这里 .x 把节点“降级”成普通 number,再加 1.0 不会自动包回节点。要写 positionLocal.x.add(1.0),否则 GLSL 端会缺一次“拆包+重包”操作,类型推断会报错。

3.3 节点的“惰性”求值

TSL 节点不会在 JS 端运行任何 GPU 代码,它只是一棵抽象语法树(AST)

const tree = positionLocal.add(vec3(0, 1, 0));
console.log(tree); // Node 对象的 toString(),类似 +vec3( 0, 1, 0 )

真正生成 WGSL/GLSL 是在 WebGPURenderer.compileAsync() 阶段。理解这一点很关键:节点的构造开销几乎是 0,但 compileAsync 是一次性成本。

4. 三类核心节点

4.1 属性节点(Attribute Nodes)

读顶点属性:

节点类型来源
positionLocalvec3几何体本地坐标
normalLocalvec3几何体本地法线
tangentLocalvec4切线(带 handedness)
uv()vec2UV 坐标
colorvec4顶点色

这些节点在顶点着色器里有效。把它们传到片段着色器要用 varyingProperty(见 §7)。

4.2 Uniform 节点

JS 端可写的全局变量:

import { uniform, color } from 'three/tsl';

const uTime = uniform(0); // 初始值 0
uTime.value = performance.now() / 1000;

避坑uniform() 返回的节点不能在顶点/片段里再被解包.x.xyz)。如果一定要解,用 .element(index),例如 uTime.element(0)

颜色 uniform 用 color() 构造,内部归一化到 linear:

import { color } from 'three/tsl';
const uTint = color('#ff0080');
uTint.value.set('#00ff80'); // .value 是 THREE.Color

4.3 函数节点(Fn)

Fn() 是 TSL 的核心抽象——把一段可复用的节点逻辑封装成函数:

import { Fn, vec3, positionLocal, time, sin } from 'three/tsl';

const wave = Fn(() => {
const offset = sin(time.add(positionLocal.x.mul(2.0))).mul(0.5);
return positionLocal.add(vec3(0, offset, 0));
});

material.positionNode = wave(); // 每次调用生成一份新 AST

避坑wave() 必须带括号调用。漏写就只是把 Fn 节点对象塞给 positionNode,TSL 会因类型不匹配(Fn vs PositionNode)报错。Three.js 编译期的提示是 PositionNode is required

Fn 还可以带参数:

const pulse = Fn(([speed, amplitude]) => {
return sin(time.mul(speed)).mul(amplitude);
});

material.colorNode = vec3(1, 0, 0).add(pulse(2.0, 0.3));

5. 把 MeshStandardMaterial 换成 MeshStandardNodeMaterial

最常见的迁移场景:保留 PBR 光照、阴影、环境贴图,只替换叠加某一段着色逻辑。

import { MeshStandardNodeMaterial } from 'three/webgpu';
// WebGL2 下 import { MeshStandardNodeMaterial } from 'three';

const material = new MeshStandardNodeMaterial({
color: 0xff0000,
metalness: 0.0,
roughness: 0.5,
});

它和 MeshStandardMaterial 共享几乎全部参数(color、metalness、roughness、map、normalMap、envMap...),多了一组 *Node 插槽:

插槽阶段默认值
positionNode顶点positionLocal
normalNode顶点normalLocal
colorNode片段base color
roughnessNode片段uniform roughness
metalnessNode片段uniform metalness
emissiveNode片段black
outputNode片段PBR 完整结果

插槽的关系

  positionLocal ──► positionNode ──► 世界变换
normalLocal ──► normalNode ──► 光照计算 ──► outputNode ──► 屏幕
baseColor ──► colorNode ──► ▲
...

你只覆盖其中一个插槽,其余插槽仍然走默认的 PBR 逻辑,光照/阴影/IBL 自动保留

5.1 实战 1:波浪 + 自发光

把 05 篇的“融化”案例用 TSL 重写一遍,并补上自发光随高度变化

import {
MeshStandardNodeMaterial,
} from 'three/webgpu';
import {
Fn, vec3, sin, time, positionLocal, mix, color,
} from 'three/tsl';

const material = new MeshStandardNodeMaterial({
color: 0x1155ff,
roughness: 0.4,
});

material.positionNode = Fn(() => {
const h = sin(time.add(positionLocal.x.mul(2.0))).mul(0.5);
return positionLocal.add(vec3(0, h, 0));
})();

// 高度越高,红色越强
material.emissiveNode = Fn(() => {
const h = positionLocal.y.add(0.5); // [-0.5, 0.5] → [0, 1]
return color(0xff0000).mul(h.max(0).min(1));
})();

阴影正确,没有任何 customDepthMaterial 代码。节点系统自动同步到 depth pass。

避坑:自发光默认是 linear 空间相加,color(0xff0000).mul(...) 不会自动 gamma 校正。如果你看到画面发灰,在 .mul() 之后手动 pow(..., vec3(2.2)) 强转 gamma。

6. 顶点修改 positionNode

positionNode 默认返回已经包含世界变换的位置(实际是 positionLocal 经 modelMatrix 变换后的结果)。覆盖它意味着你要自己写 modelMatrix

import { Fn, positionLocal, modelMatrix, vec3, time, sin } from 'three/tsl';

material.positionNode = Fn(() => {
// 1. 顶点本地动画
const h = sin(time.add(positionLocal.x.mul(2.0))).mul(0.5);
const local = positionLocal.add(vec3(0, h, 0));
// 2. 套上 modelMatrix
return modelMatrix.mul(vec4(local, 1.0));
})();

WebGL2 提示:在 WebGL2 + MeshStandardNodeMaterial 路径下,TSL 会自动帮你补 modelMatrix 变换的 GLSL 代码,直接返回 positionLocal.add(...) 也能工作。但在 WebGPU 路径下必须手动乘 modelMatrix,否则物体会被“拉回”本地原点。这个差异在 r163 文档里没写明,是踩过的坑。

7. varying:节点之间的“桥梁”

varyingProperty 用来在顶点和片段之间传递数据:

import {
Fn, positionLocal, varyingProperty, sin, time, vec3, color,
} from 'three/tsl';

const vHeight = varyingProperty('float', 'vHeight');

material.positionNode = Fn(() => {
const h = sin(time.add(positionLocal.x.mul(2.0))).mul(0.5);
vHeight.assign(h);
return positionLocal.add(vec3(0, h, 0));
})();

material.colorNode = Fn(() => {
// 这里的 vHeight 在片段着色器里是可读的标量
return color(0x1155ff).mix(color(0xff0044), vHeight.mul(2.0));
})();

ASCII 数据流:

顶点着色器                       片段着色器
positionLocal ──┐
time ──┼──► h ──► vHeight.assign(h) ──► varying ──► vHeight ──► colorNode
... ──┘

避坑varyingProperty 必须在所有用到它的 Fn 外声明。如果在 positionNodeFn 内部声明 varyingProperty,赋值会作用域失效,片段端读到 undefined 而不报错,材质发黑。

8. 不用 PBR:自定义 ShaderMaterial

如果不想继承光照,用 NodeMaterial 起步:

import { NodeMaterial, Fn, uv, time, sin, vec4 } from 'three/tsl';

const material = new NodeMaterial();

material.colorNode = Fn(() => {
const t = uv().add(vec2(time.mul(0.1), 0));
const v = sin(t.x.mul(6.28)).mul(0.5).add(0.5);
return vec4(vec3(v), 1.0);
});

NodeMaterial 没有任何插槽默认实现,所有插槽都要自己填,否则画出来是“默认紫色”。

9. Compute 节点(GPGPU 入门)

TSL 真正甩开 GLSL 的是 Compute 节点。先看传统 GPGPU 怎么做:

GPGPU 经典流程 (Three.js GPUComputationRenderer)
1. 创建 2 张纹理作为 ping-pong buffer
2. 用 FBO 渲染把 position 写入纹理
3. 下一帧用上一帧纹理作为输入,再渲染
4. 在 ShaderMaterial 里 sample 纹理读取数据

TSL 的 FnCompute 把这套封装成“一段函数”:

import {
Fn, instancedArray, vec3, time, sin, FnCompute,
} from 'three/tsl';

const count = 100000;

// 1. 用 instancedArray 声明 GPU 端 buffer
const positionBuffer = instancedArray(count, 'vec3');
const velocityBuffer = instancedArray(count, 'vec3');

const compute = FnCompute(() => {
const position = positionBuffer.element(instanceIndex);
const velocity = velocityBuffer.element(instanceIndex);

// 简单弹簧 + 时间
velocity.addAssign(position.mul(-0.01).add(vec3(sin(time), 0, 0).mul(0.001)));
position.addAssign(velocity);

// 越界回弹
position.assign(position.clamp(-10, 10));
})();

renderer.computeAsync(compute);

WebGL2 限制instancedArray 在 WebGL2 下退化为 FBO 模拟,有 16384 元素上限,且 renderer.computeAsync 会 fallback 到普通 render 调用,性能差距 10× 起。生产里 10 万粒子的 GPGPU 还是老老实实用 WebGPURenderer

10. 调试技巧

10.1 编译期排错

WebGPU 路径下,TSL 编译失败会抛 Error: Shader compilation failed 并附 WGSL 源码。在 r165 之后可以用 material.debug = true 让 Three.js 把最终 WGSL 打到 console.group

material.debug = true;

10.2 单步可视化

outputNode 临时替换成某个中间节点,看“位置”、“法线”、“某段 vary”:

material.outputNode = normalLocal.mul(0.5).add(0.5);  // 法线可视化

经典调试手段:法线可视化、UV 可视化、深度可视化。

10.3 不要在 Fn 里打 console.log

Fn 内部是 AST,根本不会在 JS 端执行console.log(h) 永远输出 undefined。要看运行时值,输出中间节点到 outputNode,让它在 GPU 端显示。

11. 性能:什么时候不用 TSL

TSL 是一层抽象,有约 5%~10% 的额外开销(WGSL 比手写 GLSL 多了一些 uniform 拷贝)。下列场景不建议用 TSL:

  • 海量三角面 + 极简顶点变换(如 1000 万面的纯位移):onBeforeCompile 或裸 ShaderMaterial 更快。
  • 严格控制 shader 字节数的移动端 H5:每多一个 uniform 都要抠。

下列场景强烈建议用 TSL:

  • 多 Pass 共享逻辑(主 pass + 深度 pass + outline pass):自动同步。
  • 需要 WebGPU 升级路径:未来切换零成本。
  • 多人协作 / 长期维护:字符串黑客维护成本远高于 5% 性能差。

12. 总结

  • TSL 解决 onBeforeCompile 的 4 大痛点:字符串匹配、类型缺失、customDepthMaterial 同步、WebGPU 升级。
  • MeshStandardNodeMaterialMeshStandardMaterial 共享 PBR 逻辑,新增 *Node 插槽用来覆盖某一段。
  • Fn() 是核心抽象,把节点逻辑封装成可复用、可组合的“函数节点”。
  • varyingProperty 跨顶点/片段传值,必须在 Fn 外声明
  • FnCompute + instancedArray 是 TSL 在 GPGPU 上的杀手锏,WebGPU 路径下才能发挥全部威力。
  • WebGL2 兼容:API 一样,但部分节点不可用、Compute 退化 FBO

思考与练习

  1. 用 TSL 实现一个“模型从下到上逐渐消失”的溶解效果(提示:噪声 + step)。
  2. 写一个 Fn 接受 vec3 normal 参数,返回 pow(dot(normal, lightDir) * 0.5 + 0.5, 2.0),对比直接写在 colorNode 里的代码量差异。
  3. 把 09 篇“波浪 + 自发光”案例改成 GPGPU 版本:10000 个粒子,position 和 velocity 用 instancedArray 存储。
  4. 调研题:搜索 Three.js 仓库的 tsl.md,找出 r163 到 r166 之间,TSL API 至少 1 处 breaking change,并写明迁移路径。