GameTech/URP 渲染实战

案例 18.8扭曲隐身:采样不透明纹理进阶

目标:光学迷彩。物体几乎透明,但它会把身后的画面轻微“弯折”,轮廓处还有一圈淡淡的亮边,运动时有热浪一样的抖动。

原理:把身后的画面拿来扭一扭

透明物体想“透出”身后的东西,有两种做法:一是 Alpha 混合,让 GPU 把自己和已有的画面按比例混合;二是自己去读“身后的画面”,想怎么处理就怎么处理。扭曲只能用第二种:我们需要读取的不是正好在自己身后的那个像素,而是旁边一点的像素。

URP 在不透明物体和天空盒画完之后,会把当时的相机颜色拷贝一份到 _CameraOpaqueTexture(需要 URP Asset 开启 Opaque Texture),这一步在 Frame Debugger 里叫 CopyColor。透明队列里的 Shader 用 DeclareOpaqueTexture.hlsl 提供的 SampleSceneColor(uv) 就能读到它:

// 摘自 URP 14 ShaderLibrary/DeclareOpaqueTexture.hlsl
TEXTURE2D_X(_CameraOpaqueTexture);
SAMPLER(sampler_CameraOpaqueTexture);

float3 SampleSceneColor(float2 uv)
{
    return SAMPLE_TEXTURE2D_X(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, UnityStereoTransformScreenSpaceTex(uv)).rgb;
}
不透明物体 天空盒 CopyColor_CameraOpaqueTexture 透明物体 后处理 在这里读取 → 只看得到不透明物体与天空
图 18-16不透明纹理的拷贝时机。透明物体读到的是“自己之前”的画面,所以看不到其他透明物体。

偏移量怎么定?最自然的是用法线:把世界法线转到观察空间,取 xy。物体正对相机的中央,观察空间法线几乎是 (0, 0, 1),xy 很小,几乎不扭曲;越靠近轮廓,法线越“横”过来,xy 越大,扭得越厉害——这和真实的玻璃球很像(轮廓处折射最强)。再叠一层流动噪声,运动时就有了热浪般的抖动。

Assets/Cookbook/Ch18/Cloak.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float t = _AutoPlay > 0.5 ? _Time.y : _Phase;
    half3 normalWS = normalize(i.normalWS);
    half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);

    // 法线转到观察空间,取 xy 作为屏幕上的“推开方向”:轮廓处法线几乎平行屏幕,推得最远
    half3 normalVS = TransformWorldToViewDir(normalWS);
    float2 offset = normalVS.xy * _Distortion;
    // 再叠一层流动噪声,让隐身体表面有“热浪”一样的抖动
    half2 n = SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, i.uv + float2(0, t * 0.2)).rg - 0.5h;
    offset += n * _NoiseDistortion;
    // 观察空间 x 向右、y 向上,与 GetNormalizedScreenSpaceUV 得到的 UV 方向一致(平台翻转已由它处理)
    half3 behind = SampleSceneColor(screenUV + offset) * _Tint.rgb;

    half rim = FresnelRim(normalWS, viewWS, _RimPower);
    half3 cloak = behind + _RimColor.rgb * rim;

    // 现形:在隐身和正常着色之间插值(实际项目可以叠一个溶解,见案例 18.1)
    half3 visible = SimpleLighting(_BaseColor.rgb, i.positionWS, normalWS, 0.4h);
    return half4(lerp(cloak, visible, _Reveal), 1);
}
void main()
{
    float t = _AutoPlay > 0.5 ? _Time.y : _Phase;
    vec3 N = normalize(normalWS);
    vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
    vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

    // 法线转到观察空间,取 xy 作为屏幕上的“推开方向”:轮廓处法线几乎平行屏幕,推得最远
    vec3 normalVS = TransformWorldToViewDir(N);
    vec2 offset = normalVS.xy * _Distortion;
    // 再叠一层流动噪声,让隐身体表面有“热浪”一样的抖动
    vec2 n = texture(_NoiseMap, uv + vec2(0.0, t * 0.2)).rg - 0.5;
    offset += n * _NoiseDistortion;
    // 观察空间 x 向右、y 向上,与屏幕 UV 方向一致(WebGL 不翻转 y)
    vec3 behind = SampleSceneColor(screenUV + offset) * _Tint.rgb;

    float rim = FresnelRim(N, viewWS, _RimPower);
    vec3 cloak = behind + _RimColor.rgb * rim;

    // 现形:在隐身和正常着色之间插值(实际项目可以叠一个溶解,见案例 18.1)
    vec3 visible = SimpleLighting(_BaseColor.rgb, positionWS, N, 0.4);
    fragColor = vec4(mix(cloak, visible, _Reveal), 1.0);
}

