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 和纯内置材质都必然拥有相同的缓存键、源码或成本。

练习(不附答案)

  1. 把一段 begin_vertex 后的局部位移翻译成 csm_Position,列出两种写法尚未处理的行为。
  2. 解释为什么自己的 CSM main 内直接修改 roughnessFactor 可能无法编译。
  3. 为“蒙皮完成后再偏移位置”和“替换基础色”分别选择实现方式,并说明锚点自由度差异。
  4. 在本地 CSM 发布代码中找到一个输出变量的默认值、允许材质类型和替换目标,建立完整链路。