GameTech/URP 渲染实战

案例 17.5膨胀与呼吸入门

目标:让物体整体膨胀收缩(呼吸、心跳、充气),以及一道从下往上传播的“脉冲”;理解硬边模型膨胀时的裂缝问题。

原理:沿法线把顶点推出去,positionOS += normalOS * amount,就是最简单的膨胀。这也是第 14 章背面外扩描边的同一个操作。呼吸只需要让 amount 随时间做正弦;在相位里减去 positionOS.y * _PulseFreq,不同高度的相位就不同,形成一道沿 Y 方向传播的鼓包。

问题出在硬边模型上。立方体的一个角在网格里其实是 3 个位置相同的顶点(分属三个面),它们的法线分别指向三个面的外侧。沿各自的法线推出去,三个顶点就分开了,立方体裂成六块分离的面片。解决思路是让同一位置的顶点拿到同一个方向:

Assets/Cookbook/Ch17/Inflate.shader(节选:deform)
void Deform(inout VAAttributes v)
{
#if defined(_DIR_RADIAL)
    float3 dir = normalize(v.positionOS.xyz);       // 同一位置 → 同一方向,硬边不裂
#else
    float3 dir = v.normalOS;
#endif
    // 呼吸:0..1 之间往复;_PulseFreq > 0 时不同高度相位不同,形成向上传播的脉冲
    float t = _Time.y * _Speed + _Phase;
    float breath = sin(t - v.positionOS.y * _PulseFreq) * 0.5 + 0.5;
    v.positionOS.xyz += dir * _Amount * breath;
    // 均匀膨胀不改变光滑表面的法线方向,这里保持原法线
}
void Deform(inout VAAttributes v)
{
#if defined(_DIR_RADIAL)
    vec3 dir = normalize(v.positionOS.xyz);       // 同一位置 → 同一方向,硬边不裂
#else
    vec3 dir = v.normalOS;
#endif
    // 呼吸:0..1 之间往复;_PulseFreq > 0 时不同高度相位不同,形成向上传播的脉冲
    float t = _Time.y * _Speed + _Phase;
    float breath = sin(t - v.positionOS.y * _PulseFreq) * 0.5 + 0.5;
    v.positionOS.xyz += dir * _Amount * breath;
    // 均匀膨胀不改变光滑表面的法线方向,这里保持原法线
}

推动方向用 [KeywordEnum(Normal, Radial)] 切换,变体只在顶点阶段生效(shader_feature_local_vertex)。注意 KeywordEnum 和 #pragma 写在 HLSLINCLUDE 里,三个 Pass 共享同一套变体,阴影和深度跟着一起切换。

