22 · 顶点变形的空间与执行顺序
把一块布吹弯,看起来只需要给顶点加一个正弦偏移。然而,偏移在哪个空间、发生在骨骼变换之前还是之后、后续阴影拿到哪份位置,决定了效果是否正确。本章先建立判断这些问题的方法。以下流程核对 Three.js r185 的 ShaderLib/meshphysical.glsl.js,Standard 和 Physical 均使用这条基本顶点链路。
1. 同一顶点有多种位置
position 是几何体 attribute 提供的局部坐标。begin_vertex 创建可修改的 transformed;之后形态目标、蒙皮和 displacement 会继续修改它。project_vertex 再计算观察空间位置 mvPosition 和裁剪空间 gl_Position。世界空间位置则由 worldpos_vertex 在需要时计算。
对普通 Mesh,忽略齐次除法,链路可写为:
这里的自定义变形通常是一个非线性函数
不能因为变量类型都是 vec3 就认为它们可以相加。局部位置加世界位移属于空间混用;如果 Mesh 存在缩放,连位移的单位也不同。
2. 选择插入位置
| 锚点 | 在它之前/之后注入的含义 | 常见用途 |
|---|---|---|
begin_vertex 后 |
初始局部位置刚创建,后续 morph/skinning 还会处理结果 | 简单平面波、明确不使用骨骼的旗帜 |
morphtarget_vertex 后 |
已有形态目标结果,蒙皮尚未处理 | 随表情一起参与骨骼运动的附加变形 |
skinning_vertex 后 |
已经过蒙皮,仍不是投影后的坐标 | 某些最终几何扰动;法线必须使用相容输入 |
displacementmap_vertex 后 |
内置位置变形已结束 | 给最终位置追加效果 |
project_vertex 前 |
修改所有后续位置使用的 transformed |
自定义最终局部位置 |
project_vertex 后 |
已产生 mvPosition、gl_Position |
有意实现屏幕效果;容易破坏其他链路 |
“放在最后最安全”也不成立。例如一段希望发生在骨骼之前的布料变形,移到蒙皮之后就改变了含义。
3. 局部空间水波的完整补丁
import { replaceOnce } from './examples/patch-utils.js';
const state = { uDemoTime: { value: 0 } };
function installPositionWave(material) {
material.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, state);
shader.vertexShader = replaceOnce(shader.vertexShader, '#include <common>',
'#include <common>\nuniform float uDemoTime;', 'wave declaration');
shader.vertexShader = replaceOnce(shader.vertexShader, '#include <begin_vertex>',
`#include <begin_vertex>
transformed.y += 0.3 * sin(2.0 * transformed.x + uDemoTime);`, 'wave position');
};
material.customProgramCacheKey = () => 'position-wave-v1';
}
这段代码适用于已烘焙到 XZ 平面的几何体。如果只执行 mesh.rotation.x = -Math.PI / 2,position 仍是原始 XY 平面的属性;函数不会自动看到旋转后的坐标。应当先理解空间,再选择 geometry.rotateX() 还是 Mesh 旋转。
该补丁刻意只处理位置,用来验证空间;完整光照和阴影版本见 水面案例。它没有替代法线计算,不能直接作为生产水面材质。
4. 世界空间变形需要往返变换
对不含 instancing、batching 的普通 Mesh,可以先计算世界位置,再修改,最后转回局部位置,使原有投影链路继续工作:
vec3 demoWorld = (modelMatrix * vec4(transformed, 1.0)).xyz;
demoWorld.y += uDemoAmplitude * sin(demoWorld.x * uDemoFrequency + uDemoTime);
transformed = (uDemoWorldToLocal * vec4(demoWorld, 1.0)).xyz;
uDemoWorldToLocal 声明为 mat4,JavaScript 值为 Matrix4,更新方式是 mesh.updateWorldMatrix(true, false) 后执行 state.uDemoWorldToLocal.value.copy(mesh.matrixWorld).invert()。若缩放轴为零,矩阵不可逆,必须禁止该输入或采用其他算法。共享材质的不同 Mesh 不能直接共用一个持续变化的逆矩阵值而不设计逐对象状态。
实例化时 modelMatrix 不包括 instanceMatrix。批量渲染也有额外矩阵;照抄上述代码会让每个实例的波相位或位置错误。这类项目要把对应变换加入往返链路,并重新处理法线。
5. 为什么不直接修改 gl_Position
如果 gl_Position.y += ...,改变的是裁剪坐标,不是米或局部长度。透视除法还会除以 w,导致近远处位移不同。与此同时,mvPosition、阴影坐标、雾距离、Physical 透射使用的世界位置可能仍代表原物体,出现“轮廓动了,高光不动”的失配。
真正需要屏幕偏移时可以这样做,但应把它定义为屏幕效果,并逐一确认深度、裁剪、阴影和交互需求,而不是把它当作通用几何变形。
验证与练习
先关闭动画,固定时间为 0;显示网格线,确认位移方向。再旋转 Mesh、改变父节点缩放,观察波是否按预期跟随。最后检查阴影和高光,本章位置补丁出现偏差正是后续两章要解决的问题。
- 将局部 Y 波动改为局部 Z 波动,预测原始 XY 平面和烘焙 XZ 平面的区别。
- 画出蒙皮前、蒙皮后增加同一局部偏移的变换顺序。
- 给世界波补丁添加逆矩阵 uniform,并禁止零缩放输入。
- 有意只修改
gl_Position,记录画面中至少两个不同步的数据或效果。