05 · 字符串替换的四种方式

返回目录 · 上一章 · 下一章

1. 保留 include,在前面插入

当后续模块需要读取你调整后的输入时,把代码放在锚点前。下面是补丁函数片段,使用前先从 patch-utils.js 导入 replaceOnce,并由调用者传入 shader:

function liftBeforeProjection(shader) {
  shader.vertexShader = replaceOnce(
    shader.vertexShader,
    '#include <project_vertex>',
    `transformed.y += 0.2;
     #include <project_vertex>`,
    'lift before projection'
  );
}

这里修改后的 transformed 会参与投影,后续以 transformed 为输入的世界位置和阴影坐标也能看到它。但这是主材质内部的数据一致性,不意味着阴影生成材质自动获得同样位移。

这个位置在 morph、蒙皮和位移贴图之后,因此并非所有“局部变形”都应该放这里。详细空间选择见第 22 章。

2. 保留 include,在后面追加

适合先让内置模块算好结果,再修改结果。例如只在采样之后染色:

function tintAfterMap(shader) {
  shader.fragmentShader = replaceOnce(
    shader.fragmentShader,
    '#include <map_fragment>',
    `#include <map_fragment>
     diffuseColor.rgb *= vec3(1.0, 0.7, 0.5);`,
    'tint after map'
  );
}

没有 map 时,原 map_fragment 的宏分支不执行,但 diffuseColor 已由模板初始化,染色仍能生效。这里的数值是线性工作空间常量,不是 CSS 色值。

顺序也决定组合语义:在顶点颜色计算之后进行 mix,可以覆盖顶点颜色;在它之前 mix,后续 color_fragment 仍会乘顶点颜色。

3. 整体替换:接管模块职责

例如自定义采样要完全取代 map_fragment,可将该锚点替换为自己的采样实现。但需要明确原模块承担的事情:map 是否存在、使用哪套 UV、采样颜色如何解码、RGB 与 alpha 如何相乘。

function replaceColorSampling(shader) {
  // 前提:另处已声明并绑定 uDemoMap,两个阶段已连接 vDemoUv。
  shader.fragmentShader = replaceOnce(
    shader.fragmentShader,
    '#include <map_fragment>',
    `vec4 demoSample = texture2D(uDemoMap, vDemoUv);
     diffuseColor *= demoSample;`,
    'custom color sampling'
  );
}

这段函数只是替换阶段示意,不能单独运行。它不再执行原来的 map_fragment;因此视频纹理等特殊内置分支也不会自动保留。第 28 章给出带完整声明、纹理配置和颜色管理前提的案例。

不要为了“插入一点代码”把 begin_vertex 替换成完全不含 transformed 声明的片段,后面模块还依赖这个变量。整体替换必须重新履行下游所需的契约。

4. 添加全局声明与工具函数

function declarePulse(shader) {
  shader.fragmentShader = replaceOnce(
    shader.fragmentShader,
    '#include <common>',
    `#include <common>
     uniform float uDemoTime;
     float demoPulse(float speed) {
       return 0.5 + 0.5 * sin(uDemoTime * speed);
     }`,
    'pulse declarations'
  );
}

同一个 uniform 在 vertex 和 fragment 都使用时,两边都要声明匹配的类型;JavaScript 只需绑定同一名字的一份描述对象。varying 则需要在两边声明相同类型,并由顶点阶段写入。

函数名、局部变量、uniform、varying 和宏都使用明确前缀。不要使用含双下划线或 gl_ 等保留命名形式,也不要用过于通用的 noise、time、color 覆盖现有符号。

5. 为什么普通 replace 不够可靠

source.replace(anchor, code) 找不到锚点时返回原字符串,不会报错。找到了多个字符串锚点时,普通字符串 replace 只替换第一个。升级版本后,你可能得到“可以编译、但效果消失”的程序。

本系列的 replaceOnce 明确要求锚点恰好出现一次:

export function replaceOnce(source, anchor, replacement, label = anchor) {
  if (typeof source !== 'string' || typeof anchor !== 'string' || anchor.length === 0 ||
      typeof replacement !== 'string') {
    throw new TypeError('replaceOnce requires non-empty string anchor and string values');
  }
  const first = source.indexOf(anchor);
  if (first < 0 || source.indexOf(anchor, first + anchor.length) >= 0) {
    throw new Error(`${label}: expected exactly one anchor`);
  }
  return source.slice(0, first) + replacement + source.slice(first + anchor.length);
}

这里用 slice 拼接,replacement 内的 $& 等不会被当作 JavaScript replace 的特殊替换指令。该工具只验证字符串结构,不理解 GLSL,也不会证明坐标空间和数学正确。

6. 重复安装与顺序冲突

保留 include 的补丁若执行两次,锚点仍可能只有一个,却被插入两份 uniform 声明。因此“锚点唯一”不能代替“补丁只安装一次”。材质工厂、安装标记和统一组合器负责后者。

另一个陷阱是两次“追加到同一 include 后”:先装 A 再装 B,最终可能变成 include → B → A,因为第二次仍围绕原 include 替换。需要确定顺序的效果应先合成一段文本,或使用明确的阶段队列,而不是依赖调用先后来猜测。

练习(不附答案)

  1. 让 replaceOnce 分别接收零个、一个、两个锚点,验证它的行为。
  2. 对同一锚点连续追加 A 和 B,观察真实顺序,再写一个保持 A→B 的组合方法。
  3. 列出整体替换 map_fragment 时需要自己接管的至少三项职责。
  4. 把顶点位置修改分别放在 project_vertex 前后,解释投影结果和其他数据为何可能不同步。