Unity Shader 半透明物体为什么不能写入深度缓冲?

从 Depth Buffer 原理到 URP Shader 实战,彻底搞懂半透明渲染的每一个环节

Chapter 01

半透明物体为什么不能写入深度缓冲?

要理解这个问题,我们需要先回到 Depth Buffer(深度缓冲,也叫 Z-Buffer)的基本工作原理。它是整个光栅化管线的核心组件之一,负责解决「谁在前面、谁被遮挡」的问题。

深度缓冲如何工作

深度缓冲是一个与颜色缓冲同分辨率的二维数组,每个像素存储一个浮点值,表示当前最近的片元(Fragment)的深度(即 z 坐标)。绘制流程如下:

  1. 一个片元经过光栅化后,携带自己的 z 值
  2. 将该 z 值与深度缓冲中对应位置的值进行比较(默认 ZTest LEqual
  3. 如果片元的 z 值 ≤ 缓冲中的 z 值,则该片元可见,同时更新深度缓冲
  4. 如果片元的 z 值 > 缓冲中的 z 值,则该片元被丢弃

问题的本质

半透明渲染需要使用 Alpha Blending(颜色混合),它的公式是:

复制代码
FinalColor = SrcAlpha × SrcColor + (1 - SrcAlpha) × DstColor
 2// 即: 最终颜色 = 源透明度 × 源颜色 + (1 - 源透明度) × 目标颜色

这个公式的关键在于 DstColor(目标颜色) ------它需要知道这个像素位置上「已经绘制了什么」。因此半透明物体的绘制顺序极其重要:必须从后往前(Back-to-Front)绘制,每一层半透明物体都需要看到它后面的所有内容。

**核心矛盾:**如果半透明物体写入了深度缓冲,那么更远的半透明物体(或它后面的不透明物体)会被深度测试直接丢弃,根本无法参与混合计算。结果就是------该透过去看到的东西,全被挡住了。

举个具体例子:假设有一扇红色玻璃窗(半透明,z=0.3),它后面有一面白墙(不透明,z=0.5)。如果红色玻璃窗先绘制,并写入了深度值 0.3,那么后续绘制白墙时,z=0.5 > 0.3,深度测试失败,白墙被丢弃------你看到的不是「红色玻璃后面的白墙」,而是一面纯红色的不透明墙。

Chapter 02

ZWrite Off 的原理与必要性

既然半透明物体写入深度会导致后方物体被错误丢弃,解决方案就很自然了:关闭深度写入

ZWrite Off 做了什么

ZWrite Off 是一条渲染状态指令,它告诉 GPU:只做深度测试(ZTest),不更新深度缓冲的值

渲染状态 ZWrite On(默认) ZWrite Off
深度测试(ZTest) ✅ 执行 ✅ 执行(仍然比较 z 值)
深度写入 ✅ 片元可见时更新 depthBuf 更新 depthBuf
颜色写入 ✅ 正常 ✅ 正常
典型用途 不透明物体 半透明物体、粒子、水面

**注意:**ZWrite Off 只是禁止写入深度,并没有禁止读取(ZTest)。半透明物体仍然会与不透明物体的深度进行比较------这很重要,它保证了半透明物体不会渲染到不透明物体「内部」。

在 HLSL 中的声明

cs 复制代码
Chapter 02
ZWrite Off 的原理与必要性
既然半透明物体写入深度会导致后方物体被错误丢弃,解决方案就很自然了:关闭深度写入。

ZWrite Off 做了什么
ZWrite Off 是一条渲染状态指令,它告诉 GPU:只做深度测试(ZTest),不更新深度缓冲的值。

渲染状态	ZWrite On(默认)	ZWrite Off
深度测试(ZTest)	✅ 执行	✅ 执行(仍然比较 z 值)
深度写入	✅ 片元可见时更新 depthBuf	❌ 不更新 depthBuf
颜色写入	✅ 正常	✅ 正常
典型用途	不透明物体	半透明物体、粒子、水面
注意:ZWrite Off 只是禁止写入深度,并没有禁止读取(ZTest)。半透明物体仍然会与不透明物体的深度进行比较------这很重要,它保证了半透明物体不会渲染到不透明物体「内部」。
在 HLSL 中的声明
HLSL
ShaderLab Pass 声明
Pass

{

    // 关闭深度写入 --- 半透明物体的核心渲染状态

    ZWrite Off


    // 深度测试仍然开启 --- 不会穿透不透明物体

    ZTest LEqual


    // Alpha 混合模式

    Blend SrcAlpha OneMinusSrcAlpha

}

Chapter 03

渲染队列 Queue = Transparent 的含义

Unity 通过渲染队列(Render Queue)控制不同物体的绘制顺序。"Queue"="Transparent" 不仅仅是一个标签,它直接决定了 GPU 的执行管线。

关键数值

渲染队列名称 Queue 值 ZWrite 说明
Background 1000 LEqual 最先绘制,通常是天空盒
Geometry 2000 On 默认值,所有不透明物体
AlphaTest 2450 On 使用 clip() 的物体,介于不透明和半透明之间
Transparent 3000 Off 半透明物体,Back-to-Front 排序
Overlay 4000 Off 最后绘制,如镜头光晕、UI

为什么 Transparent 默认 ZWrite Off? 因为 Unity 的 ShaderLab 在指定 "Queue"="Transparent" 时,URP 的渲染管线会自动在对应 Pass 中应用 ZWrite Off + Alpha Blend。这是约定俗成的渲染管线设计------Transparent 队列里的物体就是半透明的,不该写深度。

微调渲染顺序

你可以通过加减数值来微调同一队列内的绘制顺序。比如你想让水面在普通半透明物体之后绘制(确保水面总是在最前面):

ShaderLab渲染队列微调

复制代码
1"Queue"="Transparent+100"   // 在普通透明物体之后绘制
2"Queue"="Transparent-100"   // 在普通透明物体之前绘制

Chapter 04

Alpha Blend 与 Alpha Test 的性能取舍

这是两个截然不同的透明度处理策略,各自适用不同的场景。选择错误不仅影响视觉效果,还会带来严重的性能问题。

Alpha Test(clip / discard)

Alpha Test 在片元着色器中对 alpha 值做阈值判断:低于阈值的片元直接 discard(丢弃),高于阈值的完全保留。

cs 复制代码
half4 Frag(Varyings input) : SV_Target

{

    half4 col = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);


    // alpha 值低于阈值,直接丢弃整个片元

    clip(col.a - _Cutoff);

    // 等价于:

    // if (col.a < _Cutoff) discard;


    return col;

}

