☰
Unity Shader透明效果全解析:渲染顺序、深度写入与混合公式
2026/10/26 4:59:13 网站建设 项目流程

透明效果这四个字,在 Unity Shader 的入门清单里看着最人畜无害,实际却是我见过翻车率最高的一个话题。新手把 Blend SrcAlpha OneMinusSrcAlpha 敲进去,贴图一挂,玻璃是透明的,看起来成功了;然后把这个材质往场景里多摆几个,立刻出现"后面的物体被前面的挡住""两个透明面互相切割""边缘一圈黑边""粒子叠在一起变成一坨白"这类问题。这些问题不是代码写错了,而是渲染顺序、深度写入、混合公式三件事没想明白。这一篇就围绕 Unity Shader 的透明效果展开,从渲染队列的底层规则讲到手写一个能直接用在项目里的透明 Shader,再到软粒子、菲涅尔边缘、深度淡出这些实战里绕不开的进阶玩法,中间穿插我自己踩过的坑和排查思路。不管你是刚学完基础光照、准备啃透明的新手,还是已经写过几个 Shader 但总在排序问题上打转的老手,下面这些内容应该都能直接拿去用。

1. 透明效果到底难在哪:先把渲染顺序这件事说透

1.1 不透明渲染为什么"怎么写都对"

很多人学不透明 Shader 时是感觉不到顺序存在的。你写一个最简单的漫反射,物体摆满整个场景,从哪个角度看都是对的,哪怕你把材质队列改成 2001,画面也不会有任何变化。原因在于不透明物体走的是深度缓冲那条路:每个像素在写入颜色之前,会先做一次深度测试,ZTest LEqual 的意思是"只有当我比深度缓冲里已有的值更近,我才写颜色并且更新深度"。深度缓冲是一张和屏幕等大的单通道图,记录的是当前像素最近的那个片元的深度值。

这条机制带来一个非常舒服的结果——绘制顺序完全不影响最终画面。先画远的,后画的近的会覆盖它;先画近的,后画的远的直接被深度测试拒掉。两种顺序得到同一张图。这就是所谓的"顺序无关"。你在写不透明 Shader 时几乎不用关心 ZWrite 和 Queue,因为它们默认就是对的。

透明效果打破的恰恰是这个前提。半透明意味着"我要让背后的东西透出来一部分",也就是我必须把当前片元的颜色和帧缓冲里已有的颜色做一次数学运算,这个运算叫混合。混合的前提是帧缓冲里已经有"背后的颜色"了。一旦这个前提成立,绘制顺序就变成了硬约束:必须先画背后,再画前面。这就从"顺序无关"退化成了"顺序敏感",麻烦也就从这一刻开始。

1.2 渲染队列的数值规则与它真正的含义

Unity 用 Queue 标签来决定一个 SubShader 什么时候被绘制,常见取值如下表。这张表建议直接记下来,排查问题时第一件事就是确认队列对不对。

队列名称数值典型用途是否写深度
Background1000天空盒、远景背景通常不写
Geometry2000绝大多数不透明物体写
AlphaTest2450植被、铁丝网等镂空写
GeometryLast2500不透明物体的分界线写
Transparent3000半透明物体不写
Overlay4000镜头光晕、UI 叠加不写

数值本身只是排序依据,真正的分界线在 2500。2500 之前的所有队列,Unity 会认为它们写深度、参与遮挡,绘制时可以按任意顺序进行(实际上会做前后排序优化,但结果与顺序无关)。2500 之后的队列,Unity 认为它们需要混合,必须从远到近画。

这里有个特别容易被忽略的细节:从远到近的排序粒度是"物体",不是"像素"。Unity 在渲染前会收集所有队列大于 2500 的渲染器,计算它们包围盒中心到摄像机的距离,按距离从大到小排序,然后逐个绘制。一个物体内部的所有三角形,绘制顺序由网格数据的索引顺序决定,跟距离无关。这就是为什么一个凹形的透明杯子,杯壁的某一段可能出现在杯底前面——因为它们属于同一个物体,没有参与排序。

