
去年做户外大世界场景优化的时候我把场景里所有草地、水面、旗子的动画全部从脚本挪到了顶点着色器里。原来几十个 Animation 组件和 Update 循环挂在物体上优化完直接删掉一大半帧率从抖动回到稳定。很多朋友一听到顶点动画就以为是高级图形学技巧其实本质就一句话在 GPU 把顶点坐标传到屏幕之前先把位置改掉。这篇文章挑三个最常见也最实用的场景讲——风吹草动、河流波动、布料模拟。三个效果背后的数学基础其实都是正弦波、余弦波和衰减权重但真正让画面“活”起来的关键全在公式的组织结构、参数的取舍和坑的处理上。适合已经会写基本 Unlit/Standard Shader、想探索顶点动画的 Unity 开发者也适合正被场景动态效果性能瓶颈折磨想让大世界减负的人参考。1. 顶点动画怎么做设计才对路1.1 顶点着色器里到底发生了什么先理清一次渲染的流程对象的所有顶点数据从 CPU 传到 GPU进入顶点着色器再经过裁剪、光栅化、片元着色器最后输出到屏幕。绝大多数普通 Shader 在顶点阶段只是把模型顶点“原样转交”顶多做一次坐标空间变换。顶点动画的核心反而不是什么神秘技巧就是在转到裁剪空间之前按规则“篡改”一下顶点位置。打个比方顶点就是影视剧里的绿幕演员顶点着色器就是后期调整动作的人。演员绿幕拍完后期把动作改了改最终合成出来的画面自然也跟着变。你不需要真的移动模型 mesh也不需要每帧重建网格GPU 在渲染时每次都会根据公式重新算一遍顶点位置这就是为什么顶点动画几乎零 CPU 开销。那一个顶点动画 Shader 最少要设计清楚三件事权重、方向、噪声。权重哪些顶点动、动多少。草原上根不动尖动旗子顶部固定底部飘水面上离岸远的地方波澜更大这些都是权重。方向顶点往哪个方向偏移。风吹草动偏水平河流波动偏垂直布料则是一个综合位移。噪声如何让动画有自然变化。只用单个 sin 会像集体广播体操需要叠加不同频率的波形或随机相位去取得“不齐感”。这三件事想清楚剩下的就是如何组织公式、控制成本。1.2 先选模型空间还是世界空间很多新手写顶点动画喜欢直接在模型空间里算直接把 v.vertex.x 偏移然后把 v.vertex 丢进 UnityObjectToClipPos。这样做最快问题也最隐蔽——所有被合批到一起的物体使用的是同一套本地坐标风吹的方向、幅度都是“各自为政”。做大片草地时你会看到每个草堆各吹各的好像草被不同方向的风撕扯。世界空间的好处在于“位置相关”。用世界坐标的 xz 当波形输入草地、水面、旗子就能形成连贯的大范围风场。代价是 Shader 需要多做一次顶点坐标的世界变换而且合批时世界坐标并不连续不过对顶点动画这种本来就要打开动态合批或关闭静态合批的场景来说这个代价可以接受。我个人的习惯是如果是单棵草、一棵树这种独立物体直接用模型空间就行配合一个全局的风向向量控制整体如果是整片草地、整块水面必须用世界空间算波形相位否则视觉上一定穿帮。还有一个容易忽略的点顶点位置变了法线理论上也得跟着变。但对于大多数低模场景和风格化渲染法线错误在视觉上并不明显所以很多人会暂时忽略它。一旦你开始做带高光、带法线贴图的水面或布料法线不更新的问题就会瞬间暴露。后面河流波动那节会单独说怎么重建法线。2. 风吹草动从一根草到一整片草地2.1 权重怎么给最自然风吹草动最关键的不是风本身而是“草根固定、草尖摆动”这个约束。最简单的权重来自 uv.y因为一根草的模型在建模时uv 的 v 往往就是从根到尖的方向。用 saturate(v.uv.y) 就能得到根为 0、尖为 1 的线性权重。公式一眼就能看懂float weight saturate(v.uv.y);这样写有个隐患如果草的模型不是从地面开始建模uv.y 为 0 的位置可能并不在根部。你可以把 uv.y 替换成归一化模型高度float weight saturate(v.vertex.y / _PlantHeight);_PlantHeight 需要根据草的模型尺度手动设置一般取模型在 y 轴的包围盒高度即可。为什么用高度而不是 uv因为有些草模型的 uv 是被纹理平铺占用的v 不一定连续对应高度这时候按高度算权重反而更稳。更进一步的方案是给每根草一个随机相位偏移。你可以把顶点坐标的某些位做哈希或者预存一张草地的顶点色/uv2 通道作为随机数。相位偏移的目的是让每根草的生长状态、受风响应不一样。这一点在大量草同屏时尤其重要——否则风一吹全部草像克隆人开运动会一样整齐划一地往右摆假得非常出戏。2.2 风场公式从 sin 到多层波形最基本的单层风场可以用一行式说明x 方向偏移由正弦函数驱动float windX sin(_Time.y * _WindSpeed v.vertex.x * _WindFrequency) * _WindStrength * weight;_Time.y 是 Unity 内置时间_WindSpeed 控制风速v.vertex.x 作为空间频率输入会让整片草地的相位错开形成波状风场。单层 sin 能出效果但只是及格线。风在自然界不是均匀吹的它有阵风、有摆动、有回弹。所以我的做法是至少叠加两层正弦波一层慢速大幅度的主风一层快速小幅度的抖动float t _Time.y; float mainWave sin(t * _WindSpeed pos.x * 2.0) * 0.6; float detailWave sin(t * _WindSpeed * 2.7 pos.x * 5.0 pos.z * 3.0) * 0.4; float windX (mainWave detailWave) * _WindStrength * weight;两层波叠出来的效果基本就能摆脱“广播体操”感。如果再想高级一点可以用一张风场图Flow Map采样来决定每片草地的风力权重和方向不过那是另一套资产流程了不做美术资源配合的话意义不大。2.3 可直接跑的吹草 Shader下面给一个能放进 Unity 的完整 Shader。这个 Shader 用的是内置渲染管线URP 下把 CGPROGRAM 换成 HLSLPROGRAM 即可核心逻辑一样。Shader Custom/GrassWind { Properties { _MainTex (Texture, 2D) white {} _WindStrength (Wind Strength, Range(0, 2)) 0.4 _WindSpeed (Wind Speed, Range(0, 5)) 1.5 _WindFrequency (Wind Frequency, Range(0, 10)) 3.0 _PlantHeight (Plant Height, Float) 1.0 } SubShader { Tags { RenderTypeOpaque IgnoreProjectorTrue } Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_shadowcaster #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : TEXCOORD1; }; sampler2D _MainTex; float4 _MainTex_ST; fixed _WindStrength, _WindSpeed, _WindFrequency; float _PlantHeight; v2f vert (appdata v) { v2f o; // 权重根部固定尖部随风 float weight saturate(v.vertex.y / max(0.001, _PlantHeight)); float t _Time.y * _WindSpeed; float3 pos v.vertex.xyz; // 两层波形主风 细节抖动 float mainWave sin(t pos.x * _WindFrequency) * 0.6; float detailWave sin(t * 2.7 pos.z * _WindFrequency * 1.5) * 0.4; float windOffset (mainWave detailWave) * _WindStrength * weight; pos.x windOffset; pos.z cos(t * 1.3 pos.y * 2.0) * _WindStrength * weight * 0.3; // 简单近似法线扰动让光照跟着摆动 v.normal.xz windOffset * 0.5 * weight; o.worldNormal UnityObjectToWorldNormal(v.normal); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.vertex UnityObjectToClipPos(float4(pos, 1.0)); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float ndotl saturate(dot(normalize(i.worldNormal), lightDir)); return col * (ndotl * 0.7 0.3); } ENDCG } } }注意代码里我随手做了个法线扰动把 xz 方向偏移量叠加到法线上。这并不物理但在低精度光照下视觉上够用。真正要求高的水面和布料会在片元阶段重新算法线下面会讲。3. 河流波动把水面变成一组波形函数3.1 水面波浪的本质是高度场河流动和草动的最大不同是方向草是水平位移水面是垂直方向的起伏。把水面模型想象成一张网格每个顶点都有 xz 坐标它的 y 值就是一个高度函数 h(x, z, t)。这套思路叫高度场Heightfield是最容易理解的水面实现方式。脑海里把水面看作一块巨大的柔性膜风从某个方向推它膜的各处穴位开始以不同频率上下振动。GPU 每帧会为每个顶点重新计算 h所以水面永远在动而且不需要 CPU 参与。高度函数本身可以非常简单甚至第一版就用两个 sin 叠加就行float wave(float2 pos, float t) { float h 0.0; h sin(pos.x * 1.5 t) * 0.05; h sin(pos.x * 2.3 pos.y * 1.2 t * 1.3) * 0.03; return h; }但只有一个 sin 波是不够的它会像传送带一样规律运动完全没有湖水那种“碎、乱、闹”的感觉。真实的自然水面是大量不同振幅、不同方向、不同频率的波同时叠加的结果。你在岸边看到的水光粼粼本质是无数波峰波谷在光照下形成的高光闪烁。3.2 大波小波怎么叠加才划算多层波形叠加的公式样貌如下需要解释清楚每个参数的作用float waveHeight(float2 pos, float t) { float h 0.0; // 大波波长长、振幅大 h sin(pos.x * 0.8 pos.y * 0.6 t * 1.2) * 0.08; // 中波方向略偏 h sin(pos.x * 1.8 - pos.y * 1.0 t * 1.8) * 0.04; // 小波高频细节 h sin(pos.x * 4.0 pos.y * 2.2 t * 2.6) * 0.015; return h; }大波决定水面的大起伏中波打破单一方向感小波负责高频噪点。叠加之后画面的丰富度立刻不一样。每一层 sin 的输入坐标要乘上不同频率时间系数也要不同否则所有波都同频同步又会回到“整齐划一”的问题。在性能上每层波就是几个指令的数学运算。对现代 GPU 来说三层、四层波的顶点开销完全小到可以忽略真正贵的是水面片元阶段要做反射采样或者一大堆光照计算。所以放心大胆叠波不用抠那几个 sin。3.3 法线重建从高度场算出光照依据水面和草不一样草表面粗糙法线错一点看不出水面是高光材质一个错误法线会让水面像一块磨砂玻璃完全失去水光感。正确的做法是在片元阶段根据高度函数重建法线。原理很简单法线是表面切线方向的叉积。波浪高度层层叠加后每个顶点的 y 变化量和周围 xz 方向的变化量之间就能推出这个点的朝向。具体实现时可以用有限差分法取一个很小的偏移量 eps分别计算当前点、x 方向偏移点、z 方向偏移点的高度从而构成两个切线向量叉积即法线float3 calcNormal(float2 pos, float t) { float eps 0.01; float h waveHeight(pos, t); float hx waveHeight(pos float2(eps, 0.0), t); float hz waveHeight(pos float2(0.0, eps), t); float3 tangentX float3(eps, hx - h, 0.0); float3 tangentZ float3(0.0, hz - h, eps); return normalize(cross(tangentX, tangentZ)); }这样得到的法线是模型空间或世界空间下的表面朝向再结合方向光就能算出类似波浪高光的效果。每次算三个 waveHeight乘法负担可接受对水面来说回报极高。3.4 完整河流波动 Shader 示例Shader Custom/RiverWaves { Properties { _Color (Water Color, Color) (0.2, 0.5, 0.7, 1) _WaveSpeed (Wave Speed, Range(0, 3)) 1.0 _WaveScale (Wave Scale, Range(0.1, 5)) 1.0 _WaveStrength (Wave Strength, Range(0, 0.5)) 0.08 } SubShader { Tags { RenderTypeTransparent QueueTransparent } ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; float3 worldPos : TEXCOORD0; float2 uv : TEXCOORD1; }; float4 _Color; float _WaveSpeed, _WaveScale, _WaveStrength; float waveHeight(float2 pos, float t) { float h 0.0; h sin(pos.x * 0.8 pos.y * 0.6 t * 1.2) * _WaveStrength * 2.0; h sin(pos.x * 1.8 - pos.y * 1.0 t * 1.8) * _WaveStrength; h sin(pos.x * 4.0 pos.y * 2.2 t * 2.6) * _WaveStrength * 0.4; return h; } float3 calcNormal(float2 pos, float t) { float eps 0.01; float h waveHeight(pos, t); float hx waveHeight(pos float2(eps, 0.0), t); float hz waveHeight(pos float2(0.0, eps), t); float3 tangentX float3(eps, hx - h, 0.0); float3 tangentZ float3(0.0, hz - h, eps); return normalize(cross(tangentX, tangentZ)); } v2f vert (appdata v) { v2f o; float t _Time.y * _WaveSpeed; float2 wpos v.vertex.xz * _WaveScale; float h waveHeight(wpos, t); float3 pos v.vertex.xyz; pos.y h; o.worldPos mul(unity_ObjectToWorld, float4(pos, 1.0)).xyz; o.uv v.uv; o.vertex UnityObjectToClipPos(float4(pos, 1.0)); return o; } fixed4 frag (v2f i) : SV_Target { float t _Time.y * _WaveSpeed; float3 n calcNormal(i.worldPos.xz * _WaveScale, t); float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float3 viewDir normalize(_WorldSpaceCameraPos - i.worldPos); float3 halfDir normalize(lightDir viewDir); float spec pow(saturate(dot(n, halfDir)), 64); float diff saturate(dot(n, lightDir)); return _Color * (diff * 0.6 0.4) fixed4(spec, spec, spec, 1.0) * 0.6; } ENDCG } } }这个 Shader 把水面当成一个自适应高度场对象看待模型只要是一块细分足够的网格即可。如果你给的是标准平面顶点太少会导致波峰波谷太稀疏建议用程序生成的 32x32 或更高密度的网格。4. 布料模拟不用物理引擎也能飘的轻量方案4.1 什么时候该用顶点动画做布料听到布料模拟很多人的第一反应是 PhysX、Cloth 组件、甚至 PBD 物理。这些方案确实能处理碰撞、撕裂、自然下垂但代价是大量的 CPU/GPU 模拟计算。而项目里很多布料其实是不需要真正物理的背景里挂着的旗帜、远处营地的帐篷布、摆摊棚子的遮阳布、角色身上的披风如果不需要和地形碰撞。这些场合下顶点动画布料是最佳选择。它不会塌陷、不会抖动、不会因为碰撞体缺失穿模只需要一个公式让布飘起来。对没有物理但是视觉上“看起来在飘”——对于高度风格化的游戏、数字孪生场景、户外大世界远景来说这往往就是最稳妥的做法。当然如果角色布料必须垂到地面、必须被风吹到身体后面、必须与手臂碰撞那这类顶点动画方案不适用去用真正的物理布料。顶点动画布料的边界必须心里有数。4.2 悬挂布料的公式怎么推拿一面挂旗举例旗子顶部边缘固定在杆上底部和侧面自由。要让旗子飘动权重可以设计成顶部为 0底部和尾部逐渐增大float weight saturate(uv.y); // 顶部 0底部 1方向可以分成主风方向和上下垂坠感。主风方向让旗子往风的方向偏移垂坠感让旗子有一个永远向下的力。用 sin 驱动时还要让相位从顶部向底部传播。最简单的方式是用顶点到锚点的距离作为相位float distToTop length(pos - anchorPos); float phase distToTop * _PhaseScale - _Time.y * _WindSpeed; float offset sin(phase) * _WindStrength * weight;如果只用 distToTop 这一个相位整块布会像一块平板那样左右摆。稍微真实一点的做法是偏移方向可以换到 z 方向垂直旗面的方向同时叠加一个 y 方向的轻微上下浮。下面给一个完整例子。4.3 完整布料 Shader 示例Shader Custom/ClothWind { Properties { _MainTex (Texture, 2D) white {} _WindStrength (Wind Strength, Range(0, 2)) 0.3 _WindSpeed (Wind Speed, Range(0, 5)) 2.0 _PhaseScale (Phase Scale, Range(0.5, 10)) 3.0 } SubShader { Tags { RenderTypeOpaque } Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : TEXCOORD1; }; sampler2D _MainTex; float4 _MainTex_ST; float _WindStrength, _WindSpeed, _PhaseScale; v2f vert (appdata v) { v2f o; float3 pos v.vertex.xyz; // 权重uv.y 从顶部的 0 到底部的 1 float weight saturate(v.uv.y); // 锚点在布的顶部中间区域可以稍微偏上 float3 anchor float3(0.0, pos.y, 0.0); float distToAnchor length(pos.xz - anchor.xz); float phase distToAnchor * _PhaseScale - _Time.y * _WindSpeed; // 主风偏移垂直布面方向这里用 z 方向代表朝向观众 pos.z sin(phase) * _WindStrength * weight; // 让底部更自由地前后摆动 pos.z cos(phase * 0.7 1.3) * _WindStrength * weight * 0.4; // 垂坠感在摆动中一直往下压一点 pos.y - (1.0 - v.uv.y) * _WindStrength * 0.1; o.worldNormal UnityObjectToWorldNormal(v.normal); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.vertex UnityObjectToClipPos(float4(pos, 1.0)); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); float ndotl saturate(dot(normalize(i.worldNormal), normalize(_WorldSpaceLightPos0.xyz))); return col * (ndotl * 0.7 0.3); } ENDCG } } }我给这段代码故意做了一些简化法线没有和位移同步相位的随机感还不够。但在远处看一面旗帜真实度已经足够。如果你想做得更好可以把法线也用片元重建参考河流动部分但额外开销会增加常规项目里旗子不值得花这个钱。5. 绕不开的坑阴影、包围盒与批处理5.1 阴影 Pass 里也要写顶点动画顶点动画最容易翻车的点就是阴影。场景里草都往右飘但阴影还是直挺挺地立在原地一眼假。原因是 Unity 的阴影渲染有一套独立的 ShadowCaster Pass它默认继续用原始顶点位置来算深度根本没有经过 Forward Pass 里的顶点动画逻辑。解决办法很朴素把顶点动画代码复制到 ShadowCaster Pass 中。但复制容易出现两套逻辑漂移的问题所以最稳妥的做法是把顶点动画算法封装成一个函数放到 CGINCLUDE 块里Forward Pass 和 ShadowCaster Pass 各自调用同一个函数。CGINCLUDE float3 ApplyVertexAnim(float3 pos, float2 uv, float time) { // 所有顶点动画逻辑写在这 return pos; } ENDCG这样以后改动画幅度、改风向只改一处两边同步。别嫌这步麻烦大量线上项目的阴影 bug 都是因为偷懒只写了一个 Pass。5.2 动态包围盒顶点被裁剪到消失顶点动画还有一个非常阴间的问题视锥剔除。Unity 的渲染器用 MeshRenderer 的 Bounds 判断物体是否在视野内而 Bounds 默认来自原始网格。当顶点被风吹出去后如果顶点超出原始包围盒Unity 在最极端情况下会把整个网格当成不可见就直接剔除掉。表现就是远处的草全都消失走近了又突然出现。标准做法是在脚本里扩大 BoundsMeshRenderer renderer GetComponentMeshRenderer(); Bounds bounds renderer.bounds; bounds.Expand(expandSize); renderer.bounds bounds;那些被风吹得比较疯的草、大振幅的水面可以统一给一个脚本把包围盒扩大个 1.5 倍。很多开发者第一版忘了处理结果抬头一看远处秃一块排查半天发现时剔除问题。5.3 静态批处理和 Shader 动画水火不容静态批处理是 Unity 在大世界优化时常用的一招但它对顶点动画很不友好。静态批处理会把多个静态物体合并成一个 Mesh顶点坐标因此会被变换到世界空间并写死。如果 Shader 仍然依赖模型空间坐标比如 v.vertex.xyz去计算偏移合并之后的模型会导致动画错乱看起来像每个顶点向着荒诞的方向各自移动。对策有三个方向一是给这类材质所在的物体取消 Static Batch 勾选不勾 Batching Static保留 Static 用于遮挡剔除二是在 Shader 里做模型空间相关计算时统一用世界坐标反推模型坐标三是干脆用世界坐标做相位计算不依赖模型空间。做草地、水面、旗帜时我建议直接选择不勾选静态批处理代价是 Draw Call 会上去但换来的是动画正确利大于弊。5.4 SRP Batcher 与 GPU Instancing 兼容性用上 Render Pipeline 的新管线后Shader 的兼容性也要注意。SRP Batcher 要求 Shader 属性尽量都放在 CBUFFER 里如果属性散在外面会破坏批处理条件性能可能反而变差。顶点动画本身用到的 _Time、_WindStrength 这些属性应当统一组织到 CBUFFER_START(UnityPerMaterial) 中。GPU Instancing 情况类似如果同一片草地上要摆几百个草堆用 GPU Instancing 能大幅降低 Draw Call。顶点动画并不会阻止 Instancing但需要注意每个实例需要不同的时间相位时要把相位作为 Instanced 属性传入而不是大家一起用同一个 _Time.y 导致所有草堆同步摆。这个同步问题非常容易踩用了 Instancing 后反而要额外加入随机性。5.5 阴影距离、LOD 和性能的一点点建议顶点动画做得再好也不能无限做。一般建议 LOD0 用三层波形LOD1 用两层LOD2 直接关闭动画远处看不太出来省下大把顶点计算。阴影渲染时也可以用更简化的动画版本毕竟阴影对细节不敏感看到一片阴影在动就够了。6. 性能优化和扩展思考6.1 顶点动画的开销到底在哪儿顶点动画的单位成本是每帧对一个顶点做若干次 sin/cos 和乘法。单看数学量确实比不动顶点的 Shader 高但现代 GPU 的数学吞吐能力远高于带宽和顶点装配所以实际瓶颈往往不是 ALU而是顶点数和 Draw Call 数量。对一片有 10 万顶点的大草地三层波形意味着 30 万次 sin 求值但这在 GPU 上也就是一帧渲染工作的很小一部分。真正的问题往往是 Draw Call 太多导致 CPU 瓶颈而顶点动画本身并不背这个锅。所以优化思路应该是减少顶点总量、减少 Draw Call、用 LOD 和栏杆技术控制距离而不是为了省几个 sin 把动画砍掉。6.2 参数化风场、随机化和 Shader 变体让顶点动画这个方案能在项目里持续用下去参数化设计很重要。把风力、风速、频率、相位随机范围全部暴露成材质属性美术同学不用改 Shader直接在材质面板调就很方便。更进一步还可以把风的全局方向做成一个全局变量用一个 Directional Light 组件或脚本去驱动让整个场景里所有草、旗子、水面朝同一个方向动这才有大世界的整体感。随机化则建议从低成本的顶点色或者 uv3 通道获取。建模时给每根草、每块布随机刷一点顶点色Shader 里用顶点色的亮度作为额外的相位或者风力倍率。这个技巧能瞬间撕裂整齐感让画面自然非常多。6.3 再往前一步Tessellation、几何着色器与 GPU Driven顶点动画只是起点。如果你想做更密的草地但不想让模型面数爆炸可以尝试 Tessellation Shader先用低模草片在渲染时按距离分层次细分同时将细分出的新顶点继续应用同样的风场公式。网格密度和动画在 GPU 上一体完成。另外几何着色器也可以用来扩展草地。每片草作为点数据传入几何着色器在 GPU 上生成小草模型再对每个顶点应用动画。这样能把绘制和动画统一到一个渲染管线CPU 侧只需要提交几万个点数据就行。数字孪生和大世界场景里这套 GPU Driven 思路已经成了主流的优化方向。这是我个人第一次真正把三种顶点动画方案整理到一起写时的一些实操总结。写到最后我特别想强调一点顶点动画的本质是图形欺骗的艺术。想让一块布飘起来不一定要给它实现完整的物理想让水面动起来不一定要每一帧更新 CPU 网格。只要视觉上达标算法越简单、越快、越稳就是好方案。以后再做草地、水面、旗帜动画可以先停下脚本 Update 的惯性想一想这个效果能不能丢给顶点着色器做