46 · patchMap、执行顺序与手动补丁组合

返回系列目录

csm_Position 等变量覆盖常见需求;当要修改的是 AO 的特殊组合、后处理前的输出或某个尚无 CSM 变量的局部计算时,可以使用 patchMap。它仍然是字符串替换,不提供语法树分析或锚点验证。本章依据 CSM 6.4.0 + Three.js r185。

1. patchMap 的两层键是什么

类型可以读成:当自定义源码包含某个关键词时,在基础着色器中执行一组替换。

const patchMap = {
  myFeatureKeyword: {
    '要寻找的原始字符串': '替换后的字符串'
  }
};

外层不是 ShaderChunk 名称,而是启用条件;内层才是锚点到替换文本的映射。外层 '*' 表示总是执行。内层值也可以用 { type: 'fs' | 'vs', value: '...' } 限定只修改片元或顶点源码。普通字符串值会尝试修改两个阶段。

2. 一个真正改变光照结果的自定义补丁

下面在 opaque_fragment 执行前缩放 outgoingLight。此时内置反射光和自发光已经汇总,但颜色空间转换、色调映射尚未执行。它与修改基础色的区别是:连自发光和镜面反射也会一起缩放。

import * as THREE from 'three';
import CustomShaderMaterial from 'three-custom-shader-material/vanilla';

const state = { uDemoOutputGain: { value: 0.7 } };
const material = new CustomShaderMaterial({
  baseMaterial: THREE.MeshStandardMaterial,
  uniforms: state,
  vertexShader: 'void main() {}',
  fragmentShader: `
    uniform float uDemoOutputGain;
    void main() {}
  `,
  patchMap: {
    uDemoOutputGain: {
      '#include <opaque_fragment>': {
        type: 'fs',
        value: `
          outgoingLight *= uDemoOutputGain;
          #include <opaque_fragment>
        `
      }
    }
  },
  color: '#a28667',
  metalness: 0.3,
  roughness: 0.35,
  cacheKey: () => 'output-gain-before-opaque-v1'
});

关键词出现在 uniform 声明中就能触发,不要求在 main 中调用。这个例子适合演示渲染阶段;如果想做全场景曝光,应优先使用 renderer 的曝光控制,避免逐个材质打补丁。也不要把这个补丁直接用于 Depth/Distance 材质,它们没有相同的光照汇总变量与输出锚点。

3. 实际执行顺序

6.4.0 的 onBeforeCompile 顺序是:

  1. 调用记录下来的基础材质 prevOnBeforeCompile。
  2. 添加材质类型和顶点/片元阶段宏。
  3. 执行 CSM 默认 patchMap。
  4. 执行用户 patchMap。
  5. 注入 CSM 声明、默认值和你的 main 函数体。
  6. 合并 uniforms;之后 renderer 继续 include 展开与编译流程。

默认补丁的 '*' 会先展开顶层 lights_physical_fragment 和 transmission_fragment。因此到了用户 patchMap 阶段,下面的锚点可能已经不存在:

'#include <lights_physical_fragment>'

反而下面的内层语句已经暴露出来:

material.clearcoat = clearcoat;

但如果使用了 csm_Clearcoat,默认补丁又会把它改成 material.clearcoat = csm_Clearcoat;。所以不能只看 Three.js 原始 ShaderLib;必须同时考虑 CSM 默认替换完成后 的字符串形态。

同样,CSM 自己的 uniform、函数和 main 尚未注入时,用户 patchMap 已经执行。试图用 patchMap 去替换“自己 main 中刚写的那句代码”通常找不到,因为那部分文本此时还没有进入基础源码。

4. 关键词判断不是 GLSL 语义分析

实现先去掉 // 和 /* ... */ 注释,再用 JavaScript includes() 检查源码。

例如多材质共用一份包含 csm_Roughness 的文本,即使将它包进关闭的宏,也可能触发 Phong 不支持该变量的诊断。更可靠的做法是在 JavaScript 中按材质类型生成不同源码,使不兼容的关键词根本不出现。

void main() 的提取也依赖模式匹配与花括号扫描。使用常规函数形式,把辅助函数放在 main 前,避免把多个 main、复杂宏拼装或 main 后的声明当作稳定扩展接口。

5. 无匹配不会报错,重复锚点只替换第一个

CSM 调用普通字符串 replace()。锚点不存在时源码原样返回;相同锚点出现多次时只改第一次。看到画面仍能渲染,不代表补丁已经生效。

可在开发时捕获 CSM 完成后的源码,对自己植入的可执行标记做检查:

// 接在上面的 material 构造之后;只用于验证,不另改源码。
const install = material.onBeforeCompile;
material.onBeforeCompile = function (shader, renderer) {
  install.call(this, shader, renderer);
  const expected = 'outgoingLight *= uDemoOutputGain;';
  if (!shader.fragmentShader.includes(expected)) {
    throw new Error('output-gain 补丁未生效:请检查锚点和 CSM 版本');
  }
};

这个检查发生在编译回调运行时;材质从缓存直接复用程序时不一定再次执行。因此还要保留针对源码的离线检查和实际渲染验证。这里没有改程序结构,所以不新增缓存签名;如果验证包装器同时修改源码,则必须将这种修改计入结构签名。

6. 多个补丁如何组合

如果 A 和 B 都替换同一锚点,A 删除锚点后 B 会失效;A 保留锚点时 B 可能插在 A 的前面或后面,取决于字符串结构。JavaScript 对象里两个相同键也会直接覆盖,不能用重复键表达有序操作。

推荐把同一计算阶段的功能合成一次替换:

const patchMap = {
  '*': {
    '#include <opaque_fragment>': {
      type: 'fs',
      value: `
        outgoingLight *= uDemoOutputGain;
        outgoingLight += uDemoOutputTint;
        #include <opaque_fragment>
      `
    }
  }
};

相应源码需要声明 uniform float uDemoOutputGain; 和 uniform vec3 uDemoOutputTint;,JavaScript 提供匹配的 uniform 容器。把顺序写在一个位置比隐式叠加多个材质包装器更容易检查。

CSM 支持记录基础 onBeforeCompile,也有重复注入标记处理,但这不等于任意层数的 CSM 包装都没有状态副作用。传入实例会修改实例;缓存键累积、同名 uniform、默认补丁重复执行以及法线阶段先后顺序都需要审查。生产模块优先使用一个 CSM 实例、一份组合源码、一份组合 patchMap。

7. 与手动 replace 如何选择

需求 更直接的方案
修改局部顶点位置、基础颜色、PBR 常见参数 使用对应 CSM 输出变量
CSM 没有暴露的单个阶段 CSM + 小范围 patchMap
需要严格的锚点计数、多个阶段复杂顺序 自己维护带检查的 onBeforeCompile 工具
跨版本替换兼容 维护版本适配与渲染验证,不依赖库名来保证兼容

CSM 帮你维护常见映射,但 patchMap 将内部锚点依赖重新交回你的代码。缓存签名至少包含补丁版本、结构选项和基础材质类型;不要把 uniform 的当前数值加入签名。生命周期问题见上一章。

练习(暂不附答案)

  1. 把输出增益补丁改成只影响自发光,说明锚点和变量为何需要改变。
  2. 编写一个关键词只出现在注释中、一个出现在 #if 0 中的例子,对比替换是否触发。
  3. 两个功能都修改 opaque_fragment,设计一次组合替换,并给出验证两个功能均生效的方法。
  4. 为一次 Three.js 升级制定检查流程,分别检查默认 CSM 映射、自定义 patchMap 和实际 GPU 编译。