37 · csm_Position:在内置变换之前修改局部几何
本章依据 three-custom-shader-material 6.4.0 的 vanilla 实现和 Three.js r185。目标是保留 Standard 的灯光和材质响应,只修改水面的几何输入。CSM 不是另一套顶点流水线:它让我们给原流水线提供一个新起点。
1. 变量究竟接管哪里
csm_Position 是顶点阶段的 vec3,对于本章的 MeshStandardMaterial,默认初始化为几何 attribute position,表示物体局部空间的位置。CSM 发现自定义源码使用这个变量后,将:
#include <begin_vertex>
替换为:
vec3 transformed = csm_Position;
它没有替换 project_vertex,也没有直接接管 gl_Position。标准流程随后仍执行 morph target、skinning、displacement map 和投影。在普通 Mesh 上可以概括为:
position → 自定义 main 写入 csm_Position → transformed
→ 形变目标 → 骨骼 → 位移贴图 → modelView/projection → gl_Position
因此不要把 projectionMatrix * modelViewMatrix 的结果塞进这里。错误的空间会让后续再变换一次。需要的是局部位置,不是世界坐标、观察坐标或 NDC。
2. 与手动替换的对应关系
手动方式可以在 begin_vertex 后写 transformed.y += height,CSM 则在自定义 main() 中写 csm_Position.y += height。它们在普通平面上可以等效,但两个 main() 的执行位置不同:CSM 将自己的初始化与用户代码放在原始 main 的前部,所以此时不能依赖原始 main 后面才声明的 transformed、mvPosition、objectNormal。
void main() {
csm_Position = position;
csm_Position.y += 0.3 * sin(position.x * 2.0);
}
这是“替换局部位置初值”,不是“在所有变形之后追加一层世界空间波浪”。带骨骼对象上的该波浪会继续被骨骼变换;如果产品要求世界空间固定的风场,需要重新推导变形空间,或使用手动补丁在更合适的阶段注入。
3. 案例:双向叠加的水波
完整例子位于 csm-wave.js,启动方式沿用 README 的示例外壳。平面几何先 rotateX(-Math.PI / 2) 烘焙到 XZ 平面,使局部 Y 真正代表高度,而不只是旋转 Mesh 的显示方向。
高度函数为:
A 控制局部长度单位中的最大位移,两个系数的绝对值和为 1,所以这里的位移不会超过 A。空间频率决定波长,时间项决定传播速度;增大频率会增加每单位距离内的起伏次数,而不是增加幅度。
JavaScript 通过稳定的 uniform 包装对象将时间送到顶点着色器:
const uniforms = {
uCsmWaveTime: { value: 0 },
uCsmWaveAmplitude: { value: 0.35 }
};
// 构造 CSM 时传入 uniforms。
onFrame((dt, elapsed) => {
uniforms.uCsmWaveTime.value = elapsed;
});
这里是更新运行时数值,不需要每帧 update() 或 needsUpdate。频率越高,平面分段越密才越能表现曲线轮廓;顶点着色器不会凭空增加顶点。
4. 为什么同时写 csm_Normal
只移动位置而保留原法线,水面仍会像一块平板一样受光。例子用高度函数的偏导构造局部法线,并交给 csm_Normal。详细推导见第 39 章。随后 Three.js 完成 normalMatrix 变换、varying 插值以及片元光照,因而仍然获得 Standard 的高光和粗糙度响应。
这不是包含水下折射、反射捕捉或流体动力学的水系统。例子解决的是网格起伏和正确的局部光照。
5. GPU 与 CPU 的边界
例子根据最大幅度扩展包围盒并重新生成包围球。若以后允许幅度超过 0.35,应同步扩展边界。CPU position attribute 仍是原始平面,Raycaster 不会自动命中波面。
例子未开启阴影。自定义主材质不能让深度/距离材质自动获得同一变形;只开启 castShadow 会得到旧平面阴影,阴影同步见后续阴影专题。另需注意:曲面叠加 displacementMap 会再次改变位置,但本章解析法线并不包含这次位移的导数。
6. alphaHash 的额外版本兼容点
r185 的 begin_vertex 不只声明 transformed,还在 USE_ALPHAHASH 下给 varying vPosition 赋值。CSM 6.4.0 整段替换为 vec3 transformed = csm_Position;,丢掉了这个赋值。因而开启 alphaHash 后不能假定随机覆盖依然正常;未写入的 varying 会让覆盖判断失去可靠输入。
本章水面没有启用 alphaHash,不受此问题影响。确实需要组合时,可以在 CSM 的默认替换之后补回职责:
// 构造 CSM 的 patchMap 选项;仅针对已核对的 6.4.0 + r185。
const patchMap = {
'*': {
'vec3 transformed = csm_Position;': {
type: 'vs',
value: `
vec3 transformed = csm_Position;
#ifdef USE_ALPHAHASH
vPosition = vec3(position);
#endif
`
}
}
};
这里保留 r185 原始局部位置语义;若改为 csm_Position,覆盖图案会按变形位置计算,是另一种视觉选择。这个补丁应进入自己的结构缓存签名,并验证锚点确实命中,详见patchMap 专题。它不是修改 node_modules 的建议,也不意味着已支持所有材质变体。
练习(暂不提供答案)
- 将幅度改为 0,预测几何、法线和阴影需求分别如何变化,再运行验证。
- 把
geometry.rotateX()改为只旋转 Mesh,分析原公式使用的坐标为什么不再表示同一水面。 - 增加第三个沿斜方向传播的波,同时修改解析法线和最大位移边界。
- 设计一个鼠标点击水面的位置查询方案,说明什么时候采用 CPU 解析求交,什么时候可以接受平面近似。