优势: 由于丢弃的片元不写颜色也不写深度,ZWrite 可以保持开启。这意味着 GPU 的 Early-Z 优化仍然生效------被遮挡的片元在进入片元着色器之前就被深度测试丢弃了,大幅减少计算量。

劣势: 边缘锯齿(Aliasing)严重,无法实现柔和的半透明效果。早期 GPU 上 discard 会导致 Early-Z 失效(因为片元着色器执行前无法知道是否会 discard),现代 GPU 已通过"Conservative Z"等技术缓解。

Alpha Blend(混合)

Alpha Blend 不丢弃任何片元,而是将片元的颜色按透明度比例与已有颜色混合。

cs 复制代码
half4 Frag(Varyings input) : SV_Target

{

    half4 col = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);


    // 直接返回,混合由硬件 Blend 状态自动完成

    // SrcAlpha × SrcColor + (1 - SrcAlpha) × DstColor

    return col;

}

**优势:**边缘柔和,可实现玻璃、烟雾、水面等真正的半透明效果。

劣势: 必须 ZWrite Off,导致 Early-Z 优化失效。GPU 无法在片元着色器之前做深度剔除------即使某个片元最终会被更近的半透明物体完全覆盖,它仍然需要先执行片元着色器,然后再排序混合。同时需要 Back-to-Front 排序,增加了 CPU 端的排序开销。

性能对比总结

