
如果你在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.AddComponentCamera(); 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的混合、裁剪、贴图采样影响。相机挂到主相机下继承主相机的坐标和旋转保证画面内容严格对齐。allowMSAAfalse和allowHDRfalse是为了让深度RT像素内容和后处理采样的分辨率完全对齐避免MSAA解析带来的边缘偏差。3.2 深度Override Shader写法深度Shader的核心很简单顶点阶段照常变换片元阶段输出NDC深度到R通道。NDC深度不是线性值但这个值和_CameraDepthTexture的原始深度语义一致后处理用LinearEyeDepth还原非常顺滑。Shader Hidden/TransparentDepthOnly { SubShader { Tags { QueueTransparent RenderTypeTransparent } 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里声明纹理并计算屏幕UVsampler2D _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 PassPass { 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去合并深度。反正两种方案代码都在上面按项目情况挑一种就行。