GameTech/URP 渲染实战

案例 17.1旗帜飘动入门

目标:一面固定在旗杆上的旗子,波浪从旗杆向外传播,越靠外摆幅越大,并叠加噪声制造阵风般的不规则起伏;正确重算法线。

原理:旗面是一张细分平面(物体空间 XZ 平面,法线朝 +Y),位移沿 Y 方向。位移函数由三部分组成:

h(x,z,t)=A·p(u)·[sin(kx−t)+0.35sin(1.7k(0.6x+z)−1.3t)+n(x,z,t)]

法线用有限差分:在 (x, z) 处算出高度 h,再在 (x+ε, z) 和 (x, z+ε) 处各算一次,得到两条切线 (ε, hx − h, 0) 和 (0, hz − h, ε),叉乘就是新法线。

ε P = (x, h(x))Pₓ = (x+ε, h(x+ε)) 切线 T = Pₓ − P N ⟂ T 3D 中再取 z 方向的邻点得到第二条切线 B,N = normalize(B × T)
图 17-2有限差分求法线(剖面示意):用邻点的差近似切线,再求垂直方向

完整代码:

Assets/Cookbook/Ch17/Flag.shader
// 旗帜飘动:在细分平面(物体空间 XZ 平面,法线 +Y)上沿 Y 方向位移。
// 位移 = 沿旗面传播的正弦波 + 噪声扰动,振幅从旗杆处的 0 逐渐增大;
// 法线用有限差分重新计算(对位移函数在 x、z 方向各取一个邻点)。
Shader "Cookbook/Ch17/Flag"
{
    Properties
    {
        [MainTexture] _BaseMap ("Flag Texture", 2D) = "white" {}
        [MainColor] _BaseColor ("Tint", Color) = (1, 1, 1, 1)
        [NoScaleOffset] _NoiseTex ("Noise", 2D) = "gray" {}
        _Amplitude ("Amplitude", Range(0, 0.5)) = 0.18
        _WaveLength ("Wave Length", Range(0.2, 4)) = 1.3
        _Speed ("Speed", Range(0, 5)) = 2.2
        _NoiseStrength ("Noise Strength", Range(0, 1)) = 0.5
        _Phase ("Phase (for stills)", Float) = 0
        _FlagWidth ("Flag Width (object space X)", Float) = 2
        [Toggle] _RecalcNormal ("Recalculate Normal", Float) = 1
        [Toggle] _DeformShadow ("Deform In Shadow Pass", Float) = 1
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            float4 _BaseMap_ST;
            half4 _BaseColor;
            float _Amplitude, _WaveLength, _Speed, _NoiseStrength, _Phase, _FlagWidth, _RecalcNormal, _DeformShadow;
        CBUFFER_END
        TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
        TEXTURE2D(_NoiseTex); SAMPLER(sampler_NoiseTex);
        #include "Assets/Cookbook/Ch17/VertexAnimCommon.hlsl"

        // 位移只依赖旗面上的 (x, z) 坐标和时间:这是能用有限差分求法线的前提
        float FlagOffset(float2 xz)
        {
            float u = xz.x / _FlagWidth + 0.5;             // 0 = 旗杆一侧,1 = 自由端
            float t = _Time.y * _Speed + _Phase;           // 运行时 _Time 驱动;静帧用 _Phase
            float k = TWO_PI / _WaveLength;
            float pin = smoothstep(0.0, 0.25, u) * u;      // 旗杆处钉死,越往外摆得越大
            // 主波沿 +x 传播,次波斜向传播,让波峰不那么整齐
            float w = sin(k * xz.x - t) + 0.35 * sin(k * 1.7 * (xz.x * 0.6 + xz.y) - t * 1.3);
            // 噪声:随时间平移,制造“阵风”造成的不规则起伏
            float2 nuv = xz * 0.12 + float2(-t * 0.05, t * 0.02);   // 低频采样:只要大尺度的起伏
            float n = SAMPLE_TEXTURE2D_LOD(_NoiseTex, sampler_NoiseTex, nuv, 0).r * 2.0 - 1.0;
            return (w + n * _NoiseStrength * 2.0) * _Amplitude * pin;
        }

        void Deform(inout VAAttributes v)
        {
            float3 p = v.positionOS.xyz;
            float h = FlagOffset(p.xz);

            if (_RecalcNormal > 0.5)
            {
                // 有限差分:在 x、z 方向各挪一小步,得到两个“切线”,叉乘就是新法线
                const float e = 0.04;   // 步长太小会放大噪声贴图的双线性折痕
                float hx = FlagOffset(p.xz + float2(e, 0));
                float hz = FlagOffset(p.xz + float2(0, e));
                float3 tangent   = float3(e, hx - h, 0);
                float3 bitangent = float3(0, hz - h, e);
                v.normalOS = normalize(cross(bitangent, tangent));
            }
            v.positionOS.y += h;
        }

        // 阴影 Pass 专用:_DeformShadow = 0 时不变形,用来演示“影子不动”的错误
        void DeformForShadow(inout VAAttributes v)
        {
            if (_DeformShadow > 0.5) Deform(v);
        }
        #define VA_DEFORM_SHADOW DeformForShadow

        #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
            {
                float3 N = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0);   // 旗子两面都要正确受光
                half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, TRANSFORM_TEX(i.uv, _BaseMap)).rgb * _BaseColor.rgb;
                return half4(VALighting(albedo, N, i.positionWS, 0.25h, 24.0h), 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 平面,法线 +Y)上沿 Y 方向位移。(GLSL 版)
// 位移 = 沿旗面传播的正弦波 + 噪声扰动,振幅从旗杆处的 0 逐渐增大;
// 法线用有限差分重新计算(对位移函数在 x、z 方向各取一个邻点)。
Shader "Cookbook/Ch17/Flag"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _BaseMap_ST;
        uniform vec4 _BaseColor;
        uniform float _Amplitude, _WaveLength, _Speed, _NoiseStrength, _Phase, _FlagWidth, _RecalcNormal, _DeformShadow;
        uniform sampler2D _BaseMap;
        uniform sampler2D _NoiseTex;
        #include "Ch17/VertexAnimCommon.glsl"

        // 位移只依赖旗面上的 (x, z) 坐标和时间:这是能用有限差分求法线的前提
        float FlagOffset(vec2 xz)
        {
            float u = xz.x / _FlagWidth + 0.5;             // 0 = 旗杆一侧,1 = 自由端
            float t = _Time.y * _Speed + _Phase;           // 运行时 _Time 驱动;静帧用 _Phase
            float k = TWO_PI / _WaveLength;
            float pin = smoothstep(0.0, 0.25, u) * u;      // 旗杆处钉死,越往外摆得越大
            // 主波沿 +x 传播,次波斜向传播,让波峰不那么整齐
            float w = sin(k * xz.x - t) + 0.35 * sin(k * 1.7 * (xz.x * 0.6 + xz.y) - t * 1.3);
            // 噪声:随时间平移,制造“阵风”造成的不规则起伏
            vec2 nuv = xz * 0.12 + vec2(-t * 0.05, t * 0.02);   // 低频采样:只要大尺度的起伏
            float n = textureLod(_NoiseTex, nuv, 0.0).r * 2.0 - 1.0;
            return (w + n * _NoiseStrength * 2.0) * _Amplitude * pin;
        }

        void Deform(inout VAAttributes v)
        {
            vec3 p = v.positionOS.xyz;
            float h = FlagOffset(p.xz);

            if (_RecalcNormal > 0.5)
            {
                // 有限差分:在 x、z 方向各挪一小步,得到两个“切线”,叉乘就是新法线
                const float e = 0.04;   // 步长太小会放大噪声贴图的双线性折痕
                float hx = FlagOffset(p.xz + vec2(e, 0.0));
                float hz = FlagOffset(p.xz + vec2(0.0, e));
                vec3 tangent   = vec3(e, hx - h, 0.0);
                vec3 bitangent = vec3(0.0, hz - h, e);
                v.normalOS = normalize(cross(bitangent, tangent));
            }
            v.positionOS.y += h;
        }

        // 阴影 Pass 专用:_DeformShadow = 0 时不变形,用来演示“影子不动”的错误
        void DeformForShadow(inout VAAttributes v)
        {
            if (_DeformShadow > 0.5) Deform(v);
        }
        #define VA_DEFORM_SHADOW DeformForShadow

        #include "Ch17/VertexAnimPasses.glsl"
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull Off
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VAForwardVert(); }
            #endif
            #ifdef FRAGMENT
            void main()
            {
                vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0);   // 旗子两面都要正确受光
                vec3 albedo = texture(_BaseMap, TransformTex(uv, _BaseMap_ST)).rgb * _BaseColor.rgb;
                fragColor = vec4(VALighting(albedo, N, positionWS, 0.25, 24.0), 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
        }
    }
}