特性 Alpha Test (clip) Alpha Blend
ZWrite ✅ 可以开启(Early-Z 生效) ❌ 必须关闭(Early-Z 失效)
边缘质量 硬边缘,有明显锯齿 柔和渐变,视觉效果好
Overdraw 低(被遮挡片元提前丢弃) 高(所有片元都执行着色器)
排序需求 不需要特殊排序 必须 Back-to-Front 排序
典型用途 树叶、草、栅栏、铁丝网 玻璃、水、烟雾、粒子
GPU Wave 影响 Wave 内 divergent 分支,可能降低占用率 所有片元均执行,无分支分歧但计算量大

Chapter 05

URP 中实现一个正确的半透明 Shader

下面给出一个完整的 URP 半透明 Shader 实现,涵盖所有核心要点。

cs 复制代码
Shader "Custom/URP/Transparent"

{

    Properties

    {

        // 基础贴图及其采样器

        _BaseMap("Base Map", 2D) = "white" {}

        _BaseColor("Base Color", Color) = (1,1,1,1)


        // ★ 关键:控制整体透明度的滑块

        _Alpha("Alpha", Range(0,1)) = 0.5

    }


    SubShader

    {

        // ★ 关键:设置渲染队列为 Transparent

        //   Transparent 对应 Queue=3000,确保在不透明物体之后绘制

        Tags

        {

            "RenderPipeline" = "UniversalPipeline"

            "RenderType"     = "Transparent"

            "Queue"          = "Transparent"

        }


        Pass

        {

            Name "Forward"


            // ═══════════════════════════════════════════════

            // ★ 渲染状态配置 --- 半透明 Shader 的核心

            // ═══════════════════════════════════════════════


            // ★ 关键:关闭深度写入

            //   不关闭的话,后方物体会被错误遮挡

            ZWrite Off


            // 深度测试保持开启 --- 不会被不透明物体遮挡时穿透

            ZTest LEqual


            // ★ 关键:Alpha 混合模式

            //   SrcAlpha × 新颜色 + (1-SrcAlpha) × 旧颜色

            Blend SrcAlpha OneMinusSrcAlpha


            // HLSL 代码块

            HLSLPROGRAM

            #pragma vertex Vert

            #pragma fragment Frag


            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"


            // ─── 资源声明 ───

            CBUFFER_START(UnityPerMaterial)

                float4 _BaseMap_ST;

                half4  _BaseColor;

                half   _Alpha;

            CBUFFER_END


            TEXTURE2D(_BaseMap);

            SAMPLER(sampler_BaseMap);


            // ─── 数据结构 ───

            struct Attributes

            {

                float4 positionOS : POSITION;

                float2 uv : TEXCOORD0;

            };


            struct Varyings

            {

                float4 positionCS : SV_POSITION;

                float2 uv : TEXCOORD0;

            };


            // ─── 顶点着色器 ───

            Varyings Vert(Attributes input)

            {

                Varyings output;

                // 模型空间 → 裁剪空间

                output.positionCS = TransformObjectToHClip(input.positionOS.xyz);

                // UV 缩放与偏移(基于 Inspector 中的 Tiling/Offset)

                output.uv = TRANSFORM_TEX(input.uv, _BaseMap);

                return output;

            }


            // ─── 片元着色器 ───

            half4 Frag(Varyings input) : SV_Target

            {

                // 采样基础贴图

                half4 col = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);


                // 应用 Base Color

                col.rgb *= _BaseColor.rgb;


                // ★ 关键:设置最终 Alpha 值

                //   贴图 Alpha × 材质面板上的 Alpha 滑块

                col.a *= _Alpha;


                return col;

            }

            ENDHLSL

        }

    }

}

三个关键配置缺一不可:

ZWrite Off --- 不写入深度,允许后方物体可见

"Queue"="Transparent" --- 确保在不透明物体之后绘制

Blend SrcAlpha OneMinusSrcAlpha --- 启用标准 Alpha 混合

渲染顺序验证