注意:想验证这一点非常简单,随便做一个半透明的球,把摄像机推进到球内部,你会看到球内壁的前后关系是乱的。这不是 Shader 的 bug,是排序粒度决定的。

1.3 排序失效的三种典型表现

第一种是同一物体的自遮挡错乱。上面说的凹形物体、环形物体、管道都属于这一类。解决办法要么是拆分网格(把一个物体拆成多个 Renderer,让它们各自参与排序),要么是改用 Alpha Test 这种不依赖顺序的方案,要么是在 Shader 里做深度预写(先渲染一遍只写深度,再渲染颜色)。

第二种是交叉面片的穿插。两片半透明的草、两片重叠的烟雾,它们的包围盒中心距离可能非常接近,排序结果就变得随机,摄像机稍微一动,前后关系就跳变。这类问题的典型症状是"闪烁",而且闪烁发生在摄像机移动时。实战里最有效的做法是把这类物体合批成一个网格,用顶点色或 UV 控制透明度,让它们作为整体参与一次排序;或者干脆用 Alpha Test 加边缘淡出,把混合换成裁剪。

第三种是透明物体与不透明物体的边界问题。透明物体的队列在 3000,一定在不透明物体之后绘制,所以透明物体不会挡住不透明物体的正确性。但反过来,如果一个本该不透明的物体因为材质配置错误被扔进了 Transparent 队列,它就会开始和不透明物体产生视觉冲突——你会在它身上看到本不该出现的透视效果。

2. 三种主流透明方案,选错了后面全是坑

2.1 Alpha Blend:最直观,也最需要配套设置

Alpha Blend 的思路是把当前片元的颜色按 alpha 加权,和背景色混合。核心公式是结果 = 源颜色 * 源alpha + 目标颜色 * (1 - 源alpha),在 ShaderLab 里写成Blend SrcAlpha OneMinusSrcAlpha。这个方案的好处是过渡柔和,半透明渐变、玻璃、烟雾、光效都用它。

但它有三个必须同时满足的配套条件,少一个就出问题。第一,队列必须在 Transparent(3000)之后,否则混合时机不对;第二,必须ZWrite Off,因为半透明物体不应该更新深度缓冲,否则会挡住它后面的其他半透明物体;第三,物体的排序必须基本正确,否则混合顺序错了,结果就是错的。

关于 ZWrite Off,值得多说一句。很多人第一次写透明 Shader 时忘了关深度写入,现象是"透明物体后面的东西看不见了",但透明物体本身的半透明效果还在。原因是它确实做了混合,但同时把深度写了进去,导致后面的片元被深度测试拒掉。这个错误非常隐蔽,因为单看一个物体时画面是"对的",只有场景里还有其他物体时才暴露。

还有一个更隐蔽的坑:半透明物体如果 ZWrite Off,它自己内部的前后关系也无法通过深度测试解决,所以一个球体的正面和背面会叠加两次颜色,看起来比预期更"实"。想要更准确的结果,可以在 Shader 里加一个 Pass 先渲染背面再渲染正面(Cull Front 然后用 Cull Back),人为控制内部顺序。

2.2 Alpha Test(裁剪):牺牲边缘质量换取顺序无关

Alpha Test 的做法是不做混合,直接根据 alpha 阈值决定这个片元是丢弃还是保留。用clip(alpha - _Cutoff)一行就能实现。保留下来的片元是完全不透明的,正常写深度,正常参与深度测试,因此它和普通不透明物体一样是顺序无关的。

这个特性让它在某些场景下无可替代。树叶、草地、铁丝网、锁链、破布——这些形状复杂、边缘碎裂的物体,用 Alpha Blend 会疯狂排序出错,用 Alpha Test 则完全稳定。代价是边缘会有锯齿,因为 alpha 只能取 0 或 1,没有过渡。为了解决锯齿,一般会配合 Mipmap 和适当的各向异性过滤,或者用 Alpha To Coverage(多重采样下的透明覆盖)来软化成半透明,但这需要开启 MSAA,移动端要谨慎。

