顶点动画:水面、草地风摆与通用位移套路
水面起伏、草地风摆、旗帜飘动——都在顶点着色器里做(见 /notes/unity/principles-1/:顶点程序改的是几何本身)。改几何容易,让光照和阴影跟着改对才是这篇的重点。
通用结构:位移的三要素
任何顶点动画都是同一个公式的变体:
新位置 = 原位置 + 方向 × 强度 × 波形(时间, 相位)
- 方向:沿法线(膨胀/呼吸)、沿固定轴(草左右摆)、沿波形梯度(水)。
- 强度遮罩:哪些顶点能动——草用「顶点色/UV 的 y 梯度」(根部不动梢部大摆),角色呼吸用权重。
- 波形:
sin/cos组合;相位用世界坐标或 UV 错开,避免整片同步摆(一眼假)。
// 草地风摆(顶点程序核心)
float phase = i.worldPosWS.x * _WaveFreqXZ + i.worldPosWS.z * _WaveFreqXZ;
float sway = sin(_Time.y * _WindSpeed + phase);
float mask = v.color.r; // 顶点色当刚度遮罩:根 0 梢 1
o.positionOS.xyz += v.normal.xyz * sway * mask * _Strength;
空间选择(易错点):相位用世界坐标(风场钉在世界里,物体移动穿过风场),位移方向用模型空间(物体自转时草不会整体平移)。混用规则见 /notes/unity/principles-2/。
水面:多波叠加 + 解析法线
单层 sin 的水像果冻。标准做法是 2–4 个不同方向/波长的波叠加,并且法线要解析重算——位移改了表面倾斜,法线不重算光照就是平的:
// 高度函数 H(x,z),对 x、z 求偏导得到斜面梯度
float H(float2 p) {
return _A1 * sin(dot(p, _Dir1) * _F1 + _Time.y * _S1)
+ _A2 * sin(dot(p, _Dir2) * _F2 + _Time.y * _S2);
}
// 顶点/片元里:
float eps = 0.1;
float dx = (H(p + float2(eps,0)) - H(p - float2(eps,0))) / (2*eps);
float dz = (H(p + float2(0,eps)) - H(p - float2(0,eps))) / (2*eps);
float3 N = normalize(geomN - float3(dx, 0, dz)); // 由梯度倾斜法线
(解析求导更快:对 sin 直接写导数;上面差分写法是为了通用。)位移量大的水面,顶点密度要够——波长短于顶点间距会出折线穿帮,这是网格成本不是 shader 成本。
阴影与深度:最容易漏的两处
顶点动画改了轮廓,所有输出几何位置的 Pass 都必须做同样的位移:
ShadowCasterPass 不动 → 影子是静止的,和动的草完全脱节。DepthOnly/深度纹理 Pass 不动 → 屏幕空间效果(SSR、深度边缘检测)里轮廓是旧的。
工程做法:把位移函数写进共享 .hlsl include,每个 Pass 的顶点程序都调用它——一份函数,所有 Pass 引用,漏一个就穿帮。
蒙皮网格的顶点动画
角色网格已蒙皮:自定义位移必须发生在蒙皮之后(Built-in 里在 UnitySkinShaderPass/UNITY_SKINNED 路径之后加位移;URP 用 TransformObjectToWorld 前按蒙皮结果处理),否则位移方向被骨骼旋转带着乱走。简单方案:附加动画(披风、呼吸)尽量用独立网格 + 顶点动画,别和复杂蒙皮搅在一起。
常见坑
| 症状 | 原因 |
|---|---|
| 影子静止/深度效果用旧轮廓 | ShadowCaster/DepthOnly Pass 没同步位移 |
| 光照平的,波看不出来 | 法线没按梯度重算 |
| 整片草同步摆 | 相位没混入世界坐标/UV |
| 旋转的物体上动画抖动 | 相位用了模型空间坐标 |
| 草根部撕裂 | 遮罩没让根部位移为零 |
自检三问
- 为什么顶点动画的水面必须重算法线,而 UV 动画(流动贴图)不用?
- 位移函数应该怎么组织才不会漏 ShadowCaster?
- 相位用世界坐标、位移用模型空间,各防的是什么 bug?