☰
Unity Shader全透明与半透明:渲染队列、ZWrite与Blend
2026/9/30 5:45:05 网站建设 项目流程

刚上手 Unity Shader 的人,八成第一个想做的效果就是透明:要么让一个模型整体变得若隐若现,像玻璃、像幽灵;要么干脆让它彻底看不见,也就是全透明。但真动手才发现,Unity 自带的标准材质把渲染队列、深度写入这些底层开关全藏起来了,你只调一个 Alpha 值,结果不是毫无反应,就是冒出一堆黑边、闪面、穿帮的遮挡关系。这篇就从一个实际写 Shader 的人的角度,把 Unity Shader 里全透明与半透明效果的实现逻辑、代码骨架、踩坑点从头捋一遍。不管你是刚学 ShaderLab 的新手,还是被透明排序折磨过的老手,都能从里面找到能直接抄的代码和判断依据。我不会只丢一段代码给你,而是把每一行参数为什么这么写、换一种写法会出什么问题,都摊开讲清楚。

1. 透明渲染的底层逻辑:先搞懂队列和混合

很多人写透明 Shader 的第一反应是"把 Alpha 设小不就行了",然后在标准材质里把 Albedo 的 A 拉到 0.5,物体确实变透明了,但同时出现了奇怪的现象:物体背后看过去是黑的,或者模型自己身上的前后两面互相打架。这不是代码写错了,而是没理解 Unity 渲染透明物体的两套机制——渲染队列和混合模式。这两个概念如果不吃透,后面写多少 Shader 都是碰运气。

1.1 为什么透明物体必须独立排队渲染

先说不透明物体。它们渲染时会往深度缓冲(Z-Buffer)里写每个像素的深度值,谁离摄像机近谁就留下,远的像素在深度测试阶段直接被丢掉。所以不透明物体的渲染顺序其实无所谓,先画后画结果一样,因为深度测试会兜底。

透明物体就完全不一样了。它不能往深度缓冲里写深度,否则它后面的东西就被永久挡住了,透不过去。既然不写深度,就没法靠深度测试保证遮挡正确,那怎么办?只能靠绘制顺序:从远到近一层层往颜色缓冲上叠,后画的一层用混合公式盖在已画好的颜色上。这就是渲染队列存在的根本原因。

Unity 里用 SubShader 标签指定队列:

Tags { "Queue" = "Transparent" "RenderType" = "Transparent" "IgnoreProjector" = "True" }

常用的队列值有这么几个,记住它们对应的数值能帮你判断绘制先后:

队列名数值用途
Background1000天空盒、背景
Geometry2000绝大多数不透明物体
AlphaTest2450抠图镂空物体(树叶、铁丝网)
GeometryLast2500不透明物体的最后一批
Transparent3000半透明物体
Overlay4000UI、镜头光晕等最上层

同一个队列内部,Unity 会在每帧根据物体包围盒中心到摄像机的距离排序,远的先绘制。这里有个关键细节:排序用的是包围盒中心,不是逐像素。所以两个透明物体如果包围盒中心一样远,或者互相穿插,排序就会出错,出现闪烁或者遮挡颠倒。这一点后面第 5 节会专门讲怎么排查。

1.2 ZWrite 与 ZTest 的取舍逻辑

写透明 Shader 绕不开的一行是ZWrite Off。它的意思是这个 Pass 不写深度。为什么必须关掉?假设你不关,第一个半透明像素写入了深度,后面那些本来应该透过这个像素显示出来的场景像素,深度测试时会发现"已经有个更近的像素占了位置",于是被剔除,结果就是这个半透明物体后面的所有东西都看不见了,看起来就像一块不透明色块。

但ZWrite Off的代价是:透明物体自身网格如果前后自穿插(典型的是头发、裙子、透明盔甲、藤蔓),靠混合公式叠出来的颜色是错的。因为像素在颜色缓冲里叠加时没有深度信息做判断,谁先画谁就被压在下面。

