Skip to main content

05. 材质系统深度定制

在进阶开发中,我们经常遇到这样的需求:我想用 Three.js 标准的 MeshStandardMaterial 获取逼真的光照和阴影,但又想让模型表面有特殊的顶点动画,或者根据高度改变颜色。

直接写 ShaderMaterial 会失去内置的光照计算,重写一遍光照模型又太复杂。这时,Three.js 的材质修改机制就派上用场了。

1. Material.onBeforeCompile

这是 Three.js 最强大的“手术刀”。它允许你在 Shader 被编译发送给 GPU 之前,拦截并修改 GLSL 字符串。

核心流程

  1. Three.js 根据材质属性生成 GLSL 模板。
  2. 调用 onBeforeCompile(shader) 回调。
  3. 我们在回调中用 string.replace 注入自己的代码。
  4. 编译 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 的问题,节点系统会自动处理阴影贴图的顶点偏移!


思考与练习

  1. 为什么在使用 onBeforeCompile 修改顶点位置后,物体的阴影形状没有跟着改变?应该如何解决?
  2. 比较 onBeforeCompile 和 TSL (Node Material) 两种定制材质的方式,列出它们各自的优缺点。
  3. 如果你想实现一个“模型从下往上逐渐溶解消失”的特效,你会选择修改顶点着色器还是片段着色器?在 TSL 中应该如何实现这个思路?