第一次做玻璃材质的时候,我把Queue改成Transparent、ZWrite关掉、Blend SrcAlpha OneMinusSrcAlpha一写,Scene 视图里看着挺像那么回事,结果一进 Game 视图,玻璃后面的东西时有时无,两个透明物体叠在一起还会互相"打架",转个视角排序就变了。后来才发现,Unity 里的透明效果从来不是"把 alpha 调低"这么简单,它牵扯到渲染队列、混合方程、深度写入、渲染顺序一整套东西,任何一环理解错了,画面就会出现那种说不清道不明的错误。
这篇就围绕"透明效果"这个主题,把我自己在写透明 Shader 过程中踩过的坑、验证过的写法整理一遍。适合已经能写简单不透明 Shader、想搞清楚透明为什么老是出问题的朋友,也适合刚开始接触 ShaderLab 混合命令、对Blend和ZWrite半懂不懂的同学。里面的代码都是 Built-in 管线下的完整可编译版本,最后会单独说 URP 下哪里不一样。看完你至少能自己判断:这个透明效果该用混合还是裁剪、深度要不要写、排序问题从哪儿下手查。
1. 渲染队列先定生死:谁先画、谁后画
1.1 队列标签不是装饰,是硬性的绘制顺序
ShaderLab 里Tags { "Queue"="Transparent" }这一行,很多人是照着模板抄的,抄完就忘了它到底干了什么。实际上 Unity 会把场景里所有材质按Queue数值从小到大排队,先画小的,再画大的。内置的几个档位大致是这样:
| 队列名 | 数值 | 典型用途 |
|---|---|---|
| Background | 1000 | 天空盒、远景背景 |
| Geometry | 2000 | 绝大多数不透明物体(默认值) |
| AlphaTest | 2450 | 用 clip 裁剪的不透明物体 |
| Transparent | 3000 | 半透明混合物体 |
| Overlay | 4000 | 镜头光晕、UI 之类的最后叠加层 |
这个顺序的意义在于:不透明的东西先画完,深度缓冲区里就有了完整的遮挡信息;透明物体后画,才能拿自己的深度去和不透明物体的深度做比较,被挡住的部分直接丢弃。如果你把一个半透明材质的队列留在默认的Geometry,它就会和其他不透明物体混在一起乱序绘制,结果就是有时候看得见背后,有时候又被后面画的不透明物体整个盖掉。我见过最典型的现象是:物体在场景里忽隐忽现,移动相机时闪得厉害,一查队列果然还是 2000。
所以第一条铁律:只要这个材质需要和背景做颜色混合,队列就必须放到 Transparent 或更大的数值,同时RenderType一般配套写Transparent,某些依赖RenderType做相机替换的后期效果会用到它。
1.2 同一个队列内部,排序依据是物体中心到相机的距离
队列只解决"透明排在不透明后面"这一层,同一队列内部的顺序 Unity 用的是另一套逻辑:按渲染器包围盒中心到相机的距离,从远到近排。注意关键词是"物体中心",不是逐像素,也不是逐三角形。这就埋下了透明排序问题的根:一个巨大的透明面片和一个小的透明球体,中心距离的先后关系可能和实际遮挡关系完全相反。
举个我实际遇到的问题:做一片半透明的水面,水面是一个很大的 Plane,中心在原点;水里放几个半透明的小气泡。相机在水面上方俯视时,水面中心可能比气泡更远,于是水面先画,气泡后画,看起来正常;相机压低角度侧看,距离关系反转,气泡先画,水面后画,气泡就被水面糊掉了。这不是 Shader 写错了,是逐物体排序本身的局限。
业界常规的应对办法有几种:把大面片拆成网格状的小块,让排序粒度变细,代价是 Draw Call 变多;或者干脆不让它们互相混合,用深度写入配合裁剪;再或者用 OIT(Order Independent Transparency)这类逐像素排序方案,但开销和实现复杂度都上一个台阶,普通项目基本不会碰。实际项目里更常见的是"设计上避开"——别让两个半透明物体大面积互相穿插,能省掉一大堆麻烦。
1.3 手动偏移队列数值的实用场景
Queue是可以写表达式的,比如"Queue"="Transparent+10"。这个技巧在几种场景下特别好用。第一种是同一个物体有多个透明 Pass,需要严格控制先后,比如先画一层淡淡的底色再叠高光。第二种是武器特效和角色身体都是半透明,你希望特效永远盖在身体上面,那就把特效队列调到Transparent+100,用数值强行打断距离排序。
但这里要提醒一句:队列数值只能调整不同材质之间的相对顺序,解决不了同一材质内部的三角形排序。一个透明模型自身的前后表面,靠队列是排不明白的,只能靠深度写入或者双 Pass 处理,这个后面第 3 节会详细讲。
2. Blend 命令拆开看:颜色到底是怎么混上去的
2.1 源因子和目标因子的乘法关系
Blend命令的完整形式是Blend SrcFactor DstFactor,它做的事可以用一个公式概括:
最终颜色 = 当前片元颜色 × SrcFactor + 帧缓冲里已有的颜色 × DstFactor
这里的"当前片元颜色"就是 Shader 里 fragment 函数 return 出来的值,在混合语境下叫源(Source);"帧缓冲里已有的颜色"是之前画好的东西,叫目标(Destination)。两个因子就是两个乘数,可以是One、Zero、SrcAlpha、OneMinusSrcAlpha、SrcColor、DstColor等等。
最常用的那行Blend SrcAlpha OneMinusSrcAlpha,展开就是:
结果 = 片元颜色 × 片元alpha + 背景颜色 × (1 - 片元alpha)
alpha 为 1 时结果完全是片元颜色,alpha 为 0 时完全是背景,中间值按比例插值。这是标准的"over"混合,也是绝大多数半透明材质想要的。理解了这个公式,后面所有变体都好推。
2.2 几个常用组合的适用场景
我把实际项目里用得比较多的几组列一下,方便对照:
| Blend 写法 | 展开效果 | 典型用途 |
|---|---|---|
SrcAlpha OneMinusSrcAlpha | 标准半透明 | 玻璃、水面、普通透明材质 |
One OneMinusSrcAlpha | 预乘 alpha 混合 | 粒子、火焰、已经乘过 alpha 的贴图 |
SrcAlpha One | 叠加发光 | 光晕、能量罩、加色特效 |
One One | 纯加色 | 爆炸闪光、激光 |
DstColor Zero | 正片叠底 | 阴影贴图、染色层 |
加色类效果(One One)有个特点:它不看背景颜色,直接往上加,所以永远不会让画面变暗,但叠多了会迅速过曝成一片白。做能量护盾的时候我一般会把 alpha 压得很低,再配合SrcAlpha One,让边缘有一点发光感又不至于糊成一块。
2.3 预乘 alpha:透明边缘发黑的真正原因
透明贴图边缘发黑是新手最常问的问题之一,很多人第一反应是"贴图压缩出问题了"或者"颜色空间设置错了",其实根源在纹理本身。一张普通的带 alpha 的 PNG,透明区域的 RGB 通常是黑色(0,0,0),alpha 是 0。采样的时候是双线性插值,边缘像素会把旁边透明像素的黑色 RGB 一起插进来,导致边缘颜色被"染黑"。这时候你再用SrcAlpha OneMinusSrcAlpha混合,黑色乘上一个中间值的 alpha,就出现了一圈脏脏的黑边。
解决办法是预乘 alpha:在导出或处理贴图时,把 RGB 乘以 alpha,让透明区域的 RGB 也变成 0,这样插值出来的颜色在数学上是正确的。对应的 Shader 里混合模式要改成Blend One OneMinusSrcAlpha,因为 RGB 已经乘过 alpha 了,不能再乘一次。如果贴图不方便重新导出,也可以在 fragment 里手动乘:
fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv) * _Color; col.rgb *= col.a; // 手动预乘 return col; }配合Blend One OneMinusSrcAlpha使用。这个组合在粒子系统里几乎是标配,因为粒子贴图边缘多、叠加密,非预乘的话黑边会非常明显。顺带说一句,Unity 的粒子默认材质用的就是预乘思路,自己写粒子 Shader 时最好保持一致,不然换材质的时候颜色会对不上。
3. ZWrite 开关的取舍:不写深度换来什么,又失去什么
3.1 关闭深度写入之后到底发生了什么
透明材质几乎都会写ZWrite Off,理由很简单:如果透明物体写入深度,后面画的透明物体就会被它挡住,而实际上我们应该能透过它看到后面的东西。这个逻辑没错,但很多人对它的理解有偏差,以为ZWrite Off就等于"不做深度测试",这是两码事。
ZWrite Off只是不往深度缓冲区里写值,ZTest LEqual(默认值)依然生效。也就是说,透明片元仍然会拿自己的深度和缓冲区里已有的深度比较,如果它比已有的深度更远(被不透明物体挡住),照样会被丢弃。所以透明物体被墙挡住的时候表现是正确的,问题只出在透明物体彼此之间:大家都不写深度,缓冲区里那个位置还是更远处不透明物体的深度值,于是所有透明片元都能通过测试,谁后画谁覆盖谁。
这就解释了一个经典现象:两个透明窗户前后叠放,转动相机,谁在前面会突然跳变。因为排序依据是物体中心距离,一旦距离关系变化,绘制顺序就变了,而深度测试又拦不住,画面自然就翻脸。
3.2 两 Pass 写法:先占坑再上色
针对单个模型自身前后表面穿插的问题(比如一个透明的球,你能同时看到它前半球和后半球),有个很实用的写法:拆成两个 Pass,第一个 Pass 只写深度不上色,第二个 Pass 正常混合但不写深度。
Shader "Custom/TransparentTwoPass" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Tint", Color) = (1,1,1,1) } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } // Pass 1: 只写深度,不输出颜色 Pass { ZWrite On ColorMask 0 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 0; } ENDCG } // Pass 2: 正常混合 Pass { ZWrite Off Blend SrcAlpha OneMinusSrcAlpha CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; 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 = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { return tex2D(_MainTex, i.uv) * _Color; } ENDCG } } }ColorMask 0的意思是四个颜色通道都不输出,这个 Pass 纯粹是在"占坑",把模型最近表面的深度写进缓冲区。第二个 Pass 再做混合时,ZTest LEqual就会把模型背面的片元挡掉,于是你只看到前表面,不会出现前后表面混在一起的诡异效果。
3.3 这个方案的边界在哪里
两 Pass 写法不是万能的,它解决的是"单个物体自身"的排序,跨物体的透明叠加它管不了。原因很直接:第一个 Pass 一旦写了深度,后面画的别的透明物体就会被这个深度挡住,本来应该透过玻璃看到的东西反而消失了。所以这个方案适合用在封闭的、不需要和别的透明物体互相叠加的模型上,比如一个孤立的玻璃球、一个水晶道具。
真正麻烦的场景,比如一池子互相穿插的透明液体,还是得回到"拆网格 + 控制排序"或者"干脆用不透明加裁剪"的思路。我在项目里判断的标准是:如果透明物体之间会出现大面积互相遮挡,就不要指望 Shader 层面能自动排好,要么改美术资源,要么改设计。这个结论听起来不够"技术",但确实是实际项目里最省事的做法。
4. 透明度测试:clip 裁剪和混合是两条路
4.1 clip 是硬边,混合是软边
clip(x)这个函数的行为很简单:如果参数小于 0,当前片元直接丢弃,不写颜色也不写深度。配合一个阈值,就能做出"要么全要、要么全不要"的效果,这就是透明度测试(Alpha Test)。它和混合最大的区别是:混合的片元仍然参与深度写入(如果开了 ZWrite),而裁剪掉的片元完全不存在。
fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv) * _Color; clip(col.a - _Cutoff); // alpha 低于阈值直接丢弃 return col; }因为被裁剪的片元不写深度,剩下的片元就是标准的不透明渲染,所以队列可以放在AlphaTest(2450),排在 Geometry 后面、Transparent 前面。这样它既能被更远的不透明物体正常遮挡,又不会和半透明物体搅在一起。
4.2 什么时候该用裁剪而不是混合
判断标准其实很朴素:这个效果需不需要半透明的过渡。树叶、草、铁丝网、破洞的布料,这些贴图要么完全不透明要么完全透明,中间过渡很少,用裁剪最合适,因为它没有排序问题、不需要关深度写入、还能正常投射阴影。反过来,玻璃、烟雾、水、光晕,这些必须要有从透明到不透明的渐变,那就只能走混合。
这里有个常见的误区:有人为了让树叶边缘看起来柔和,把裁剪和混合混着用,结果既有关不掉的硬边又带来了排序问题。我的建议是二选一,别混。要软边就老老实实走 Transparent 队列,接受排序限制;要性能和省心就用裁剪,把贴图的 alpha 做成接近二值的。
4.3 裁剪材质的阴影投射坑
裁剪材质有个特别容易忘的地方:阴影 Pass 里也得做同样的裁剪。默认的 ShadowCaster Pass 如果不处理 alpha,阴影就会按整个面片的形状投出来,树叶会在地面投出一块方方正正的影子,非常出戏。解决方式是在阴影 Pass 的 fragment 里也调用一次clip,参数和主 Pass 保持一致。
如果用 Surface Shader,可以在#pragma surface后面加addshadow,让 Unity 自动生成带裁剪的阴影 Pass。手写顶点片元着色器的话就得自己补。我一般会单独写一个 ShadowCaster Pass,把LightMode设成ShadowCaster,里面复用同一套采样和裁剪逻辑。这个细节不注意,画面看起来就是"哪儿都怪但说不上来",排查起来还挺费时间。
5. 双面渲染与法线方向:Cull Off 没那么简单
5.1 默认背面剔除是有原因的
不透明物体默认Cull Back,背面三角形直接不画,省一半光栅化开销。透明物体如果保持Cull Back,你会看到一个很奇怪的现象:一个透明的球,只能看到朝向你的那一面,转过去之后背面消失了,整个物体看起来像被切开。所以很多透明 Shader 会写Cull Off,前后表面都画。
但Cull Off带来两个新问题。第一是绘制次数翻倍,片元着色器跑两遍,移动端上这个开销不能忽略。第二是背面法线方向是反的,如果你在片元里用了法线做光照或者菲涅尔,背面算出来的结果会是错的,表现为前后表面亮度不一致、边缘一圈诡异的暗边。
5.2 背面法线翻转的正确做法
在片元里可以用FRONT_FACE语义判断当前片元属于正面还是背面,然后翻转法线:
fixed4 frag (v2f i, fixed facing : VFACE) : SV_Target { float3 n = normalize(i.worldNormal) * (facing > 0 ? 1 : -1); // 后续用 n 做光照计算 return col; }VFACE在正面时是正值,背面是负值。这个写法在双面布料、树叶、纸片类效果里几乎是必须的,不加的话背面看起来会像蒙了一层灰。要注意的是,VFACE在部分老的移动端 GPU 上有兼容性问题,如果目标平台比较杂,可以退而求其次,用视线方向和法线的点积来判断朝向,虽然不精确但兼容性好。
5.3 树叶和裙摆这类资源的处理倾向
树叶、裙摆、旗帜这类东西,本质上是一片薄片,正反两面都要能看到,按道理应该Cull Off。但实际项目里我更倾向于让美术直接做双面模型(正反各一层,法线朝外),Shader 保持Cull Back。原因有三个:阴影投射更准确、光照计算更简单、不需要处理法线翻转。代价是顶点数翻倍,但对于树叶这种面数本来就不高的资源来说完全可以接受。
只有在动态生成或者资源已经定死没法改的情况下,我才会用Cull Off加VFACE的方案。这个取舍没有绝对对错,取决于资源管线能不能配合。
6. 玻璃、软粒子与管线差异:几个真实场景的写法
6.1 玻璃效果需要采样屏幕颜色
单纯的半透明混合做不出玻璃的折射感,因为你能看到的只是物体的颜色和背景按 alpha 混合,没有"透过玻璃看到扭曲背景"的效果。要做出折射感,得拿到屏幕纹理。Built-in 管线里有两种方式:GrabPass和_CameraOpaqueTexture。
GrabPass会在绘制该物体前把屏幕抓成一张纹理,写法是:
GrabPass { "_BackgroundTex" } sampler2D _BackgroundTex; float4 _BackgroundTex_TexelSize; fixed4 frag (v2f i) : SV_Target { float2 uv = i.grabPos.xy / i.grabPos.w; uv += sin(uv * 20 + _Time.y) * 0.01; // 简单的扭曲 fixed4 bg = tex2D(_BackgroundTex, uv); return bg * _Color; }GrabPass的问题是开销大,每次抓屏都是一次全屏拷贝,移动端上尤其明显。更推荐的方式是提前在管线的某一步生成一张不透明纹理,然后所有玻璃材质共用这一张,采样成本就降下来了。这也是 URP 里_CameraOpaqueTexture的思路,需要在管线设置里勾选生成。
6.2 软粒子靠深度纹理做淡出
粒子系统里最恼人的画面问题之一,是粒子穿过地面或者墙壁时出现一条硬邦邦的切割线。原因还是透明不写深度,粒子和不透明物体的交界处没有任何过渡。解决办法是采样深度纹理,算出粒子和场景表面的距离,距离太近就把 alpha 压下去:
sampler2D _CameraDepthTexture; fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv) * _Color; float sceneDepth = LinearEyeDepth( SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)) ); float particleDepth = i.screenPos.w; float fade = saturate((sceneDepth - particleDepth) / _FadeDistance); col.a *= fade; return col; }_FadeDistance控制淡出的过渡范围,值越大过渡越柔和,但粒子看起来也越"虚"。我一般从 0.3 到 1.0 之间试,根据场景尺度调。这个技巧在爆炸烟尘、雾气、雨滴效果里几乎是标配,不加的话粒子贴地的表现会非常脏。
这里有个坑要提醒:_CameraDepthTexture需要相机开启深度纹理生成,Built-in 里是在相机组件上挂脚本设置depthTextureMode,URP 里则是在管线资产里勾选。另外深度纹理的精度在不同平台上不一致,做淡出时最好留一点余量,别把阈值卡得太死,否则在某些设备上会出现淡淡的残影。
6.3 Built-in 和 URP 的写法差异
前面的代码都是 Built-in 管线下的写法,URP 里不能直接用,主要差在几处:
| 项目 | Built-in | URP |
|---|---|---|
| 代码块 | CGPROGRAM / ENDCG | HLSLPROGRAM / ENDHLSL |
| 头文件 | UnityCG.cginc | Core.hlsl、Lighting.hlsl |
| 顶点变换 | UnityObjectToClipPos | TransformObjectToHClip |
| 屏幕深度 | _CameraDepthTexture | _CameraDepthTexture(需管线启用) |
| 不透明纹理 | GrabPass | _CameraOpaqueTexture |
| 标签 | RenderType为主 | 还需要"RenderPipeline"="UniversalPipeline" |
URP 下还有一个细节要注意:混合模式的默认值和 Built-in 一致,但如果你在 Shader Graph 里做,Alpha端口接上去之后,材质面板上的Surface Type要选Transparent,否则混合不会生效。这个问题在从 Built-in 迁移的时候特别容易犯,表现为"代码完全一样,但就是不透明"。
7. 一次透明材质"消失"的排查实录
7.1 现象描述和第一反应
之前遇到一个状况:写好的透明护盾材质,在编辑器 Scene 视图里完全正常,切到 Game 视图后,物体在某些角度整片消失,换个角度又出现,而且消失的时机和相机位置强相关。第一反应是深度测试的问题,毕竟消失得这么"干净",很像被什么看不见的东西挡住了。
7.2 按顺序逐层排除
我一般按这个顺序排查透明相关的问题,从最外层往内查:
- 先看材质的
Queue和RenderType,确认在 Transparent 或更大。这一步排除队列错位。 - 再看
ZWrite是不是关着,ZTest有没有被误写成Always或Greater。 - 打开 Frame Debugger,逐 Draw Call 看物体到底有没有被画出来。这一步能确定是"根本没画"还是"画了但看不见"。
- 检查包围盒,有些动态生成的网格边界算错了,会被相机视锥剔除误杀。
- 最后才怀疑 Shader 代码里的混合公式和 alpha 计算。
这次排查到第二步就发现了问题:ZTest被写成了Greater。因为之前抄的模板是给某个特殊效果改过的,当时没注意,Greater的意思"深度更大才通过",导致几乎所有正常深度关系的片元都被丢弃,只有被遮挡的部分反而显示出来。改成LEqual之后立刻正常。
7.3 这类问题的通用经验
这次排查让我养成了一个习惯:凡是透明材质出问题,先在 Frame Debugger 里确认物体有没有被绘制,再谈别的。因为"看不见"可能是三种完全不同的原因:被剔除(视锥、图层)、被深度测试丢弃(ZTest/ZWrite)、被混合公式算没了(alpha 为 0 或者因子写反)。这三种原因的排查路径完全不同,不先定位到哪一类,很容易在错误的方向上浪费时间。
还有一个容易忽略的点:透明材质如果用了Cull Off加ZWrite Off,在某些显卡上会触发奇怪的深度精度问题,表现为边缘有细微的闪烁噪点。这种时候试着把相机的近裁面稍微拉大一点(比如从 0.01 改到 0.1),深度精度问题往往能缓解不少。这个技巧不解决根本问题,但作为临时手段挺管用。
最后再分享一个我自己在用的检查清单,写新的透明 Shader 时对着过一遍,能避开八成的坑:队列对不对、ZWrite该不该关、混合因子和贴图是否匹配(预乘还是非预乘)、Cull模式是否和资源匹配、阴影 Pass 有没有做裁剪、双面材质的法线有没有翻转、屏幕纹理的坐标有没有做透视除法。这几条都确认过,剩下的基本就是美术资源和排序粒度的问题了。