常见的解决办法有两种。第一种是关掉背面剔除并分两趟画,先画背面再画正面,让正面最后叠上去:

Pass { Cull Front ZWrite Off Blend SrcAlpha OneMinusSrcAlpha // ... 这里写正面的片元逻辑 } Pass { Cull Back ZWrite Off Blend SrcAlpha OneMinusSrcAlpha // ... 同样一份逻辑再写一遍 }

第二种是深度预写:先用一个 Pass 只写深度不写颜色(ColorMask 0),再正常画透明。这样透明物体的自穿插能得到正确的深度遮挡,同时因为是分离的 Pass,颜色缓冲还是按混合规则叠。这两种方案我在不同项目里都用过,前者简单,适合结构简单的模型;后者效果更稳,但多一个 Pass 意味着多一次绘制,移动端要权衡。

注意:深度预写 Pass 一定要放在普通透明 Pass 前面,否则深度已经确定,后面的颜色就没意义了。

1.3 透明度测试和透明度混合,选错方向全盘皆输

这是新手最容易混淆的一对概念,它们解决的是完全不同的问题。

透明度测试(AlphaTest / Alpha Clipping)的逻辑是:片元里算出一个 Alpha,低于阈值的像素直接discard掉,剩下的像素当作完全不透明来处理,照常写深度,照常走 Geometry 或 AlphaTest 队列。它的特点是边缘是硬边,没有过渡,放大看会有明显锯齿。树叶、草丛、铁丝网、栅栏这种"要么完全显示要么完全不显示"的材质用它最合适,因为它写深度,所以互相之间遮挡关系天然正确,性能也好。

fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv) * _Color; clip(col.a - _Cutoff); // 低于 _Cutoff 的像素直接丢弃 return col; }

透明度混合(AlphaBlend)的逻辑是:不丢弃任何像素,而是把当前像素的颜色和颜色缓冲里已有的颜色按公式混合。它不写深度,走 Transparent 队列,边缘可以做到柔和过渡,玻璃、水面、烟雾、光效都靠它。代价是排序问题、Overdraw 性能问题都会找上门。

所以选型的判断标准很简单:需要半透明的视觉感受(能看见后面的东西并且颜色被调和),用混合;只需要形状裁剪、不需要真的看见后面,用测试。有些人为了省事,给树叶也上混合,结果一片树叶树就得处理大量 Overdraw,帧率直接掉。

2. 从零写一个全透明 Shader

这一节咱们真刀真枪写一个能用的透明 Shader。所谓"全透明",在 Unity 的语境里就是 Alpha 值可以一路压到 0 的透明方案,它作为透明着色器的通用底座,把参数调到 0 就隐形,调到 0.5 就是半透明。所以先把通用骨架搭好,剩下的只是参数调多少的问题。

2.1 Shader 骨架:Properties 与 SubShader 标签怎么定

完整的 ShaderLab 结构分四块:Shader 声明、Properties 属性块、SubShader 子着色器、FallBack 兜底。透明 Shader 的 Properties 一般至少要有主贴图、颜色、透明度三个:

Shader "Custom/SimpleTransparent" { Properties { _MainTex ("Main Texture", 2D) = "white" {} _Color ("Tint Color", Color) = (1,1,1,1) _Alpha ("Alpha", Range(0,1)) = 0.5 } SubShader { Tags { "Queue" = "Transparent" "RenderType" = "Transparent" "IgnoreProjector" = "True" } Pass { ZWrite Off Blend SrcAlpha OneMinusSrcAlpha Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" // ... 顶点和片元逻辑 ENDCG } } FallBack "Transparent/VertexLit" }

这里每一行都有讲究。Queue = Transparent保证它在不透明物体之后绘制;RenderType = Transparent是被摄像机替换着色器识别用的,做后处理或者深度渲染时全靠它;IgnoreProjector关掉投影器影响,避免一些奇怪的投影片段。ZWrite Off和Blend前面已经讲过原因。

2.2 顶点与片元函数逐行拆解

顶点函数负责把模型顶点从对象空间转到裁剪空间,同时传递 UV:

struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; float4 _MainTex_ST; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; }

UnityObjectToClipPos是 Unity 提供的工具函数,等价于先乘 MVP 矩阵再转裁剪空间,比自己手写矩阵乘法省事且不容易错。TRANSFORM_TEX负责把贴图的 Tiling 和 Offset 应用到 UV 上,_MainTex_ST是 Unity 根据属性面板里贴图设置自动填充的,格式是 (TilingX, TilingY, OffsetX, OffsetY)。如果你忘了写这一行,贴图的平铺和偏移在面板上改了也没反应,这是新手常犯的低级错误。

片元函数里做混合前的 Alpha 处理:

fixed4 _Color; float _Alpha; fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv) * _Color; col.a *= _Alpha; return col; }

