尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity透明物体深度获取实战:原理、方案与调试

Unity透明物体深度获取实战:原理、方案与调试 做Unity开发时间长了你会遇到一个特别拧巴的需求场景里有一块半透明玻璃、一片水面、或者一个全息投影角色你想拿到它的深度数据拿去给扫描线、给描边、给X光透视、给后期模糊做边界。于是很自然地打开Unity写一句float depth tex2D(_CameraDepthTexture, uv).r;跑起来一看深度图上只有不透明物体的深度透明物体跟不存在一样玻璃后面该有什么还是什么。这不是你写法错了而是引擎渲染流程本身就决定了透明物体默认不向深度缓冲写数据。这篇文章就围绕这个坑展开先把背后的渲染原理讲透再列出几种不同项目阶段能落地的采集方案最后把我在实际项目中踩过的坑和排查思路全部整理出来。不管你是刚接触Unity的新手还是已经被半透明深度折磨过好几轮的老手这篇文章应该都能帮你省下不少调试时间。1. 透明物体深度为什么这么难拿1.1 渲染顺序与深度写入的“潜规则”要理解这个问题得先回到深度缓冲本身。Z-Buffer 是最原始的隐藏面消除方案每个像素存一个到相机的距离后面再画同一个像素时如果新距离更近就覆盖颜色和深度如果更远就直接丢弃。这套逻辑对不透明物体是没问题的因为不透明物体最终只会露出最靠近相机的那一层表面。但半透明物体不行。半透明物体需要把后面的颜色混合进来所以渲染顺序非常关键必须先画远处的东西再画近处的半透明玻璃颜色才能正确叠加。为了实现这个顺序Unity把渲染队列拆成 Opaque不透明和 Transparent透明两大类。不透明物体在Queue2000前后统一渲染深度写入是打开的透明物体的队列在Queue3000默认状态是ZWrite Off也就是只做深度测试、不往深度缓冲里写自己的深度。这么设计的本意是透明物体之间不互相遮挡后面的透明物体也要参与混合不能因为深度测试被前面一个透明物体挡住。但副作用就是_CameraDepthTexture在采样时只能拿到不透明层和天空盒的深度透明物体跟没渲染过一样。你写再多tex2D代码也在主深度纹理里读不到半点玻璃或水面的影子。URP 和 HDRP 里的情况也一样。虽然底层 Pass 名字变了但渲染顺序依然遵循“先 Opaque、后 Transparent”的大原则深度纹理的生成时机往往也停在不透明阶段。所以无论你用内置渲染管线还是 Scriptable Render Pipeline透明物体深度缺失这个问题都是绕不开的。1.2 深度数据到底在做什么用有人可能会问透明物体既然视觉上能看出来为什么非要单独拿一份深度数据真正常见的需求其实是这几种描边与轮廓特效。全息人物、冰块、玻璃器皿需要描边时边缘检测要同时考虑透明物体的深度差否则描边会在透明区域断掉。遮挡与透视反馈。MR/AR 场景里半透明墙壁背后的虚拟物体要显示为 X 光效果或者玩家指针射线穿过半透明门时需要知道这扇门在屏幕上的深度位置好决定射线是命中还是穿透。特效与场景融合。粒子系统做软粒子效果时需要场景深度来控制粒子靠近地面时的渐隐。如果场景里有一块水面或玻璃粒子落到上面也应该有软粒子效果这时候不包含透明物体的深度纹理就不够用了。数字孪生与UI叠加。在数字孪生或 XR 项目里数据面板、标注信息要根据真实物体和虚拟半透明物体的前后关系做遮挡不然标注会直接穿模。这些场景的共同点是透明物体深度不是给透明物体自己用的而是给其他渲染物体、后处理或逻辑脚本用的。所以你需要在自己可控的管线位置上把透明物体的深度“单独抠出来”存进一张自己的 RTRenderTexture再喂给后续的使用方。2. 方案选型不同项目该怎么选透明物体深度的采集方案大体有四条路可以走。没有绝对最优的关键看你的项目体量、渲染管线和透明物体的数量。2.1 方案A单独相机 DepthOnly Shader思路最简单额外创建一个辅助相机让它只渲染透明物体所在的 Layer把渲染结果输出到一张 RenderTexture。配合一个专门输出深度的 Shader就能拿到透明物体的深度图。这个方案我最早在项目里用过优点是好理解、调试直观。你只要在场景里添加一个相机调好 CullingMask设好 targetTexture一个深度 RT 就有了。适合透明物体数量少、用不着做复杂优化的小场景比如一个玻璃展柜、一块演示用的水面。但代价也很明显。辅助相机要多跑一遍渲染流程如果场景里的透明物体特别多比如几千个粒子CPU 侧的 DrawCall、GPU 侧的顶点处理和 Shader 执行量都会翻倍。所以做特效密集的正式项目时我不会首选这个方案。2.2 方案BCommandBuffer 在管线缝隙里绘制深度CommandBuffer 或者 URP 下的 ScriptableRendererFeature能让你在“主相机渲染到某一阶段时”插入一段自定义渲染命令。比如在AfterOpaque和BeforeTransparent之间插入一个 Pass只把透明物体画到一张独立的深度 RT 上。做法的本质和方案A差不多区别是不再额外启用一个相机而是复用主相机的剔除结果和渲染上下文性能开销会低一些。内置渲染管线下可以用camera.AddCommandBufferURP 里则是通过ScriptableRendererFeature插入ScriptableRenderPass。方案B适合透明物体数量多、或者你不想维护一堆相机参数的项目。代价是需要稍微了解 CommandBuffer 的 API以及不同渲染管线下的兼容写法。如果你是 URP 项目直接从方案B入手是更现代、更可维护的选择。2.3 方案C改Shader把ZWrite打开这招属于“最省事但不推荐随便用”的偏方直接把透明 Shader 的ZWrite Off改成ZWrite On或者给透明 Shader 额外加一个只写深度的 Pass。改完之后主深度缓冲里确实会多出透明物体的深度后续采样_CameraDepthTexture也能读到一部分。但代价是透明物体一旦写入了深度它们之间的远近关系就固化了。两个重叠的半透明玻璃杯会互相遮挡后面那个玻璃杯被全部挡掉颜色混合顺序彻底乱掉。做半透明度表现的项目这属于不可接受的副作用。所以方案C我只在一种情况下推荐项目里透明物体数量极少、彼此不重叠、而且你确定不会引入复杂的半透明排序需求。否则老老实实走方案A或B别图那几分钟改 Shader 的功夫。2.4 方案D躲在URP的RendererFeature后面URP 目前是很多新项目的首选所以单独说一下。在 URP 下Camera.AddCommandBuffer已经不怎么好用了官方推荐用ScriptableRendererFeature来插入自定义 Pass。你需要在ScriptableRenderPass.Execute里创建 CommandBuffer设ConfigureTarget指向自己的 Depth RT用DrawRenderers按 Layer 过滤只画透明物体最后执行。这套写法的好处是能很好地跟 URP 的渲染事件协作比如在RenderPassEvent.AfterRenderingOpaques之后执行前面的不透明深度已经画完你的透明深度再叠上去得到的就是一份合成的深度图。缺点嘛代码量比方案A大一些而且 URP 版本之间 API 有过调整。如果你对 ScriptableRendererFeature 不熟写起来会有一点头疼。2.5 选型对比表方案复杂度DrawCall增加对半透明排序的影响适用管线适用场景A单独相机 DepthOnly Shader低透明物体数量无内置管线/URP透明物体少的小场景BCommandBuffer中透明物体数量剔除复用无内置管线/URP透明物体数量较多C改Shader ZWrite On极低无额外DrawCall严重所有管线透明物体极少且不重叠DURP RendererFeature中高透明物体数量无URP新项目、需要长期维护我的建议是如果你只是做技术验证或者临时功能先上方案A跑通流程如果是正式项目直接按管线选B或D。方案C尽量不要进正式代码里。3. 实操细节从采集到验证3.1 深度材质与RenderTexture设置这里有一个非常关键的坑我得单独拿出来说如果你用RenderTextureFormat.Depth创建 RT它只是一块深度缓冲很多平台上没法直接绑定给普通 Shader 采样。而当你需要“读取深度数据”时最稳妥的做法是创建一张带颜色通道的 Float RT把深度值写进颜色里。我常用的 RT 配置是RenderTextureFormat.RFloat或RenderTextureFormat.RGFloat分辨率可以按主相机渲染分辨率的一半来。这样既能采样又能省一半显存。代码如下RenderTexture depthRT new RenderTexture(Screen.width / 2, Screen.height / 2, 0, RenderTextureFormat.RFloat); depthRT.name TransparentDepthRT; depthRT.filterMode FilterMode.Point; depthRT.wrapMode TextureWrapMode.Clamp;辅助相机或者 CommandBuffer 渲染时就用这个 RT 作为颜色目标。配套的 Shader 也很简单核心是把顶点变换后的深度值写到颜色通道Shader Custom/TransparentDepthOnly { SubShader { Tags { QueueTransparent RenderTypeTransparent } Pass { ColorMask R ZWrite On ZTest LEqual Blend Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; float depth : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.depth o.pos.z / o.pos.w; #if defined(UNITY_REVERSED_Z) o.depth 1.0 - o.depth; #endif o.depth o.depth * 0.5 0.5; return o; } half4 frag (v2f i) : SV_Target { return half4(i.depth, 0.0, 0.0, 1.0); } ENDCG } } }ColorMask R保证了只写 R 通道ZWrite On是为了保留深度测试的次序ZTest LEqual保证离相机近的透明表面覆盖远的。深度值映射到 [0, 1] 后后续采样和调试都会方便很多。3.2 相机同步与生命周期管理如果选择方案A的辅助相机还有一个隐藏麻烦辅助相机必须跟着主相机走。我最早是直接在Update里把主相机的 transform 赋值给辅助相机后来发现还有 FOV、近裁面、远裁面、正交模式这些问题漏一个就出问题。建议用一个专门的脚本来管理这样切场景、重启相机、释放 RT 都能兜住public class TransparentDepthCamera : MonoBehaviour { public Camera mainCamera; public LayerMask transparentLayer; public float depthScale 0.5f; private Camera depthCamera; private RenderTexture depthRT; private void OnEnable() { CreateDepthCamera(); CreateDepthRT(); } private void OnDisable() { Cleanup(); } private void CreateDepthCamera() { depthCamera gameObject.AddComponentCamera(); depthCamera.cullingMask transparentLayer; depthCamera.clearFlags CameraClearFlags.SolidColor; depthCamera.backgroundColor Color.white; depthCamera.enabled false; } private void CreateDepthRT() { depthRT new RenderTexture( Mathf.Max(1, Screen.width / 2), Mathf.Max(1, Screen.height / 2), 0, RenderTextureFormat.RFloat); depthRT.name TransparentDepthRT; depthRT.filterMode FilterMode.Point; depthRT.wrapMode TextureWrapMode.Clamp; depthCamera.targetTexture depthRT; } private void LateUpdate() { if (mainCamera null || depthCamera null) return; depthCamera.transform.SetPositionAndRotation( mainCamera.transform.position, mainCamera.transform.rotation); depthCamera.orthographic mainCamera.orthographic; if (mainCamera.orthographic) { depthCamera.orthographicSize mainCamera.orthographicSize; } else { depthCamera.fieldOfView mainCamera.fieldOfView; } depthCamera.nearClipPlane mainCamera.nearClipPlane; depthCamera.farClipPlane mainCamera.farClipPlane; depthCamera.Render(); Shader.SetGlobalTexture(_TransparentDepthTex, depthRT); } private void Cleanup() { if (depthRT ! null) { depthRT.Release(); depthRT null; } } }几个要点辅助相机的enabled一定要设为 false否则它会把主相机的内容再渲染一遍可能造成递归或重复渲染。手动调用depthCamera.Render()需要谨慎。如果这段代码是在主相机渲染过程中触发的比如OnRenderImage里一定要确认不会造成递归调用。主相机应该把透明物体所在 Layer 排除掉否则主相机渲染一次透明物体、辅助相机再渲染一次就会产生双份开销视觉上也可能出现奇怪的排序问题。使用全局变量Shader.SetGlobalTexture(_TransparentDepthTex, depthRT)可以让场景里所有 Shader 直接引用这张深度纹理省去一批材质传递。3.3 把深度图喂给主Shader拿到深度 RT 之后关键一步是搞清楚怎么从这张图里还原出可用的深度数据。因为我上面的 Shader 把深度写成了线性的 [0, 1] 视深所以采样时只要读 R 通道再映射一次即可float transparentDepth tex2D(_TransparentDepthTex, uv).r;如果你想要世界空间距离就在管线里传入_ProjectionParams等参数做还原。我这里写的是线性视深方便直接跟场景深度做比较也方便做描边算法里的深度差计算。用深度纹理做描边时通常会比较相邻像素的深度差值。透明深度图的边缘处数值变化会很陡峭这时候用ddx/ddy或者 Sobel 算子都能提取轮廓。要注意的是半透明物体如果没有边缘信息比如一大片水面深度值本身是连续的描边效果不一定自然需要配合法线或者颜色阈值。3.4 可视化验证别用眼睛猜深度写深度数据这类功能最忌讳就是“我猜它应该没问题”。深度图渲染到 RT 之后直接在 Scene 视图里是看不到的必须用某种方式把内容反馈到屏幕上否则很容易出现“功能接了、效果错误、还找不到原因”的情况。我的习惯是做一个 Debug 材质把深度 RT 映射成伪彩色显示在一个全屏 Quad 或者 UI 面板上。伪彩色比灰度直观得多因为灰度图在远处差别太小几乎看不出层次。用 HSV 或者彩虹色映射深浅变化一眼就能看出来Shader Hidden/ShowTransparentDepth { Properties { _MainTex (Depth Tex, 2D) white {} _Scale (Scale, Float) 8.0 } SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float _Scale; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag (v2f i) : SV_Target { float d tex2D(_MainTex, i.uv).r * _Scale; half3 color frac(half3(d, d 0.33, d 0.66)); return half4(color, 1.0); } ENDCG } } }验证标准很简单记住三个值——近处鲜艳、远处偏暗玻璃边缘处应该有明显的深度跳变跳变方向和背后的几何体遮挡关系保持一致。如果只是一层死白或死黑大概率是 RT 格式、Shader 输出范围或者 Clear 设置出了问题。3.5 性能与内存开销评估深度 RT 的成本不小不能忽略。全分辨率下的 1920×1080 Float RT每像素 4 字节单张约 8MB。如果还开了 Depth Buffer又会多 4 字节每像素。两次加起来一大块显存就被吃掉了。分辨率减半之后内存能降到大约 2MB 到 4MB对性能的影响也小一些。额外相机方案中CPU 侧会多一次相机剔除和最简 Shader 渲染DrawCall 等于透明物体的批次数量GPU 侧主要消耗在顶点变换上。如果透明物体是大量粒子的情况这部分成本会很明显。CommandBuffer 方案由于复用了主相机的剔除结果CPU 侧更省但对深度 Shader 的要求仍然存在。移动端和 WebGL 平台要额外注意 Float 纹理采样支持。RenderTextureFormat.RFloat在 OpenGL ES 3.0 大部分机器上可用但部分低端机不行遇到兼容性问题可以退回RenderTextureFormat.RGFloat或者ARGBFloat也可以用RenderTextureFormat.ARGBHalf做半精度存储精度稍低但对深度运算影响不大。代码里建议先检测private RenderTextureFormat GetDepthFormat() { if (SystemInfo.SupportsRenderTextureFormat(RenderTextureFormat.RFloat)) return RenderTextureFormat.RFloat; if (SystemInfo.SupportsRenderTextureFormat(RenderTextureFormat.RGFloat)) return RenderTextureFormat.RGFloat; return RenderTextureFormat.ARGBHalf; }4. 常见问题与排查技巧实录4.1 深度纹理全白或全黑这个现象出现频率最高。我遇到过的原因有三个第一RT 没有 Clear初始值残留导致整片区域都是不确定数据第二辅助相机的backgroundColor设置成了灰色或白色没有正确清成远平面以外的无效值第三Shader 输出深度时o.pos.z / o.pos.w在 D3D 和 OpenGL 下的范围不一样需要根据UNITY_REVERSED_Z做归一化。解决顺序建议先看 Clear 设置再看 RT 格式最后在 Shader 里加一行调试输出把深度值直接映射成红绿蓝三色确认数值分布是否连续。4.2 透明物体深度“穿模”如果深度 RT 里出现了透明物体背后物体的深度顺序还乱了多半是深度测试没设置对。辅助渲染透明物体时需要把ZTest设为LEqual并且保证 RT 在开始画之前被 Clear 成一个大值比如 1.0。否则旧帧残留或者透明物体的背面也参与写入就会出现反直觉的遮挡关系。另一个常见错误是透明物体的材质用了不裁剪背面的 Shader导致深度 RT 里同时有正面和背面的深度。对于一些薄片物体问题不大但遇到封闭体积的水面、玻璃球时需要按需求关闭背面写入或者用双面渲染但保证深度排序符合预期。4.3 半透明排序导致的深度错乱两个半透明物体重叠时如果只画一层深度拿到的是“最靠近相机的那一层”的深度还是“所有透明表面的混合深度”答案是单次渲染只能拿到一个深度值取决于深度测试的结果。对于两个互相穿插的玻璃杯一片玻璃的背面可能比另一片玻璃的正面更近最终结果就变成了前后混合视觉上深度值会跳来跳去。如果业务对深度精度要求很高比如要精确描出每个玻璃杯的外轮廓就需要考虑深度剥离Depth Peeling思路第一遍画最近深度第二遍用ZTest Greater画次近距离深度反复多次叠加。这个方案开销很大适合离线或者高级渲染场景运行时特效项目慎用。大多数实际需求里只采集“最近表面深度”就够用了。4.4 URP/WebGL平台差异URP 项目里如果沿用内置管线的Camera.AddCommandBuffer思路可能会发现命令始终不执行。因为 URP 自己管理渲染流程不再读取Camera上的 CommandBuffer 列表。要改成ScriptableRendererFeature方案在RenderPassEvent.AfterRenderingOpaques之后插入 Pass。WebGL 平台的坑主要在格式支持和异步读取上。深度 RT 如果用了RenderTextureFormat.Depth在部分浏览器上采样会返回 0使用 Float RT 时也要确认SystemInfo.SupportsRandomRWTextureOnRT是否满足需求。发布 WebGL 前最好先在一个目标浏览器上跑一遍深度图的 Debug 可视化否则等用户访问再报错排查成本会高很多。4.5 问题速查表现象常见原因解决办法深度图全黑Clear 设置错误或 RT 未清屏检查 ClearFlags初始化为 Color.white深度图全白深度输出范围错误值恒为 1检查o.pos.z/o.pos.w归一化逻辑深度值乱跳ZTest 设置错误或背面也写入设置ZTest LEqual按需关闭背面透明物体深度穿透没有单独清除深度 RT每次渲染前 Clear 深度目标CommandBuffer 不执行URP 下用了内置管线 API改用 ScriptableRendererFeatureWebGL 上采样全是 0Float RT 格式不支持检测SystemInfo换半浮点格式高级别的两个半透明物体深度错乱单层深度采集无法解决采用深度剥离思路最后再分享一个小技巧这算是我个人在深度数据调试上最深的体会任何跟深度、像素打交道的工作先做一步可视化验证再接入业务逻辑。我最早做这个需求的时候对着一块白花花或者黑乎乎的 RT 折腾了很久最后发现只是 ClearFlags 没设对。后来养成了习惯——拿到深度 RT 的第一时间不接描边、不接遮挡先用 Debug 材质把它画在屏幕上确认数值连续、范围正确、边缘跳变符合预期再往业务里接。这套流程可能多花半小时但后面省下的排查时间是按天算的。希望这篇东西能帮你少走这些弯路。
返回列表