从 Depth Buffer 原理到 URP Shader 实战,彻底搞懂半透明渲染的每一个环节
Chapter 01
半透明物体为什么不能写入深度缓冲?
要理解这个问题,我们需要先回到 Depth Buffer(深度缓冲,也叫 Z-Buffer)的基本工作原理。它是整个光栅化管线的核心组件之一,负责解决「谁在前面、谁被遮挡」的问题。
深度缓冲如何工作
深度缓冲是一个与颜色缓冲同分辨率的二维数组,每个像素存储一个浮点值,表示当前最近的片元(Fragment)的深度(即 z 坐标)。绘制流程如下:
- 一个片元经过光栅化后,携带自己的 z 值
- 将该 z 值与深度缓冲中对应位置的值进行比较(默认
ZTest LEqual) - 如果片元的 z 值 ≤ 缓冲中的 z 值,则该片元可见,同时更新深度缓冲
- 如果片元的 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)可以清楚地看到绘制顺序:
- 所有
Geometry队列的不透明物体先绘制,建立完整的深度缓冲 - 你的半透明物体在
Transparent队列中绘制,ZWrite Off 不影响已有的深度缓冲 - 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 自定义渲染流程。大致步骤:
- 在 Transparent 队列之前插入一个自定义 RenderFeature
- 使用
CommandBuffer.Blit或 Compute Shader 分配额外缓冲区 - 片元着色器中通过 UAV(RWTexture2D)写入链表节点或累积值
- 最后用一个全屏 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 通常够用 |