观察空间的 x 向右、y 向上,与 GetNormalizedScreenSpaceUV 返回的屏幕 UV 方向一致(平台差异已经由它处理),所以偏移可以直接加。完整代码:

Assets/Cookbook/Ch18/Cloak.shader
// 案例 18.8:扭曲隐身(光学迷彩)。采样 _CameraOpaqueTexture,按法线把屏幕 UV 推开一点,
// 物体就像一块“透明的玻璃”把身后的画面弯折了。URP Asset 需开启 Opaque Texture。
Shader "Cookbook/Ch18/Cloak"
{
    Properties
    {
        _Distortion ("Distortion (screen UV)", Range(0,0.15)) = 0.04
        _NoiseMap ("Ripple Noise", 2D) = "gray" {}
        _NoiseDistortion ("Noise Distortion", Range(0,0.05)) = 0.01
        _Tint ("Tint", Color) = (0.9,0.95,1,1)
        [HDR] _RimColor ("Rim Color", Color) = (0.4,0.9,1.4,1)
        _RimPower ("Rim Power", Range(0.5,8)) = 4
        _Reveal ("Reveal (0=invisible 1=visible)", Range(0,1)) = 0
        _BaseColor ("Visible Base Color", Color) = (0.7,0.3,0.2,1)
        [Toggle] _AutoPlay ("Auto Play (_Time)", Float) = 0
        _Phase ("Phase (seconds)", Float) = 0
    }

    SubShader
    {
        // 透明队列:画它的时候,不透明物体和天空已经拷贝进 _CameraOpaqueTexture 了
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "Cloak"
            Tags { "LightMode"="UniversalForward" }
            ZWrite On           // 写深度:隐身物体自己的前后遮挡仍然正确(它不是半透明混合,而是整块替换)
            Blend Off

            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"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareOpaqueTexture.hlsl"

            TEXTURE2D(_NoiseMap); SAMPLER(sampler_NoiseMap);

            CBUFFER_START(UnityPerMaterial)
                half   _Distortion;
                float4 _NoiseMap_ST;
                half   _NoiseDistortion;
                half4  _Tint;
                half4  _RimColor;
                half   _RimPower;
                half   _Reveal;
                half4  _BaseColor;
                half   _AutoPlay;
                float  _Phase;
            CBUFFER_END

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

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

            half4 frag(Varyings i) : SV_Target
            {
                float t = _AutoPlay > 0.5 ? _Time.y : _Phase;
                half3 normalWS = normalize(i.normalWS);
                half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
                float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);

                // 法线转到观察空间,取 xy 作为屏幕上的“推开方向”:轮廓处法线几乎平行屏幕,推得最远
                half3 normalVS = TransformWorldToViewDir(normalWS);
                float2 offset = normalVS.xy * _Distortion;
                // 再叠一层流动噪声,让隐身体表面有“热浪”一样的抖动
                half2 n = SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, i.uv + float2(0, t * 0.2)).rg - 0.5h;
                offset += n * _NoiseDistortion;
                // 观察空间 x 向右、y 向上,与 GetNormalizedScreenSpaceUV 得到的 UV 方向一致(平台翻转已由它处理)
                half3 behind = SampleSceneColor(screenUV + offset) * _Tint.rgb;

                half rim = FresnelRim(normalWS, viewWS, _RimPower);
                half3 cloak = behind + _RimColor.rgb * rim;

                // 现形:在隐身和正常着色之间插值(实际项目可以叠一个溶解,见案例 18.1)
                half3 visible = SimpleLighting(_BaseColor.rgb, i.positionWS, normalWS, 0.4h);
                return half4(lerp(cloak, visible, _Reveal), 1);
            }
            ENDHLSL
        }
    }
}
// 案例 18.8:扭曲隐身(光学迷彩)。采样 _CameraOpaqueTexture,按法线把屏幕 UV 推开一点,
// 物体就像一块“透明的玻璃”把身后的画面弯折了。URP Asset 需开启 Opaque Texture。(GLSL 版,对应 Ch18/Cloak.shader)
Shader "Cookbook/Ch18/Cloak"
{
    SubShader
    {
        // 透明队列:画它的时候,不透明物体和天空已经拷贝进 _CameraOpaqueTexture 了
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "Cloak"
            Tags { "LightMode"="UniversalForward" }
            ZWrite On           // 写深度:隐身物体自己的前后遮挡仍然正确(它不是半透明混合,而是整块替换)
            Blend Off

            GLSLPROGRAM
            #include "Ch18/Ch18Common.glsl"
            // _CameraOpaqueTexture / SampleSceneColor 在 Core.glsl 里(对应 DeclareOpaqueTexture.hlsl)

            uniform sampler2D _NoiseMap;
            uniform float _Distortion;
            uniform vec4  _NoiseMap_ST;
            uniform float _NoiseDistortion;
            uniform vec4  _Tint;
            uniform vec4  _RimColor;
            uniform float _RimPower;
            uniform float _Reveal;
            uniform vec4  _BaseColor;
            uniform float _AutoPlay;
            uniform float _Phase;

            VARYING vec3 positionWS;
            VARYING vec3 normalWS;
            VARYING vec2 uv;

            #ifdef VERTEX
            void main()
            {
                positionWS = TransformObjectToWorld(positionOS.xyz);
                gl_Position = TransformWorldToHClip(positionWS);
                normalWS = TransformObjectToWorldNormal(normalOS);
                uv = TransformTex(texcoord0.xy, _NoiseMap_ST);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                float t = _AutoPlay > 0.5 ? _Time.y : _Phase;
                vec3 N = normalize(normalWS);
                vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
                vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

                // 法线转到观察空间,取 xy 作为屏幕上的“推开方向”:轮廓处法线几乎平行屏幕,推得最远
                vec3 normalVS = TransformWorldToViewDir(N);
                vec2 offset = normalVS.xy * _Distortion;
                // 再叠一层流动噪声,让隐身体表面有“热浪”一样的抖动
                vec2 n = texture(_NoiseMap, uv + vec2(0.0, t * 0.2)).rg - 0.5;
                offset += n * _NoiseDistortion;
                // 观察空间 x 向右、y 向上,与屏幕 UV 方向一致(WebGL 不翻转 y)
                vec3 behind = SampleSceneColor(screenUV + offset) * _Tint.rgb;

                float rim = FresnelRim(N, viewWS, _RimPower);
                vec3 cloak = behind + _RimColor.rgb * rim;

                // 现形:在隐身和正常着色之间插值(实际项目可以叠一个溶解,见案例 18.1)
                vec3 visible = SimpleLighting(_BaseColor.rgb, positionWS, N, 0.4);
                fragColor = vec4(mix(cloak, visible, _Reveal), 1.0);
            }
            #endif
            ENDGLSL
        }
    }
}
图 18-17扭曲隐身。左:现形状态(Reveal=1,正常着色);中:只有法线扭曲;右:加上菲涅尔亮边与噪声抖动。注意砖缝在轮廓附近被明显弯折。

常见坑