GameTech/URP 渲染实战

案例 18.5全息投影:扫描线、闪烁、故障与色差进阶

目标:做一个科幻风格的全息投影:半透明发光的“光壳”,表面有横向扫描线,有一条亮带缓慢扫过,偶尔闪烁、偶尔横向错位(故障),轮廓有 RGB 分离的彩边。

原理:全息是“光”,不是“物”

全息投影在设定上是一团光,它不遮挡后面的东西,叠在一起只会更亮。这决定了它的渲染状态:

在此之上,四个图层逐个叠加。

① 菲涅尔光壳

只输出 _HoloColor * (BaseAlpha + rim):正对相机的部分只有很淡的底色,轮廓最亮。中空的“光壳”感一下就出来了——这也是全息和普通半透明材质最大的区别:普通半透明是中间实、边缘虚,全息反过来。

② 世界空间扫描线

扫描线用世界坐标 Y 而不是 UV:frac(y * density - t * speed) 得到一个 0~1 的锯齿波,取中间一段为亮线。用世界坐标的好处是条纹在所有物体上间距一致、方向一致(永远水平),物体旋转时条纹不跟着转,更像投影仪在一层层“扫描”出来的。另加一条用指数衰减算出的宽亮带,相对物体原点从下往上循环移动。

Assets/Cookbook/Ch18/Hologram.shader(节选:scan)
// 细扫描线:沿世界 Y 的周期条纹。返回 0~1,1 是亮线
half Scanlines(float y, float t)
{
    float s = frac(y * _ScanDensity - t * _ScanSpeed);
    return smoothstep(0.25, 0.15, abs(s - 0.5));   // 每个周期中间约 40% 是亮线,边缘柔化防锯齿
}
// 细扫描线:沿世界 Y 的周期条纹。返回 0~1,1 是亮线
float Scanlines(float y, float t)
{
    float s = fract(y * _ScanDensity - t * _ScanSpeed);
    return smoothstep(0.25, 0.15, abs(s - 0.5));   // 每个周期中间约 40% 是亮线,边缘柔化防锯齿
}

③ 闪烁与故障

闪烁就是一个随时间随机跳变的亮度系数:把时间按 1/20 秒取整 floor(t * 20),再用哈希函数映射成 0~1 的随机数,同一个 1/20 秒内值不变,下一段跳到另一个值,很像电压不稳。故障(Glitch)在顶点着色器里做:同样把时间离散成“拍”,每一拍按概率决定是否触发;触发时把物体按高度切成若干横带,每条横带随机左右平移。因为平移发生在顶点上,被切开的三角形会被拉伸,产生撕裂的细丝——这正是我们想要的数字故障感。

Assets/Cookbook/Ch18/Hologram.shader(节选:glitch)
Varyings vert(Attributes v)
{
    Varyings o;
    float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
    float t = HoloTime();
    // 故障:时间按 1/12 秒取整得到“当前这一拍”,这一拍里按概率决定要不要抖
    float beat = floor(t * 12.0);
    float glitchOn = step(1.0 - _GlitchChance, Hash11(beat)) * _Toggles.z;
    // 把物体按高度切成一条条横带,每条带随机左右平移
    float slice = floor(positionWS.y * 9.0);
    float shift = (Hash21(float2(slice, beat)) - 0.5) * 2.0;
    shift *= step(0.7, Hash21(float2(slice * 1.7, beat + 3.1)));  // 只有部分横带移动
    positionWS.x += shift * _GlitchAmount * glitchOn;

    o.positionWS = positionWS;
    o.positionCS = TransformWorldToHClip(positionWS);
    o.normalWS = TransformObjectToWorldNormal(v.normalOS);
    o.objectY = TransformObjectToWorld(float3(0, 0, 0)).y;
    return o;
}
void main()
{
    vec3 pWS = TransformObjectToWorld(positionOS.xyz);
    float t = HoloTime();
    // 故障:时间按 1/12 秒取整得到“当前这一拍”,这一拍里按概率决定要不要抖
    float beat = floor(t * 12.0);
    float glitchOn = step(1.0 - _GlitchChance, Hash11(beat)) * _Toggles.z;
    // 把物体按高度切成一条条横带,每条带随机左右平移
    float slice = floor(pWS.y * 9.0);
    float shift = (Hash21(vec2(slice, beat)) - 0.5) * 2.0;
    shift *= step(0.7, Hash21(vec2(slice * 1.7, beat + 3.1)));  // 只有部分横带移动
    pWS.x += shift * _GlitchAmount * glitchOn;

    positionWS = pWS;
    gl_Position = TransformWorldToHClip(pWS);
    normalWS = TransformObjectToWorldNormal(normalOS);
    objectY = TransformObjectToWorld(vec3(0.0)).y;
}
提示这里用的 Hash11/Hash21 是常见的“无三角函数哈希”,输入相同输出就相同,在 GPU 上比 frac(sin(x) * 43758.5) 那种写法更稳定(后者在不同 GPU、不同精度下结果可能差异很大)。哈希与噪声的原理见第 4 章。

