ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity URP半透明渲染:Cull与ZWrite的开关组合技巧

Unity URP半透明渲染:Cull与ZWrite的开关组合技巧 做Unity URP项目的那段时间我最大的噩梦之一就是半透明材质一片叶子背光面黑成炭两层半透明玻璃叠在一起谁在前完全看心情草尖在夕阳下反而把太阳光遮得严严实实。后来把Shader里能调的渲染参数翻了个底朝天才明白URP下半透明物体渲染不是“把Blend设为Alpha”这么简单真正决定成败的是两个很容易被忽略的开关双面渲染Cull和深度写入ZWrite。这篇我就把这两个开关背后的渲染原理、组合用法、以及我在实际项目中踩过坑后的解决方案一次性讲清楚。适合刚迁移到URP的团队、做植被或玻璃材质的TA、以及被半透明排序折磨的程序员。1. 半透明物体在URP里为什么总出问题从渲染规则到参数盲区1.1 渲染队列不透明从近到远半透明从远到近先聊一个基础但很多人忽略的事情Unity的渲染顺序。大部分不透明物体都在Opaque队列Unity会按从近到远的大致顺序逐个绘制并配合深度测试裁剪掉被遮挡的像素这样能省下大量填充率。半透明物体则完全换了一套逻辑它们被放在Transparent队列渲染顺序是从远到近。原因很好理解——半透明颜色要和后面已经画好的颜色做混合只有先把远处的东西画出来近处的透明面才能把颜色叠上去。这个差异在URP里依然保留但它带来一个天然限制半透明物体之间的排序依赖引擎对物体中心点距离的估算。两个半透明面交叉、重叠、中心点接近时这个估算经常不准于是你看到两个面在你眼前“轮岗”一会儿你挡我一会儿我挡你或者从某个角度看过去明显是错误顺序。很多新手以为这是渲染Bug其实这是半透明混合的底层逻辑决定的。如果你想要精确到像素级的遮挡关系就必须借助深度写入和AlphaClip这些“不透明思维”的手段来补救。这也是我认为做半透明渲染前必须先把队列、ZWrite、Cull、Blend这四者的关系理顺的原因。1.2 Surface Type一改隐藏的ZWrite坑就埋下了在URP的Lit或Unlit材质上把Surface Type从Opaque切换成Transparent后Inspector面板会多出Blending Mode、Render Face等选项。变化不仅仅是渲染队列从Geometry变成了Transparent更重要的是Unity会自动把ZWrite关掉。为什么URP默认要关掉ZWrite因为半透明混合需要让所有透明像素都能参与颜色混合如果半透明物体自己也写深度它就会挡住后面更远的半透明物体导致后面的透明物体无法被正确混合。所以Transparent默认ZWrite Off在大多数场景下是安全的。可问题也在这里当你只是想要“一个能看见后面的半透明板子”时没问题但当你做树叶、头发片、粒子屏障这类需要一定遮挡关系的东西时你必须在某个地方重新打开ZWrite。如果你不知道这个默认行为遇到问题时会完全摸不着头脑。URP的Transparent材质Inspector里其实暴露了Depth Write选项但很多人根本不会去看折叠区域里那个不起眼的勾选框。1.3 双面渲染和深度写入其实就是两个开关的组合做半透明物体时两个参数共同决定最终画面开关作用常见的坑Cull双面渲染控制是否绘制背对摄像机的三角形面开了双面但法线没修背面光照全错ZWrite深度写入控制这个透明物体是否会往深度缓冲里写值决定它能否“挡住”后面的像素关闭时排序容易错开启时又会挡住本应透明的物体两个开关排列组合能派生出四种典型状态Cull Back ZWrite Off标准玻璃适合大部分单纯透光物体。Cull Off ZWrite Off双面玻璃、全息、粒子背面也可见但排序更不稳定。Cull Off ZWrite On双面半透明屏障有遮挡关系但自身排序需要小心。Cull Off Alpha Clip ZWrite On树叶、头发片上最常用的状态本质是“透明物体当不透明画”。后面所有实操都是在取舍这四种状态。所以先记住没有一套配置能适配所有半透明物体每个参数都要知道你在为哪种视觉需求付代价。2. 双面渲染不要只记Cull Off法线翻转是必修课2.1 开启双面渲染的三种姿势材质、Shader Graph、HLSL先说最直接的开启方式下面三种任选其一使用URP自带Lit/Unlit材质时在Inspector的Surface Options里把Render Face改成Both。这一步本质上就是设置Shader的Cull Off。使用Shader Graph时打开Graph Inspector在UniversalSurface Options里同样把Render Face改为Both。注意创建的主节点类型决定选项名Lit主节点和Unlit主节点都有。自己写Shader时在Pass里写Cull Off即可。你也可以保留Cull Front只画背面但绝大多数双面需求都是Cull Off。从GPU层面看Cull Off意思是“所有朝向的三角形都进入光栅化”。默认情况下Unity只渲染正面通常逆时针判为正面顺时针判为背面开启Cull Off后背面的三角形数量直接翻倍这是最直观的性能代价。2.2 为什么Cull Off之后背光面还是发黑很多人开了双面渲染后第一反应是“背面终于出来了”但紧接着就发现背面颜色特别黑或者高光反射的位置完全错乱。这是因为GPU在光栅化背面三角形时并不会自动把三角形的法线也翻转过来。举个例子一片水平放置的叶子正面法线朝上背面法线也朝上因为同一个顶点法线只有一个三角形朝向变了法线数据没变。从背面看叶子时光照计算依然认为这片叶子是朝上的结果就是太阳光从下方照过来背面区域一片漆黑因为漫反射计算用点积把光线拒之门外。Shader Graph中需要把Normal Vector和Face Sign节点相乘。Face Sign在正面返回1在反面返回-1乘到法线上就能让背面法线反转为正确方向。自己写HLSL时片元着色器里可以用语义SV_IsFrontFace或旧式VFACE判断当前像素是不是背面然后取反法线。// 示例在片元着色器里根据朝向翻转法线 half4 frag(Varyings input, half facing : VFACE) : SV_Target { #if defined(_DOUBLE_SIDED_BLEND) float3 normalWS normalize(input.normalWS); // facing 在背面通常为负正反面乘一下即可 normalWS * facing; #endif // 之后正常做光照计算 }提示在Shader Graph中双击查看主节点的Surface Options里面如果开启了Render Face Both生成的代码里不会自动帮你处理法线翻转所以Face Sign这一步不能省。2.3 树叶案例法线翻转后的光照修正我做过一个森林场景叶子全是用Quad片做的。一开始直接在Shader Graph里把Render Face改成Both结果从树冠底部往上看时所有叶片都是黑的整片森林像被烧过一样。排查了两个小时最后发现就是法线朝向问题。当时的修正方法分两步获取Face Sign节点得到1或-1。用Multiply节点把顶点法线Normal Vector和Face Sign相乘再送到光照输入。如果用的是Lit Shader Graph最好把修正后的法线连接到Nomal端口不同版本拼写可能为Normal否则只有颜色修正高光还是会出错。做完之后不管是正看叶子背面还是从正下方仰视光照都正常了。背面的亮度稍微压低一点也可以接受毕竟真实叶背比叶面透光性差。不过你还要注意双面渲染对阴影也有影响。开启Cull Off后同一个面片会投出更完整的阴影但有些时候阴影会变得“闷”因为背面也参与阴影投射。如果不需要背面投影可以在ShadowCaster Pass里保留Cull Back让阴影只按正面计算。3. 深度写入又爱又恨的参数用好了才是真正的高手3.1 为什么URP要把透明材质的ZWrite默认关掉深度写入是渲染管线里最容易被“半透明开发者”误解的选项。URP对Transparent表面默认ZWrite Off目的是保证半透明物体之间能互相混合不被深度缓冲拦路。但是关闭ZWrite带来的连锁反应是这个透明物体不会写入深度也就不会“挡住”后面的不透明物体。这个逻辑对于普通玻璃来说刚好正确因为你希望看到玻璃后面的东西。可对于需要使用混合来显示半透明遮挡物的物体比如半透明墙、能量屏障你希望它有一个明确的深度边界挡住墙后面的角色同时自身又带半透明效果。这种情况下默认配置就是错的。更麻烦的是半透明物体如果不写深度它和后续绘制的其他半透明物体之间的顺序就完全依赖于排序算法。当你需要确定性遮挡时轻度使用ZWrite是一种有效手段。3.2 从材质面板到Shader代码控制ZWrite的方法在URP/Lit材质面板上只要Surface Type是TransparentInspector里会有一个Depth Write勾选框。不同Unity版本位置不同通常在Surface Options折叠区附近。勾上就等于Shader里写了ZWrite On。在Shader Graph里主节点Surface Options也有Depth Write选项。如果你找不到可以确认是否开启了Advanced Options或升级URP包。自己写Shader时更直接Pass { Tags { LightMode UniversalForward } ZWrite On ZTest LEqual Cull Off Blend SrcAlpha OneMinusSrcAlpha // ... }注意深度测试ZTest和深度写入ZWrite是两回事ZTest决定当前像素是否通过深度测试ZWrite决定通过测试后是否把深度值写入缓冲。半透明材质通常保留ZTest LEqual但有时为了排序可以改成ZTest Always这种用法要非常克制否则会破坏遮挡关系。3.3 双Pass方案用ColorMask 0先写深度再渲染透明如果只是想既保持半透明混合又让物体有一个稳定的深度轮廓我强烈建议试试双Pass方案。思路很简单第一个Pass只写深度不写颜色第二个Pass正常做透明混合。这个方案在处理“半透明玻璃门”“全息屏障”时非常好用。第一个Pass的写法大致长这样Pass { Name DepthOnly Tags { LightMode SRPDefaultUnlit } Cull Off ZWrite On ColorMask 0 HLSLPROGRAM #pragma vertex vert #pragma fragment frag // 这里只需要输出深度不需要任何颜色计算 // frag可以返回 fixed4(0,0,0,0)反正会被ColorMask扔掉。 ENDHLSL }第二个Pass就是正常的透明渲染Cull可以按需求设置为Back或OffZWrite保持OffBlend照常。这样Unity先把这个物体的深度写进ZBuffer再画透明颜色时后面的半透明物体会被它的深度挡住而它自己内部的像素不会互相挡住。这个方案有个小代价Draw Call翻倍。如果一个场景里有几十个半透明屏障多出来的Pass会显著增加CPU提交压力。我通常只在数量少、且遮挡关系能明显改善的情况下使用。3.4 那些必须开启深度写入的场景以下几种情况ZWrite On基本属于刚需Alpha Clip镂空材质草、树叶、头发卡片这类材质虽然看起来只是“切掉一部分像素”但本质上是让像素写入深度后参与遮挡否则后续的透明粒子会透过树叶显示。半透明“不透明化”场景比如一面半透明的全息墙你希望它挡住角色而不是让角色从墙里透出来。需要深度轮廓的半透明物体比如瀑布贴片、光柱如果关闭ZWrite后面的角色会完整透出显得没有体积感。使用Alpha To Coverage透明度到覆盖率时必须写入深度并且通常需要启用MSAA否则锯齿会非常明显。注意开启ZWrite的半透明材质在多个透明物体互相遮挡时会放弃“半透明互相混合”的视觉效果。这不是Bug而是你主动选择了“先写深度再混合颜色”的策略。想两全其美回到双Pass套路。4. 实战配置玻璃、树叶、全息、粒子的推荐参数与思路4.1 一张参数对照表解决大部分配置选择根据我自己的项目经验下面这张表能覆盖绝大多数半透明渲染需求。推荐直接抄遇到具体产品再微调。素材类型Surface TypeRender QueueBlendCullZWrite备注普通玻璃窗TransparentTransparentSrcAlpha, OneMinusSrcAlphaBackOff如果双层玻璃闪烁改用双Pass双面玻璃瓶TransparentTransparentSrcAlpha, OneMinusSrcAlphaOffOff记得法线翻转否则光照错全息投影/能量屏障TransparentTransparentSrcAlpha, OneOffOn或双PassZWrite Only时颜色更稳树叶/草AlphaClipOpaqueGeometryNoneOffOn启用Alpha Clipping阈值0.3~0.5半透明树叶透光TransparentTransparentSrcAlpha, OneMinusSrcAlphaOff双Pass同时处理法线翻转和次表面光粒子AdditiveTransparentTransparentSrcAlpha, OneBackOff开口粒子慎用Cull Off填充率翻倍头发卡片Hair cardsOpaqueGeometryNoneOffOn配合Alpha To Coverage注意AlphaClip本质上不是半透明混合它属于不透明渲染的变体但因为最终视觉上有很多透明区域我习惯把它放进半透明专题一起讨论。使用AlphaClip的材质深度写入默认就是On的这能避免很多常见的排序问题。4.2 叶子与草用AlphaClip替代混合深度和排序一次到位在植被上我基本不会用传统Transparent Blend模式。原因前面说过混合模式关闭深度写入三角形片之间相互穿插时排序会乱远处叶子可能“叠”在近处叶子上面出现人间蒸发或暗中闪烁。我的标准做法是Surface Type设置为Opaque不要设为Transparent。打开Alpha Clipping设置一个合理的阈值比如0.4。Render Face设置为Both并在ShaderGraph里接上Face Sign翻转法线。阴影投射保持开启ShadowCaster Pass会自动处理AlphaClip。这样做虽然舍弃了叶片边缘的柔和渐变但换来了两件事一是所有叶片都有正确的深度遮挡关系二是性能比真正的半透明混合好得多因为在移动端和WebGL上避免透明混合排序永远是最稳妥的选择。如果需要叶片边缘有柔和的半透明感尽量使用Dither Pattern抖动透明而不是Blend模式。抖动透明利用片元位置生成网格状剔除在视觉上接近半透明同时保持不透明渲染的深度写入特性缺点是容易有噪点降低分辨率时尤其明显。4.3 玻璃与全息屏障双Pass或ZWrite On的选择玻璃是最经典的半透明物体。普通玻璃用Cull Back ZWrite Off就够了。但如果玻璃里还有包装液体、气泡、双层结构透明排序就会变得非常头疼。这时候我的经验是把玻璃的“深度轮廓”单独写一遍颜色层再做混合。以玻璃瓶为例我常常用两个Mask一个“纯深度”材质Cull OffZWrite OnColorMask 0只负责给瓶身一个深度轮廓。一个“颜色”材质Cull OffZWrite OffBlend Alpha负责贴图、反射、折射模拟。两个材质用同一个网格第二层永远在第一个深度层之后绘制瓶身后面的其他透明体不会再“穿过”瓶身瓶子和瓶子之间也不会出现忽前忽后的闪烁。这个方案虽然费一点Draw Call但能稳定解决很多商包项目中要求“半透明物体之间有正确遮挡”的需求。全息屏障的推荐设置可以更激进一点直接用Transparent ZWrite On Blend SrcAlpha One。这样屏障本身留下硬边角色从屏障后面走过时会被屏障“切一刀”而不是完全透过来。如果你希望屏障边缘是圆的或者发光的可以再加一个Fresnel节点在边缘增加亮度不需要额外Pass。4.4 粒子与特效慎开双面渲染慎开深度写入粒子系统是双面渲染的“重灾区”。很多美术同学为了省事直接把粒子材质的Render Face调成Both于是画面里出现了双倍网格填充移动端帧率直接往下掉。粒子面片通常很小而且数量巨大开双面使每个粒子的overdraw翻倍再加上半透明混合本身的高填充率消耗性能很容易失控。我的原则是普通粒子烟雾、火花、光效保持Cull Back除非粒子片确实需要从背面看。就算需要双面也优先让粒子朝向摄像机Billboard模式而不是开启双面。粒子材质通常也不开ZWrite。但如果你有“粒子碰撞地面后要留在地面上方”的需求可以给粒子材质单独做一个DepthOnly Pass让粒子体块有深度轮廓这样后面的地面特效就不会穿透粒子。代价是粒子渲染顺序被部分固定混合效果需要重新测试。5. 半透明渲染“翻车现场”排行与调试链路5.1 背后的东西消失了多为ZWrite和RenderQueue在打架很多人第一次遇到“半透明物体背后不透明物体消失”时第一反应是Shader写错了。实际我排查下来最常见的组合是材质是Opaque队列但Shader内部用了Blend SrcAlpha OneMinusSrcAlpha并且ZWrite On。Opaque队列在URP中作为不透明物体渲染但它又做透明混合结果它会先写深度又把颜色混到已经画完的背景上。看起来就是“背后物体被吃掉了一部分”。这种问题的根源是RenderQueue和Blend状态搭配不一致。或者场景中有多个透明物体后面的先画前面的ZWrite On导致后面已经画的混合结果直接被覆盖。排查方法很简单用Frame DebuggerWindow Analysis Frame Debugger切到半透明物体的Draw Call查看Render State里的ZWrite和ZTest。如果ZWrite是On而你并不需要它写深度直接在Shader或材质上关掉。如果这个半透明物体确实需要写深度建议把它的RenderQueue调到Transparent并考虑双Pass方案避免影响同队列的其他透明物体。5.2 背面黑斑法线翻转没到位我见过太多树叶、花瓣、衣服布料正面漂亮背面黑成一团。症状是开启Cull Off后从背面看某些像素大面积发黑但轮廓和贴图是正确的。定位方法在ShaderGraph调试模式下观察Normal Vector是否恒为世界空间上向量。如果是十有八九是法线没有翻转。修复方法前面已经讲过用Face Sign乘上法线。但还有一种容易漏掉的情况当背面被顶点动画弯曲时法线也要跟随顶点变换。如果顶点着色器里用旋转矩阵或三角函数改变了模型顶点位置却没有重新计算法线或者用法线贴图但没把法线从切线空间转换到世界空间背面法线会错得更离谱。这种情况需要同时修normalWS和tangentWS。5.3 半透明边缘黑边纹理RGB与Alpha出血问题半透明纹理常常遇到边缘黑边。最常见原因是美术在PS里做了一张带Alpha的贴图但RGB通道在透明区域仍然保留了黑色信息。透明区域的RGB虽然是黑Alpha却是0混合后边缘像素partial覆盖时黑RGB会和半透明混在一起形成黑边。修复首选是纹理内容层面让美术把透明区域的RGB填充为相邻颜色或者使用Unity的Alpha is Transparency选项在Texture Import Settings里勾选让导入时自动处理RGB裁剪。其次可以考虑在Shader里使用Premultiplied Alpha混合模式Blend One OneMinusSrcAlpha并让颜色预乘Alpha。这样相当于在采样后执行rgb * a透明区域的黑色RGB不会漏出来黑边会明显改善。代价是颜色会比预期偏暗需要调整贴图亮度。另外如果开启MipMaps低层Mip会把半透明像素和相邻像素混在一起黑色RGB更容易污染边缘。可以在高级选项里把MipMap的AlphaCutoff设高一些或者对UI不太重要的半透明贴图直接关掉MipMaps。5.4 交叉面闪烁深度冲突与排序不稳两个半透明平面完全重叠或者一条带子穿过自己的身体时会出现高频率闪烁和“面片叠影”。这种闪烁主要分两种深度冲突两个面深度差异极小深度测试结果不稳定。解决方案是手动拉开微小距离或者在Shader中给该物体的投影矩阵加一个Depth Bias。URP的Pass标签里没有全局DepthBias你可以在顶点输出时对pos.z做微小偏移或者使用Offset指令如在HLSL的Rasterizer State中设置Offset 1, 1。透明排序不稳两个透明物体的中心点距离相仿引擎无法稳定判断谁先绘制。解决方案是把其中一个物体拆分到不同RenderQueue或者用一个DepthOnly Pass锁定深度甚至把其中一个半透明物体临时改为Opaque AlphaClip让它在不透明阶段先画。我更推荐预防思维同一模型内部要避免自穿插不同半透明物体要避免中心点过于接近。如果美术结构上必须穿插那就把其中一个材质改成不透明AlphaClip这是最简单粗暴的解法。5.5 从控制台到Frame Debugger的定位习惯遇到半透明渲染问题我的排查顺序通常是看材质面板确认Surface Type、Blend、Cull、ZWrite四项是否符合预期经常80%的问题在这里。看Console和Shader编译警告有没有_DOUBLE_SIDED没定义、Global Keyword冲突之类。开Frame Debugger选中事故物体的Draw Call检查Render State和Queue。Frame Debugger能告诉你这个物体的实际深度写入和深度测试状态以及它在列表中的第几个Draw Call。改用无光照Shader测试把材质临时换成URP/Unlit关闭所有光照计算。如果问题消失说明是光照或法线问题如果问题还在说明是渲染状态问题。缩小范围逐个关闭场景中的后处理效果。Bloom、DOF和Color Grading都会对半透明混合结果产生视觉干扰比如Bloom会把半透明边上的亮度值扩散开看起来像黑边或光晕。我最后再强调一点经验半透明渲染的问题很少是单一参数造成的。修好ZWrite可能引入新排序问题开双面又要做法线翻转调完所有参数还得回来看性能。做这套东西不能怕麻烦每个实验都建议在材质上留一个Debug开关方便项目后期按平台、按场景切换策略。我的习惯是写一个半透明的辅助脚本运行时按设备等级自动切配置高端机用双Pass保视觉低端机退化成AlphaClip保帧率。希望这篇能帮你少走我走过的弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进