URP 渲染实战 / 第 16 章 透明、混合与模板测试 / 案例 16.5
案例 16.5抖动透明(Dither)入门
目标:让物体“半透明”却不进透明队列——依然写深度、依然投影、完全不用排序。
原理:抖动透明也叫“纱窗透明”(Screen-door Transparency):不是把颜色混合进去,而是按一个固定的阈值图案,丢弃一部分像素。不透明度 0.5 就丢掉一半的像素,远看起来就是半透明的。阈值图案用 4×4 的 Bayer 矩阵,它的 16 个阈值均匀分布在 0~1,相邻像素的阈值尽量拉开,所以任何不透明度下丢弃的像素都分布得很均匀:
Assets/Cookbook/Ch16/DitherFade.shader(节选:bayer)
// 4×4 Bayer 矩阵:16 个阈值均匀分布在 0..1,且相邻像素的阈值尽量“错开” float Bayer4x4(uint2 p) { const float m[16] = { 0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0 }; p %= 4; return (m[p.y * 4 + p.x] + 0.5) / 16.0; } // opacity 为 0.5 时,正好有一半像素的阈值低于它而被保留 void DitherClip(float2 pixelPos, half opacity) { clip(opacity - Bayer4x4((uint2)pixelPos)); }
// 4×4 Bayer 矩阵:16 个阈值均匀分布在 0..1,且相邻像素的阈值尽量“错开” float Bayer4x4(uvec2 p) { const float m[16] = float[16]( 0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0); p %= 4u; return (m[p.y * 4u + p.x] + 0.5) / 16.0; } #ifdef FRAGMENT // opacity 为 0.5 时,正好有一半像素的阈值低于它而被保留 void DitherClip(vec2 pixelPos, float opacity) { if (opacity - Bayer4x4(uvec2(pixelPos)) < 0.0) discard; } #endif
Assets/Cookbook/Ch16/DitherFade.shader
// 抖动透明(Dither / Screen-door Transparency):不混合,而是按屏幕位置的阈值图案丢弃一部分像素。 // 物体仍然是不透明队列、写深度、不需要排序;代价是近看能看到网点。阴影 Pass 用同样的抖动。 Shader "Cookbook/Ch16/DitherFade" { Properties { _BaseColor ("Color", Color) = (0.9, 0.5, 0.25, 1) _Fade ("Opacity", Range(0, 1)) = 0.5 [Toggle] _DitherShadow ("Dither In Shadow Pass", Float) = 1 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" } HLSLINCLUDE #include "Assets/Cookbook/Ch16/Ch16Common.hlsl" CBUFFER_START(UnityPerMaterial) half4 _BaseColor; half _Fade; float _DitherShadow; CBUFFER_END // 4×4 Bayer 矩阵:16 个阈值均匀分布在 0..1,且相邻像素的阈值尽量“错开” float Bayer4x4(uint2 p) { const float m[16] = { 0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0 }; p %= 4; return (m[p.y * 4 + p.x] + 0.5) / 16.0; } // opacity 为 0.5 时,正好有一半像素的阈值低于它而被保留 void DitherClip(float2 pixelPos, half opacity) { clip(opacity - Bayer4x4((uint2)pixelPos)); } ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } HLSLPROGRAM #pragma vertex Ch16Vert #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(Varyings i) : SV_Target { DitherClip(i.positionCS.xy, _Fade); // SV_POSITION.xy 在片元里就是像素坐标 return half4(SimpleLighting(_BaseColor.rgb, normalize(i.normalWS), i.positionWS, 0.5h), 1); } ENDHLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 HLSLPROGRAM #pragma vertex vert #pragma fragment frag float4 vert(Attributes v) : SV_POSITION { return Ch16ShadowPositionCS(v.positionOS.xyz, v.normalOS); } half4 frag(float4 positionCS : SV_POSITION) : SV_Target { // 阴影贴图里的“像素”是阴影贴图的纹素,抖动后经过 PCF 软阴影过滤,会得到浅一些的阴影 if (_DitherShadow > 0.5) DitherClip(positionCS.xy, _Fade); return 0; } ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R HLSLPROGRAM #pragma vertex vert #pragma fragment frag float4 vert(Attributes v) : SV_POSITION { return TransformObjectToHClip(v.positionOS.xyz); } half4 frag(float4 positionCS : SV_POSITION) : SV_Target { DitherClip(positionCS.xy, _Fade); return 0; } ENDHLSL } } }
// 抖动透明(Dither / Screen-door Transparency):不混合,而是按屏幕位置的阈值图案丢弃一部分像素。(GLSL 版) // 物体仍然是不透明队列、写深度、不需要排序;代价是近看能看到网点。阴影 Pass 用同样的抖动。 Shader "Cookbook/Ch16/DitherFade" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" } GLSLINCLUDE #include "Ch16/Ch16Common.glsl" uniform vec4 _BaseColor; uniform float _Fade; uniform float _DitherShadow; // 4×4 Bayer 矩阵:16 个阈值均匀分布在 0..1,且相邻像素的阈值尽量“错开” float Bayer4x4(uvec2 p) { const float m[16] = float[16]( 0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0); p %= 4u; return (m[p.y * 4u + p.x] + 0.5) / 16.0; } #ifdef FRAGMENT // opacity 为 0.5 时,正好有一半像素的阈值低于它而被保留 void DitherClip(vec2 pixelPos, float opacity) { if (opacity - Bayer4x4(uvec2(pixelPos)) < 0.0) discard; } #endif ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } GLSLPROGRAM #ifdef VERTEX void main() { Ch16Vert(); } #endif #ifdef FRAGMENT void main() { DitherClip(gl_FragCoord.xy, _Fade); // gl_FragCoord.xy 在片元里就是像素坐标(对应 HLSL 的 SV_POSITION.xy) fragColor = vec4(SimpleLighting(_BaseColor.rgb, normalize(normalWS), positionWS, 0.5), 1.0); } #endif ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 GLSLPROGRAM #ifdef VERTEX void main() { gl_Position = Ch16ShadowPositionCS(positionOS.xyz, normalOS); } #endif #ifdef FRAGMENT void main() { // 阴影贴图里的“像素”是阴影贴图的纹素,抖动后经过 PCF 软阴影过滤,会得到浅一些的阴影 if (_DitherShadow > 0.5) DitherClip(gl_FragCoord.xy, _Fade); fragColor = vec4(0.0); } #endif ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R GLSLPROGRAM #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); } #endif #ifdef FRAGMENT void main() { DitherClip(gl_FragCoord.xy, _Fade); fragColor = vec4(gl_FragCoord.z); } #endif ENDGLSL } } }
URP 自己的 LOD 交叉淡入淡出(LOD Cross-Fade)就是抖动透明:LODCrossFade.hlsl 里用屏幕坐标采样一张抖动纹理 _DitheringTexture,两个 LOD 级别用互补的阈值丢弃像素,过渡期间两者拼起来恰好覆盖每一个像素。

阴影的抖动有个细节:ShadowCaster Pass 里的 SV_POSITION.xy 是阴影贴图的像素坐标,所以抖动图案是“印”在阴影贴图上的。软阴影的 PCF 过滤会把这些网点平均,于是地面上得到的是一个浅一些的、平滑的阴影,而不是网点状的阴影。这一点在第 23 章“半透明物体的阴影”里会进一步展开。
提示抖动透明的网点在静止画面上很明显,但如果每帧偏移抖动图案(例如按帧号平移 Bayer 坐标),再交给时间抗锯齿(TAA)在多帧之间累积,网点几乎看不出来,这是很多现代游戏处理“角色挡住镜头时淡出”的标准做法。URP 14 的相机 Anti-aliasing 选项里提供了 TAA,但它依赖历史帧,并不是每个项目都会开启;在没有时间累积的情况下,可以用更大的抖动矩阵(8×8)或蓝噪声纹理让网点更不规则、更不显眼。