关键点在于col.a *= _Alpha这一句。如果你直接用col.a = _Alpha,就会把贴图自己的 Alpha 通道信息抹掉,带透明通道的贴图(比如 PNG 带 alpha)就废了。乘法是保留贴图 alpha 同时整体缩放透明度的正确做法。

2.3 Blend 混合模式的选型,别只会一种

Blend SrcAlpha OneMinusSrcAlpha是最常用的传统 Alpha 混合,公式展开是:结果 = 当前像素颜色 × Alpha + 已有颜色 × (1 - Alpha)。它的效果就是标准的"半透明覆盖"。

但混合模式远不止这一种,不同场景要用不同的:

Blend 写法公式效果典型用途
Blend SrcAlpha OneMinusSrcAlpha传统 Alpha 混合玻璃、水、半透明 UI
Blend One OneMinusSrcAlpha预乘 Alpha 混合粒子系统、带预乘的贴图
Blend One One叠加,颜色线性相加发光、火焰、能量罩
Blend DstColor Zero正片叠底阴影、暗化层
Blend SrcAlpha One加性带 Alpha 控制光晕、霓虹

那个预乘 Alpha(Premultiplied Alpha)很容易踩坑。如果你用Blend One OneMinusSrcAlpha,但片元返回的颜色没有预先乘过 Alpha,画面会整体偏亮,因为颜色被原样加进去了。正确做法是在片元里先做col.rgb *= col.a;,然后再返回。粒子系统里这个坑特别常见,很多人直接照抄别人的粒子 Shader,结果发现颜色比预期亮一截,就是这里没对上。

实操心得:改 Blend 模式的时候,最好同时截图对比,别凭想象。加性混合在深色背景和亮色背景下的观感差别极大,一定要放到目标场景里看。

3. 半透明效果实战:玻璃、软粒子与渐变消失

有了上面的骨架,接下来把它变成真正能在项目里用的东西。半透明在项目里的需求其实是几个大类:一类是物理材质的模拟,比如玻璃、水面;一类是特效面片和场景的融合,比如烟雾、光柱;还有一类是交互反馈,比如物体逐渐消失、渐显。这三类的技术侧重点完全不同,分开讲。

3.1 玻璃材质:用 Fresnel 做出边缘厚度感

纯正的均匀半透明看起来像塑料片,真正像玻璃的关键在于边缘比中间更亮,这就是菲涅尔效应。实现上只要拿视角方向和法线方向做点乘即可:

fixed4 frag (v2f i) : SV_Target { float3 N = normalize(i.worldNormal); float3 V = normalize(_WorldSpaceCameraPos - i.worldPos); float fresnel = pow(1.0 - saturate(dot(N, V)), _FresnelPower); fixed4 tex = tex2D(_MainTex, i.uv) * _Color; tex.rgb += fresnel * _FresnelColor.rgb; tex.a = lerp(tex.a, _EdgeAlpha, fresnel); return tex; }

这里pow的指数_FresnelPower控制边缘过渡的锐利程度,值越大边缘越窄越亮,一般取 2 到 5 之间。要让这个菲涅尔生效,v2f 结构里必须把世界空间法线和世界空间位置传下来,顶点函数里用UnityObjectToWorldNormal和mul(unity_ObjectToWorld, v.vertex)算出来。

