18 · 补丁组合与升级维护

返回系列目录

给材质添加颜色脉冲后再添加溶解,不能简单地两次赋值 onBeforeCompile:后一次会覆盖前一次。即使把两个回调串起来,它们也可能改同一个锚点、声明同名变量,或以相反顺序执行。可维护的补丁系统需要明确所有权和阶段顺序。

最容易忽略的顺序反转

设源码是 #include <color_fragment>。先替换为“include + A”,再对保留的 include 替换为“include + B”,最后得到“include + B + A”。回调调用顺序是 A、B,实际执行顺序却是 B、A。

对同一个锚点的多个追加片段,应先收集,再一次性生成。对于替换整个 Chunk 的补丁,同一位置通常只能有一个所有者;不能默认两个完整替换可以自动合并。

一个范围明确的颜色补丁组合器

下例只负责片元 color_fragment 之后的追加。它不是通用 GLSL 解析器,也不接管未知第三方回调。限制范围让行为容易验证。

import { replaceOnce } from './patch-utils.js';

const demoInstalledMaterials = new WeakSet();

export function installColorPatches(material, patches) {
  if (demoInstalledMaterials.has(material)) throw new Error('Patches already installed');
  const ids = new Set();
  const names = new Set();
  const uniforms = {};
  const declarations = [];
  const bodies = [];
  const signatures = [];

  for (const patch of patches) {
    if (ids.has(patch.id)) throw new Error(`Duplicate patch: ${patch.id}`);
    ids.add(patch.id);
    signatures.push([patch.id, patch.version, patch.structureKey || '']);
    declarations.push(patch.declarations);
    bodies.push(patch.body);
    for (const name of Object.keys(patch.uniforms)) {
      if (names.has(name)) throw new Error(`Duplicate uniform: ${name}`);
      names.add(name);
      uniforms[name] = patch.uniforms[name];
    }
  }

  const declarationSource = declarations.join('\n');
  const bodySource = bodies.join('\n');
  const key = JSON.stringify(['demo/color-stack/v1', signatures]);

  // 调用者必须将此安装器作为该材质补丁回调的唯一所有者。
  material.onBeforeCompile = shader => {
    for (const name of Object.keys(uniforms)) {
      if (Object.prototype.hasOwnProperty.call(shader.uniforms, name)) {
        throw new Error(`Built-in uniform collision: ${name}`);
      }
      shader.uniforms[name] = uniforms[name];
    }
    shader.fragmentShader = replaceOnce(
      shader.fragmentShader,
      '#include <common>',
      `#include <common>\n${declarationSource}`,
      'stack declarations'
    );
    shader.fragmentShader = replaceOnce(
      shader.fragmentShader,
      '#include <color_fragment>',
      `#include <color_fragment>\n${bodySource}`,
      'stack color'
    );
  };
  material.customProgramCacheKey = () => key;
  material.needsUpdate = true;
  demoInstalledMaterials.add(material);
  return uniforms;
}

每个 patch 形如:

const tintPatch = {
  id: 'demo-tint',
  version: 1,
  structureKey: '',
  uniforms: { uDemoTintAmount: { value: 0.3 } },
  declarations: 'uniform float uDemoTintAmount;',
  body: `diffuseColor.rgb *= mix(vec3(1.0), vec3(1.0, 0.4, 0.2),
    clamp(uDemoTintAmount, 0.0, 1.0));`
};

installColorPatches(material, [tintPatch, anotherPatch]) 会按数组顺序执行片段。时间等 uniform 值不进入缓存签名;structureKey 仅记录确实改变代码的选项。声明和代码在安装时被快照固定,后续修改 patch.body 不会悄悄改变已安装逻辑。要变更结构,应创建新配置并重建受控材质,或为系统实现显式重配置 API。

从局部工具扩展为工程模块

真实项目可以设置明确的阶段槽位:顶点位移、法线修正、基础颜色、粗糙度、自发光和最终输出。每个槽位登记唯一锚点及执行顺序;一个补丁可以声明它依赖另一个补丁,但不要默默依赖数组碰巧的排列。

还要记录空间契约。例如一个补丁输出 vDemoLocalPosition,另一个不能将它当世界坐标。法线的输入、输出是否归一化,也应作为模块接口描述。字段名字不同不代表数学语义兼容。

与已有第三方回调组合时,至少保留其调用、this、renderer 参数和原缓存键,还要确认其锚点和源码形态。盲目包装并不能解决语义冲突;未知插件之间应采用显式适配,而不是承诺“任意补丁自动兼容”。

升级 Three.js 时验证什么

  1. 固定并记录当前版本;对照 ShaderLib 主体和目标 Chunk 源码,确认锚点仍存在且唯一。
  2. 检查变量定义先后、坐标空间与字段名。找到同名 include 不代表内部语义没变。
  3. 检查 material feature defines 和程序缓存签名。开关组合可能增加此前没有测试的分支。
  4. 测试有无贴图、顶点颜色、阴影、实例化、非均匀缩放,以及实际使用的 Phong/Standard/Physical 类型。
  5. 保留能暴露差异的画面,例如移动光源、倾斜面和不同粗糙度,而不是只看纯色正面截图。

r185 的物理材质内部字段例如 diffuseContribution、specularColorBlended 不应照搬其他版本的代码记忆。内部 Chunk 是可查看的实现,并不是所有变量都拥有跨版本稳定承诺。

测试分层

字符串检查发现锚点缺失和重复;源码构建检查发现语法拼接问题;浏览器 WebGL 编译检查发现 GLSL 声明、宏和类型错误;视觉验证才能发现法线、空间、颜色管理及阴影不一致。Vite 构建成功不能代替 GPU 编译和视觉检查。

练习(暂不提供答案)

  1. 复现“先追加 A 再追加 B,实际 B 在 A 前执行”的现象,并用集中组装修复。
  2. 为组合器增加一个独立 emissive 槽位,说明为什么不能复用 color 槽位位置。
  3. 为一个已有插件编写适配清单,列出回调、缓存和 uniform 方面需要确认的事实。
  4. 为带贴图、阴影和实例化的风吹补丁制定升级验证矩阵,区分静态检查和视觉检查。