从入行做Shader到现在,我被问得最多的问题之一,就是透明效果到底怎么做。做玻璃要透明,做水面要透明,做树叶和栅栏也要透明。但很多人一上手就发现,材质明明把Alpha调到0.5了,画面却完全不是想象的样子——要么后面的物体看不到了,要么物体自己变成"磨砂黑",要么两个半透明物体交叉的地方出现诡异的颜色断层。
这些问题,基本都是没分清Unity Shader里两种透明方案的区别:透明度测试(Alpha Test)和透明度混合(Alpha Blend)。这两个名字看着像,原理却完全是两码事。这篇文章我把两种方案的本质、代码实现、适用场景、以及我实际踩过的坑一次性讲清楚。不管你是在做风格化场景、写UI特效,还是搞粒子系统,这套东西都能直接用上。
1. 先把透明这件事说清楚:Alpha不是唯一的主角
很多初学者把"透明"理解成很简单的操作:把贴图的Alpha通道或者材质的透明度调一下,看起来不就透明了吗?但在实时渲染里,"透明"是一个比"颜色"复杂得多的概念。GPU渲染一帧画面,并不是把每个像素的颜色覆盖上去就完了,它还要处理深度、混合、遮挡关系。同一个位置的像素,可能从后到前被反复计算很多次。
这里必须先引入三个关键概念,因为它们决定了透明效果最终长什么样:
渲染队列(Queue):Unity渲染物体有先后顺序,用Tags里的"Queue"控制。不透明物体的Queue默认为Geometry(数值2000),AlphaTest的Queue是2450,Transparent的Queue是3000。这些数值不是随便定的——不透明物体必须先画,因为它们互相遮挡的关系靠深度缓冲来判定。如果先画透明物体,再画不透明物体,透明物体的颜色会被不透明物体直接覆盖,那透明就没有意义了。
深度写入与深度测试(ZWrite / ZTest):GPU渲染一个像素前,先看它的深度值是否通过深度测试——只有比当前深度缓冲里更靠近相机的像素才能通过。深度写入则是把这个像素的深度值写进缓冲。不透明物体默认两个都开,所以后面的物体自然被遮挡。透明物体的情况就很特殊,后面细说。
混合因子(Blend):GPU允许你把新渲染的像素颜色和已经存在帧缓冲里的颜色进行加权混合,这就是Blend指令。透明度混合的所有效果,本质上都来自Blend因子组合。
明白了这三样东西,再来理解标题里的两种方案,就通了:
透明度测试的思路是"一刀切":片元着色器里判断Alpha值,低于阈值的像素直接丢弃(discard),GPU连颜色缓冲和深度缓冲都不写入。这个方案没有真正的渐变过渡,边缘是硬的——要么完全可见,要么完全不可见。
透明度混合的思路是"叠加融合":保留所有像素,但让新像素的颜色按比例和后面已有的颜色混合在一起,实现渐变半透明。这个方案可以做出玻璃、烟雾这类软透效果,但要付出渲染顺序和深度的代价。
2. 透明度测试:镂空树叶、栅栏与铁丝网的"一刀切"
2.1 原理拆解:clip函数到底做了什么
透明度测试在Unlit/Forward等光照管线里的实现非常直接。片元着色器里,大多数情况会用HLSL内置的clip函数:
clip(texColor.a - _Cutoff);这行代码的真实含义是:如果括号里的结果小于0,就立即丢弃这个像素。也就是说,Alpha值小于_Cutoff的像素被"撕掉",大于等于的保留。它执行的是GPU级别的像素杀灭操作——不写入颜色,不写入深度,也不参与后续任何计算。
所以它天然适合那些"从视觉上不需要柔和边框"的东西:铁丝网、栅栏、树叶、窗棂、纸片人。这些物体的共同点是"要么有、要么无",没有中间状态。
但你要清楚一个隐蔽的特性:clip通过后,输出像素的Alpha其实仍然是1.0,是一个完全不透明的像素。这就意味着,透明度测试不产生真正的半透明,它只是通过"删除一部分像素"模拟了镂空效果。如果你的预期是"看到物体半遮半掩的渐变色",那这个方案给不了你。
2.2 可直接复用的Cutout Shader完整实现
下面这个Shader是我在实际项目里常用的简化模板,支持贴图Alpha通道、简便的漫反射光照和双面镂空,适合树叶、植被、栅栏之类的场景:
Shader "Custom/TransparentCutout" { Properties { _MainTex ("Main Texture", 2D) = "white" {} _Cutoff ("Alpha Cutoff", Range(0,1)) = 0.5 _Color ("Tint Color", Color) = (1,1,1,1) } SubShader { Tags { "Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TransparentCutout" } Pass { Tags { "LightMode"="ForwardBase" } Cull Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" #include "Lighting.cginc" sampler2D _MainTex; float4 _MainTex_ST; fixed _Cutoff; fixed4 _Color; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal = UnityObjectToWorldNormal(v.normal); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 tex = tex2D(_MainTex, i.uv) * _Color; clip(tex.a - _Cutoff); // 透明度测试的关键一行 fixed3 worldNormal = normalize(i.worldNormal); fixed3 lightDir = normalize(_WorldSpaceLightPos0.xyz); fixed ndotl = saturate(dot(worldNormal, lightDir)); fixed3 color = tex.rgb * (ndotl * _LightColor0.rgb + unity_ambient.rgb); return fixed4(color, 1.0); // 注意:输出Alpha恒为1 } ENDCG } } Fallback "Diffuse" }几个细节值得留意:
Queue用AlphaTest而不是Transparent。2450这个值保证它排在所有不透明物体之后、所有真半透明物体之前渲染。对性能友好的关键就在这——透明物体不需要跟其他透明物体排序,绘制开销非常可控。
Cull Off:树叶、草、布料经常需要双面可见。不关背面剔除的话,翻过来的叶片会直接消失。代价是GPU像素填充量翻倍,所以如果不是必须双面,尽量开着Cull Back。
Fallback用Diffuse:灯光映射ShadowCaster时,Unity会回退到Diffuse的阴影Pass。但Diffuse不带clip,树叶的阴影会变成一个实心方块。真正要做植被阴影,建议单独写一个ShadowCaster Pass,并在里面重复clip逻辑,这里只做简化演示。
2.3 为什么边缘总有锯齿:MSAA救不了AlphaTest
透明度测试最让人头疼的老毛病,就是边缘锯齿。贴图Alpha在边界处往往是1到0的突变,直接clip某条线上,像素只有"保留/丢弃"二值选择。当你把UV放大看,那些阶梯状锯齿就无所遁形。
为什么开了抗锯齿还是没有用?MSAA的原理是计算多个子采样点的覆盖率和深度,最后把颜色混合到一个像素里。但clip是片元着色器里的硬性丢弃,discard之后子采样点根本没有颜色数据,MSAA无从混合。所以AlphaTest边缘的锯齿,本来就是纯几何像素边界,抗锯齿算法拿它没办法。
那怎么办?最实用的改良是给Alpha渐变区域加权平滑。思路是不要用二值判断,而是用smoothstep在_Cutoff附近构造一个0到1的过渡:
fixed alpha = tex.a; fixed cutoff = smoothstep(_Cutoff - 0.08, _Cutoff + 0.08, alpha); clip(cutoff - 0.5);这样处理之后,贴图Alpha在边界处只要有一定渐变过渡,渲染结果就会柔和很多。当然,贴图里必须有足够的中间Alpha值,否则怎么平滑都是白搭。这也是为什么真正的植被贴图通常会在叶片边缘做一圈AA过渡,而不只是二值抠图。
2.4 双面镂空与Unity的"吐出来的阴影"彩蛋
还有个小坑,用Cutout做植被时,你可能遇到一个奇特现象:物体的阴影是完整的实心图形,但物体本身有镂空。这就是我上面说的ShadowCaster里没有clip。要解决得写一个阴影Pass:
Pass { Tags { "LightMode" = "ShadowCaster" } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_shadowcaster #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_ST; fixed _Cutoff; struct v2f { V2F_SHADOW_CASTER; float2 uv : TEXCOORD1; }; v2f vert (appdata_base v) { v2f o; TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) o.uv = TRANSFORM_TEX(v.texcoord, _MainTex); return o; } float4 frag (v2f i) : SV_Target { fixed4 tex = tex2D(_MainTex, i.uv); clip(tex.a - _Cutoff); SHADOW_CASTER_FRAGMENT(i) } ENDCG }这块代码可以直接扔进上面那个Fallback的位置。注意一点:普通Cutout物体的阴影贴图会统计所有通过clip的像素,如果镂空太细碎,阴影的锯齿会比本体更明显。项目里一般会把阴影Pass的_Cutoff调高一点,让阴影更"收敛"。
3. 透明度混合:玻璃、水面与烟雾的"叠加游戏"
3.1 原理:Blend指令与混合因子的三种常用搭配
透明度混合不是丢弃像素,而是把当前像素的颜色(源颜色)和帧缓冲里已有的颜色(目标颜色)按权重相加。Shader里最常见的代码是:
Blend SrcAlpha OneMinusSrcAlpha这句的意思是:最终颜色 = 当前像素颜色 × 当前像素Alpha + 帧缓冲已有颜色 × (1 - 当前像素Alpha)。如果你把一张Alpha为0.5的红色贴图画上去,结果就是50%的红加50%的背景。这就是玻璃、水面这类材质的核心逻辑。
除了默认混合,实际项目里常用的还有两种:
| Blend指令 | 混合效果 | 典型用途 |
|---|---|---|
| SrcAlpha OneMinusSrcAlpha | 标准Alpha融合,前景越实、背景越虚 | 玻璃、水、半透明罩子 |
| One One | 加法混合,颜色不断叠加变亮 | 火焰、光晕、闪电特效 |
| OneMinusDstColor One | 滤色混合,受背景颜色影响较高 | 某些发光烟雾、全息投影 |
举个判断的例子:粒子火焰如果用标准Alpha混合,粒子重叠区域会越来越亮还是越来越黑?答案是越来越亮,但叠加方式会让整体偏灰。改成One One加法混合,重叠区域会快速过曝,更接近火焰烧灼的亮色感。
3.2 带高光的半透明Shader完整实现
下面这个Shader是我在玻璃和水面类效果上常用的模板。它保留了Blinn-Phong高光,同时用贴图Alpha控制整体透明度:
Shader "Custom/TransparentBlend" { Properties { _MainTex ("Main Texture", 2D) = "white" {} _TintColor ("Tint Color", Color) = (1,1,1,0.5) _Gloss ("Gloss", Range(8,256)) = 32 } SubShader { Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" } Pass { Tags { "LightMode"="ForwardBase" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" #include "Lighting.cginc" sampler2D _MainTex; float4 _MainTex_ST; fixed4 _TintColor; fixed _Gloss; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 tex = tex2D(_MainTex, i.uv) * _TintColor; fixed3 worldNormal = normalize(i.worldNormal); fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos); fixed3 lightDir = normalize(_WorldSpaceLightPos0.xyz); fixed ndotl = saturate(dot(worldNormal, lightDir)); fixed ndotv = saturate(dot(worldNormal, viewDir)); fixed3 spec = pow(ndotv, _Gloss) * _LightColor0.rgb; fixed3 color = tex.rgb * (ndotl * _LightColor0.rgb + unity_ambient.rgb) + spec; return fixed4(color, tex.a); } ENDCG } } Fallback "Transparent/Diffuse" }这个Shader的最大特点是输出Alpha来自贴图和TintColor的乘积。如果你需要整个材质统一半透明,直接调整_TintColor的Alpha;如果你想让液面、图案边缘有不同透明度,就用贴图的Alpha通道。
3.3 ZWrite Off之后,物体之间如何排序
这是透明度混合里最核心、也最让人头大的问题。
你可以把"深度缓冲"理解为一堵唯一的墙——每个像素记录离相机最近的物体深度。不透明物体都往这堵墙上写自己的深度,所以后画的物体会被先画且更近的物体挡住。半透明物体如果也往墙上写深度,那么一旦远处的透明物体被近处的透明物体挡住,近处的半透明根本不会跟远处半透明做混合——这违背了"透明"的意义。
所以标准做法是ZWrite Off。但关闭深度写入后,有一个连锁反应:
GPU不再知道透明物体之间的前后遮挡关系。于是Unity想了一个折中方案:按物体到摄像机的距离排序,从远到近一个接一个画。Unity的排序基准是渲染器(Renderer)的包围盒中心点,不是网格表面。对球体、平面这类规则物体还好,遇到复杂网格或者交叉物体,中心点排序就会出错。
最典型的例子:两个交叉放置的半透明平面,A的包围盒中心偏左,B的偏右。相机从某个角度一看,A应该在前面;从另一个角度看,B又在前面。无论怎么排,总有一侧会渲染错。这个问题的根源不是Blend指令本身,而是"一个物体、一个深度"这种粗粒度的排序机制,跟像素级的实际遮挡不匹配。
3.4 解决"越叠越浓":常见的绘制顺序陷阱
我在项目里做过一个失误案例:场景里放了十层半透明的蓝色薄雾,每一层Alpha是0.1。按照透明的算法,十层叠加起来应该接近0.65左右的浓度。但实际跑起来发现颜色偏浓发灰,像墨汁。
这是因为透明排序并不稳定:如果薄雾层之间还有角色或者别的半透明物体,绘制顺序一乱,某几层会先于背景被混合,另几层后于背景混合,颜色权重就偏了。要缓解"越叠越浓",有三个经验值:
- 尽量把同材质的透明物体做成一个整体Mesh,减少透明物体数量。
- 给透明物体选择更低的Alpha值,预留出叠加空间。
- 如果层次实在多,考虑用Additive混合而不是Alpha混合,避免累加出浑浊感。
除此之外,还有一个性能开销常被忽略:ZWrite Off意味着整个屏幕上的半透明像素都要参与颜色混合,GPU的像素填充率(Fillrate)会大幅上升。移动端GPU的Overdraw一旦超过3倍,帧率能肉眼可见地掉。
4. 实战选型:什么时候用Test、什么时候用Blend
4.1 一张表看清两者的边界
很多新手拿到一个透明需求,第一反应就是"把Alpha调半透明"。实际上,需求到底适合哪种方案,用下面这张表基本可以判断:
| 对比维度 | 透明度测试 Alpha Test | 透明度混合 Alpha Blend |
|---|---|---|
| 边缘形态 | 硬边缘,二值判断 | 软渐变,连续过渡 |
| 渲染队列 | AlphaTest (2450) | Transparent (3000) |
| 深度写入 | 保留 | 关闭 |
| 排序要求 | 无需与其他透明体排序 | 依赖距离排序,易出错 |
| 半透明渐变 | 不支持 | 支持 |
| 典型用途 | 树叶、栅栏、铁丝网、头发 | 玻璃、水面、烟雾、全息 |
| 性能风险 | 低,写入少 | 高,像素混合过多 |
| 常见痛点 | 边缘锯齿、阴影实心 | 排序错误、越叠越浓 |
判断的核心理由只有一条:你需不需要"渐变透明"。如果需要,只能用Blend;如果只是要镂空,Test是更优解。很多人不管三七二十一全用Blend,结果锯齿没了,但半透明物体一多就出现各种穿插Bug,排查半天发现是对方案选型错了。
4.2 从渲染队列和深度写入看系统开销
同样是一面百叶窗,用Test做,GPU只需要处理窗户本身覆盖到的像素,那些镂空区域在片元着色器里直接丢弃,不给后续渲染造成负担。用Blend做,GPU不仅要处理整扇窗户的所有像素,还要保证窗户后面的每个物体都先渲染完。如果窗户后面有一堆角色、武器、特效,所有这些物体的像素都会被多混合一遍,Overdraw立刻翻倍。
移动端尤其敏感。现在的手机GPU,尤其是TBR(Tile-Based Rendering)架构,对Overdraw的容忍度很低。一个场景里如果有几十个全屏粒子+半透明罩,光填充率就能把GPU拖垮。我做过一个纯移动端的战斗场景,原来的特效团队习惯把所有弹道特效都做成Blend,帧率平均只有23帧。后来把子弹本体和爆点换成了Cutout+显式排序的特效图集,Overdraw从4.5降到1.7,帧率涨到52帧。这不完全是Shader问题,但说明选型对性能的影响远大于单个指令的微优化。
4.3 组合使用:远处Cutout、近处Blend
实际开发里,一个场景往往不是非黑即白。以树木为例:近距离看树冠,需要看到叶片的镂空轮廓和摇曳感,用AlphaTest;但远处看整片树林,树冠会糊成一团黑,那种时候很多人会希望它有点雾化的透明感,把远处的树改成混合模式,让它跟背景淡雅地融合。
这里有个常见技巧:按距离切换材质的关键词。在脚本里根据主相机到物体的距离,动态启用材质的关键字:
// 伪代码:按视距切换透明度方案 if (distance < 50) { mat.DisableKeyword("_USE_BLEND"); mat.EnableKeyword("_USE_TEST"); mat.renderQueue = 2450; } else { mat.DisableKeyword("_USE_TEST"); mat.EnableKeyword("_USE_BLEND"); mat.renderQueue = 3000; }Shader侧用两个Pass配合关键字控制,性能上付出的代价只是材质切换,收益是远景表现力大幅提升。这种做法我在场景植被优化里反复用过,比单纯LOD切换更平滑。
4.4 别把Blend用在你不需要半透明的地方
有些刚入门的朋友只要看到贴图带Alpha,就一股脑把Shader切到Transparent,结果发现UI上的字体、贴花、小图标全部变模糊了。原因很简单——混合模式下,每个像素都要和背景加权,背景一复杂,贴花边缘就会出现半透明叠影。
正确的做法是:如果你的贴图Alpha只是用来抠图,根本没有渐变需求,那就用Cutout。只有当你明确需要"看到后面的东西"这一层语义时,才用Blend。在移动端,这个选择可以显著降低场景的填充压力。
5. 进阶实操:让半透明物体"看起来对"的几个细节
5.1 双Pass透明:先写深度再做混合
前面我说了ZWrite Off会让半透明物体之间失去遮挡关系。现实中有个折中手段:用一个"纯写深度、不输出颜色"的Pass来恢复透明物体自身的遮挡,然后再跑标准的混合Pass。
// 第一个Pass:只写深度,不发颜色 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 0; } ENDCG } // 第二个Pass:标准混合 Pass { ZWrite Off Blend SrcAlpha OneMinusSrcAlpha // ... 正常的透明渲染代码 }这样做的效果是:半透明物体在深度缓冲中有自己的"占位符",后面的透明物体如果被挡住,就不会混合上来;而同一物体内部的渲染,仍然可以正确透过自身看到后面的世界。这个技巧在做大型半透明罩子、能量盾时会非常有用。要注意,它解决的是"物体与物体之间"的遮挡,并不能解决像素级排序问题——两个交叉的半透明平面仍然可能出错。
5.2 Fresnel边缘光与轮廓增强
半透明物体最大问题是"透明后轮廓感弱"。一个玻璃杯如果全是均匀半透明,很难看出它的立体结构。这时候加一个Fresnel边缘光就很有用。原理是让视线方向与法线方向的夹角越大,边缘高光越强:
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos); fixed ndotv = saturate(dot(worldNormal, viewDir)); fixed fresnel = pow(1.0 - ndotv, 3.0); // 边缘强度 fixed3 emission = fresnel * _EdgeColor.rgb * _EdgeIntensity;把这行加到透明度混合Shader的输出里,玻璃杯的边缘会出现一道半透明的光晕,轮廓立刻清楚了很多。这个经验在粒子护盾、全息投影、能量屏障上同样适用。
5.3 用帧调试器观察透明效果的绘制顺序
排查透明渲染问题的第一工具不是看代码,而是看Unity的Frame Debugger(Window > Analysis > Frame Debugger)。它能逐DrawCall展示渲染顺序、每个物体的Queue、用到的ShaderPass,以及某个像素是被谁写的、谁混合的。
我的排查习惯是这么来的:先在帧调试器里找到出问题的透明物体,看它的渲染队列是否预期;然后看它和其他透明物体的绘制先后顺序;最后逐个Pass验证是否被剪裁、混合因子是否正确。大多数"莫名其妙"的半透明Bug,根源都是绘制顺序错乱,帧调试器一眼就能看出来。
举个我处理过的实际案例:一个地下城场景,玩家角色走出门的瞬间,门的玻璃把后面的怪物全部"吞"了。排查发现是门的Shader漏写了Queue=Transparent,结果被当成不透明物体渲染,后面的怪物反而先画。改完Queue后,玻璃可以直接照出怪物,问题彻底消失。
5.4 URP下的透明处理差异
如果你用的不是内置渲染管线,而是URP(Universal Render Pipeline),透明效果的实现入口略有不同,但底层原则完全一样。URP里Shader Graph或者手写HLSL都支持Surface Type切换,Opaque和Transparent的区别就在于:
URP的Transparent默认会关闭深度写入,也需要手动选择混合模式。但URP的RenderQueue排序策略和内置管线基本一致,仍然依赖包围盒中心点距离排序。所以前面讲的排序陷阱在URP里一个都不会少。真要稳妥地处理大量半透明物体,可以考虑在URP里做"半透明物体按深度排序后再渲染"的CustomRenderPass,这一步属于进阶玩法,这里不展开。
最后分享一点我自己的经验
每次做透明效果,我都要反复提醒自己三件事:
透明不是Alpha越大越好,而是"排序对了才好"。画面里一旦出现两三个半透明物体交叉,优先考虑把它们的数量减到最少,或者直接合并成Mesh。透明物体的数量和渲染正确性不是线性关系,而是指数级增加的复杂度。
调试透明Bug,先调Queue,再调ZWrite,最后调Blend。顺序反了容易越调越乱。很多时候"看起来发黑"不是混合模式的问题,而是通道饱和度、雾效或者后处理色彩空间导致的。关掉后处理再看一遍,往往能省下一晚上的排查时间。
透明效果里最容易出彩的,反而是那些看似没用上的细节。一个只写深度的Pass、一个Fresnel边缘光、一组精细的半透明过渡贴图——这些细节比任何炫酷的混合因子都更能让画面"看起来贵"。但基础的两条路线,Test和Blend,始终是所有透明工作的基石。把它们吃透了,再难的特效也都只是在这两条主线上做变奏而已。