玻璃这种材质还有一个绕不开的问题:它本身是透明物体,但它背后的东西也是透明的时候,两者会互相打架。解决办法是把玻璃的渲染队列稍微往前排,比如设成Queue = "Transparent+1",让它比其他透明物体后画,因为后画的在上面。这个"队列偏移"的小技巧在多个透明层叠加的场景里非常好用,比如水面之上罩一层透明的冰。

3.2 软粒子:用深度图消除透明面片和场景的硬相交

做特效的人都会遇到这个问题:一个火焰面片或者烟雾粒子插进地面的时候,地面和面片的分界线会形成一条非常生硬的直线,因为透明面片是直接切进不透明几何体的。要让交界面柔和过渡,就要用软粒子技术,靠摄像机深度图算出来。

原理是:在片元里采样当前像素位置对应的场景深度,和自己这个像素的深度比较。如果两者很接近,说明这个像素就贴着场景表面,把它的 Alpha 压到接近 0,就形成软过渡。

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

几个必须注意的点:第一,摄像机必须开启深度纹理模式,代码里写Camera.main.depthTextureMode |= DepthTextureMode.Depth;或者在有后处理的管线下天然就有。第二,UNITY_PROJ_COORD这个宏不能省,它处理平台差异,在某些图形 API 上必须做透视除法修正。第三,_SoftFadeScale这个缩放系数控制软边缘的宽度,太大整个粒子会变淡,太小又看不出软化效果,通常从 1 开始调,配合粒子尺寸调整。

踩过的坑:在一次移动端项目里,我开了软粒子之后帧率掉了将近 15%,原因是每帧都在读深度图,而且粒子的 Overdraw 本来就高。后来把软粒子的开关做成了 LOD 级别,低端机直接关掉,用一张预先做好的遮罩贴图代替。所以软粒子好用,但别在所有平台上无脑开。

3.3 脚本控制渐变消失:用 MaterialPropertyBlock 避免材质实例化

渐进消失的需求很常见:物体被拾取后淡出、角色死亡后模糊消散。很多人第一反应是脚本里改renderer.material.SetFloat("_Alpha", a),但这里有个性能陷阱——renderer.material会创建一份新的材质副本,每调用一次就多一个材质实例,多个同类物体会破坏合批,DrawCall 蹭蹭往上涨。

正确的做法是用MaterialPropertyBlock:

public class FadeController : MonoBehaviour { public float fadeDuration = 1.0f; private static readonly int AlphaID = Shader.PropertyToID("_Alpha"); private Renderer _renderer; private MaterialPropertyBlock _mpb; private float _elapsed; void Awake() { _renderer = GetComponent<Renderer>(); _mpb = new MaterialPropertyBlock(); } void Update() { _elapsed += Time.deltaTime; float alpha = Mathf.Clamp01(1f - _elapsed / fadeDuration); _renderer.GetPropertyBlock(_mpb); _mpb.SetFloat(AlphaID, alpha); _renderer.SetPropertyBlock(_mpb); if (alpha <= 0f) { _renderer.enabled = false; enabled = false; } } }

Shader.PropertyToID把字符串编译成整数 ID,避免每次 set 都要做字符串哈希,这个优化在大批量对象上很值。另外注意_mpb是复用的,不用每次都 new,这也是个小性能点。

还有一个容易忽略的地方:Alpha 到 0 之后应该关掉 Renderer。Alpha 为 0 的透明物体依然会被提交渲染,依然占用 DrawCall 和 Overdraw,只是颜色输出近乎透明而已。所以淡出动画结束后一定要renderer.enabled = false或者gameObject.SetActive(false)。什么时候用哪个?enabled = false只停渲染,脚本和碰撞体还在;SetActive(false)整体停掉,但如果对象很多,频繁 SetActive 会触发内部状态变化有开销。一般渐变消失用前者就够。

4. Shader Graph 和手写 ShaderLab,到底用哪个

