GameTech/URP 渲染实战

案例 17.3草地风动进阶

目标:一大片草(6 万根,合并成一个网格)在世界空间风场中起伏:成片的阵风扫过草地,每根草又各有细小的摆动;根部不动、越往上越弯。

原理:草的顶点动画需要两类“每顶点数据”,这是顶点动画最常见的设计——美术在 DCC 软件里刷顶点色,或者程序生成网格时直接写入:

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 传给片元。

图 17-56 万根草(合并为一个网格)。左:无风;右:风从左向右吹,近处成片的草被阵风压弯、草尖提亮,远处可以看到阵风的明暗条带
常见坑① 草的包围盒必须预留弯曲的余量(GrassField.Build 里手动扩大了 bounds)。② 一个合并的大网格会被整体剔除或整体保留,看不见的部分也在消耗顶点着色器;大面积草地应该分块,或者用 GPU Instancing 每根(每簇)一个实例——第 20 章会讲 Graphics.RenderMeshInstanced 方案和角色压草的交互。③ 风向在 C# 里设置成全局 Shader 变量(Shader.SetGlobalVector)而不是每个材质一份,所有植被才能共享同一个风场。