把这个 Shader 赋给场景中的一个物体后,通过 Frame Debugger(Window → Analysis → Frame Debugger)可以清楚地看到绘制顺序:

  1. 所有 Geometry 队列的不透明物体先绘制,建立完整的深度缓冲
  2. 你的半透明物体在 Transparent 队列中绘制,ZWrite Off 不影响已有的深度缓冲
  3. GPU 将半透明物体的颜色与深度缓冲中已通过测试的像素进行 Alpha 混合

Chapter 06

OIT(Order Independent Transparency)方案概览

Back-to-Front 排序有一个根本缺陷:它只能处理物体级别的排序,无法处理物体自身相交(Self-Intersection)或多个半透明物体交错重叠的情况。OIT 技术的目标就是彻底消除排序依赖。

为什么大多数项目不用 OIT?

方案 额外显存 额外 Pass 精确度 移动端支持 复杂度
Back-to-Front(传统) 1 物体级
Depth Peeling 1-2 个深度纹理 N(层数) 像素级
Per-Pixel Linked Lists SSBO 缓冲区 2 像素级 ⚠️ Vulkan/DX12
Weighted Blended 2 个累积纹理 2 近似 ⚠️ 需 MRT

实际建议: 对于大多数 Unity 项目,传统的 Back-to-Front 排序 + ZWrite Off 已经足够。OIT 主要在以下场景中才有必要考虑:

• 大量半透明粒子交错重叠(如烟雾、火焰)

• 复杂的半透明模型自身相交(如头发、 foliage)

• 医疗/科学可视化等对精度要求极高的场景

URP 中的 OIT 可行性

在 URP 中实现 OIT 需要通过 ScriptableRenderFeature 自定义渲染流程。大致步骤:

  1. 在 Transparent 队列之前插入一个自定义 RenderFeature
  2. 使用 CommandBuffer.Blit 或 Compute Shader 分配额外缓冲区
  3. 片元着色器中通过 UAV(RWTexture2D)写入链表节点或累积值
  4. 最后用一个全屏 Pass 对累积结果做最终混合并输出

**注意:**URP 的 Forward 渲染路径对 MRT 和 UAV 的支持有限。如果项目需要完整的 OIT,建议评估是否需要切换到 Deferred 渲染路径,甚至考虑 HDRP。移动端 WebGL2 / GLES3.0 环境下 SSBO 和 Image Load Store 的支持也存在碎片化问题。


Summary

核心要点速查

概念 核心结论
深度缓冲问题 半透明物体写入深度会导致后方物体被 ZTest 丢弃,无法参与混合
ZWrite Off 关闭深度写入,但保留深度测试(仍不会穿透不透明物体)
Queue=Transparent 值 3000,在不透明物体之后绘制,内部按距离 Back-to-Front 排序
Alpha Test clip/discard,可保持 ZWrite On,适合树叶/草,边缘硬
Alpha Blend 必须 ZWrite Off,适合玻璃/水/烟,边缘柔,Overdraw 高
OIT 消除排序依赖,精确但昂贵;实际项目中 Back-to-Front 通常够用
相关推荐
晚枫歌F2 小时前
三层时间轮的实现
网络·unity·游戏引擎
咸鱼永不翻身4 小时前
Lua脚本事件检查工具
unity·lua·工具
leo__5206 小时前
单载波中继系统资源分配算法MATLAB仿真程序
算法·matlab·unity
努力长头发的程序猿7 小时前
Unity使用ScriptableObject序列化资源
unity·游戏引擎
mxwin7 小时前
Unity Shader 手写基于 PBR 的 URP Lit Shader 核心光照计算
unity·游戏引擎·shader
小贺儿开发7 小时前
Unity3D 智能云端数字标牌系统
unity·阿里云·人机交互·视频·oss·广告·互动
魔士于安8 小时前
Unity windows 同步 异步 打开文件文件夹工具
游戏·unity·游戏引擎·贴图·模型
笑虾8 小时前
cocos2d-x lua 加载 Cocos Studio 导出的 csb
游戏引擎·lua·cocos2d
魔士于安8 小时前
unity lowpoly 风格 城市 建筑 道路 交通标志
游戏·unity·游戏引擎·贴图·模型