这是个绕不过去的选择问题。Unity 现在主推 Shader Graph,可视化连线看起来门槛低,但透明这种精细控制的场景,两者的差别还挺明显。我自己的判断是分情况用,强行统一没必要。

4.1 Shader Graph 的优势和它的透明设置陷阱

图的可视化优势在于迭代快、团队沟通直观,美术同学也能上手改。但透明相关的设置藏在 Graph Settings 里,不是拖个节点就完事。几个关键开关:

  • Surface Type选Transparent,这是前提。
  • Blending Mode有 Alpha、Premultiply、Additive、Multiply 几种,对应不同的 Blend 公式,跟前面手写时的混合模式是一回事。
  • Render Face决定是 Front、Back 还是 Both,对应Cull设置。
  • Depth Write一般要勾掉(透明不写深度),但做复杂叠加时可能需要特殊处理。

最容易踩的坑是Alpha Clipping 和 Transparent 同时打开。理论上它们可以并存(先裁剪再混合),但实际效果经常出乎意料,因为 Shader Graph 生成的代码里这两段逻辑的先后顺序和你想的不完全一样。我的建议是做硬边裁剪就纯用 Alpha Clip 加 Opaque,做透明混合就纯用 Transparent,别混着来。

4.2 手写 ShaderLab 的不可替代性

