刚上手 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" }常用的队列值有这么几个,记住它们对应的数值能帮你判断绘制先后:
| 队列名 | 数值 | 用途 |
|---|---|---|
| Background | 1000 | 天空盒、背景 |
| Geometry | 2000 | 绝大多数不透明物体 |
| AlphaTest | 2450 | 抠图镂空物体(树叶、铁丝网) |
| GeometryLast | 2500 | 不透明物体的最后一批 |
| Transparent | 3000 | 半透明物体 |
| Overlay | 4000 | UI、镜头光晕等最上层 |
同一个队列内部,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 的时候对照着过一遍:
- 队列选对。只需要抠形状用 AlphaTest(队列 2450),需要真透明用 Transparent(队列 3000),别混。
- ZWrite 该关就关,除非你真需要深度预写。默认透明 Pass 一律
ZWrite Off。 - Blend 公式和贴图匹配。用传统 Alpha 贴图配
SrcAlpha OneMinusSrcAlpha;用预乘贴图配One OneMinusSrcAlpha,别拿错。 - 多透明层用队列偏移分层,从内到外依次
Transparent、Transparent+1、Transparent+2。 - 片元里能省的计算全省。透明 Shader 对片元的开销极其敏感,
normalize、pow这类操作能放到顶点里算就别放片元。 - 淡出动画结束后关渲染器,别让 Alpha 为 0 的物体继续占 DrawCall。
- 用 Frame Debugger 验证。改完透明 Shader,一定要看一遍绘制顺序,确认符合预期再提交。
这些点看着琐碎,但每一条都是我或者周围同事真栽过的坑。透明效果做得好看不难,难的是在性能受限的情况下还稳得住,这靠的就是把这些底层开关一个个都摸清楚。
我个人在这些年做透明效果的最大体会是,别急着写代码,先把队列、深度写入、混合模式这三个变量想清楚它们各自要取什么值,代码其实几分钟就写完了;反倒是没想清楚就下手,后面排查闪烁、黑边、穿帮的时间够你写十遍 Shader。如果你正准备让某个物体在半透明和全透明之间来回切换,记得把_Alpha用一个共享属性管起来,脚本和材质面板都用同一个,改起来才不容易乱。