GameTech/URP 渲染实战

案例 18.10X 光扫描线进阶

目标:一道水平光带从下往上扫过场景里的物体,光带下方紧跟着一段“X 光”样式的拖尾:暗色底、亮轮廓、世界空间网格线;拖尾之后物体恢复正常。

原理:世界高度上的两条带

令 d = 扫描高度 − 世界 y。扫描线是 |d| 很小的一条带,用指数衰减 exp(−|d| / width) 得到一条两侧柔和的亮线;拖尾是 0 < d < 拖尾长度的一段,1 − d / length 从前沿的 1 降到末端的 0。两者都只依赖世界坐标,所以场景里所有使用这个材质的物体会被同一道光扫过——前提是它们的扫描高度相同。

屏幕空间等宽的网格线

X 光样式里的网格线是世界坐标的整数线:abs(frac(g − 0.5) − 0.5) 是点到最近整数的距离。问题是“多近算在线上”:用固定阈值,近处的线很粗、远处的线细到消失并产生闪烁。正确的做法是用 fwidth(g)——它是 g 在相邻像素间的变化量,也就是“一个像素对应多少个 g 单位”。用距离除以 fwidth,就把距离换算成了像素数,线宽在屏幕上恒定约 1 像素,远近一致,几乎没有锯齿。

Assets/Cookbook/Ch18/ScanLine.shader(节选:grid)
// 世界空间网格线:三个轴各算一次“离最近整格线有多近”,用 fwidth 保持线宽恒定为约 1 像素以上
half WorldGrid(float3 p, float density)
{
    float3 g = p * density;
    float3 dist = abs(frac(g - 0.5) - 0.5);          // 到最近整数的距离
    float3 w = fwidth(g) * 1.2;
    float3 lines = 1.0 - saturate(dist / max(w, 1e-4));
    return max(lines.x, max(lines.y, lines.z));
}
// 世界空间网格线:三个轴各算一次“离最近整格线有多近”,用 fwidth 保持线宽恒定为约 1 像素以上
float WorldGrid(vec3 p, float density)
{
    vec3 g = p * density;
    vec3 dist = abs(fract(g - 0.5) - 0.5);          // 到最近整数的距离
    vec3 w = fwidth(g) * 1.2;
    vec3 lines = 1.0 - saturate(dist / max(w, vec3(1e-4)));
    return max(lines.x, max(lines.y, lines.z));
}
Assets/Cookbook/Ch18/ScanLine.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half3 normalWS = normalize(i.normalWS);
    half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    half3 lit = SimpleLighting(_BaseColor.rgb, i.positionWS, normalWS, _Smoothness);

    float d = _ScanHeight - i.positionWS.y;           // > 0:在扫描线下方(已经被扫过)
    half scanLine = exp(-abs(d) / _ScanWidth);        // 前沿亮线,指数衰减
    half trail = (d > 0.0) ? saturate(1.0 - d / max(_TrailLength, 1e-4)) : 0.0h;

    // X 光样式:暗底 + 轮廓 + 网格
    half rim = FresnelRim(normalWS, viewWS, 2.0h);
    half grid = WorldGrid(i.positionWS, _GridDensity);
    half3 xray = _XRayTint.rgb + _ScanColor.rgb * (rim * 0.8h + grid * 0.5h);

    // 拖尾越靠近前沿越“X 光”,远处渐渐恢复正常着色
    half3 color = lerp(lit, xray, smoothstep(0.0h, 0.35h, trail));
    color += _ScanColor.rgb * scanLine * 2.0h;
    return half4(color, 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
    vec3 lit = SimpleLighting(_BaseColor.rgb, positionWS, N, _Smoothness);

    float d = _ScanHeight - positionWS.y;             // > 0:在扫描线下方(已经被扫过)
    float scanLine = exp(-abs(d) / _ScanWidth);       // 前沿亮线,指数衰减
    float trail = (d > 0.0) ? saturate(1.0 - d / max(_TrailLength, 1e-4)) : 0.0;

    // X 光样式:暗底 + 轮廓 + 网格
    float rim = FresnelRim(N, viewWS, 2.0);
    float grid = WorldGrid(positionWS, _GridDensity);
    vec3 xray = _XRayTint.rgb + _ScanColor.rgb * (rim * 0.8 + grid * 0.5);

    // 拖尾越靠近前沿越“X 光”,远处渐渐恢复正常着色
    vec3 color = mix(lit, xray, smoothstep(0.0, 0.35, trail));
    color += _ScanColor.rgb * scanLine * 2.0;
    fragColor = vec4(color, 1.0);
}