Assets/Cookbook/Ch17/Inflate.shader
// 膨胀 / 呼吸:沿某个方向把顶点推出去。
//   方向 = 网格法线:最简单,但硬边模型(立方体)的同一位置有多个法线不同的顶点,推开后会裂缝;
//   方向 = 从中心指向顶点:同一位置的顶点方向相同,硬边不会裂开(适合大致凸的形体)。
// 呼吸:振幅随时间做正弦,相位再加上沿 Y 的偏移,就成了一道从下往上走的“脉冲”。
Shader "Cookbook/Ch17/Inflate"
{
    Properties
    {
        _BaseColor ("Color", Color) = (0.85, 0.45, 0.4, 1)
        _Amount ("Inflate Amount", Range(-0.2, 0.5)) = 0.08
        [KeywordEnum(Normal, Radial)] _Dir ("Push Direction", Float) = 0
        _PulseFreq ("Pulse Spatial Frequency", Float) = 0
        _Speed ("Breath Speed", Float) = 2
        _Phase ("Phase (for stills)", Float) = 1.5708
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #pragma shader_feature_local_vertex _DIR_NORMAL _DIR_RADIAL
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor;
            float _Amount, _PulseFreq, _Speed, _Phase;
        CBUFFER_END
        #include "Assets/Cookbook/Ch17/VertexAnimCommon.hlsl"

        void Deform(inout VAAttributes v)
        {
        #if defined(_DIR_RADIAL)
            float3 dir = normalize(v.positionOS.xyz);       // 同一位置 → 同一方向,硬边不裂
        #else
            float3 dir = v.normalOS;
        #endif
            // 呼吸:0..1 之间往复;_PulseFreq > 0 时不同高度相位不同,形成向上传播的脉冲
            float t = _Time.y * _Speed + _Phase;
            float breath = sin(t - v.positionOS.y * _PulseFreq) * 0.5 + 0.5;
            v.positionOS.xyz += dir * _Amount * breath;
            // 均匀膨胀不改变光滑表面的法线方向,这里保持原法线
        }

        #include "Assets/Cookbook/Ch17/VertexAnimPasses.hlsl"
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            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) : SV_Target
            {
                return half4(VALighting(_BaseColor.rgb, normalize(i.normalWS), i.positionWS, 0.5h, 48.0h), 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            HLSLPROGRAM
            #pragma vertex VAShadowVert
            #pragma fragment VANullFrag
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex VADepthVert
            #pragma fragment VANullFrag
            ENDHLSL
        }
    }
}
// 膨胀 / 呼吸:沿某个方向把顶点推出去。(GLSL 版)
//   方向 = 网格法线:最简单,但硬边模型(立方体)的同一位置有多个法线不同的顶点,推开后会裂缝;
//   方向 = 从中心指向顶点:同一位置的顶点方向相同,硬边不会裂开(适合大致凸的形体)。
// 呼吸:振幅随时间做正弦,相位再加上沿 Y 的偏移,就成了一道从下往上走的“脉冲”。
Shader "Cookbook/Ch17/Inflate"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        #pragma shader_feature_local_vertex _DIR_NORMAL _DIR_RADIAL
        uniform vec4 _BaseColor;
        uniform float _Amount, _PulseFreq, _Speed, _Phase;
        #include "Ch17/VertexAnimCommon.glsl"

        void Deform(inout VAAttributes v)
        {
        #if defined(_DIR_RADIAL)
            vec3 dir = normalize(v.positionOS.xyz);       // 同一位置 → 同一方向,硬边不裂
        #else
            vec3 dir = v.normalOS;
        #endif
            // 呼吸:0..1 之间往复;_PulseFreq > 0 时不同高度相位不同,形成向上传播的脉冲
            float t = _Time.y * _Speed + _Phase;
            float breath = sin(t - v.positionOS.y * _PulseFreq) * 0.5 + 0.5;
            v.positionOS.xyz += dir * _Amount * breath;
            // 均匀膨胀不改变光滑表面的法线方向,这里保持原法线
        }

        #include "Ch17/VertexAnimPasses.glsl"
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VAForwardVert(); }
            #endif
            #ifdef FRAGMENT
            void main()
            {
                fragColor = vec4(VALighting(_BaseColor.rgb, normalize(normalWS), positionWS, 0.5, 48.0), 1.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VAShadowVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { VANullFrag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VADepthVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { VANullFrag(); }
            #endif
            ENDGLSL
        }
    }
}
图 17-8从左到右:原始纽结;沿法线膨胀 0.07;沿 Y 方向传播的脉冲(不同高度膨胀程度不同,纽结上出现鼓包);两个立方体,左边沿网格法线膨胀而裂成六块,右边沿径向膨胀保持闭合
常见坑① 膨胀量较大时,凹处的表面会互相穿插(纽结管子相交处),这是“沿法线位移”的固有局限。② 均匀膨胀不改变光滑曲面的法线方向,所以这里没有重算法线;但脉冲这种不均匀的位移会让表面倾斜,严格说应当重算(可以用有限差分沿切线方向取邻点),幅度小时可以忽略。③ 阴影 Pass 也要用同样的方向(我们的框架天然保证),否则裂缝会出现在影子里。