关键代码讲解:位移函数只依赖 (x, z) 和时间,这是有限差分的前提——如果函数里用了 UV 或顶点色这类“只有当前顶点才有”的数据,邻点就算不出来。所以钉住函数里的 u 不是从 UV 取,而是从 x 坐标换算:

Assets/Cookbook/Ch17/Flag.shader(节选:displace)
// 位移只依赖旗面上的 (x, z) 坐标和时间:这是能用有限差分求法线的前提
float FlagOffset(float2 xz)
{
    float u = xz.x / _FlagWidth + 0.5;             // 0 = 旗杆一侧,1 = 自由端
    float t = _Time.y * _Speed + _Phase;           // 运行时 _Time 驱动;静帧用 _Phase
    float k = TWO_PI / _WaveLength;
    float pin = smoothstep(0.0, 0.25, u) * u;      // 旗杆处钉死,越往外摆得越大
    // 主波沿 +x 传播,次波斜向传播,让波峰不那么整齐
    float w = sin(k * xz.x - t) + 0.35 * sin(k * 1.7 * (xz.x * 0.6 + xz.y) - t * 1.3);
    // 噪声:随时间平移,制造“阵风”造成的不规则起伏
    float2 nuv = xz * 0.12 + float2(-t * 0.05, t * 0.02);   // 低频采样:只要大尺度的起伏
    float n = SAMPLE_TEXTURE2D_LOD(_NoiseTex, sampler_NoiseTex, nuv, 0).r * 2.0 - 1.0;
    return (w + n * _NoiseStrength * 2.0) * _Amplitude * pin;
}
// 位移只依赖旗面上的 (x, z) 坐标和时间:这是能用有限差分求法线的前提
float FlagOffset(vec2 xz)
{
    float u = xz.x / _FlagWidth + 0.5;             // 0 = 旗杆一侧,1 = 自由端
    float t = _Time.y * _Speed + _Phase;           // 运行时 _Time 驱动;静帧用 _Phase
    float k = TWO_PI / _WaveLength;
    float pin = smoothstep(0.0, 0.25, u) * u;      // 旗杆处钉死,越往外摆得越大
    // 主波沿 +x 传播,次波斜向传播,让波峰不那么整齐
    float w = sin(k * xz.x - t) + 0.35 * sin(k * 1.7 * (xz.x * 0.6 + xz.y) - t * 1.3);
    // 噪声:随时间平移,制造“阵风”造成的不规则起伏
    vec2 nuv = xz * 0.12 + vec2(-t * 0.05, t * 0.02);   // 低频采样:只要大尺度的起伏
    float n = textureLod(_NoiseTex, nuv, 0.0).r * 2.0 - 1.0;
    return (w + n * _NoiseStrength * 2.0) * _Amplitude * pin;
}
Assets/Cookbook/Ch17/Flag.shader(节选:deform)
void Deform(inout VAAttributes v)
{
    float3 p = v.positionOS.xyz;
    float h = FlagOffset(p.xz);

    if (_RecalcNormal > 0.5)
    {
        // 有限差分:在 x、z 方向各挪一小步,得到两个“切线”,叉乘就是新法线
        const float e = 0.04;   // 步长太小会放大噪声贴图的双线性折痕
        float hx = FlagOffset(p.xz + float2(e, 0));
        float hz = FlagOffset(p.xz + float2(0, e));
        float3 tangent   = float3(e, hx - h, 0);
        float3 bitangent = float3(0, hz - h, e);
        v.normalOS = normalize(cross(bitangent, tangent));
    }
    v.positionOS.y += h;
}