Alpha Test 在移动端还有性能代价。被clip或discard掉片元的着色器,会破坏 GPU 的 Early-Z 优化:正常情况下 GPU 可以在片元着色器执行之前就用深度测试剔除被遮挡的片元,但一旦 Shader 里存在 discard 指令,硬件就无法提前判断这个片元是否会被丢弃,只能老老实实跑完整个片元着色器。所以在中低端设备上,大面积的 Alpha Test 植被其实比想象中更耗性能。

提示:队列用 AlphaTest(2450)而不是 Transparent,是这个方案能保持顺序无关的关键。用错队列会让本来正确的方案产生排序问题。

2.3 预乘 Alpha:解决边缘黑边的正解

预乘 Alpha(Premultiplied Alpha)是一个容易被跳过、但做特效时迟早要遇到的方案。普通 Alpha Blend 在纹理过滤、多层级混合、以及从带 alpha 的渲染目标往屏幕合成时,会产生边缘发黑或发灰的问题。根本原因是 RGB 通道在透明区域存储的是无意义的值(或者被插值污染的值),混合时这部分颜色会漏出来。

预乘的做法是在纹理制作或采样阶段就把 RGB 乘以 alpha,让透明区域的 RGB 变成 0,混合公式随之改为Blend One OneMinusSrcAlpha。这样透明区域的贡献天然为零,边缘过渡干净。粒子系统、UI 图集、从 RenderTexture 合成到屏幕的场景,都应该优先考虑这条路。

三种方案的取舍可以用下面这张表快速判断:

方案队列ZWrite顺序敏感边缘质量典型场景
Alpha BlendTransparentOff是好玻璃、烟雾、水面
Alpha TestAlphaTestOn否差(锯齿)植被、铁丝网、镂空
预乘 AlphaTransparentOff是最好粒子、特效、RT 合成

还有一个常被一起提到的方案是叠加混合(Blend One One),它不做 alpha 加权而是直接把颜色相加,适合火焰、光晕、能量特效。缺点是颜色会迅速饱和到白色,叠太多层就变成一坨亮斑,所以用它的时候通常要把单层的颜色调暗。

3. 手写一个工程可用的透明 Shader

3.1 属性块与 Tags:先把地基打对

先看完整的结构骨架。我一般把透明 Shader 的 Tags 固定写成三件套:Queue、RenderType、IgnoreProjector。Queue 决定绘制时机,RenderType 用于着色器替换(比如某些后处理或烘焙流程需要按类型替换),IgnoreProjector 让投影器组件跳过这个物体——半透明物体被投影器照到会出现很奇怪的亮块,直接忽略最省事。

Shader "Custom/TransparentBasic" { Properties { _MainTex ("Base Texture", 2D) = "white" {} _Color ("Tint Color", Color) = (1,1,1,1) _Alpha ("Overall Alpha", Range(0,1)) = 0.6 _FresnelPower ("Fresnel Power", Range(1,8)) = 3 _FresnelStrength ("Fresnel Strength", Range(0,1)) = 0.5 } SubShader { Tags { "Queue" = "Transparent" "RenderType" = "Transparent" "IgnoreProjector" = "True" } LOD 200 Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back Pass { Name "FORWARD" Tags { "LightMode" = "ForwardBase" } // 顶点/片元着色器见下一节 } } Fallback "Transparent/VertexLit" }

LOD 200是个兜底值,当目标设备不支持时 Unity 会往下找更低 LOD 的 SubShader,最后落到 Fallback。给透明 Shader 配 Fallback 的时候要注意别写Fallback "Diffuse",那是不透明版本,在极端情况下会让物体变成实心块。

3.2 混合模式与光照:透明物体也要吃光

一个常见的误区是透明 Shader 不需要光照。实际上除了纯自发光特效,绝大部分透明物体仍然需要接受主光和球谐光照,否则放在场景里会和环境脱节,看起来像贴上去的。这里我用一个简化的兰伯特加环境光模型,重点是演示 alpha 的计算链路。

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; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; float _Alpha; float _FresnelPower; float _FresnelStrength; 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) * _Color; float3 n = normalize(i.worldNormal); float3 l = normalize(_WorldSpaceLightPos0.xyz); float ndotl = saturate(dot(n, l)); fixed3 diffuse = tex.rgb * (_LightColor0.rgb * ndotl + ShadeSH9(float4(n,1))); fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb * tex.rgb; float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos); float fresnel = pow(1.0 - saturate(dot(n, viewDir)), _FresnelPower); float finalAlpha = tex.a * _Alpha * lerp(1.0, fresnel, _FresnelStrength); return fixed4(diffuse + ambient, finalAlpha); }

