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,渲染队列设为Geometry(2000),开启ZWrite On,用ColorMask 0屏蔽颜色输出。这样它在渲染时只往深度缓冲里写值,不产生任何可见像素。显示颜色的材质球保持原来的半透明Shader,渲染队列Transparent,ZWrite Off,正常混合。
关键在于渲染顺序:写深度的材质先渲染(因为队列是2000,在不透明阶段),深度纹理生成时就能捕获到它的深度;显示颜色的材质后渲染(队列3000),此时深度纹理已经生成完毕,景深后处理就能拿到正确的深度信息,从而对半透明物体做出正确的虚化判断。
这个方案的好处是:不需要改后处理管线,不需要CommandBuffer,不需要额外的Renderer Feature,纯Shader层面的操作,兼容URP和Built-in管线,性能开销也可控。
2. 双材质球方案的完整实现细节
思路清楚了,接下来是具体怎么落地。这一部分我会把Shader代码、材质配置、挂载方式、参数调节都讲透,你照着做就能复现。
2.1 深度写入Shader的编写
先写负责写深度的Shader。这个Shader要尽可能简单,因为它只干一件事:往深度缓冲写值。
Shader "Custom/DepthOnly" { SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } 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设为Transparent,Blending Mode设为Alpha,Render Queue保持Transparent(3000),ZWrite保持Off。
如果你用的是自定义Shader,确保它的Tags里Queue是Transparent,Pass里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 = GetComponent<Renderer>(); 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是不是Geometry,ZWrite是不是On,ColorMask是不是0;显示材质的Queue是不是Transparent,ZWrite是不是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 Off |
4. 进阶优化与扩展思路
基础方案跑通之后,还有一些进阶玩法可以探索。这一部分分享几个我在实际项目里用过的优化和扩展思路。
4.1 基于距离的深度写入开关
前面提到,远处的半透明物体景深已经糊了,深度写入的精度要求不高。可以写一个脚本,根据物体与摄像机的距离,动态启用或禁用深度材质。
using UnityEngine; public class DepthMaterialLOD : MonoBehaviour { public float depthWriteDistance = 30f; private Renderer rend; private Material[] originalMaterials; private Material[] singleMaterial; void Start() { rend = GetComponent<Renderer>(); 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加一个挂载脚本,但解决的是困扰很多人的半透明景深问题。如果你也在做类似的项目,不妨试试这个方案,踩过的坑我都帮你填过了,照着做应该能少走不少弯路。