07 · 顶点位置与变形链路
本章核对 Three.js r185 的 Phong/Physical 顶点源码。最有价值的问题不是“哪里能改变 position”,而是“改变之后,投影、世界位置、阴影和法线是否看到同一个表面”。内置 attribute position 是输入,不应赋值;实际变形通常修改局部变量 transformed。
1. 整条位置链路
主流程为 begin_vertex → morphtarget_vertex → skinning_vertex → displacementmap_vertex → project_vertex → worldpos_vertex,期间穿插对数深度、裁剪、视线位置和阴影计算。法线链路在位置链路之前执行。这里的 transformed 是尚未应用模型视图矩阵的坐标;InstancedMesh 的实例矩阵稍后也会加入。
对普通网格,最终裁剪坐标为:
这是齐次裁剪坐标,尚未除以 w。NDC、CSS 像素都不是这个阶段 transformed 的单位。
2. begin_vertex:建立可修改的局部位置
输入是几何 attribute position,输出 vec3 transformed = vec3(position)。开启 alphaHash 时还给 vPosition 赋原始位置。这个模块位于 main() 中,不能把声明放到它之前再希望获得 transformed。
最简单的局部正弦波可以追加在这里:
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
`#include <begin_vertex>
transformed.y += uDemoAmplitude * sin(position.x * uDemoFrequency + uDemoTime);`
);
前提是已经在全局声明并注入三个 float uniform。振幅与模型局部坐标同单位,频率是弧度/局部长度,时间在此例中按 1 rad/s 推进。如果希望频率以“每米几次波峰”表达,要乘 2.0 * PI。
它适合不含后续复杂变形的教学网格。用于 morph 网格时,后续基底权重和 morph 叠加还会处理这个改动,因此不必然保持所期望的振幅。它也不自动同步法线。
3. morphtarget_vertex:形态目标叠加
宏 USE_MORPHTARGETS 开启时,模块将 transformed 乘 morphTargetBaseInfluence,然后累计每个形态目标数据乘权重。目标数据由 getMorph(gl_VertexID, i, 0) 获取,相关函数和 uniforms 来自前面的 morph 声明模块。
相对目标近似为 p = pBase + Σ wi Δpi;绝对目标则需要基底剩余权重。不要直接把所有 morph 都描述成“在 position 上加偏移”,因为几何的 morphTargetsRelative 决定数据含义。
想让表情或形态变化完成后再做独立的呼吸起伏,可在该 include 之后改变 transformed。若希望起伏跟随蒙皮,仍让后面的 skinning 处理它。如果把整个模块删掉,morphTargetInfluences 虽然还在 JavaScript 更新,画面也不会响应。
4. skinning_vertex:骨骼影响
输入 transformed、skinWeight、前面 skinbase_vertex 建立的骨骼矩阵,以及 bindMatrix/bindMatrixInverse。它把点变到绑定空间,按权重混合骨骼变换,最后变回当前网格的局部计算空间。
在这里之前加局部偏移,会被骨骼旋转;在之后加偏移,则是在已经完成蒙皮的网格局部空间添加。比如衣服缝线细小扰动可能希望跟随身体,而整个人物被冲击波推移可能希望作用于蒙皮后的形状。选择取决于效果定义。
不要只复制 skinning_vertex 到一个没有 skinning 声明和骨骼初始化的自制 shader。也不要把 CPU Object3D 的层级变换误认为这一段骨骼加权计算。
5. displacementmap_vertex:沿法线位移
它在 USE_DISPLACEMENTMAP 下读取 displacementMap 红通道,并执行:
transformed += normalize(objectNormal)
* (texture2D(displacementMap, vDisplacementMapUv).x * displacementScale + displacementBias);
输入是已经变形过的位置与 objectNormal,输出仍是 transformed。位移贴图属于数据纹理,应保持非颜色空间语义。它只对现有顶点位移:两三角形平面不会凭一张高分辨率图自动生成丰富轮廓。
在这一模块之后追加程序化变形,可以确保基础位移贴图保留。要注意其采样使用自己的 UV,不是 vMapUv。源码没有从任意高度图自动推导完整的新曲面法线,所以法线贴图或自定义法线修正仍可能需要。
6. project_vertex:从局部位置到裁剪空间
模块创建 mvPosition = vec4(transformed, 1),依次应用 batchingMatrix、instanceMatrix(若启用),然后 modelViewMatrix,最后 gl_Position = projectionMatrix * mvPosition。其中 mvPosition 是观察空间位置。
推荐在 include 之前改 transformed、保留 include:
shader.vertexShader = shader.vertexShader.replace(
'#include <project_vertex>',
`transformed.y += uDemoAmplitude * sin(transformed.x * uDemoFrequency + uDemoTime);
#include <project_vertex>`
);
这样后面的 worldpos 也会读取修改后的 transformed。若在 include 后仅修改 gl_Position.y,屏幕轮廓变了,但 mvPosition 和 worldPosition 没变,光照、雾、裁剪及阴影仍以旧位置为依据。屏幕空间描边可以有意这么做,但应明确其与真实表面变形不同。
7. worldpos_vertex:有条件的世界位置
它从 transformed 重新开始,应用 batching/instancing/modelMatrix,生成 vec4 worldPosition。重要的是,这个变量受宏条件保护:环境贴图、距离材质、阴影、透射或聚光灯投影等需求才会令它出现。普通无环境无阴影材质中,include 存在也不保证变量存在。
不要在其后无条件写 vDemoWorld = worldPosition.xyz。如果只面向普通 Mesh,可以独立计算 (modelMatrix * vec4(transformed, 1.0)).xyz;支持 InstancedMesh/BatchedMesh 则必须同步对应矩阵链。例如:
vec4 demoWorld = vec4(transformed, 1.0);
#ifdef USE_BATCHING
demoWorld = batchingMatrix * demoWorld;
#endif
#ifdef USE_INSTANCING
demoWorld = instanceMatrix * demoWorld;
#endif
vDemoWorld = (modelMatrix * demoWorld).xyz;
把它注入在 transformed 已完成变形而相关矩阵已经可用的位置,并在两端声明 varying vec3 vDemoWorld。它适合世界高度着色或世界坐标噪声;世界坐标越大,精度问题越值得关注。
8. batching_vertex 与实例矩阵为何不能漏掉
batching_vertex 准备当前批次的矩阵,project_vertex 和 worldpos_vertex 才消费该矩阵。InstancedMesh 的每个实例也不是简单共享 modelMatrix:它有独立 instanceMatrix。只用 modelMatrix 计算的世界坐标会让多个实例表现得像重叠在同一处。
如果要在“真正世界 X”上计算波相位,必须先包含实例变换求世界坐标;如果还要把世界方向位移写回 transformed,则需要相应逆变换。不要为了一个全局波浪每顶点盲目求通用 inverse;普通对象可在 CPU 计算适合的转换数据作为 uniform,复杂实例则设计明确的数据通道。
9. 场景验证:一面固定底边的旗帜
设平面局部 x 从 0 到 L,靠杆一侧 x=0。可用 weight = smoothstep(0.0, L, position.x),位移为 z += A * weight * sin(kx - ωt)。固定端的权重为零,因此不会跟着飘走。网格必须沿 x 有足够分段,阴影需要同样的公式,法线需要响应 z 的变化;CPU 包围盒也需要包住最大振幅。
这说明锚点选择只解决位置链路的一部分。一个完整效果还要回答时间状态由谁管理、法线在哪修正、阴影如何共享、拾取是否接受近似。后面的实战章节会组合这些部分。
练习(暂不提供答案)
- 分别在 begin_vertex 后与 project_vertex 后改变位置,比较启用雾和阴影时的差异。
- 让两实例共享材质,以世界 X 而非各自局部 X 决定变色;验证实例旋转后仍正确。
- 设计一个 morph 后、skinning 前的附加偏移,描述它随骨骼旋转的预期表现。
- 计算旗帜最大振幅需要扩大多少包围范围,并说明为何更新 CPU 顶点法线不能直接感知 GPU 位移。