这段代码里有几个点值得单独讲。UNITY_LIGHTMODEL_AMBIENT是老版本内置变量,在新版本管线下依然可以用,但更稳妥的做法是直接用ShadeSH9采样球谐,它在没有实时 GI 的场景里也能给出一个合理的环境色。菲涅尔项pow(1 - dot(n, viewDir), power)的效果是:正对摄像机的面 alpha 低(更透明),边缘侧面 alpha 高(更实),这个模型非常接近真实的玻璃和水面观感,是性价比极高的一行代码。

注意:_WorldSpaceLightPos0在平行光下是方向向量,在点光源下是世界坐标,需要判断w分量。上面为了简洁只处理了平行光,工程里如果有多光源需求,应该改成#pragma multi_compile_fwdbase配合AutoLight.cginc里的宏。

3.3 双面透明与法线翻转的处理

叶片、布料、纸片这类物体需要双面渲染,写Cull Off就行。但有一个连带问题:背面渲染时法线是朝外的,光照会算错,看起来背面是黑的。正确的做法是在片元着色器里判断当前是正面还是背面,如果是背面就把法线取反。

fixed4 frag (v2f i, fixed facing : VFACE) : SV_Target { float3 n = normalize(i.worldNormal); n = facing > 0 ? n : -n; // 后续光照计算使用 n }

VFACE是 Unity 提供的语义,正面是正数,背面是负数,一行就能解决。这个技巧在做树叶、旗帜、飘带的时候几乎是必备的。另外要注意,Cull Off会让绘制量翻倍,一个面数较高的模型双面渲染后开销接近两倍,移动端要控制面数。

另一个细节是透明物体一般不需要投影,但如果你确实想让半透明物体投出阴影,需要额外的 ShadowCaster Pass,并且通常要接受"阴影是不透明的"这个事实——因为阴影贴图里没有 alpha 混合的概念。想要柔和阴影,只能靠阴影贴图的软采样,或者干脆用假的贴地阴影。

3.4 一个可以直接抄的软粒子版本

粒子、烟雾、光束这类效果,最典型的问题是"粒子与场景物体的交线非常生硬"——粒子穿墙时会在墙面上留下一条清晰的分界线。解决方法是软粒子:采样摄像机深度图,比较粒子片元和场景表面的深度差,在接近时把 alpha 淡出。

sampler2D _CameraDepthTexture; float _SoftDistance; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.screenPos = ComputeScreenPos(o.pos); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv) * _Color; float sceneZ = LinearEyeDepth( SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos))); float partZ = i.screenPos.w; float fade = saturate((sceneZ - partZ) / _SoftDistance); col.a *= fade; return col; }

这段代码生效的前提是在 C# 侧给摄像机开启深度纹理:

void Start() { GetComponent<Camera>().depthTextureMode |= DepthTextureMode.Depth; }

_SoftDistance控制淡出的距离范围,一般设 0.3 到 1.0 之间,太大会让粒子在离墙面很远的地方就消失,太小则起不到柔和作用。我在做烟雾效果时习惯先把它设成 0.5,然后在场景里贴着墙走一圈,看交线是否自然,再微调。

