35 · CSM 与手动替换:同一条渲染管线,两种控制方式
从本章开始,在前 34 章的基础上学习 three-custom-shader-material(CSM)6.4.0 + Three.js 0.185.1。以本项目安装的包、类型定义和实际补丁代码为准,不把在线最新版本的说明直接套到当前依赖。
CSM 的全称是 Custom Shader Material,它不是 Three.js 自带的 ShaderMaterial,也不是让浏览器支持了一组新 GLSL 关键字。它把你对 csm_* 变量的赋值,连接到内置材质的特定计算位置。
1. 手动替换与 CSM 的等价意图
假设要让普通网格的局部 y 随 x 波动。手动方式需要找到 begin_vertex,保留原逻辑后改 transformed:
// 片段:material 已创建;replaceOnce 见第 05 章。
material.onBeforeCompile = (shader) => {
shader.vertexShader = replaceOnce(
shader.vertexShader,
'#include <begin_vertex>',
`#include <begin_vertex>
transformed.y += 0.2 * sin(position.x * 3.0);`,
'local wave'
);
};
material.customProgramCacheKey = () => 'local-wave/v1';
CSM 的作者代码则表达为:
void main() {
csm_Position = position;
csm_Position.y += 0.2 * sin(position.x * 3.0);
}
CSM 发现源码使用 csm_Position 后,把内置 #include <begin_vertex> 替换为 vec3 transformed = csm_Position;。你的 main 会先计算 csm_Position,随后原材质继续处理 morph、蒙皮、位移贴图和投影。
两者可以得到相同位置效果,但不是每段手动补丁都能一对一翻译:手动方式可以选择蒙皮前或蒙皮后,csm_Position 的默认插入位置是固定的。两段波动示意都未修正法线,也未同步阴影,完整处理见后续章节。
2. CSM 把什么工作封装起来
| 工作 | 手动 onBeforeCompile | CSM |
|---|---|---|
| 选择内置材质 | 直接构造 Material | 通过 baseMaterial 构造或传入实例 |
| 寻找常见位置 / 颜色锚点 | 自己阅读并替换 | 使用预定义输出变量,由默认 patchMap 连接 |
| 组织自定义声明和 main | 自己安排全局与执行位置 | 拆分自定义声明和 main,注入内置 main 开头 |
| 绑定 uniform | 自己维护 shader.uniforms | 通过 uniforms 配置合并,仍需自己管理状态和类型 |
| 区分程序结构 | customProgramCacheKey | 默认源码哈希加基础键,可用 cacheKey 扩展 |
| 修改未覆盖的内部逻辑 | 自己替换 | 使用 patchMap,重新面对锚点与版本维护 |
| 法线、阴影、CPU 拾取 | 自己设计 | 仍需自己设计,CSM 不自动推导几何关系 |
CSM 最适合“常见修改点 + 内置照明”的情况,例如程序化颜色、规则形变和粗糙度遮罩。若你需要重新安排大段渲染算法,它的固定输出位置可能不够,此时 patchMap 或前面介绍的手动方法更明确。
3. csm_* 是输出变量,不是 uniform
csm_Position 是顶点阶段可写的 vec3,csm_DiffuseColor 是片元阶段可写的 vec4。它们先由 CSM 初始化,再由你的 GLSL 修改,最后被替换位置读取。不要在 JavaScript 的 uniforms 中创建一个同名字段来“控制 csm_Position”,也不要重复声明库已提供的这些变量。
应该通过自定义参数驱动它们:
uniform float uDemoAmplitude;
void main() {
csm_Position.y += uDemoAmplitude * sin(position.x);
}
JavaScript 负责 uDemoAmplitude,CSM 负责 csm_Position 的声明、默认值和接入点。坐标空间、角度单位和波面导数仍由你负责。
4. 你的 main 不是内置 main 的替代品
CSM 6.4.0 会提取你的全局声明、函数和 main 函数体,把函数体放进原材质 main 的开头,原材质后面的执行链继续保留。可以理解为:
CSM 默认输出值初始化
→ 你的 main 函数体
→ 内置 main 原有逻辑(在各自阶段读取被 patchMap 修改的输出)
因此在自己的 main 里通常还不能读取内置 main 后面才声明的 diffuseColor、normal、roughnessFactor 或 material 结构体。使用相应 csm 输出,或者在你明确知道时序的 patchMap 中修改内部变量。
直接在自己的 main 里写 gl_Position = ... 可能被后面的 project_vertex 覆盖。直接写 gl_FragColor = ... 也可能被后面的输出覆盖。对应接口分别是 csm_PositionRaw 和 csm_FragColor,但它们保留和绕过的步骤也有边界,不能只看名字理解。
5. 自动化不等于任意材质都兼容
CSM 维护一份材质类型允许表和关键字到补丁的映射。比如 6.4.0 的 csm_Emissive 只允许 Standard、Physical,即使原生 Phong 也有 emissive 属性,也不能据此认定这个 CSM 输出支持 Phong。需要时使用 Phong 原生属性或有针对性的 patchMap。
某些 README 表格存在简化或错误:csm_FragNormal 在本地声明中实际是 vec3;roughness 输出不要求你真的创建 roughnessMap;csm_PointSize 后面仍可能经历透视尺寸衰减。各章会按源码解释。
CSM 仍然依赖 Three.js 内部字符串。6.4.0 的 csm_DepthAlpha 与当前深度 / 距离路径存在具体兼容问题,第 47 章给出源码分析和可运行的 discard 替代方案。不能因为普通颜色示例成功就推断所有输出均可用。
6. 什么时候继续用手动替换
需要在特定中间阶段保留已计算的 map、顶点颜色或 normalMap 结果时,手动修改可能比 CSM 的“覆盖某个结果”更合适。需要接入一个 CSM 没覆盖的字段时,可以使用 patchMap,但要看 CSM 是否已把那个 include 展开成具体代码。
CSM 可以减少重复的接线代码,却不会消除程序变体、uniform 所有权、纹理颜色空间和性能开销。未使用的默认变量可能被 GPU 编译器优化掉,但不应假定任何 CSM 和纯内置材质都必然拥有相同的缓存键、源码或成本。
练习(不附答案)
- 把一段 begin_vertex 后的局部位移翻译成 csm_Position,列出两种写法尚未处理的行为。
- 解释为什么自己的 CSM main 内直接修改 roughnessFactor 可能无法编译。
- 为“蒙皮完成后再偏移位置”和“替换基础色”分别选择实现方式,并说明锚点自由度差异。
- 在本地 CSM 发布代码中找到一个输出变量的默认值、允许材质类型和替换目标,建立完整链路。