如果你在Unity里处理过透明物体相关的后处理或渲染特效,大概率撞上过一个诡异现象:场景里明明摆着玻璃、水面、半透明角色,可一旦尝试用_CameraDepthTexture去拿深度数据,这些东西就像从深度纹理里蒸发了一样,后面的物体直接贯穿而过。透明物体深度数据在Unity里经常被当成“取不到”的数据,但实际项目里的折射边缘融合、半透遮挡、传送门内容、描边、深度探测,全都卡在等这一份深度数据上。
这篇文章就围绕“Unity获取透明物体深度数据”这件事展开,我会先讲清楚为什么透明物体默认不会写进深度纹理,再对比几种获取深度数据的方案,给出可以直接抄走的Shader和C#实现,最后把我自己踩过的坑和排查经验一并列出来。内容以URP为主,内置渲染管线的原理也通用,偏后处理、特效、交互项目的同学可以直接参考。不论你是被半透明遮挡判断卡住的初级开发者,还是想优化深度数据精度的进阶用户,这篇都能给你一个相对完整的解决路径。
1. 透明物体到底为什么消失在深度纹理里
想解决问题,先得搞清楚透明物体“消失”的机制。很多人在Shader里开了ZWrite On就以为能拿到深度,结果采样_CameraDepthTexture依然全是1,原因没那么简单,得从深度纹理的生成链路说起。
1.1 深度缓冲与采样深度纹理的“两条路径”
Unity里的深度相关数据其实分两条路径,很多人混淆了。
第一条是GPU硬件深度缓冲。相机在渲染每个物体时,GPU内部会维护一张深度缓冲,决定当前像素谁前谁后。默认情况下透明物体也会参与深度测试,只是不写深度——也就是说,透明物体会被不透明物体遮挡,但它自己不遮挡别人。这张深度缓冲你在Shader里通常拿不到、也采不了,它是硬件内部状态。
第二条是可供Shader采样的深度纹理,也就是我们说的_CameraDepthTexture或CameraDepthTexture。这张RT是引擎额外生成的,通常有两种生成方式:一是在渲染不透明物体之前单独跑一个DepthOnly Pass,把不透明物体当作“纯深度”画一遍;二是在不透明渲染结束后把GPU深度缓冲拷贝一份到RT。不管哪种方式,生成阶段只包含不透明队列,透明物体根本不在里面。这就是为什么你开了ZWrite On,透明物体也不会出现在_CameraDepthTexture里——因为生成深度纹理的阶段压根不会去渲染透明队列。
提示:这里的“透明队列”特指
RenderQueue在3000区间的物体。URP里Depth Prepass的过滤条件是不透明队列,这也是透明物体深度缺失的根本原因。
1.2 透明渲染的三个默认行为,以及一个常见误判
Unity对透明物体有三个默认行为,这三个行为单独看都合理,合在一起就让深度数据变得很难拿:
Queue = Transparent:排在3000区间,在不透明物体之后渲染,保证混合顺序靠后。ZWrite Off:默认关闭深度写入,否则后面的透明物体会因为深度测试被前面的半透明物体剔除,混合顺序直接错乱。RenderType = Transparent:用于着色器替换、后处理分类,UREP的深度预通道也会把它排除。
我最初做传送门特效时,尝试过给透明Shader直接加ZWrite On,结果确实有一部分深度数据出来了,但随之而来的是半透明物体之间的遮挡全乱套:一个半透明水晶球放在半透明屏障后面,屏障把水晶球的像素剔除了一半,边缘出现大量穿孔,比没有深度数据还难看。这就是把“深度测试里的ZWrite”和“深度纹理生成”混为一谈的典型后果。
要走出这个误区,你得明白:想要透明物体的深度数据,不能用默认机制硬凑,必须主动“额外渲染一次”透明物体。下面两套方案的核心思路都是这个,区别只在于把深度写到哪、怎么读。
2. 拿到透明物体深度数据到底能做什么
方案写多了容易陷入技术自嗨,先聊需求。不同类型的项目对透明物体深度数据的要求差别很大,选错采集方式,后面全白干。
2.1 四种常见项目需求
我大概归纳了四类常见需求,在实际项目里遇到最多。
第一类是后处理遮挡判断。比如X-Ray透视、传送门看背面、半透明墙壁后的敌人显示描边。这类需求不一定要精确到厘米级的线性深度,只需要知道“不透明物体是否被透明物体挡住”,对深度数据精度要求不高,但要求采集结果稳定、不破坏场景原有渲染顺序。
第二类是半透明材质自身的交互。典型例子是水面折射,需要知道水面背后物体的距离,用来做岸边浪花融合或水下模糊;还有玻璃边缘厚度计算,需要拿到从相机到玻璃表面的深度。这类需求对深度精度要求中等,更看重深度值的三维连续性,直接采样_CameraDepthTexture里的非线性深度往往不够,得还原成线性深度或视空间深度。
第三类是物理探测/拾取。比如点击场景里的玻璃杯,判断点击距离;或者做深度传感器模拟。这类需求需要的是连续浮点深度,而不是比较器式“是否被遮住”的布尔结果,对数据精度和坐标对齐要求最高。
第四类是特效交互。比如粒子与透明物体互相遮挡时改变颜色或大小,或者角色走进半透明力场时产生边缘晕染。这类需求通常只关心某个局部区域的深度关系,对性能开销很敏感。
需求不一样,方案选型就不一样。如果只是要“遮没遮住”,可以在后处理里把透明物体覆盖的像素做个Stencil标记,根本不需要深度值;但如果你要做折射边缘融合或精确拾取,就必须拿到连续深度数据,而且要尽量存线性深度。
2.2 想清楚深度数据的“精度、坐标系、伴随副作用”
选方案之前,有三个维度的硬指标必须定下来,不然最后调试时根本不知道卡在哪。
精度维度:深度纹理常规用的是16位或24位非线性深度,近处精度高、远处精度低。如果你要把深度写进自定义RT,建议存线性01深度或NDC深度,并在采样时用LinearEyeDepth还原。这样处理浮点纹理时的精度最可控。如果要存世界坐标或视空间坐标,用R32F单通道浮点纹理最稳,但移动端对浮点纹理支持需要实测,退一步可以用R16F,远距离场景精度会差一些但对大多数视觉特效用得过去。
坐标系维度:深度值本身没有语义,一定要先想清楚它代表的是“01线性深度”、“线性视空间z”还是“相机距离”。这三者换算关系不同,后处理里如果搞混,画面会出现奇怪的近大远小错位。我后面方案里统一存NDC深度(即positionCS.z / positionCS.w),采样时用LinearEyeDepth还原,这套组合最通用,内置管线和URP行为一致。
伴随副作用维度:这是最容易忽略的一点。有些方案(比如直接在透明Shader上加ZWrite On,或者用Renderer Feature把透明物体画进主深度缓冲)虽然能拿到深度,但会改变场景主渲染的遮挡结果。如果项目里半透明物体之间还有视觉遮挡关系,这种副作用可能是致命的。所以我在方案对比中会把“是否改变主渲染流程”作为最重要的一项指标。
3. 推荐方案:独立深度相机 + R32F深度RT
这个方案是我在项目里用得最多也是最稳的:不动主相机的深度纹理,不改场景里原本透明物体的渲染队列,额外用一个相机专门把透明物体渲染成一张“深度图”。它适合绝大多数需要连续深度数据的项目,副作用最小,排查也方便。
3.1 Layer、相机配置
先把所有需要采集深度的透明物体放到一个单独的Layer上,比如叫TransparentDepth。这一步别偷懒,如果直接用整个透明队列,会把场景里所有粒子、UI特效、半透明装饰全部画进去,性能白牺牲。
然后创建深度相机。可以用代码动态生成,也可以用相机物体手动配置,关键是CopyFrom主相机的参数保证视角完全一致:
public class TransparentDepthCam : MonoBehaviour { public Camera mainCam; public LayerMask depthMask; public Material depthMat; private Camera depthCam; private RenderTexture depthRT; void OnEnable() { depthRT = new RenderTexture(Screen.width, Screen.height, 0, RenderTextureFormat.RFloat); depthRT.filterMode = FilterMode.Point; depthRT.wrapMode = TextureWrapMode.Clamp; var go = new GameObject("TransparentDepthCamera"); go.transform.SetParent(mainCam.transform, false); depthCam = go.AddComponent<Camera>(); depthCam.CopyFrom(mainCam); depthCam.clearFlags = CameraClearFlags.SolidColor; depthCam.backgroundColor = Color.black; depthCam.cullingMask = depthMask; depthCam.depth = mainCam.depth - 1; depthCam.allowHDR = false; depthCam.allowMSAA = false; depthCam.targetTexture = depthRT; depthCam.enabled = true; depthCam.SetReplacementShader(depthMat.shader, "RenderType"); } void OnDisable() { if (depthRT != null) { depthRT.Release(); Destroy(depthRT); } if (depthCam != null) Destroy(depthCam.gameObject); } }几个配置要点解释一下:
RenderTextureFormat.RFloat不是Depth格式,而是单通道浮点颜色纹理。因为我们要把这个深度图当作普通贴图在后处理里采样,如果直接用Depth格式,很多平台采样会有问题,还需要额外的拷贝步骤。RFloat可以直接tex2D读取。
SetReplacementShader是关键。它让深度相机在渲染透明Layer时,不管物体原本用的什么透明Shader,全部替换成depthMat。这样我们就能统一控制深度输出逻辑,不受原Shader的混合、裁剪、贴图采样影响。
相机挂到主相机下,继承主相机的坐标和旋转,保证画面内容严格对齐。allowMSAA=false和allowHDR=false是为了让深度RT像素内容和后处理采样的分辨率完全对齐,避免MSAA解析带来的边缘偏差。
3.2 深度Override Shader写法
深度Shader的核心很简单:顶点阶段照常变换,片元阶段输出NDC深度到R通道。NDC深度不是线性值,但这个值和_CameraDepthTexture的原始深度语义一致,后处理用LinearEyeDepth还原非常顺滑。
Shader "Hidden/TransparentDepthOnly" { SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } Pass { ZWrite On ZTest LEqual ColorMask R HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionCS : SV_POSITION; }; Varyings vert(Attributes input) { Varyings output; output.positionCS = TransformObjectToHClip(input.positionOS.xyz); return output; } half4 frag(Varyings input) : SV_Target { float ndcDepth = input.positionCS.z / input.positionCS.w; #if UNITY_REVERSED_Z ndcDepth = 1.0 - ndcDepth; #endif return half4(ndcDepth, 0, 0, 0); } ENDHLSL } } }注意我加了UNITY_REVERSED_Z处理。这里有个容易踩的细节:DX平台(以及移动端的Vulkan)深度范围是逆转的,NDC深度在近处大、远处小,而GL平台是正常的近处小、远处大。如果不做兼容,同一个后处理在PC上正常、手机上错乱。所以统一在写入时把深度转成“近处0、远处1”的线性01方向,后处理那边就不用关心平台差异了。
ColorMask R表示只写入R通道。RFloat纹理只用了单通道,写RGB反而浪费带宽。这个Shader是一次完整替换渲染,所以不受原透明Shader是否带顶点动画的影响——当然,如果你的透明物体有顶点动画或骨骼动画,原Shader怎么变形的,这里也要在顶点阶段复现同样的逻辑,否则深度数据和实际画面会错位。
3.3 在物体Shader/后处理中采样深度图
深度图生成好后,采样路径就跟普通深度纹理一样了。在主相机的后处理Shader里,声明纹理并计算屏幕UV:
sampler2D _TransparentDepthTex; float4 _TransparentDepthTex_TexelSize; float4 screenPos = ComputeScreenPos(positionCS); float2 uv = screenPos.xy / screenPos.w; float rawDepth = tex2D(_TransparentDepthTex, uv).r; float linearEyeDepth = LinearEyeDepth(rawDepth, _ZBufferParams); // 例如: float opaqueDepth = SampleSceneDepth(uv); float transparentDepth = linearEyeDepth; float behind = (opaqueDepth > transparentDepth + 0.1) ? 1.0 : 0.0;这里SampleSceneDepth是URP自带的采样主深度纹理函数,需要引用DeclareDepthTexture.hlsl。如果做的是普通ImageEffect或者全屏后处理,这个流程就是完整的。
注意一点:_TransparentDepthTex的UV要和主相机完全对齐,因为我们用CopyFrom复制的相机参数,所以ComputeScreenPos直接用没问题。但要保证两张RT分辨率一致,如果主相机渲染到半分辨率RT,深度相机也必须渲染到半分辨率RT,否则采样会整体偏移或出现边缘锯齿。实际项目中我一般直接绑定主相机的像素宽高,动态创建RT,避免硬编码。
提示:这个方案最舒服的地方在于,它完全不改变主相机的深度缓冲和透明队列渲染。场景原本是什么样,加了深度采集后还是什么样,只是额外生成了一张我们自己的深度图。
4. 进阶方案:Renderer Feature把透明物体画进_CameraDepthTexture
如果你不想额外维护一个相机,希望深度数据直接合并进URP的_CameraDepthTexture里,可以用Renderer Feature在Depth Prepass阶段把透明物体“补画”进深度纹理。这个方案省一张RT,但副作用更大,适合对后处理采样路径有严格统一要求的项目。提前说清楚:它会同时影响后续Opaque阶段的深度测试,场景里半透明遮挡结果可能会变,需要根据项目实际验证。
4.1 前置条件与风险确认
采用这个方案前,项目必须满足两个前置条件。
第一个是URP的Depth prepass必须开启。在URP Asset的Depth Priming Mode里选择Auto或Enabled,这样_CameraDepthTexture是在不透明物体渲染前通过DepthOnly Pass生成的,我们才能通过Renderer Feature在它生成后再追加写一层。如果DepthPriming是Disabled且靠Opaque后拷贝深度,那我们在Prepass阶段写进去的深度最后会被拷贝结果覆盖,等于白写。
第二个是透明物体的Shader必须提供可复用的DepthOnly Pass。URP内置Lit/Unlit模板一般自带这个Pass,但你自己写的透明Shader不一定有。没有的话,即使Feature调用了DrawRenderers,也找不到匹配的Pass,画面一片空白。可以先给透明Shader补上一个标准DepthOnly Pass:
Pass { Name "DepthOnly" Tags { "LightMode" = "DepthOnly" } ZWrite On ColorMask 0 Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionCS : SV_POSITION; }; Varyings vert(Attributes input) { Varyings output; output.positionCS = TransformObjectToHClip(input.positionOS.xyz); return output; } half4 frag(Varyings input) : SV_Target { return 0; } ENDHLSL }如果你的透明材质有Alpha Clip(比如树叶、破损布料),记得在fragment里加上clip判断,否则深度会覆盖镂空区域。
4.2 Renderer Feature的C#实现
接下来写一个简单的Renderer Feature。核心是添加一个ScriptableRenderPass,在AfterRenderingPrePasses事件里,用DrawRenderers把透明队列用DepthOnlyTag再画一遍。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class TransparentDepthFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public RenderPassEvent passEvent = RenderPassEvent.AfterRenderingPrePasses; public LayerMask layerMask = ~0; } public Settings settings = new Settings(); private TransparentDepthPass depthPass; public override void Create() { depthPass = new TransparentDepthPass(settings.passEvent, settings.layerMask); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { renderer.EnqueuePass(depthPass); } class TransparentDepthPass : ScriptableRenderPass { private LayerMask layerMask; public TransparentDepthPass(RenderPassEvent passEvent, LayerMask mask) { renderPassEvent = passEvent; layerMask = mask; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd = CommandBufferPool.Get("TransparentDepth"); // 只筛选透明队列 FilteringSettings filtering = new FilteringSettings(RenderQueueRange.transparent, layerMask); // 匹配Shader中LightMode = DepthOnly的Pass ShaderTagId depthTag = new ShaderTagId("DepthOnly"); DrawingSettings drawing = new DrawingSettings(depthTag, renderingData.cameraData.camera); context.DrawRenderers(renderingData.cullResults, ref drawing, ref filtering); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } }在URP Asset的Renderer列表里添加这个Feature,并指定LayerMask为透明深度层。启动后,_CameraDepthTexture里就会出现透明物体的深度,后处理直接采样即可。
4.3 这个方案最容易翻车的两个点
第一个坑是透明物体会被重复渲染。因为主相机的透明阶段还会再画一次这些物体,Feature在Prepass阶段也画了一次,等于透明物体多了两个Draw Call。如果场景里有大量半透明粒子,性能会明显掉。优化办法是把LayerMask收得尽量小,只对真正需要深度数据的透明物体开这个Layer,不要无脑全场景透明层。
第二个坑是深度语义会变。我们往_CameraDepthTexture里写入了透明深度,之后不透明物体在主渲染阶段深度测试时,可能因为看到“前方已经有透明深度”而被剔除。这等于透明物体获得了类似不透明物体的遮挡能力,如果项目不需要这种遮挡,画面就会出现诡异的穿孔。我建议只在你明确就是要让透明物体“参与遮挡判断”的时候用这个方案;如果只是想在后处理里拿到透明物体深度,而不希望它影响场景渲染,老老实实用独立深度相机方案。
5. 我踩过的坑和问题排查实录
这部分是实际操作中最容易卡人的地方。我把印象比较深的几个问题按“现象、原因、解决”整理出来,每个都很典型。
5.1 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 深度图采样出来全黑或全白 | 深度值方向反了或没开启DepthTexture | 检查Camera.depthTextureMode是否包含Depth;在写入端处理UNITY_REVERSED_Z |
| 透明物体的深度拿不到,但ZWrite已开 | URP Depth Prepass不渲染透明队列 | 不要依赖ZWrite,用独立深度相机或Renderer Feature主动补画 |
| 深度相机画面和主相机错位 | 相机参数不一致或挂载方式错误 | 使用CopyFrom复制主相机,并挂在主相机节点下 |
| R32F纹理在手机上读取异常 | 移动端浮点纹理支持不完整 | 降级为R16F或RGBAHalf,并做真机测试 |
| 透明物体之间半透明遮挡顺序全乱 | 直接给透明Shader开了ZWrite On | 改回ZWrite Off,改用独立深度RT方案 |
| 后处理里采样的深度有锯齿或偏移 | RT分辨率不一致或filterMode问题 | 保证深度RT与屏幕分辨率一致,采样用Point模式,避免双线性插值污染边缘 |
| WebGL平台没有深度数据 | WebGL浮点纹理或深度纹理扩展受限 | 用半精度纹理,或退化为写颜色通道的深度RT方案 |
5.2 移动端和WebGL的性能提醒
移动端做透明物体深度采集,最大的敌人不是精度,而是带宽和Draw Call。
独立深度相机方案会多一次完整场景的透明层渲染。如果LayerMask里挂了好几百个粒子系统,开销会非常难看。我的经验是尽量简化深度相机的渲染内容:把需要深度数据的物体做成几个大的Mesh合并,或者用低模替身(用简单的Cube/Quad代替高精度角色)参与深度渲染。深度数据要求的是“大致前后关系”,不需要和原模型完全相等,很多视觉效果根本看不出来区别。
WebGL上还要注意浮点纹理扩展。URP打包WebGL2默认通常支持OES_texture_float,但部分老设备有问题。最保险的做法是,检测运行时是否支持RenderTextureFormat.RFloat,不支持就降级为RenderTextureFormat.R16或者用普通半精度颜色纹理。另外WebGL的深度纹理倒置问题也很常见,写入端统一把深度方向转成近处0远处1,能避开大部分平台差异。
5.3 用FrameDebugger定位深度问题
如果你遇到深度数据不对但找不到原因,强烈建议配合FrameDebugger逐帧排查。我举一个真实的例子:用Renderer Feature方案后,FrameDebugger里能看到透明物体的DepthOnly Pass确实执行了,但后处理采样仍然是黑屏。后来一查,问题不在写端,而是读端——我在后处理Shader里用了SAMPLE_TEXTURE2D采样深度纹理,但忘了URP的_CameraDepthTexture在部分平台有UV翻转,直接采样上下颠倒了采样到黑屏区域。换成SampleSceneDepth函数后一切正常。
说明用FrameDebugger时,不仅要看Pass有没有执行,还要看RT的生成结果。把深度RT在FrameDebugger里可视化出来,图上应该能明确看到透明物体的轮廓;如果轮廓没有,问题在写入侧;如果有轮廓但后处理取不到,问题在采样侧。顺着这条线排查,比瞎改Shader效率高得多。
5.4 扩展思路:重建世界坐标和更高级的用法
拿到线性深度后,最常见的延伸需求是重建世界坐标,用来做体积雾、视差遮挡、反推透明物体背后的物体位置。重建公式不复杂:
float rawDepth = SampleSceneDepth(uv); float linearEyeDepth = LinearEyeDepth(rawDepth, _ZBufferParams); float4 clipPos = float4(uv * 2.0 - 1.0, rawDepth, 1.0); #if UNITY_UV_STARTS_AT_TOP clipPos.y = -clipPos.y; #endif float4 worldPos = mul(UNITY_MATRIX_I_VP, clipPos); worldPos /= worldPos.w;用深度纹理重建世界坐标时,最容易错的是忘记处理Platform翻转和NDC的w分量。上面这套是URP下比较通用的写法,注意UNITY_MATRIX_I_VP只有在开启了深度纹理且相机支持时才是有效的。
如果重建出来的坐标抖得厉害,多半是深度精度不足。解决办法是存线性01深度时,把范围限制到相机近远裁剪面之间,或者用视图空间Z而不是相机距离。我通常把远裁剪面控制在一个合理的值(比如100米),比用默认10000米对远处精度友好得多。
再扩展一下,透明物体深度还能配合Stencil做“边缘描边”:生成深度RT后,和主深度纹理做一次差值,差值超过阈值的像素就是透明物体边界,可以渲染描边或者做边缘发光。这个玩法很实用,比单纯用深度做遮挡判断更出效果。
坦率说,透明物体深度数据并不是Unity开箱即用的能力,但也不是什么黑科技。核心就两句话:不要在默认深度纹理上指望透明物体,要主动额外渲染一次;深度数据的取值方向和坐标系语义,要在写入端一次性统一好。我个人现在做项目,如果只是取透明物体深度来做后处理特效,默认直接上独立深度相机加R32F深度RT,简单、副作用小、好调试;如果项目深度纹理管线已经深度定制,才会考虑用Renderer Feature去合并深度。反正两种方案代码都在上面,按项目情况挑一种就行。