案例 16.3透明排序问题与双 Pass 预深度进阶
目标:复现半透明物体最常见的两类排序错误,并用“先写深度、再画颜色”的双 Pass 解决自身穿插问题。
原理:Alpha 混合不满足交换律:先画 A 再画 B,和先画 B 再画 A,结果不同。正确的结果要求同一像素上的所有半透明片元从远到近依次混合。而 URP(以及几乎所有光栅化渲染器)只做到了“物体级别从远到近”,于是有两类问题:
- 物体之间:两个物体互相穿插,或者一个大物体(比如一大片半透明水面)的包围盒中心比小物体近、但其实有一部分在小物体后面——无论按中心怎么排,总有一部分是错的。
- 物体自身:一个凹形物体(纽结、头发、多层衣服)自己的三角形之间也有前后关系,但一次 Draw Call 内三角形按索引顺序光栅化,和远近无关。不写深度时,后面的三角形如果索引靠后,就会画在前面的三角形“上面”。
针对“物体自身”的问题,一个经典而便宜的解法是双 Pass 预深度:第一个 Pass 只写深度、不写颜色(ColorMask 0),把这个物体最前面那层表面的深度先占好;第二个 Pass 正常混合颜色,ZTest LEqual 让只有“最前面那层”的片元通过。结果是物体自身不再错乱——代价是看不到物体自己的背面和内部(它变成了一个“半透明的实心体”),对角色、头发、玻璃制品来说这往往正是想要的效果。
// 双 Pass 半透明:第一个 Pass 只写深度(不写颜色),第二个 Pass 用 LEqual 正常混合。 // 结果是每个像素只留下“离相机最近的那层表面”,自身前后层次不再错乱。 // URP 的 DrawObjectsPass 按 SRPDefaultUnlit → UniversalForward 的顺序绘制同一物体的多个 Pass, // 所以把预深度放在 SRPDefaultUnlit 里,就能保证它先于颜色 Pass 执行。 Shader "Cookbook/Ch16/TransparentPrepass" { Properties { _BaseColor ("Color (A = opacity)", Color) = (0.3, 0.7, 1, 0.45) } SubShader { Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" } HLSLINCLUDE #include "Assets/Cookbook/Ch16/Ch16Common.hlsl" CBUFFER_START(UnityPerMaterial) half4 _BaseColor; CBUFFER_END ENDHLSL Pass { Name "DepthPrepass" Tags { "LightMode"="SRPDefaultUnlit" } ZWrite On ColorMask 0 // 只写深度,不动颜色 HLSLPROGRAM #pragma vertex vert #pragma fragment frag float4 vert(Attributes v) : SV_POSITION { return TransformObjectToHClip(v.positionOS.xyz); } half4 frag() : SV_Target { return 0; } ENDHLSL } Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off ZTest LEqual // 深度相等的那层(最前面的表面)才能通过 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 { return half4(SimpleLighting(_BaseColor.rgb, normalize(i.normalWS), i.positionWS, 0.8h), _BaseColor.a); } ENDHLSL } } }
// 双 Pass 半透明:第一个 Pass 只写深度(不写颜色),第二个 Pass 用 LEqual 正常混合。(GLSL 版) // 结果是每个像素只留下“离相机最近的那层表面”,自身前后层次不再错乱。 // URP 的 DrawObjectsPass 按 SRPDefaultUnlit → UniversalForward 的顺序绘制同一物体的多个 Pass, // 所以把预深度放在 SRPDefaultUnlit 里,就能保证它先于颜色 Pass 执行。 Shader "Cookbook/Ch16/TransparentPrepass" { SubShader { Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" } GLSLINCLUDE #include "Ch16/Ch16Common.glsl" uniform vec4 _BaseColor; ENDGLSL Pass { Name "DepthPrepass" Tags { "LightMode"="SRPDefaultUnlit" } ZWrite On ColorMask 0 // 只写深度,不动颜色 GLSLPROGRAM #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(0.0); } #endif ENDGLSL } Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off ZTest LEqual // 深度相等的那层(最前面的表面)才能通过 GLSLPROGRAM #ifdef VERTEX void main() { Ch16Vert(); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(SimpleLighting(_BaseColor.rgb, normalize(normalWS), positionWS, 0.8), _BaseColor.a); } #endif ENDGLSL } } }
两个 Pass 的执行顺序靠 LightMode 保证。URP 的 DrawObjectsPass 在构造时按固定顺序登记了三个 Shader Tag,绘制同一个物体时按这个顺序执行匹配的 Pass:
// 摘自 URP 14 Runtime/Passes/DrawObjectsPass.cs new ShaderTagId[] { new ShaderTagId("SRPDefaultUnlit"), new ShaderTagId("UniversalForward"), new ShaderTagId("UniversalForwardOnly") }
所以把预深度 Pass 标成 SRPDefaultUnlit、颜色 Pass 标成 UniversalForward,前者一定先执行。对比渲染结果(图 16-6 右图)可以验证这一点:如果顺序反了,预深度就完全没有作用,结果会和左图一样。

顺带看看 URP Lit 自己怎么处理透明物体的深度写入。Lit 材质的 Advanced Options 里有一个 Depth Write(属性名 _ZWriteControl,取值 Auto / ForceEnabled / ForceDisabled):Auto 时不透明写深度、透明不写;强制开启则透明物体也写深度——也就是本例中图的“单 Pass ZWrite On”,会有同样的缺块问题。编辑器脚本在设置 ZWrite 的同时,还会调用 material.SetShaderPassEnabled("DepthOnly", zwrite):不写深度的材质,它的 DepthOnly Pass 也被禁用。再结合 UniversalRenderer 里深度预通道的构造参数 new DepthOnlyPass(…, RenderQueueRange.opaque, …) 可以知道:深度预通道只画不透明队列,透明队列的物体即使带了 DepthOnly Pass 也不会进入 _CameraDepthTexture;反过来,如果你为了解决排序把某个半透明材质的队列改进了不透明范围,它的 DepthOnly Pass 就会写进深度图,SSAO、软粒子、水面深度这些依赖深度图的效果都会“看到”它,这往往不是你想要的。