④ 色差(RGB 分离)

真实投影设备的色差,来自不同波长的光折射角度不同。Shader 里模拟它的思路很简单:同一个图案,R、G、B 三个通道分别在略微错开的位置采样。扫描线 R 通道往上错一点、B 通道往下错一点,条纹边缘就出现红蓝镶边;菲涅尔的 R、B 通道用略微不同的指数,轮廓处也有了彩边。错开量以“一个扫描线周期”为单位:错开整周期就又重合了,0.2~0.35 个周期最明显。

Assets/Cookbook/Ch18/Hologram.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float t = HoloTime();
    half3 normalWS = normalize(i.normalWS);
    half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);

    // 1) 菲涅尔:轮廓亮、正对暗,让中空的“光壳”感出来
    half rim = FresnelRim(normalWS, viewWS, _RimPower);

    // 2) 扫描线 + 一条缓慢上移的宽亮带;色差开启时 R/B 两个通道错开采样
    // 错开量以“一条扫描线周期”为单位:错开整周期就又重合了,0.2~0.35 个周期最明显
    half chroma = _ChromaShift * _Toggles.w;
    float shift = chroma / _ScanDensity;
    half3 lines = half3(Scanlines(i.positionWS.y + shift, t),
                        Scanlines(i.positionWS.y,         t),
                        Scanlines(i.positionWS.y - shift, t));
    lines = lerp(1.0h, lines, _ScanStrength * _Toggles.x);
    float bandPos = frac(t * 0.35) * 2.6 - 0.3;                  // 相对物体原点的高度
    half band = exp(-abs(i.positionWS.y - i.objectY - bandPos) / _BandWidth) * _BandStrength * _Toggles.x;

    // 3) 闪烁:每 1/20 秒随机一个亮度,再叠一个快速正弦,模拟电压不稳
    float flick = 0.55 + 0.45 * Hash11(floor(t * 20.0));
    flick *= 0.9 + 0.1 * sin(t * 90.0);
    half flicker = lerp(1.0h, flick, _FlickerAmount * _Toggles.y);

    // 色差的另一半:R/B 通道的菲涅尔用不同的指数,轮廓处出现彩色镶边
    half3 rimRGB = half3(FresnelRim(normalWS, viewWS, _RimPower * (1.0h - chroma)),
                         rim,
                         FresnelRim(normalWS, viewWS, _RimPower * (1.0h + chroma)));

    half3 intensity = (_BaseAlpha + rimRGB) * lines + band;
    half3 color = _HoloColor.rgb * intensity * flicker;
    return half4(color, 1);
}
void main()
{
    float t = HoloTime();
    vec3 N = normalize(normalWS);
    vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);

    // 1) 菲涅尔:轮廓亮、正对暗,让中空的“光壳”感出来
    float rim = FresnelRim(N, viewWS, _RimPower);

    // 2) 扫描线 + 一条缓慢上移的宽亮带;色差开启时 R/B 两个通道错开采样
    // 错开量以“一条扫描线周期”为单位:错开整周期就又重合了,0.2~0.35 个周期最明显
    float chroma = _ChromaShift * _Toggles.w;
    float shift = chroma / _ScanDensity;
    vec3 lines = vec3(Scanlines(positionWS.y + shift, t),
                      Scanlines(positionWS.y,         t),
                      Scanlines(positionWS.y - shift, t));
    lines = mix(vec3(1.0), lines, _ScanStrength * _Toggles.x);
    float bandPos = fract(t * 0.35) * 2.6 - 0.3;                  // 相对物体原点的高度
    float band = exp(-abs(positionWS.y - objectY - bandPos) / _BandWidth) * _BandStrength * _Toggles.x;

    // 3) 闪烁:每 1/20 秒随机一个亮度,再叠一个快速正弦,模拟电压不稳
    float flick = 0.55 + 0.45 * Hash11(floor(t * 20.0));
    flick *= 0.9 + 0.1 * sin(t * 90.0);
    float flicker = mix(1.0, flick, _FlickerAmount * _Toggles.y);

    // 色差的另一半:R/B 通道的菲涅尔用不同的指数,轮廓处出现彩色镶边
    vec3 rimRGB = vec3(FresnelRim(N, viewWS, _RimPower * (1.0 - chroma)),
                       rim,
                       FresnelRim(N, viewWS, _RimPower * (1.0 + chroma)));

    vec3 intensity = (_BaseAlpha + rimRGB) * lines + band;
    vec3 color = _HoloColor.rgb * intensity * flicker;
    fragColor = vec4(color, 1.0);
}