// 阴影 Pass 专用:_DeformShadow = 0 时不变形,用来演示“影子不动”的错误
void DeformForShadow(inout VAAttributes v)
{
    if (_DeformShadow > 0.5) Deform(v);
}
#define VA_DEFORM_SHADOW DeformForShadow
void Deform(inout VAAttributes v)
{
    vec3 p = v.positionOS.xyz;
    float h = FlagOffset(p.xz);

    if (_RecalcNormal > 0.5)
    {
        // 有限差分:在 x、z 方向各挪一小步,得到两个“切线”,叉乘就是新法线
        const float e = 0.04;   // 步长太小会放大噪声贴图的双线性折痕
        float hx = FlagOffset(p.xz + vec2(e, 0.0));
        float hz = FlagOffset(p.xz + vec2(0.0, e));
        vec3 tangent   = vec3(e, hx - h, 0.0);
        vec3 bitangent = vec3(0.0, hz - h, e);
        v.normalOS = normalize(cross(bitangent, tangent));
    }
    v.positionOS.y += h;
}

// 阴影 Pass 专用:_DeformShadow = 0 时不变形,用来演示“影子不动”的错误
void DeformForShadow(inout VAAttributes v)
{
    if (_DeformShadow > 0.5) Deform(v);
}
#define VA_DEFORM_SHADOW DeformForShadow
图 17-3左:只位移、不重算法线,几何在起伏(看旗面上的自阴影和右边缘轮廓),但明暗几乎是平的;中:有限差分重算法线,波峰波谷的明暗清楚;右:再叠加低频噪声,起伏不再规则
调参建议真实的旗帜在风中,靠近自由端的部分波长更短、摆动更剧烈,可以让 k 随 u 增大;旗子在大风中还会整体“缩短”(布料不可拉伸,起伏越大投影长度越短),可以按局部斜率把顶点向旗杆方向拉回一点。这些都只是在位移函数里多写一项,有限差分会自动处理法线。