提示:SAMPLE_DEPTH_TEXTURE_PROJ和UNITY_PROJ_COORD这套宏在内置管线下工作正常,如果你在别的渲染管线下工作,深度采样 API 和屏幕 UV 的获取方式都不一样,需要按对应管线的写法替换,不要照抄。

4. 常见问题排查实录:那些让人怀疑人生的现象

4.1 透明物体"消失"或直接变成纯色块

这是最高频的问题,排查顺序建议固定成一条链路。第一步看 Queue 和 ZWrite:如果 ZWrite 忘了关,同时队列又设成了 Geometry,物体就会被当作不透明物体处理,看起来就是一块实心色。第二步看 Blend 是否写全,Blend SrcAlpha OneMinusSrcAlpha少写一半(比如只写Blend SrcAlpha One)会导致颜色异常叠加。第三步检查纹理的 alpha 通道:很多贴图导出时带的是"没有 alpha"的格式,采样出来的 a 恒为 1,怎么调都不透明。第四步确认材质上的_Color的 alpha 是不是被设成了 1。

还有一种特殊情况是"只在某些角度消失"。这通常是 Cull 的问题——如果你的模型法线朝内,Cull Back就把整个物体剔掉了。把 Cull 改成 Off 试一下,如果物体出现了,那就是法线方向的问题,需要修模型或者改 Cull 设置。

4.2 边缘一圈黑边或白边

黑边的成因有三类,需要区分。第一类是纹理本身的问题:非预乘的 alpha 贴图,透明区域的 RGB 是纯黑(或者被压缩污染),纹理过滤时这些黑色会渗到边缘,产生黑边。解决方法是在图像工具里做"边缘扩边"处理,或者改用预乘 alpha。第二类是色彩空间问题:在 Linear 空间下,alpha 混合的计算和 Gamma 空间不同,如果贴图没有正确标记 sRGB,边缘会偏暗。第三类是多层叠加:同一个面上叠了两三层半透明,每层都贡献一点黑,累积起来就明显了。

白边的原因通常是自家的 Shader 在透明区域输出了非零的颜色。检查片元返回值,如果 alpha 是 0 但 rgb 不是 0,在某些混合模式下仍然会贡献颜色。养成习惯:return fixed4(color.rgb, alpha)里的 color 在 alpha 趋近 0 时最好也趋近 0,或者用预乘。

4.3 排序问题速查表

我在项目里整理过一份透明问题的对照表,这里分享出来,遇到现象可以直接对号入座。

现象最可能的原因处理方式
后面的物体被挡住ZWrite 未关闭设 ZWrite Off
同一物体内部前后错乱排序按物体粒度拆网格或改用 Alpha Test
摄像机移动时前后跳变包围盒中心距离接近合批或改 Alpha Test
透明物体被不透明物体遮挡队列设置过低Queue 设为 Transparent
粒子穿墙有硬边无深度淡出加软粒子逻辑
双面材质背面发黑法线未翻转用 VFACE 取反法线
透明区域颜色偏亮队列在不透明之前检查 Queue 与 Blend

这张表覆盖了大概八成的问题,剩下两成通常是工程配置层面的,比如后处理顺序、相机的渲染目标格式(有些格式不带 alpha 通道)、以及 SRP Batcher 对材质属性的兼容性要求。碰到实在查不出来的,我的笨办法是把 Shader 简化到只输出return fixed4(1,0,0,0.5);,如果这样都不对,问题一定在渲染配置而不在 Shader 逻辑里;如果这样对了,再一行一行加回去,定位到具体是哪一行引入的偏差。

4.4 移动端的 Overdraw:透明的真实成本

桌面上你不太会感知到透明的性能问题,移动端就完全不一样了。半透明物体不写深度,意味着被它覆盖的每一个像素都要完整跑一遍片元着色器,而且多个半透明物体叠加时,同一个像素会被反复计算。这个指标叫 Overdraw,在中低端手机上,全屏三层半透明就足以让帧率掉下来。

