GameTech/URP 渲染实战

案例 16.3透明排序问题与双 Pass 预深度进阶

目标:复现半透明物体最常见的两类排序错误,并用“先写深度、再画颜色”的双 Pass 解决自身穿插问题。

原理:Alpha 混合不满足交换律:先画 A 再画 B,和先画 B 再画 A,结果不同。正确的结果要求同一像素上的所有半透明片元从远到近依次混合。而 URP(以及几乎所有光栅化渲染器)只做到了“物体级别从远到近”,于是有两类问题:

相机 物体之间:大平面的中心更远,先画;但它右半边其实在小球前面 物体自身:同一个网格的前后两层 按三角形索引顺序画,与远近无关 一次 Draw Call 内部没有排序
图 16-5透明排序错误的两种来源

针对“物体自身”的问题,一个经典而便宜的解法是双 Pass 预深度:第一个 Pass 只写深度、不写颜色(ColorMask 0),把这个物体最前面那层表面的深度先占好;第二个 Pass 正常混合颜色,ZTest LEqual 让只有“最前面那层”的片元通过。结果是物体自身不再错乱——代价是看不到物体自己的背面和内部(它变成了一个“半透明的实心体”),对角色、头发、玻璃制品来说这往往正是想要的效果。

Assets/Cookbook/Ch16/TransparentPrepass.shader
// 双 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 右图)可以验证这一点:如果顺序反了,预深度就完全没有作用,结果会和左图一样。

图 16-6半透明纽结(不透明度 0.55)。左:ZWrite Off,管子内部的后层被画在前层之上,层次混乱;中:单 Pass ZWrite On,先画的三角形挡住后画的,哪些后层能看到取决于三角形顺序,出现不规则的“缺块”;右:双 Pass 预深度,只保留最前面一层,干净一致

顺带看看 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、软粒子、水面深度这些依赖深度图的效果都会“看到”它,这往往不是你想要的。

✓ 预深度适合
单个凹形的半透明物体(角色淡出、幽灵、玻璃雕塑),想要“看起来像实心体变透明”。
✗ 预深度不适合
需要看到内部结构的物体(透明容器里的液体、玻璃杯的背面);它还会挡住排在它之后绘制的、位于它后面的其他半透明物体。
深入彻底解决透明排序要靠顺序无关透明(Order-Independent Transparency,OIT):深度剥离(Depth Peeling)逐层剥离、逐像素链表(A-Buffer)、或近似的加权混合 OIT(Weighted Blended OIT,用加权平均代替顺序混合,只需两个 RT、一次绘制)。URP 14 没有内置 OIT,可以用 Renderer Feature 自己实现(第 25 章的知识足够)。在那之前,工程上的常用手段是:把大型透明物体拆成小块让排序更准确;调整 Sorting Priority 手动指定顺序;以及——能用 Alpha Clip 或抖动解决的就别用 Alpha 混合。