45 · CSM 的 Uniform、update、缓存与克隆

返回系列目录

本章以本地 three-custom-shader-material 6.4.0 的 vanilla 实现和 Three.js r185 为准。这里最重要的区分是:改变数据值、改变程序源码、创建另一份效果实例,是三种不同操作。 CSM 简化了注入过程,但没有消除程序缓存和 JavaScript 对象引用。

1. 稳定状态:不必等待 onBeforeCompile

假设设备外壳需要随时间在两种颜色之间变化。先创建 uniform 容器,再交给 CSM;渲染循环只更新值:

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

const state = {
  uDemoTime: { value: 0 },
  uDemoColorA: { value: new THREE.Color('#397e94') },
  uDemoColorB: { value: new THREE.Color('#e58469') }
};
const vertexShader = `void main() {}`;
const fragmentShader = `
  uniform float uDemoTime;
  uniform vec3 uDemoColorA;
  uniform vec3 uDemoColorB;
  void main() {
    float amount = 0.5 + 0.5 * sin(uDemoTime);
    csm_DiffuseColor.rgb = mix(uDemoColorA, uDemoColorB, amount);
  }
`;
const material = new CustomShaderMaterial({
  baseMaterial: THREE.MeshStandardMaterial,
  vertexShader,
  fragmentShader,
  uniforms: state,
  roughness: 0.45
});

// 在既有渲染循环中执行;单位是秒。
function updateFrame(elapsed) {
  state.uDemoTime.value = elapsed;
}

构造阶段对 uniform 字典做了浅合并:最外层对象通常不是传入的 state,但其中的 { value } 容器仍然是同一对象。编译时 CSM 又将自定义 uniform 合并到 Three.js 的 shader uniforms,并把 material.uniforms 指向合并结果。因此建议保留自己的 state,不要假定 material.uniforms 永远只含自己声明的键。

uDemo 是例子命名空间。不要自定义 diffuse、roughness、projectionMatrix,也不要占用 csm_ 或 csm_internal_ 前缀。CSM 的 uniform 合并允许覆盖内置同名项,不会替你识别语义冲突。

2. GLSL 类型是程序契约

GLSL 声明 JavaScript 常用值 更新方式
float number state.uDemoTime.value = elapsed
int 整数 number 先取整并限制范围,再赋值
bool boolean 修改 .value
vec2/vec3/vec4 Vector2/3/4;颜色常用 Color .value.set(...) 或 .copy(...)
mat3/mat4 Matrix3/4 .value.copy(matrix)
sampler2D Texture 替换 .value 为兼容纹理

JavaScript 的 number 本身不区分 int/float,但 GPU 按已编译的 GLSL 类型上传数据。把 uniform float uDemoTime 的值改成 Vector3 不会把声明自动变成 vec3。纹理是资源对象,不是 URL;先加载纹理,再赋给 sampler。颜色纹理、数据纹理应按用途设置颜色空间,见纹理混合。

更新 Vector3 时优先 .copy(),有利于保留其他模块持有的引用;赋一个新的 Vector3 也可以,只要修改的是仍被共享的 .value。反过来,state.uDemoTime = { value: 2 } 会断开旧容器关系,已经编译的 shader 可能继续读取旧容器。

固定数组长度、宏控制的分支和循环展开属于编译结构。CSM 不改变 GLSL 规则;运行时循环与固定上限策略见循环与 defines。不要为了每帧改变一个 float 调用 update()。

3. 6.4.0 的 update 不能当作普通局部合并

它接受 vertexShader、fragmentShader、uniforms、patchMap、cacheKey。类型上这些参数是可选的,但本版本实现有一项必须理解的行为:

// 这是实现行为的简化,不是推荐业务代码。
const strippedVertex = stripComments(vertexShader || '');
const strippedFragment = stripComments(fragmentShader || '');

新 onBeforeCompile 闭包使用这两个局部字符串。没有传入的源码会变成空字符串,而不是自动沿用 material.vertexShader。虽然实例上的源码字段可能仍显示旧值,新程序却不再执行旧注入代码。uniforms 如果提供,也是赋给实例,不是自动与上一份自定义字典做增量合并。

因此以下做法不能作为可靠的值更新:

// 不推荐:会重装编译回调,且没有把原源码传回来。
material.update({ uniforms: state });

如果确实需要编辑源码,保存完整配置并完整传入:

const patchMap = {};
const compileOptions = {
  vertexShader,
  fragmentShader,
  uniforms: state,
  patchMap,
  cacheKey: () => 'device-scan-v2'
};
material.update(compileOptions);

源码结构不同的版本必须使用不同缓存签名。update() 自己设置 needsUpdate,但它不是材质属性的通用更新函数;颜色、粗糙度等仍通过材质属性修改。

4. cacheKey 与缓存函数累积

6.4.0 将传入的 cacheKey() 结果作为自定义部分;结果为空等假值时,退回到去注释后顶点与片元源码的哈希。该默认哈希没有编码 patchMap。相同源码、不同替换表可能发生缓存碰撞,所以替换表的版本和结构选项必须进入稳定签名。

源码还将本次签名与此前的 customProgramCacheKey() 返回值拼接。每次 update() 都再包一层,反复调用会产生历史相关的缓存键和调用链。因此不要在每帧调用它,也不要把无限次热编辑更新设计为长期运行模型。

推荐规则:

Three.js 自身的灯光、defines、贴图等程序参数仍参与整体缓存。这里讨论的是 CSM 增加的那一部分键。

5. clone 的浅共享与旧配置陷阱

6.4.0 的 clone() 从 __csm 读取构造时的源码、uniforms、patchMap 和 cacheKey,再创建一个 CSM。update() 没有同步更新这份初始元数据。因此更新后的材质直接 clone,可能重新得到构造时的效果。

此外,clone 把原始 uniforms 参数交给新构造函数;浅合并不会深克隆 { value }、Vector3 或纹理。两个材质有不同的外层字典,不代表动画状态独立。

更可靠的办法是显式工厂:

function createDeviceMaterial({ phase = 0, color = '#397e94' } = {}) {
  const uniforms = {
    uDemoPhase: { value: phase },
    uDemoColor: { value: new THREE.Color(color) }
  };
  const material = new CustomShaderMaterial({
    baseMaterial: THREE.MeshStandardMaterial,
    uniforms,
    vertexShader: 'void main() {}',
    fragmentShader: `
      uniform float uDemoPhase;
      uniform vec3 uDemoColor;
      void main() {
        csm_DiffuseColor.rgb = uDemoColor *
          (0.7 + 0.3 * sin(uDemoPhase));
      }
    `,
    roughness: 0.5,
    cacheKey: () => 'device-factory-v1'
  });
  return { material, uniforms };
}
const first = createDeviceMaterial();
const second = createDeviceMaterial({ phase: Math.PI });
second.uniforms.uDemoPhase.value += 0.1; // 不改变 first。

需要共享时间时,单独把同一个时间容器传给工厂;需要独立颜色时,每次新建 Color。纹理一般可以共享,但纹理释放应由明确的资源拥有者负责。不要为了复制所有值而盲目复制 GPU 纹理。

6. baseMaterial 传构造函数还是实例

传 THREE.MeshStandardMaterial 时,CSM 自己创建基础实例。传已经存在的 material 实例时,6.4.0 会在该实例上写入 name、update、uniforms、源码、__csm、编译回调和缓存回调,并应用额外材质参数。这不是无副作用的包装。

若基础实例还被其他 Mesh 使用,可能意外改变其他对象。优先传构造函数;必须复用已有配置时,可以先克隆普通基础材质,再安装 CSM,但原材质上自定义的 onBeforeCompile 逻辑不会因为普通 clone 就自动完整保留。复杂组合应由自己的工厂重新安装,见下一章。

练习(暂不附答案)

  1. 创建两份独立材质,但让它们共享时间容器、使用独立颜色。列出哪些对象引用应相同,哪些必须不同。
  2. 先构造材质,再只调用 update({ uniforms })。对比实例源码字段与实际 onBeforeCompile 输出,解释差异。
  3. 两份相同源码使用不同 patchMap,为它们设计可复用、不会随时间增长的缓存签名。
  4. 将运行时源码编辑器改成工厂重建,并说明旧材质、共享纹理和 Mesh 引用分别怎样处理。