完整 Shader:

Assets/Cookbook/Ch18/Hologram.shader
// 案例 18.5:全息投影。菲涅尔轮廓 + 世界空间扫描线 + 闪烁 + 故障横移 + 色差(RGB 分离)。
// 透明队列、加法混合、不写深度:全息是“光”,不遮挡后面的东西。
Shader "Cookbook/Ch18/Hologram"
{
    Properties
    {
        [HDR] _HoloColor ("Holo Color", Color) = (0.25,1.1,2.2,1)
        _BaseAlpha ("Base Alpha", Range(0,1)) = 0.12
        _RimPower ("Rim Power", Range(0.5,8)) = 2.2
        [Header(Scanlines)]
        _ScanDensity ("Scanline Density (per meter)", Float) = 60
        _ScanSpeed ("Scanline Speed", Float) = 1.5
        _ScanStrength ("Scanline Strength", Range(0,1)) = 0.6
        _BandWidth ("Scan Band Width", Range(0.01,1)) = 0.25
        _BandStrength ("Scan Band Strength", Range(0,4)) = 1.2
        [Header(Flicker and Glitch)]
        _FlickerAmount ("Flicker Amount", Range(0,1)) = 0.3
        _GlitchAmount ("Glitch Offset (m)", Range(0,0.3)) = 0.06
        _GlitchChance ("Glitch Chance", Range(0,1)) = 0.3
        [Header(Chromatic)]
        _ChromaShift ("RGB Shift (fraction of a scanline)", Range(0,0.5)) = 0.2
        [Header(Time)]
        [Toggle] _AutoPlay ("Auto Play (_Time)", Float) = 0
        _Phase ("Phase (seconds, for screenshots)", Float) = 0
        _Toggles ("Feature Toggles (scan, flicker, glitch, chroma)", Vector) = (1,1,1,1)
    }

    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "Hologram"
            Tags { "LightMode"="UniversalForward" }
            Blend One One           // 加法:越叠越亮,背景不会被盖住
            ZWrite Off
            Cull Back

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Assets/Cookbook/Ch18/Ch18Common.hlsl"

            CBUFFER_START(UnityPerMaterial)
                half4 _HoloColor;
                half  _BaseAlpha;
                half  _RimPower;
                float _ScanDensity;
                float _ScanSpeed;
                half  _ScanStrength;
                half  _BandWidth;
                half  _BandStrength;
                half  _FlickerAmount;
                half  _GlitchAmount;
                half  _GlitchChance;
                half  _ChromaShift;
                half  _AutoPlay;
                float _Phase;
                half4 _Toggles;
            CBUFFER_END

            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; };
            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float3 positionWS : TEXCOORD0;
                half3  normalWS   : TEXCOORD1;
                float  objectY    : TEXCOORD2;   // 物体原点高度,扫描带相对它移动
            };

            float HoloTime() { return _AutoPlay > 0.5 ? _Time.y : _Phase; }

            Varyings vert(Attributes v)
            {
                Varyings o;
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                float t = HoloTime();
                // 故障:时间按 1/12 秒取整得到“当前这一拍”,这一拍里按概率决定要不要抖
                float beat = floor(t * 12.0);
                float glitchOn = step(1.0 - _GlitchChance, Hash11(beat)) * _Toggles.z;
                // 把物体按高度切成一条条横带,每条带随机左右平移
                float slice = floor(positionWS.y * 9.0);
                float shift = (Hash21(float2(slice, beat)) - 0.5) * 2.0;
                shift *= step(0.7, Hash21(float2(slice * 1.7, beat + 3.1)));  // 只有部分横带移动
                positionWS.x += shift * _GlitchAmount * glitchOn;

                o.positionWS = positionWS;
                o.positionCS = TransformWorldToHClip(positionWS);
                o.normalWS = TransformObjectToWorldNormal(v.normalOS);
                o.objectY = TransformObjectToWorld(float3(0, 0, 0)).y;
                return o;
            }

            // 细扫描线:沿世界 Y 的周期条纹。返回 0~1,1 是亮线
            half Scanlines(float y, float t)
            {
                float s = frac(y * _ScanDensity - t * _ScanSpeed);
                return smoothstep(0.25, 0.15, abs(s - 0.5));   // 每个周期中间约 40% 是亮线,边缘柔化防锯齿
            }

            half4 frag(Varyings i) : SV_Target
            {
                float t = HoloTime();
                half3 normalWS = normalize(i.normalWS);
                half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);

                // 1) 菲涅尔:轮廓亮、正对暗,让中空的“光壳”感出来
                half rim = FresnelRim(normalWS, viewWS, _RimPower);

                // 2) 扫描线 + 一条缓慢上移的宽亮带;色差开启时 R/B 两个通道错开采样
                // 错开量以“一条扫描线周期”为单位:错开整周期就又重合了,0.2~0.35 个周期最明显
                half chroma = _ChromaShift * _Toggles.w;
                float shift = chroma / _ScanDensity;
                half3 lines = half3(Scanlines(i.positionWS.y + shift, t),
                                    Scanlines(i.positionWS.y,         t),
                                    Scanlines(i.positionWS.y - shift, t));
                lines = lerp(1.0h, lines, _ScanStrength * _Toggles.x);
                float bandPos = frac(t * 0.35) * 2.6 - 0.3;                  // 相对物体原点的高度
                half band = exp(-abs(i.positionWS.y - i.objectY - bandPos) / _BandWidth) * _BandStrength * _Toggles.x;

                // 3) 闪烁:每 1/20 秒随机一个亮度,再叠一个快速正弦,模拟电压不稳
                float flick = 0.55 + 0.45 * Hash11(floor(t * 20.0));
                flick *= 0.9 + 0.1 * sin(t * 90.0);
                half flicker = lerp(1.0h, flick, _FlickerAmount * _Toggles.y);

                // 色差的另一半:R/B 通道的菲涅尔用不同的指数,轮廓处出现彩色镶边
                half3 rimRGB = half3(FresnelRim(normalWS, viewWS, _RimPower * (1.0h - chroma)),
                                     rim,
                                     FresnelRim(normalWS, viewWS, _RimPower * (1.0h + chroma)));

                half3 intensity = (_BaseAlpha + rimRGB) * lines + band;
                half3 color = _HoloColor.rgb * intensity * flicker;
                return half4(color, 1);
            }
            ENDHLSL
        }
    }
}
// 案例 18.5:全息投影。菲涅尔轮廓 + 世界空间扫描线 + 闪烁 + 故障横移 + 色差(RGB 分离)。
// 透明队列、加法混合、不写深度:全息是“光”,不遮挡后面的东西。(GLSL 版,对应 Ch18/Hologram.shader)
Shader "Cookbook/Ch18/Hologram"
{
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "Hologram"
            Tags { "LightMode"="UniversalForward" }
            Blend One One           // 加法:越叠越亮,背景不会被盖住
            ZWrite Off
            Cull Back

            GLSLPROGRAM
            #include "Ch18/Ch18Common.glsl"

            uniform vec4  _HoloColor;
            uniform float _BaseAlpha;
            uniform float _RimPower;
            uniform float _ScanDensity;
            uniform float _ScanSpeed;
            uniform float _ScanStrength;
            uniform float _BandWidth;
            uniform float _BandStrength;
            uniform float _FlickerAmount;
            uniform float _GlitchAmount;
            uniform float _GlitchChance;
            uniform float _ChromaShift;
            uniform float _AutoPlay;
            uniform float _Phase;
            uniform vec4  _Toggles;

            VARYING vec3  positionWS;
            VARYING vec3  normalWS;
            VARYING float objectY;     // 物体原点高度,扫描带相对它移动

            float HoloTime() { return _AutoPlay > 0.5 ? _Time.y : _Phase; }

            #ifdef VERTEX
            void main()
            {
                vec3 pWS = TransformObjectToWorld(positionOS.xyz);
                float t = HoloTime();
                // 故障:时间按 1/12 秒取整得到“当前这一拍”,这一拍里按概率决定要不要抖
                float beat = floor(t * 12.0);
                float glitchOn = step(1.0 - _GlitchChance, Hash11(beat)) * _Toggles.z;
                // 把物体按高度切成一条条横带,每条带随机左右平移
                float slice = floor(pWS.y * 9.0);
                float shift = (Hash21(vec2(slice, beat)) - 0.5) * 2.0;
                shift *= step(0.7, Hash21(vec2(slice * 1.7, beat + 3.1)));  // 只有部分横带移动
                pWS.x += shift * _GlitchAmount * glitchOn;

                positionWS = pWS;
                gl_Position = TransformWorldToHClip(pWS);
                normalWS = TransformObjectToWorldNormal(normalOS);
                objectY = TransformObjectToWorld(vec3(0.0)).y;
            }
            #endif

            #ifdef FRAGMENT
            // 细扫描线:沿世界 Y 的周期条纹。返回 0~1,1 是亮线
            float Scanlines(float y, float t)
            {
                float s = fract(y * _ScanDensity - t * _ScanSpeed);
                return smoothstep(0.25, 0.15, abs(s - 0.5));   // 每个周期中间约 40% 是亮线,边缘柔化防锯齿
            }

            void main()
            {
                float t = HoloTime();
                vec3 N = normalize(normalWS);
                vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);

                // 1) 菲涅尔:轮廓亮、正对暗,让中空的“光壳”感出来
                float rim = FresnelRim(N, viewWS, _RimPower);

                // 2) 扫描线 + 一条缓慢上移的宽亮带;色差开启时 R/B 两个通道错开采样
                // 错开量以“一条扫描线周期”为单位:错开整周期就又重合了,0.2~0.35 个周期最明显
                float chroma = _ChromaShift * _Toggles.w;
                float shift = chroma / _ScanDensity;
                vec3 lines = vec3(Scanlines(positionWS.y + shift, t),
                                  Scanlines(positionWS.y,         t),
                                  Scanlines(positionWS.y - shift, t));
                lines = mix(vec3(1.0), lines, _ScanStrength * _Toggles.x);
                float bandPos = fract(t * 0.35) * 2.6 - 0.3;                  // 相对物体原点的高度
                float band = exp(-abs(positionWS.y - objectY - bandPos) / _BandWidth) * _BandStrength * _Toggles.x;

                // 3) 闪烁:每 1/20 秒随机一个亮度,再叠一个快速正弦,模拟电压不稳
                float flick = 0.55 + 0.45 * Hash11(floor(t * 20.0));
                flick *= 0.9 + 0.1 * sin(t * 90.0);
                float flicker = mix(1.0, flick, _FlickerAmount * _Toggles.y);

                // 色差的另一半:R/B 通道的菲涅尔用不同的指数,轮廓处出现彩色镶边
                vec3 rimRGB = vec3(FresnelRim(N, viewWS, _RimPower * (1.0 - chroma)),
                                   rim,
                                   FresnelRim(N, viewWS, _RimPower * (1.0 + chroma)));

                vec3 intensity = (_BaseAlpha + rimRGB) * lines + band;
                vec3 color = _HoloColor.rgb * intensity * flicker;
                fragColor = vec4(color, 1.0);
            }
            #endif
            ENDGLSL
        }
    }
}
图 18-9全息的四个图层逐个叠加。从左到右:① 只有菲涅尔光壳;② 加扫描线;③ 加闪烁与故障横移(拍图时强制本拍触发);④ 加 RGB 色差。均开启 Bloom。
图 18-10全息投影台。投影台上的光圈同样使用全息材质(一个细圆环),扫描亮带正好经过物体中部。Bloom 让轮廓光晕染开来。

常见坑与扩展