05. 材质系统深度定制
在进阶开发中,我们经常遇到这样的需求:我想用 Three.js 标准的 MeshStandardMaterial 获取逼真的光照和阴影,但又想让模型表面有特殊的顶点动画,或者根据高度改变颜色。
直接写 ShaderMaterial 会失去内置的光照计算,重写一遍光照模型又太复杂。这时,Three.js 的材质修改机制就派上用场了。
1. Material.onBeforeCompile
这是 Three.js 最强大的“手术刀”。它允许你在 Shader 被编译发送给 GPU 之前,拦截并修改 GLSL 字符串。
核心流程
- Three.js 根据材质属性生成 GLSL 模板。
- 调用
onBeforeCompile(shader)回调。 - 我们在回调中用
string.replace注入自己的代码。 - 编译 Shader。
示例:让标准材质产生“融化”效果
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 });
material.onBeforeCompile = (shader) => {
// 1. 注入 Uniforms
shader.uniforms.time = { value: 0 };
// 方便后续在外部更新:material.userData.shader.uniforms.time.value = t;
material.userData.shader = shader;
// 2. 注入顶点着色器逻辑
// 找到 #include <begin_vertex> (这是处理顶点位置的地方)
// 把它替换为包含我们需要逻辑的代码
shader.vertexShader = shader.vertexShader.replace(
"#include <begin_vertex>",
`
#include <begin_vertex>
// transformed 是 Three.js 定义的当前顶点位置变量
float height = sin(time + position.x * 2.0) * 0.5;
transformed.y += height;
`,
);
// 3. 注入头部声明 (Varying / Uniform 定义)
shader.vertexShader = "uniform float time;\n" + shader.vertexShader;
};
优点 vs 缺点
- 优点:完美保留阴影、PBR 光照、环境贴图等所有内置功能,只需编写核心特效逻辑。
- 缺点:依赖字符串匹配,如果 Three.js 核心库更新了 ShaderChunk 的变量名(极少发生但有可能),代码可能会失效。
2. ShaderChunk 与 ShaderLib
Three.js 的 Shader 也是模块化的。所有的内置 Shader 代码片段都存储在 THREE.ShaderChunk 中。
- 复用:你可以在自己的
ShaderMaterial中引用内置片段。// 在你的 Shader 中
#include <common>
#include <lights_pars_begin> - 修改:你甚至可以修改
THREE.ShaderChunk全局替换某种逻辑(慎用,会影响所有材质)。
3. 自定义深度材质 (Custom Depth Material)
巨坑预警:当你通过 onBeforeCompile 修改了顶点位置(如波浪动画),你会发现阴影还是原来的形状!
原因:阴影贴图生成时,Three.js 使用了另一套简化版的材质(通常是 MeshDepthMaterial)来渲染场景深度。这个材质并没有包含你的顶点动画逻辑。
解决:你需要为物体指定 customDepthMaterial,并把同样的顶点动画逻辑注入进去。
const depthMaterial = new THREE.MeshDepthMaterial({
depthPacking: THREE.RGBADepthPacking,
});
depthMaterial.onBeforeCompile = (shader) => {
// ... 注入和上面一模一样的顶点变换逻辑 ...
};
mesh.customDepthMaterial = depthMaterial;
4. 材质继承与扩展
对于大型项目,建议封装一个工具函数 extendMaterial,专门处理正则替换和 Uniform 合并,避免代码里到处是 string.replace。
总结
- 简单特效 ->
ShaderMaterial - 需要光照/阴影 + 特效 ->
MeshStandardMaterial+onBeforeCompile - 修改顶点后 -> 别忘了
customDepthMaterial
5. 前沿技术:Node Material 与 TSL (Three.js Shading Language)
onBeforeCompile 虽然强大,但本质上是“字符串黑客(String Hacking)”,维护成本极高。为了解决这个问题,并迎接 WebGPU 时代,Three.js 引入了革命性的 Node Material 系统和 TSL (Three.js Shading Language)。
5.1 什么是 Node Material 和 TSL?
在现代 Three.js(r160+)中,你可以不再编写原生的 GLSL 字符串。取而代之的是使用 JavaScript/TypeScript API(即 TSL)来构建着色器逻辑。
- 跨平台:同一套 TSL 代码,Three.js 会自动将其编译为 WGSL(用于 WebGPU)或 GLSL(用于 WebGL2)。
- 模块化与类型安全:不再需要复杂的字符串拼接和正则替换,逻辑被封装成一个个可复用的“节点(Node)”。
- 完美继承光照:你可以轻松地在标准 PBR 材质的基础上,叠加自己的节点逻辑,而不需要修改底层源码。
5.2 TSL 示例:实现顶点波浪动画
import { MeshStandardNodeMaterial } from 'three/nodes';
import { positionLocal, time, sin, vec3 } from 'three/tsl';
// 1. 创建基于节点的标准材质
const material = new MeshStandardNodeMaterial({ color: 0xff0000 });
// 2. 使用 TSL 编写顶点变换逻辑
// 相当于:position.y += sin(time + position.x * 2.0) * 0.5;
const waveOffset = sin( time.add( positionLocal.x.mul( 2.0 ) ) ).mul( 0.5 );
const newPosition = positionLocal.add( vec3( 0, waveOffset, 0 ) );
// 3. 注入到材质的 positionNode
material.positionNode = newPosition;
优势:代码极其优雅,完全不需要关心 customDepthMaterial 的问题,节点系统会自动处理阴影贴图的顶点偏移!
思考与练习
- 为什么在使用
onBeforeCompile修改顶点位置后,物体的阴影形状没有跟着改变?应该如何解决? - 比较
onBeforeCompile和 TSL (Node Material) 两种定制材质的方式,列出它们各自的优缺点。 - 如果你想实现一个“模型从下往上逐渐溶解消失”的特效,你会选择修改顶点着色器还是片段着色器?在 TSL 中应该如何实现这个思路?