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 个坑:
- 字符串黑客(String Hacking):
replace是无脑文本替换。一旦 Three.js 升级把<begin_vertex>改名或者把transformed改成transformed_,整段逻辑静默失效,没有编译错误,阴影/动画突然没了。 - 没有类型:写错
transformed为transform,GLSL 编译器在某些场景会延后报错,或者直接把整个材质 fallback 成默认。 - 要手动同步
customDepthMaterial:阴影贴图走另一套材质,忘写 → 阴影是“直”的。 - 不能跨后端:
onBeforeCompile注入的是 GLSL 字符串,WebGPU 不认。如果你想升级到WebGPURenderer,所有特效都要重写成 WGSL。
TSL(Three.js Shading Language)的设计目标就是把以上 4 点全部干掉:
| 痛点 | TSL 的解法 |
|---|---|
| 字符串匹配 | 直接赋值 material.positionNode = ... |
| 类型 | TS 强类型 + 节点求值器在编译期校验 |
| customDepthMaterial | 节点系统自动同步到深度 Pass |
| WebGPU/WebGL2 双跑 | 同一份 TSL,编译期决定输出 WGSL 或 GLSL |
MeshStandardNodeMaterial 与 MeshStandardMaterial 不是并列的两套实现,而是同一份 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、运算符结果、函数——都是节点。1、vec3(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)
读顶点属性:
| 节点 | 类型 | 来源 |
|---|---|---|
positionLocal | vec3 | 几何体本地坐标 |
normalLocal | vec3 | 几何体本地法线 |
tangentLocal | vec4 | 切线(带 handedness) |
uv() | vec2 | UV 坐标 |
color | vec4 | 顶点色 |
这些节点在顶点着色器里有效。把它们传到片段着色器要用 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 外声明。如果在positionNode的Fn内部声明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 升级。 MeshStandardNodeMaterial与MeshStandardMaterial共享 PBR 逻辑,新增*Node插槽用来覆盖某一段。Fn()是核心抽象,把节点逻辑封装成可复用、可组合的“函数节点”。varyingProperty跨顶点/片段传值,必须在 Fn 外声明。FnCompute+instancedArray是 TSL 在 GPGPU 上的杀手锏,WebGPU 路径下才能发挥全部威力。- WebGL2 兼容:API 一样,但部分节点不可用、Compute 退化 FBO。
思考与练习
- 用 TSL 实现一个“模型从下到上逐渐消失”的溶解效果(提示:噪声 + step)。
- 写一个
Fn接受vec3 normal参数,返回pow(dot(normal, lightDir) * 0.5 + 0.5, 2.0),对比直接写在colorNode里的代码量差异。 - 把 09 篇“波浪 + 自发光”案例改成 GPGPU 版本:10000 个粒子,position 和 velocity 用
instancedArray存储。 - 调研题:搜索 Three.js 仓库的
tsl.md,找出 r163 到 r166 之间,TSL API 至少 1 处 breaking change,并写明迁移路径。