Unity半透明景深失效?双材质球方案彻底解决
1. 半透明景深失效的根源拆解半透明物体在Unity里做景深十有八九会遇到一个很尴尬的现象场景里那些玻璃、水面、粒子特效在开了Post-Processing的Depth of Field之后要么完全糊成一片要么干脆像贴纸一样浮在画面上跟周围环境的虚化程度对不上。这个问题我在做建筑可视化和游戏场景的时候踩过不止一次今天就把我实际用下来最稳的一套方案——双材质球方案——完整拆一遍。先说清楚这个方案能干什么它让半透明物体在景深效果下能够像不透明物体一样正确地参与深度计算该虚化的地方虚化该清晰的地方清晰同时保留半透明本身的混合效果。适合谁看做过URP或者Built-in管线后处理、被半透明景深坑过的Unity开发者以及正在做建筑漫游、产品展示、游戏特效这类需要景深氛围的项目的人。1.1 为什么半透明物体在景深里会“失灵”要解决问题得先知道问题从哪来。Unity的景深效果不管是URP的Depth of Field还是Built-in管线配合Post Processing Stack核心依赖的是深度纹理Depth Texture。这个深度纹理记录的是每个像素距离摄像机的深度值后处理阶段根据这个深度值判断哪些像素该模糊、模糊多少。问题就出在这里半透明物体默认不写入深度纹理。Unity里绝大多数半透明Shader的渲染队列是Transparent队列号3000而深度纹理的生成通常发生在不透明物体渲染之后、半透明物体渲染之前。也就是说当深度纹理被“拍照”的时候半透明物体还没画上去深度纹理里根本没有它们的位置信息。结果就是景深后处理拿到的深度图里半透明物体所在的位置要么是背景的深度要么是天空盒的深度。景深算法一看“哦这个像素深度很远该模糊”于是把半透明物体也一起模糊了或者反过来背景很近半透明物体就被当成近处物体保持清晰。无论哪种视觉上都是错的。1.2 常见的错误尝试和它们的坑我见过也试过不少“偏方”这里列几个典型的顺便说说为什么它们不够好。第一种是直接把半透明Shader的渲染队列改成AlphaTest或者Geometry。这样确实能写入深度了但代价是半透明物体之间的排序会乱掉而且它会被当成不透明物体处理混合效果可能出问题边缘会出现硬切。对于玻璃这种需要柔和透明的物体基本不可用。第二种是在Shader里手动输出深度用ZWrite On配合ColorMask 0先渲染一遍深度。这个思路是对的但实现起来要额外写一个Pass而且如果场景里半透明物体很多Draw Call会翻倍性能上不划算。第三种是用CommandBuffer手动抓深度。这个方案灵活但代码复杂度高而且跟URP的Renderer Feature配合起来比较绕维护成本大。这些方案不是不能用而是各有各的局限。双材质球方案的核心思路是把“写深度”和“显示颜色”这两件事拆开用两个材质球分别负责既保证了深度正确又保留了半透明的混合效果而且实现相对干净。1.3 双材质球方案的核心思路所谓双材质球就是给同一个半透明物体挂两个材质一个负责写深度一个负责显示颜色。写深度的材质用不透明的渲染方式只写深度不写颜色显示颜色的材质保持半透明正常混合。具体来说写深度的材质球使用一个极简的Shader渲染队列设为Geometry2000开启ZWrite On用ColorMask 0屏蔽颜色输出。这样它在渲染时只往深度缓冲里写值不产生任何可见像素。显示颜色的材质球保持原来的半透明Shader渲染队列TransparentZWrite Off正常混合。关键在于渲染顺序写深度的材质先渲染因为队列是2000在不透明阶段深度纹理生成时就能捕获到它的深度显示颜色的材质后渲染队列3000此时深度纹理已经生成完毕景深后处理就能拿到正确的深度信息从而对半透明物体做出正确的虚化判断。这个方案的好处是不需要改后处理管线不需要CommandBuffer不需要额外的Renderer Feature纯Shader层面的操作兼容URP和Built-in管线性能开销也可控。2. 双材质球方案的完整实现细节思路清楚了接下来是具体怎么落地。这一部分我会把Shader代码、材质配置、挂载方式、参数调节都讲透你照着做就能复现。2.1 深度写入Shader的编写先写负责写深度的Shader。这个Shader要尽可能简单因为它只干一件事往深度缓冲写值。Shader Custom/DepthOnly { SubShader { Tags { RenderTypeOpaque QueueGeometry } Pass { ZWrite On ColorMask 0 Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); return o; } fixed4 frag (v2f i) : SV_Target { return fixed4(0,0,0,0); } ENDCG } } }这个Shader有几个关键点需要说明。Queue设为Geometry确保它在不透明阶段渲染早于深度纹理生成。ZWrite On开启深度写入这是核心。ColorMask 0屏蔽所有颜色通道的输出这样它不会在画面上留下任何痕迹。Cull Back是默认的背面剔除如果你的半透明物体是双面的比如玻璃杯可能需要改成Cull Off这个后面会细说。注意这个Shader不需要处理光照、不需要纹理采样越简单越好。每多一个计算都是在浪费性能因为它本来就不产生可见像素。2.2 半透明显示Shader的调整显示颜色的Shader就是你原本用的半透明Shader但需要确认几个设置。以URP的Lit Shader为例Surface Type设为TransparentBlending Mode设为AlphaRender Queue保持Transparent3000ZWrite保持Off。如果你用的是自定义Shader确保它的Tags里Queue是TransparentPass里ZWrite Off、Blend SrcAlpha OneMinusSrcAlpha。这些是标准半透明配置一般不用改。有一个细节要注意深度写入材质和显示材质必须使用同一个Mesh。也就是说你不能给深度材质用一个简化版的模型否则深度信息会对不上。两个材质球挂在同一个Renderer上Unity会自动用同一个Mesh渲染两次。2.3 材质球的挂载方式在Unity里一个Renderer默认只能挂一个材质。要挂两个材质有两种方式。第一种是通过代码动态添加。在脚本里获取Renderer组件然后给它赋值一个材质数组using UnityEngine; public class DualMaterialSetup : MonoBehaviour { public Material depthMaterial; public Material displayMaterial; void Start() { Renderer rend GetComponentRenderer(); if (rend ! null depthMaterial ! null displayMaterial ! null) { rend.materials new Material[] { depthMaterial, displayMaterial }; } } }这里用rend.materials而不是rend.material因为materials是数组可以挂多个。顺序很重要深度材质放前面显示材质放后面。虽然渲染顺序主要由Queue决定但同一Queue内的相对顺序会受材质数组顺序影响把深度材质放前面更保险。第二种是在Editor里手动拖拽。选中物体在Inspector的Mesh Renderer组件里找到Materials列表把Size改成2然后把深度材质和显示材质分别拖进去。这种方式适合场景里物体不多的情况直观但批量操作麻烦。实操心得如果场景里有大量半透明物体建议写一个编辑器脚本批量处理或者用Prefab Variant统一管理。手动一个个拖几十个物体下来眼睛都花了。2.4 参数调节与效果验证挂好之后打开Post-Processing的Depth of Field调节Focus Distance和Aperture观察半透明物体的虚化效果。正常情况下半透明物体应该跟周围的不透明物体一样根据距离正确虚化。如果发现半透明物体还是不对检查这几个点深度材质的Queue是不是GeometryZWrite是不是OnColorMask是不是0显示材质的Queue是不是TransparentZWrite是不是Off两个材质是不是挂在同一个Renderer上。验证深度是否正确有一个简单方法在Scene视图里打开Wireframe或者Depth视图URP下可以通过Frame Debugger查看深度纹理看看半透明物体有没有出现在深度图里。如果出现了说明深度写入成功。3. 实操过程中的关键环节与避坑指南代码和配置都给了但实际做的时候还是会遇到各种意外。这一部分我把踩过的坑和对应的解法整理出来能帮你省不少时间。3.1 双面半透明物体的处理前面提到Cull Back的问题。如果你的半透明物体是双面的比如玻璃杯、窗帘、树叶只渲染正面会导致深度信息不完整。从某些角度看深度图里会缺一块景深效果就会出问题。解法是把深度材质的Cull改成Off让正反面都写入深度。但这样会带来一个新问题双面渲染时两个面的深度值会互相覆盖最终深度图里记录的是离摄像机更近的那个面。对于景深来说这通常是可以接受的因为景深关心的是“这个像素大概在什么深度”而不是精确的每个面的深度。如果对精度要求极高比如做产品级渲染可以考虑用两个Pass分别渲染正反面或者用ZWrite On配合Offset来微调深度值。但大多数场景下Cull Off就够了。3.2 半透明物体之间的遮挡关系双材质球方案解决的是半透明物体与景深的关系但半透明物体之间的排序问题依然存在。Unity对半透明物体的排序是基于物体中心点的距离对于大面积的半透明物体比如水面排序可能会出错。这个问题的根源不在景深而在半透明渲染本身。双材质球方案不会让这个问题变好也不会让它变坏。如果你的场景里半透明物体之间有严重的排序问题需要单独处理比如手动设置RenderQueue或者用Sorting Group或者把大物体拆成小块。注意不要试图用深度写入来解决半透明排序问题。深度写入会让半透明物体变成“硬边”失去透明的柔和感得不偿失。3.3 性能开销的评估双材质球方案会让半透明物体的Draw Call翻倍因为同一个Mesh要渲染两次。对于Draw Call敏感的项目比如移动端这个开销需要评估。实测下来一个半透明物体从1个Draw Call变成2个如果场景里有50个半透明物体就多了50个Draw Call。这个数量在PC端基本无感但在移动端可能会有压力。优化的思路有几个。一是合并深度写入Pass如果多个半透明物体用的是同一个深度材质Unity的合批机制可能会把它们合在一起减少实际Draw Call。二是按距离剔除远处的半透明物体如果景深已经糊得看不清了深度写入的精度要求可以降低甚至可以考虑不写。三是用LOD近距离用双材质远距离用单材质因为远距离景深模糊程度高深度误差不明显。3.4 与URP Renderer Feature的兼容性如果你用的是URP并且自定义了Renderer Feature来做后处理需要注意深度纹理的生成时机。URP默认会在DepthPrepass或者CopyDepth阶段生成深度纹理双材质球的深度写入材质因为Queue是Geometry会在这个阶段被渲染进去所以兼容性没问题。但如果你在Renderer Feature里手动抓深度并且抓取的时机在半透明渲染之后那就抓不到深度材质的写入了。这种情况下需要调整Renderer Feature的执行顺序确保它在半透明渲染之前抓深度。3.5 常见问题速查表问题现象可能原因解决方法半透明物体完全不虚化深度材质Queue不是Geometry检查Tags里的Queue设置半透明物体边缘有硬边深度材质Cull Back导致改成Cull Off半透明物体颜色变暗深度材质ColorMask没设0确认ColorMask 0景深效果时有时无深度纹理生成时机不对检查URP Renderer Feature顺序Draw Call过高半透明物体数量多考虑LOD或距离剔除双面物体深度缺失Cull Back只渲染正面改成Cull Off4. 进阶优化与扩展思路基础方案跑通之后还有一些进阶玩法可以探索。这一部分分享几个我在实际项目里用过的优化和扩展思路。4.1 基于距离的深度写入开关前面提到远处的半透明物体景深已经糊了深度写入的精度要求不高。可以写一个脚本根据物体与摄像机的距离动态启用或禁用深度材质。using UnityEngine; public class DepthMaterialLOD : MonoBehaviour { public float depthWriteDistance 30f; private Renderer rend; private Material[] originalMaterials; private Material[] singleMaterial; void Start() { rend GetComponentRenderer(); originalMaterials rend.materials; singleMaterial new Material[] { originalMaterials[1] }; } void Update() { float dist Vector3.Distance( Camera.main.transform.position, transform.position ); if (dist depthWriteDistance) { if (rend.materials.Length 1) rend.materials singleMaterial; } else { if (rend.materials.Length 1) rend.materials originalMaterials; } } }这个脚本的逻辑很简单距离超过阈值时只保留显示材质去掉深度材质距离近时恢复双材质。阈值设多少合适一般设成景深开始明显模糊的距离比如Focus Distance的1.5到2倍。具体数值需要根据项目调。实操心得这个优化在移动端效果明显Draw Call能降不少。但要注意频繁切换材质数组会有GC开销建议加一个状态判断只在状态变化时切换不要每帧都赋值。4.2 深度材质的合批优化如果场景里有很多半透明物体共用同一个深度材质Unity的静态合批或动态合批可能会把它们合并。要触发合批需要满足几个条件相同的材质、相同的Shader变体、顶点数在限制以内。深度材质的Shader很简单没有纹理、没有光照变体很少合批条件比较容易满足。实测下来几十个半透明物体用同一个深度材质Draw Call能从几十降到个位数。但要注意合批之后深度写入的顺序可能会变如果半透明物体之间有重叠深度图里记录的可能不是最近的那个面。对于景深来说这个误差通常可以接受因为景深本身就是模糊效果对深度的精度要求没有阴影那么高。4.3 与自定义后处理的配合如果你的项目用了自定义的后处理比如自己写的景深Shader双材质球方案同样适用。核心逻辑不变深度纹理里要有半透明物体的深度信息后处理才能正确计算。自定义后处理时需要注意深度纹理的采样方式。URP的_CameraDepthTexture是单通道的采样时用.r或者.x。如果自己抓深度确保格式是Depth或者Shadowmap不要用颜色格式。另外如果自定义后处理里用了LinearEyeDepth或者Linear01Depth来转换深度值确保转换参数跟深度纹理的生成方式匹配。URP下通常用LinearEyeDepth(depth, _ZBufferParams)Built-in管线类似。4.4 在移动端的适配要点移动端用双材质球方案有几个额外的注意点。一是Shader复杂度深度材质的Shader要尽可能简单避免在移动GPU上产生额外开销。二是精度问题移动端的深度缓冲精度可能不如PC深度写入时要注意ZWrite的精度设置必要时用Offset来避免Z-Fighting。三是发热和耗电双倍Draw Call意味着GPU负载增加长时间运行可能会发热需要做好性能监控。我在一个移动端的建筑展示项目里用过这个方案场景里有大概20个半透明玻璃幕墙。开启双材质球后Draw Call从40多涨到60多帧率从60掉到55左右。后来加了距离剔除把远处的玻璃幕墙深度写入关掉帧率回到了58到60基本可接受。4.5 与其他景深方案的对比市面上还有几种半透明景深的处理方案这里简单对比一下方便你根据项目情况选择。方案优点缺点适用场景双材质球实现简单兼容性好不改管线Draw Call翻倍中小规模场景PC和主机改渲染队列零额外开销半透明排序乱边缘硬对透明效果要求不高的场景CommandBuffer抓深度灵活可控代码复杂维护成本高大型项目有专门TA自定义后处理效果最可控开发量大调试麻烦产品级渲染效果优先双材质球方案的优势在于平衡实现成本低效果稳定性能开销可控。对于大多数项目来说这是性价比最高的选择。5. 实际项目中的经验总结最后分享几个我在实际项目里积累的经验都是文档里不会写的。第一个经验是深度材质的Shader越简单越好。我一开始为了“通用”在深度Shader里加了一些条件判断和纹理采样结果性能反而下降了。后来把Shader精简到只剩顶点变换和深度写入性能才恢复正常。深度材质不产生可见像素任何多余的计算都是浪费。第二个经验是材质数组的顺序会影响渲染顺序。虽然Queue决定了大的渲染阶段但同一Queue内的物体材质数组的顺序会影响它们的相对渲染顺序。把深度材质放在数组前面能确保它在同一帧内更早写入深度减少深度冲突的概率。第三个经验是不要忽略Scene视图的调试。在Scene视图里切换到Depth模式能直观看到深度纹理里有没有半透明物体。这个调试方法比看Game视图猜要高效得多。URP下可以通过Frame Debugger查看每个Pass的深度纹理Built-in管线可以用Camera的DepthTextureMode来可视化。第四个经验是批量处理用编辑器脚本。如果场景里有几十上百个半透明物体手动挂材质球是不现实的。写一个编辑器脚本遍历所有使用半透明材质的Renderer自动添加深度材质能省大量时间。脚本的核心逻辑就是获取Renderer检查材质数组长度如果只有1个就添加深度材质。第五个经验是测试不同角度和距离。景深效果跟摄像机角度和距离强相关测试时要在不同位置、不同距离、不同角度下观察半透明物体的虚化效果。有些问题只在特定角度下才会暴露比如双面物体的深度缺失正面看没问题侧面看就露馅了。这套双材质球方案我在三个项目里用过从PC端的建筑漫游到移动端的产品展示效果都挺稳。核心代码就那几十行Shader加一个挂载脚本但解决的是困扰很多人的半透明景深问题。如果你也在做类似的项目不妨试试这个方案踩过的坑我都帮你填过了照着做应该能少走不少弯路。