顶点动画:水面、草地风摆与通用位移套路

Unity 知识 · Shader 区域 · 常见效果 · 第 5 篇 更新于 2026-10-15

水面起伏、草地风摆、旗帜飘动——都在顶点着色器里做(见 /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 都必须做同样的位移:

  • ShadowCaster Pass 不动 → 影子是静止的,和动的草完全脱节。
  • DepthOnly/深度纹理 Pass 不动 → 屏幕空间效果(SSR、深度边缘检测)里轮廓是旧的。

工程做法:把位移函数写进共享 .hlsl include,每个 Pass 的顶点程序都调用它——一份函数,所有 Pass 引用,漏一个就穿帮。

蒙皮网格的顶点动画

角色网格已蒙皮:自定义位移必须发生在蒙皮之后(Built-in 里在 UnitySkinShaderPass/UNITY_SKINNED 路径之后加位移;URP 用 TransformObjectToWorld 前按蒙皮结果处理),否则位移方向被骨骼旋转带着乱走。简单方案:附加动画(披风、呼吸)尽量用独立网格 + 顶点动画,别和复杂蒙皮搅在一起。

常见坑

症状原因
影子静止/深度效果用旧轮廓ShadowCaster/DepthOnly Pass 没同步位移
光照平的,波看不出来法线没按梯度重算
整片草同步摆相位没混入世界坐标/UV
旋转的物体上动画抖动相位用了模型空间坐标
草根部撕裂遮罩没让根部位移为零

自检三问

  • 为什么顶点动画的水面必须重算法线,而 UV 动画(流动贴图)不用?
  • 位移函数应该怎么组织才不会漏 ShadowCaster?
  • 相位用世界坐标、位移用模型空间,各防的是什么 bug?