完整 Shader:

Assets/Cookbook/Ch18/ScanLine.shader
// 案例 18.10:X 光扫描线。一道水平光带沿世界 Y 扫过物体,光带后方的“拖尾”区域变成 X 光样式:
// 暗色底 + 菲涅尔轮廓 + 世界空间网格线。全部基于世界坐标,多个物体共用一个扫描高度就能连成一片。
Shader "Cookbook/Ch18/ScanLine"
{
    Properties
    {
        [MainColor] _BaseColor ("Base Color", Color) = (0.7,0.7,0.7,1)
        _Smoothness ("Smoothness", Range(0,1)) = 0.4
        [HDR] _ScanColor ("Scan Color", Color) = (0.3,1.6,1.2,1)
        _ScanHeight ("Scan Height (World Y)", Float) = 1
        _ScanWidth ("Scan Line Width (m)", Range(0.005,0.3)) = 0.03
        _TrailLength ("Trail Length (m)", Range(0,3)) = 0.8
        _GridDensity ("Grid Lines per Meter", Float) = 10
        _XRayTint ("X-Ray Base", Color) = (0.02,0.06,0.08,1)
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry" "RenderPipeline"="UniversalPipeline" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor;
            half  _Smoothness;
            half4 _ScanColor;
            float _ScanHeight;
            half  _ScanWidth;
            half  _TrailLength;
            float _GridDensity;
            half4 _XRayTint;
        CBUFFER_END
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }

            HLSLPROGRAM
            #pragma vertex vert
            #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
            #pragma multi_compile _ _FORWARD_PLUS
            #include "Assets/Cookbook/Ch18/Ch18Common.hlsl"

            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; };
            struct Varyings  { float4 positionCS : SV_POSITION; float3 positionWS : TEXCOORD0; half3 normalWS : TEXCOORD1; };

            Varyings vert(Attributes v)
            {
                Varyings o;
                o.positionWS = TransformObjectToWorld(v.positionOS.xyz);
                o.positionCS = TransformWorldToHClip(o.positionWS);
                o.normalWS = TransformObjectToWorldNormal(v.normalOS);
                return o;
            }

            // 世界空间网格线:三个轴各算一次“离最近整格线有多近”,用 fwidth 保持线宽恒定为约 1 像素以上
            half WorldGrid(float3 p, float density)
            {
                float3 g = p * density;
                float3 dist = abs(frac(g - 0.5) - 0.5);          // 到最近整数的距离
                float3 w = fwidth(g) * 1.2;
                float3 lines = 1.0 - saturate(dist / max(w, 1e-4));
                return max(lines.x, max(lines.y, lines.z));
            }

            half4 frag(Varyings i) : SV_Target
            {
                half3 normalWS = normalize(i.normalWS);
                half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
                half3 lit = SimpleLighting(_BaseColor.rgb, i.positionWS, normalWS, _Smoothness);

                float d = _ScanHeight - i.positionWS.y;           // > 0:在扫描线下方(已经被扫过)
                half scanLine = exp(-abs(d) / _ScanWidth);        // 前沿亮线,指数衰减
                half trail = (d > 0.0) ? saturate(1.0 - d / max(_TrailLength, 1e-4)) : 0.0h;

                // X 光样式:暗底 + 轮廓 + 网格
                half rim = FresnelRim(normalWS, viewWS, 2.0h);
                half grid = WorldGrid(i.positionWS, _GridDensity);
                half3 xray = _XRayTint.rgb + _ScanColor.rgb * (rim * 0.8h + grid * 0.5h);

                // 拖尾越靠近前沿越“X 光”,远处渐渐恢复正常着色
                half3 color = lerp(lit, xray, smoothstep(0.0h, 0.35h, trail));
                color += _ScanColor.rgb * scanLine * 2.0h;
                return half4(color, 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            HLSLPROGRAM
            #pragma vertex ShadowVert
            #pragma fragment ShadowFrag
            #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
            #include "Assets/Cookbook/Ch18/Ch18ShadowPass.hlsl"
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex DepthVert
            #pragma fragment DepthFrag
            #include "Assets/Cookbook/Ch18/Ch18ShadowPass.hlsl"
            ENDHLSL
        }
    }
}
// 案例 18.10:X 光扫描线。一道水平光带沿世界 Y 扫过物体,光带后方的“拖尾”区域变成 X 光样式:
// 暗色底 + 菲涅尔轮廓 + 世界空间网格线。全部基于世界坐标,多个物体共用一个扫描高度就能连成一片。
// (GLSL 版,对应 Ch18/ScanLine.shader)
Shader "Cookbook/Ch18/ScanLine"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry" "RenderPipeline"="UniversalPipeline" }

        GLSLINCLUDE
        uniform vec4  _BaseColor;
        uniform float _Smoothness;
        uniform vec4  _ScanColor;
        uniform float _ScanHeight;
        uniform float _ScanWidth;
        uniform float _TrailLength;
        uniform float _GridDensity;
        uniform vec4  _XRayTint;
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }

            GLSLPROGRAM
            #include "Ch18/Ch18Common.glsl"

            VARYING vec3 positionWS;
            VARYING vec3 normalWS;

            #ifdef VERTEX
            void main()
            {
                positionWS = TransformObjectToWorld(positionOS.xyz);
                gl_Position = TransformWorldToHClip(positionWS);
                normalWS = TransformObjectToWorldNormal(normalOS);
            }
            #endif

            #ifdef FRAGMENT
            // 世界空间网格线:三个轴各算一次“离最近整格线有多近”,用 fwidth 保持线宽恒定为约 1 像素以上
            float WorldGrid(vec3 p, float density)
            {
                vec3 g = p * density;
                vec3 dist = abs(fract(g - 0.5) - 0.5);          // 到最近整数的距离
                vec3 w = fwidth(g) * 1.2;
                vec3 lines = 1.0 - saturate(dist / max(w, vec3(1e-4)));
                return max(lines.x, max(lines.y, lines.z));
            }

            void main()
            {
                vec3 N = normalize(normalWS);
                vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
                vec3 lit = SimpleLighting(_BaseColor.rgb, positionWS, N, _Smoothness);

                float d = _ScanHeight - positionWS.y;             // > 0:在扫描线下方(已经被扫过)
                float scanLine = exp(-abs(d) / _ScanWidth);       // 前沿亮线,指数衰减
                float trail = (d > 0.0) ? saturate(1.0 - d / max(_TrailLength, 1e-4)) : 0.0;

                // X 光样式:暗底 + 轮廓 + 网格
                float rim = FresnelRim(N, viewWS, 2.0);
                float grid = WorldGrid(positionWS, _GridDensity);
                vec3 xray = _XRayTint.rgb + _ScanColor.rgb * (rim * 0.8 + grid * 0.5);

                // 拖尾越靠近前沿越“X 光”,远处渐渐恢复正常着色
                vec3 color = mix(lit, xray, smoothstep(0.0, 0.35, trail));
                color += _ScanColor.rgb * scanLine * 2.0;
                fragColor = vec4(color, 1.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #define SHADOW_PASS
            #include "Ch18/Ch18ShadowPass.glsl"
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #define DEPTH_PASS
            #include "Ch18/Ch18ShadowPass.glsl"
            ENDGLSL
        }
    }
}
图 18-19X 光扫描线,扫描高度从左到右为 0.35、0.95、1.6 米。亮线下方是 0.8 米长的 X 光拖尾,拖尾末端渐渐恢复正常着色。开启 Bloom。

让所有物体共用一个扫描高度

演示里每个材质单独设置 _ScanHeight。实际项目中扫描是一个全局事件(雷达、技能),更好的做法是把它做成全局变量:从 CBUFFER 里移出来,单独声明 float _ScanHeight;,C# 用 Shader.SetGlobalFloat("_ScanHeight", h) 每帧设置一次,所有材质同时生效。注意全局变量不能留在 UnityPerMaterial 里:CBUFFER 里的变量由材质提供数值,Shader.SetGlobalFloat 设置的全局值对它不起作用;同时也要把它从 Properties 块里删掉,避免在检视面板里出现一个“改了没用”的滑条。

如果想做“以某点为中心的环形扫描波,扫过整个地面和所有物体”,在每个物体的 Shader 里做就不合适了——地面、草、角色都要改 Shader。那是屏幕空间技术的典型场景:用深度图重建世界坐标,在一个全屏 Pass 里统一画,第 28 章会实现它。