URP 渲染实战 / 第 18 章 溶解、全息与护盾 / 案例 18.8
案例 18.8扭曲隐身:采样不透明纹理进阶
目标:光学迷彩。物体几乎透明,但它会把身后的画面轻微“弯折”,轮廓处还有一圈淡淡的亮边,运动时有热浪一样的抖动。
原理:把身后的画面拿来扭一扭
透明物体想“透出”身后的东西,有两种做法:一是 Alpha 混合,让 GPU 把自己和已有的画面按比例混合;二是自己去读“身后的画面”,想怎么处理就怎么处理。扭曲只能用第二种:我们需要读取的不是正好在自己身后的那个像素,而是旁边一点的像素。
URP 在不透明物体和天空盒画完之后,会把当时的相机颜色拷贝一份到 _CameraOpaqueTexture(需要 URP Asset 开启 Opaque Texture),这一步在 Frame Debugger 里叫 CopyColor。透明队列里的 Shader 用 DeclareOpaqueTexture. 提供的 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; }
偏移量怎么定?最自然的是用法线:把世界法线转到观察空间,取 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 } } }

常见坑
- 前景泄漏:如果隐身物体前面挡着一个不透明物体,扭曲后的 UV 可能采样到那个前景物体的颜色,看起来像前景“漏”进了隐身体里。轮廓处偏移量大时最明显。解决办法是采样偏移位置的深度、判断它是否比自己更近,详见第 19 章水面折射的“防前景泄漏”。
- 看不到其他透明物体:如图 18-16,拷贝发生在透明物体之前,隐身体背后的粒子、玻璃、另一个全息都不会出现在扭曲画面里。想让它们也出现,需要在透明物体之后再做一次拷贝(自定义 Renderer Feature,第 25 章),代价是多一次全屏拷贝。
- Opaque Texture 降采样:URP Asset 里 Opaque Downsampling 默认是 2x Bilinear,读到的是半分辨率画面,隐身体会比周围略糊。追求清晰可以改成 None,代价是带宽。
- 不投射阴影:本例没有 ShadowCaster Pass——一个隐身的东西在地上留下清晰的影子会穿帮。如果设计上需要“现形时有影子”,可以加阴影 Pass 并按
_Reveal做抖动透明阴影(第 23 章)。 - ZWrite On:本例写深度,因为隐身体的每个像素都“完全替换”了背景,不需要和后面的透明物体混合;写深度可以保证它自身前后遮挡正确。