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() 都再包一层,反复调用会产生历史相关的缓存键和调用链。因此不要在每帧调用它,也不要把无限次热编辑更新设计为长期运行模型。
推荐规则:
- 数值动画:修改 uniform 值,缓存键不变。
- 少量结构切换:完整
update(),明确结构签名,并检查程序数量。 - 编辑器反复改源码、多层补丁或生产环境版本切换:用完整配置工厂创建新材质,替换引用后释放不再使用的旧材质。
- 时间、随机数、对象 UUID 不进入结构签名,除非确实需要独立程序;独立 uniform 本来就不要求独立程序。
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 就自动完整保留。复杂组合应由自己的工厂重新安装,见下一章。
练习(暂不附答案)
- 创建两份独立材质,但让它们共享时间容器、使用独立颜色。列出哪些对象引用应相同,哪些必须不同。
- 先构造材质,再只调用
update({ uniforms })。对比实例源码字段与实际 onBeforeCompile 输出,解释差异。 - 两份相同源码使用不同 patchMap,为它们设计可复用、不会随时间增长的缓存签名。
- 将运行时源码编辑器改成工厂重建,并说明旧材质、共享纹理和 Mesh 引用分别怎样处理。