Shader Graph 再方便,遇到下面这些场景还是要回到代码:

  • 需要在同一个 Shader 里根据关键字切换多套逻辑(#pragma multi_compile),图里做起来很绕。
  • 需要精确控制渲染队列偏移(Transparent+1这种),图目前给不了这么细的开关。
  • 需要针对不同平台走不同分支(用#if defined(SHADER_API_MOBILE)这类宏)。
  • 需要做深度预写这种多 Pass 结构。

而且调试上,手写 Shader 出错了你能直接看代码逻辑,Shader Graph 生成的代码有重命名和包装,定位问题反而更慢。所以我的实际做法是:常规材质效果用 Graph 提效,涉及透明排序、多 Pass、平台分支的用代码。两者并不冲突,一个项目里混用很正常。

5. 渲染顺序与深度排序问题排查

这一节是整篇里最值钱的部分,因为透明效果 90% 的诡异表现都来自排序,而这类问题的排查往往靠经验,文档里基本不写。

5.1 透明物体互相穿插、闪面、黑边到底怎么来的

先说闪面。两个透明面片重叠时,如果它们的包围盒中心到摄像机距离几乎相同,Unity 每帧排序的结果可能在两者之间反复横跳,于是看到的就是画面在闪。这是包围盒中心排序的先天缺陷,解决思路是打破对称:给其中一个设置不同的渲染队列(比如 +1),或者手动改变它的包围盒(Renderer.bounds),或者干脆把两个面片合成一个模型。

再说黑边。透明物体的边缘出现一圈黑色,通常有两个原因。一是贴图本身的问题,PNG 在导出时边缘像素的 RGB 变成了黑色,而 Alpha 是渐变的,混合时这些黑色像素被掺进来。检查方法是在片元里直接把col.rgb = 1;强制白色看看黑边还在不在,如果在就说明是几何穿插,不在就是贴图或者混合公式的问题。这也是为什么做透明贴图时要求"边缘 RGB 要和主体颜色一致,不要用纯黑填充"。

第三个是透明物体背后的东西被挡死。前面讲 ZWrite 时说过,如果误开了深度写入,透明物体后面的所有东西都会被剔除。检查方法是在 Frame Debugger 里看这个 Pass 是不是写了深度。

5.2 常见问题速查表

现象可能原因解决方向
物体完全不透明,Alpha 无效果队列设成了 Geometry,或 ZWrite 没关检查 Queue 标签和 ZWrite
物体背后变成黑色Blend 用了 SrcAlpha One 等加性模式换成 SrcAlpha OneMinusSrcAlpha
透明物体互相穿插闪烁排序距离近似队列偏移或合并模型
边缘有黑边贴图边缘 RGB 为黑修正贴图边缘颜色
全透明后依然有性能开销Alpha 为 0 但未关渲染淡出结束关闭 Renderer
粒子面片插进地面有硬线未开启软粒子采样深度图做软化
玻璃后面看其他透明物体错乱多透明层渲染顺序使用 Transparent+N 分层
移动端帧率骤降Overdraw 过高缩小面片、减少叠加层、加 LOD

这张表我是从实际项目里一类类攒出来的,遇到问题时先对照排查,能省掉大量试错时间。

排查透明问题的万能工具是 Frame Debugger。它能一帧帧告诉你每个 DrawCall 的绘制顺序、用的哪个 Shader、写没写深度。我几乎每次遇到透明排序问题都会先开它看一眼,比盲猜快得多。

6. 移动端与性能:半透明是最容易翻车的地方

最后聊聊性能,因为透明效果在 PC 上跑得好不代表在移动端也能跑。半透明是所有渲染方式里最奢侈的一种,原因只有一个词:Overdraw,也就是同一像素被重复绘制的次数。

6.1 理解 Overdraw:透明物体的性能杀手

不透明物体靠深度测试,通常一个像素只被真正着色一次,早深度测试把后面的都剔了。透明物体不写深度,就意味着它背后的所有像素都得老老实实走完片元着色器,然后混合。屏幕上叠了五层半透明,就是五次完整的片元计算加混合,像素填充率直接翻五倍。

移动端 GPU 的填充率本来就紧张,屏幕分辨率又高,所以透明效果一多帧率立刻掉。我实测过一个场景:满屏的火焰粒子,面片数量不算多,但因为层层叠加,Overdraw 达到了 8 左右,在手机上直接掉到 25 帧。后来把粒子面片尺寸缩小、重叠层数控制在 3 层以内,帧率就回到 55 以上。

降低 Overdraw 的手段有这么几个。缩小面片尺寸,让透明区域尽量只覆盖真正需要的像素;减少叠加层数,尤其是粒子系统,别为了效果堆太多发射器;用 AlphaTest 替代 AlphaBlend,能做硬边的就别做软边;开启 LOD 分级,低端机直接关掉次要的透明特效。

6.2 一份可以直接照做的优化清单

结合前面所有内容,整理一份实操清单,写新 Shader 的时候对照着过一遍:

  1. 队列选对。只需要抠形状用 AlphaTest(队列 2450),需要真透明用 Transparent(队列 3000),别混。
  2. ZWrite 该关就关,除非你真需要深度预写。默认透明 Pass 一律ZWrite Off。
  3. Blend 公式和贴图匹配。用传统 Alpha 贴图配SrcAlpha OneMinusSrcAlpha;用预乘贴图配One OneMinusSrcAlpha,别拿错。
  4. 多透明层用队列偏移分层,从内到外依次Transparent、Transparent+1、Transparent+2。
  5. 片元里能省的计算全省。透明 Shader 对片元的开销极其敏感,normalize、pow这类操作能放到顶点里算就别放片元。
  6. 淡出动画结束后关渲染器,别让 Alpha 为 0 的物体继续占 DrawCall。
  7. 用 Frame Debugger 验证。改完透明 Shader,一定要看一遍绘制顺序,确认符合预期再提交。

这些点看着琐碎,但每一条都是我或者周围同事真栽过的坑。透明效果做得好看不难,难的是在性能受限的情况下还稳得住,这靠的就是把这些底层开关一个个都摸清楚。

我个人在这些年做透明效果的最大体会是,别急着写代码,先把队列、深度写入、混合模式这三个变量想清楚它们各自要取什么值,代码其实几分钟就写完了;反倒是没想清楚就下手,后面排查闪烁、黑边、穿帮的时间够你写十遍 Shader。如果你正准备让某个物体在半透明和全透明之间来回切换,记得把_Alpha用一个共享属性管起来,脚本和材质面板都用同一个,改起来才不容易乱。

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

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

立即咨询