URP 渲染实战 / 第 17 章 顶点动画 / 案例 17.3
案例 17.3草地风动进阶
目标:一大片草(6 万根,合并成一个网格)在世界空间风场中起伏:成片的阵风扫过草地,每根草又各有细小的摆动;根部不动、越往上越弯。
原理:草的顶点动画需要两类“每顶点数据”,这是顶点动画最常见的设计——美术在 DCC 软件里刷顶点色,或者程序生成网格时直接写入:
- 弯曲权重:根部为 0、草尖为 1。写在顶点色 R 里。实际弯曲量按权重的平方分配,模拟草茎下部硬、上部软。
- 根部位置:同一根草的所有顶点都存一份相同的根部坐标(UV1.xyz)。风场用根部的世界坐标采样,整根草拿到同一个风力值,才会整体弯曲而不是“扭成麻花”。
Assets/Cookbook/Ch17/GrassField.cs(节选:build)
public static Mesh Build(float size, int count, int seed = 7) { var rnd = new System.Random(seed); float R() => (float)rnd.NextDouble(); var v = new List<Vector3>(); var n = new List<Vector3>(); var c = new List<Color>(); var uv = new List<Vector2>(); var uv1 = new List<Vector4>(); var idx = new List<int>(); float[] hs = { 0f, 0.35f, 0.7f }; // 三段的高度比例 for (int b = 0; b < count; b++) { var root = new Vector3((R() - 0.5f) * size, 0, (R() - 0.5f) * size); float height = 0.35f + 0.35f * R(); float width = 0.03f + 0.02f * R(); float yaw = R() * Mathf.PI * 2f; var side = new Vector3(Mathf.Cos(yaw), 0, Mathf.Sin(yaw)); // 叶片宽度方向 var face = Vector3.Cross(side, Vector3.up); // 叶片朝向 float lean = (R() - 0.5f) * 0.25f; // 自然的前倾 float random = R(); int start = v.Count; for (int k = 0; k < 3; k++) { float h = hs[k], w = width * (1f - h * 0.8f); var center = root + Vector3.up * (h * height) + face * (lean * h * h * height); v.Add(center - side * w); v.Add(center + side * w); for (int s = 0; s < 2; s++) { n.Add(face); c.Add(new Color(h, random, 0, 1)); uv.Add(new Vector2(s, h)); uv1.Add(new Vector4(root.x, root.y, root.z, height)); } } v.Add(root + Vector3.up * height + face * (lean * height)); // 草尖 n.Add(face); c.Add(new Color(1, random, 0, 1)); uv.Add(new Vector2(0.5f, 1)); uv1.Add(new Vector4(root.x, root.y, root.z, height)); for (int k = 0; k < 2; k++) { int a = start + k * 2; idx.AddRange(new[] { a, a + 2, a + 1, a + 1, a + 2, a + 3 }); } idx.AddRange(new[] { start + 4, start + 6, start + 5 }); } var m = new Mesh { name = "GrassField", indexFormat = IndexFormat.UInt32 }; m.SetVertices(v); m.SetNormals(n); m.SetColors(c); m.SetUVs(0, uv); m.SetUVs(1, uv1); m.SetTriangles(idx, 0); // 顶点会被风吹离原位:包围盒要留余量,否则摄像机边缘的草会被错误剔除 m.bounds = new Bounds(new Vector3(0, 0.4f, 0), new Vector3(size + 1.5f, 2f, size + 1.5f)); return m; }
风场由两部分叠加:阵风是一张沿风向平移的噪声图(fBm),用根部世界坐标采样,相邻的草拿到相近的值,于是成片的草一起被压弯,一道道“风浪”扫过草地;摆动是每根草相位不同的小幅正弦,相位由每根草的随机数和它在风向上的位置决定。
Assets/Cookbook/Ch17/Grass.shader(节选:deform)
void Deform(inout VAAttributes v) { float h = v.color.r; // 0 根 → 1 尖 float rnd = v.color.g; float bladeHeight = v.uv1.w; float3 rootWS = TransformObjectToWorld(v.uv1.xyz); float2 wind = normalize(_WindDir.xy); float t = _Time.y * _WindSpeed + _Phase; // 1) 阵风:噪声图沿风向平移,用根部的世界坐标采样 → 整根草取同一个值,相邻的草相近 float2 nuv = rootWS.xz * _WindScale - wind * t * 0.15; float gust = SAMPLE_TEXTURE2D_LOD(_WindNoise, sampler_WindNoise, nuv, 0).r; gust = smoothstep(0.3, 0.75, gust); // 2) 摆动:每根草相位不同的小幅正弦,避免整齐划一 float sway = sin(t * 3.0 + rnd * TWO_PI + dot(rootWS.xz, wind) * 2.0) * _Sway; // 3) 弯曲:根部不动,按 h² 分配;水平位移 d 时高度大约降低 d²/2(保持叶长) float bend = (_WindStrength * (0.2 + gust) + sway) * h * h; float3 dirOS = mul((float3x3)GetWorldToObjectMatrix(), float3(wind.x, 0, wind.y)); v.positionOS.xyz += dirOS * bend * bladeHeight; v.positionOS.y -= 0.5 * bend * bend * bladeHeight; v.uv2 = float4(gust, 0, 0, 0); // 把阵风强度交给片元,用来提亮被风压弯的草尖 }
void Deform(inout VAAttributes v) { float h = v.color.r; // 0 根 → 1 尖 float rnd = v.color.g; float bladeHeight = v.uv1.w; vec3 rootWS = TransformObjectToWorld(v.uv1.xyz); vec2 wind = normalize(_WindDir.xy); float t = _Time.y * _WindSpeed + _Phase; // 1) 阵风:噪声图沿风向平移,用根部的世界坐标采样 → 整根草取同一个值,相邻的草相近 vec2 nuv = rootWS.xz * _WindScale - wind * t * 0.15; float gust = textureLod(_WindNoise, nuv, 0.0).r; gust = smoothstep(0.3, 0.75, gust); // 2) 摆动:每根草相位不同的小幅正弦,避免整齐划一 float sway = sin(t * 3.0 + rnd * TWO_PI + dot(rootWS.xz, wind) * 2.0) * _Sway; // 3) 弯曲:根部不动,按 h² 分配;水平位移 d 时高度大约降低 d²/2(保持叶长) float bend = (_WindStrength * (0.2 + gust) + sway) * h * h; vec3 dirOS = mat3(unity_WorldToObject) * vec3(wind.x, 0.0, wind.y); // HLSL:mul((float3x3)GetWorldToObjectMatrix(), dir) v.positionOS.xyz += dirOS * bend * bladeHeight; v.positionOS.y -= 0.5 * bend * bend * bladeHeight; v.uv2 = vec4(gust, 0.0, 0.0, 0.0); // 把阵风强度交给片元,用来提亮被风压弯的草尖 }
弯曲后高度略微下降:草尖水平移动 d 时,若保持叶长 H 不变,高度降低约 d²/(2H)(由 H − √(H² − d²) 的泰勒展开得到)。代码里 bend 是相对于草高的比例,所以写成 0.5 * bend * bend * bladeHeight。没有这一项,草被吹弯时会显得被“拉长”了。
Assets/Cookbook/Ch17/Grass.shader
// 草地风动:风场是一张在世界 XZ 上平移的噪声图(阵风),再叠加每根草相位不同的小幅摆动。 // 弯曲量按高度权重的平方分配:根部不动、越靠上越弯;同时略微压低高度,近似保持草叶长度。 Shader "Cookbook/Ch17/Grass" { Properties { _RootColor ("Root Color", Color) = (0.12, 0.3, 0.08, 1) _TipColor ("Tip Color", Color) = (0.6, 0.8, 0.3, 1) _GustColor ("Gust Highlight", Color) = (0.95, 0.95, 0.6, 1) [NoScaleOffset] _WindNoise ("Wind Noise", 2D) = "gray" {} _WindDir ("Wind Direction (xz)", Vector) = (1, 0.3, 0, 0) _WindStrength ("Wind Strength", Range(0, 2)) = 0.8 _WindScale ("Wind Noise Scale", Float) = 0.12 _WindSpeed ("Wind Speed", Float) = 1 _Sway ("Sway", Range(0, 0.5)) = 0.12 _Phase ("Phase (for stills)", Float) = 0 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) half4 _RootColor, _TipColor, _GustColor; float4 _WindDir; float _WindStrength, _WindScale, _WindSpeed, _Sway, _Phase; CBUFFER_END TEXTURE2D(_WindNoise); SAMPLER(sampler_WindNoise); #include "Assets/Cookbook/Ch17/VertexAnimCommon.hlsl" void Deform(inout VAAttributes v) { float h = v.color.r; // 0 根 → 1 尖 float rnd = v.color.g; float bladeHeight = v.uv1.w; float3 rootWS = TransformObjectToWorld(v.uv1.xyz); float2 wind = normalize(_WindDir.xy); float t = _Time.y * _WindSpeed + _Phase; // 1) 阵风:噪声图沿风向平移,用根部的世界坐标采样 → 整根草取同一个值,相邻的草相近 float2 nuv = rootWS.xz * _WindScale - wind * t * 0.15; float gust = SAMPLE_TEXTURE2D_LOD(_WindNoise, sampler_WindNoise, nuv, 0).r; gust = smoothstep(0.3, 0.75, gust); // 2) 摆动:每根草相位不同的小幅正弦,避免整齐划一 float sway = sin(t * 3.0 + rnd * TWO_PI + dot(rootWS.xz, wind) * 2.0) * _Sway; // 3) 弯曲:根部不动,按 h² 分配;水平位移 d 时高度大约降低 d²/2(保持叶长) float bend = (_WindStrength * (0.2 + gust) + sway) * h * h; float3 dirOS = mul((float3x3)GetWorldToObjectMatrix(), float3(wind.x, 0, wind.y)); v.positionOS.xyz += dirOS * bend * bladeHeight; v.positionOS.y -= 0.5 * bend * bend * bladeHeight; v.uv2 = float4(gust, 0, 0, 0); // 把阵风强度交给片元,用来提亮被风压弯的草尖 } #include "Assets/Cookbook/Ch17/VertexAnimPasses.hlsl" ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } Cull Off HLSLPROGRAM #pragma vertex VAForwardVert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH half4 frag(VAVaryings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target { float h = i.color.r, rnd = i.color.g; // 细长的草叶法线意义不大:往“上”方向偏,整片草地的明暗更柔和 float3 N = normalize(normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0) + float3(0, 1.5, 0)); half3 albedo = lerp(_RootColor.rgb, _TipColor.rgb, h) * (0.8h + 0.4h * rnd); albedo = lerp(albedo, _GustColor.rgb, i.extra.x * h * h * 0.6h); half ao = lerp(0.45h, 1.0h, h); // 草根互相遮挡,简单压暗 return half4(VALighting(albedo, N, i.positionWS, 0.1h, 16.0h) * ao, 1); } ENDHLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull Off HLSLPROGRAM #pragma vertex VAShadowVert #pragma fragment VANullFrag ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull Off HLSLPROGRAM #pragma vertex VADepthVert #pragma fragment VANullFrag ENDHLSL } } }
// 草地风动:风场是一张在世界 XZ 上平移的噪声图(阵风),再叠加每根草相位不同的小幅摆动。(GLSL 版) // 弯曲量按高度权重的平方分配:根部不动、越靠上越弯;同时略微压低高度,近似保持草叶长度。 Shader "Cookbook/Ch17/Grass" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } GLSLINCLUDE uniform vec4 _RootColor, _TipColor, _GustColor; uniform vec4 _WindDir; uniform float _WindStrength, _WindScale, _WindSpeed, _Sway, _Phase; uniform sampler2D _WindNoise; #include "Ch17/VertexAnimCommon.glsl" void Deform(inout VAAttributes v) { float h = v.color.r; // 0 根 → 1 尖 float rnd = v.color.g; float bladeHeight = v.uv1.w; vec3 rootWS = TransformObjectToWorld(v.uv1.xyz); vec2 wind = normalize(_WindDir.xy); float t = _Time.y * _WindSpeed + _Phase; // 1) 阵风:噪声图沿风向平移,用根部的世界坐标采样 → 整根草取同一个值,相邻的草相近 vec2 nuv = rootWS.xz * _WindScale - wind * t * 0.15; float gust = textureLod(_WindNoise, nuv, 0.0).r; gust = smoothstep(0.3, 0.75, gust); // 2) 摆动:每根草相位不同的小幅正弦,避免整齐划一 float sway = sin(t * 3.0 + rnd * TWO_PI + dot(rootWS.xz, wind) * 2.0) * _Sway; // 3) 弯曲:根部不动,按 h² 分配;水平位移 d 时高度大约降低 d²/2(保持叶长) float bend = (_WindStrength * (0.2 + gust) + sway) * h * h; vec3 dirOS = mat3(unity_WorldToObject) * vec3(wind.x, 0.0, wind.y); // HLSL:mul((float3x3)GetWorldToObjectMatrix(), dir) v.positionOS.xyz += dirOS * bend * bladeHeight; v.positionOS.y -= 0.5 * bend * bend * bladeHeight; v.uv2 = vec4(gust, 0.0, 0.0, 0.0); // 把阵风强度交给片元,用来提亮被风压弯的草尖 } #include "Ch17/VertexAnimPasses.glsl" ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } Cull Off GLSLPROGRAM #ifdef VERTEX void main() { VAForwardVert(); } #endif #ifdef FRAGMENT void main() { float h = vertColor.r, rnd = vertColor.g; // 细长的草叶法线意义不大:往“上”方向偏,整片草地的明暗更柔和 vec3 N = normalize(normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0) + vec3(0.0, 1.5, 0.0)); vec3 albedo = mix(_RootColor.rgb, _TipColor.rgb, h) * (0.8 + 0.4 * rnd); albedo = mix(albedo, _GustColor.rgb, extra.x * h * h * 0.6); float ao = mix(0.45, 1.0, h); // 草根互相遮挡,简单压暗 fragColor = vec4(VALighting(albedo, N, positionWS, 0.1, 16.0) * ao, 1.0); } #endif ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull Off GLSLPROGRAM #ifdef VERTEX void main() { VAShadowVert(); } #endif #ifdef FRAGMENT void main() { VANullFrag(); } #endif ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull Off GLSLPROGRAM #ifdef VERTEX void main() { VADepthVert(); } #endif #ifdef FRAGMENT void main() { VANullFrag(); } #endif ENDGLSL } } }
着色上做了三个风格化处理:法线向上偏(细长草叶的几何法线意义不大,向上偏能让整片草地的明暗更柔和统一);根部压暗模拟草丛的相互遮挡;被阵风压弯的草尖略微提亮(真实草地上,被风压倒的草叶露出更亮的背面),阵风强度由 Deform 写入 uv2.x 传给片元。

常见坑① 草的包围盒必须预留弯曲的余量(
GrassField.Build 里手动扩大了 bounds)。② 一个合并的大网格会被整体剔除或整体保留,看不见的部分也在消耗顶点着色器;大面积草地应该分块,或者用 GPU Instancing 每根(每簇)一个实例——第 20 章会讲 Graphics.RenderMeshInstanced 方案和角色压草的交互。③ 风向在 C# 里设置成全局 Shader 变量(Shader.SetGlobalVector)而不是每个材质一份,所有植被才能共享同一个风场。本案例出自《URP 渲染实战》第 17 章「顶点动画」。在章节中阅读上下文 →