优化手段有几个方向。一是控制覆盖面积:能用小面片解决的不要用大面片,特效的贴图尽量把透明区域裁掉,别用一张 1024 的大图只画中间一小团火。二是减少层数:粒子数量、烟雾层数直接决定叠加次数。三是降低片元复杂度:透明 Shader 里少用 pow、少做多次纹理采样,能用顶点计算的不要放到片元。四是用 Alpha Test 替代 Alpha Blend:对于不需要柔和过渡的物体(比如远处的草),换成裁剪能显著减少混合开销,代价是锯齿。

还有一个小技巧:对于大面积的全屏半透明效果(比如受伤红屏、水下滤镜),不要用一个巨大的面片去做,而是用后处理或者直接操作相机背景色,成本低一个数量级。

5. 进阶玩法:让透明效果真正好看

5.1 深度淡出与"软相交"的通用写法

软粒子的思路可以推广到任何需要和场景融合的效果上,比如角色脚下的光圈、地面上的能量场、水面与岸边的接触线。核心逻辑都是"比较当前片元和场景表面的深度差,靠得越近越透明"。区别只在于淡出的方向——粒子是靠近物体时淡出,而地面光圈可能是远离地面时淡出,把saturate里的减法顺序调换一下就行。

需要留意的是深度图的分辨率。摄像机的深度纹理默认和屏幕分辨率一致,但在某些平台上为了省内存会降采样,这时候淡出的边缘会显得粗糙。如果你的效果对边缘精度要求高,可以在质量设置里把深度纹理调到高精度,或者自己用 CommandBuffer 渲染一张全分辨率的深度图,当然开销也会上去。

5.2 菲涅尔边缘在很多效果里的复用

前面代码里出现的菲涅尔项,其实是一个万金油。做玻璃时它让边缘更实,做护盾时它让边缘发光,做水面时它模拟掠射角的反射增强,做角色被击中时的闪烁时它只亮轮廓。写法都是同一行pow(1 - saturate(dot(normal, viewDir)), power),只是后面接的东西不同。

调整_FresnelPower的时候有个直觉可以借用:值越小,边缘过渡越宽,整体看起来越"朦胧";值越大,边缘越窄越锐利,像描了一圈边。做能量护盾我一般用 2 到 3,做玻璃用 4 到 6,做水面用 5 左右。这个参数没有标准答案,全看你想表达的材质感觉。

5.3 什么时候该放弃纯混合,改用"预写深度"

有一个场景纯 Alpha Blend 是解决不了的:一个复杂的半透明物体,比如一个雕花的玻璃瓶,它自身有大量重叠的面。这时候无论怎么排序,内部都会错。工程上常用的解法是"两遍渲染"——第一个 Pass 只写深度不写颜色(ColorMask 0,ZWrite On),第二个 Pass 正常混合但ZWrite Off,并且ZTest LEqual。这样物体自身只保留最前面的一层,内部重叠被深度测试解决掉,代价是失去了"透过前面看到后面"的真实感,视觉上更接近"半透明的实心壳"。

这个取舍在游戏里通常是划算的,因为玩家根本分辨不出"玻璃瓶内部有两层"和"只有一层"的区别,但排序错乱的闪烁他们一眼就能看见。是否采用,取决于你的效果需要多"物理正确"。

我个人的体会是,透明效果这一块,写代码的时间只占三成,剩下七成都在和排序、深度、混合这三件事较劲。每次接手一个新的透明效果需求,我的第一反应不是打开 Shader 编辑器,而是先问自己三个问题:这个物体需不需要透过它看到背后(决定 Blend 还是 Clip);它自身有没有重叠结构(决定要不要预写深度);它会不会和其他半透明物体大量叠加(决定怎么控制 Overdraw)。把这三个问题想清楚,代码往往二十分钟就写完了;想不清楚,调一整天也可能只是在错误的方案上打转。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询