直接开门见山说结论:在Unity里要做一个圆形遮罩,用Shader去干绝对是最快、最省、最干净的路子,没有之一。你不需要下载任何插件,不需要写复杂的C#裁剪逻辑,甚至不用美术额外切图,一个几十行的小Shader就能把这件事做成“一劳永逸”的状态。无论你是做新手引导的镂空高亮,还是做小地图的战争迷雾边缘、角色脚下的范围提示圈,又或者是加载界面那种从中心往外扩散的圆环动画,这个圆形遮罩Shader都能一套代码全给你端平。
这篇文章适合已经在Unity里做过基础组件操作、但Shader接触不深的开发者。我不打算给你抄一段代码就完事,而是把背后的核心原理、两种最常用的实现方式、以及我在真实项目里踩过的坑全都倒出来,保证你看完不仅会抄,还能自己改,能知道哪里会出问题以及为什么出问题。
1. 这玩意能做什么:圆形遮罩的应用场景与项目拆解
1.1 场景清单:圆形遮罩到底能帮我们解决什么问题
先说最直观的几个使用场景,帮助你先建立画面感。第一个就是“新手引导”。很多游戏在引导玩家点击按钮时,会在全屏加一个变暗的蒙层,然后在目标按钮上面抠出一个圆形的镂空区域,让玩家视线聚焦到那个按钮上。这个效果用UGUI的Mask组件做会很吃力,因为Mask组件的原理是矩形裁剪,你想抠出圆形镂空,要么准备一张带孔洞的九宫格图,要么叠加好几个Mask互相嵌套,实现在不同分辨率下的适配非常痛苦。
第二个场景是“技能指示器”或“角色脚下光环”。比如你在玩MOBA类游戏,选技能时地面会显示一个圆形范围提示,这个圆形边界要清晰,半透明填充要均匀,还要能跟着鼠标旋转或者缩放。用贴图的话,不同尺寸要用不同精度的图,放大了还会模糊,用Shader画一个圆就能保证任意缩放都锐利。
第三个场景是“扩散效果”。比如吃鸡游戏里的毒圈、平A释放时的冲击波、加载界面的过渡动画。这些效果本质上都是一个圆的半径随时间变化,边缘带一点羽化,整体呈现“从某一个点向外冲刷”的感觉。用Shader实现这种动态效果非常自然,因为你只需要在C#端把圆的半径参数从0改到最大值就行,所有的插值和平滑计算都交给GPU的数学单元去完成。
第四个场景是“小地图迷雾”或者“探索区域解锁”。很多地图类游戏会有一个“已探索范围”的圆形光圈,走到哪里,哪里就亮起来。如果你用一个Shader来控制这个圆形遮罩的中心点和半径,就可以做到平滑地扩展可见区域,视觉上非常柔和。
我自己在真实项目里用得最多的其实是“环形进度条”的变体。一个技能CD转圈的加载动画,Shader版本比做序列帧动画节省了大量资源,而且可以随手改成半圆弧、双弧线、内圈淡出外圈深色等多种样式,改参数就跟调颜色一样快。所以你看,圆形遮罩这个东西听起来简单,但它是一个“基础数学能力”,可以说是Shader世界里你好朋友,只要掌握了它,后面做多边形遮罩、做网格形变、做噪声扰动边缘,全都是在它基础上加几行代码的事。
1.2 为什么选Shader而不是Mask组件或者摄像机裁剪
接下来解释一个最关键的问题:同样是做遮罩,为什么用Shader更快更干净?我先说UGUI自带的那套Mask方案,它的工作方式是把被裁剪对象的顶点数据收集起来,在渲染时通过模板缓冲(Stencil Buffer)来判断当前像素到底该不该显示。你每次在场景里添加一个Mask组件,Unity就会在内部开启一个额外的渲染Pass,把Mask区域写入模板缓冲。这个方案的明显缺点是:模板缓冲是全局状态,如果你一个界面里有十几个Mask,渲染状态切换的开销会成倍增加,而且多个Mask之间的嵌套关系一旦复杂,性能就会急剧下降,还容易出现“Mask区域内依然能看到不该显示的边缘”这种硬锯齿问题。
而Shader方案呢?它是在GPU的像素着色器阶段直接判断“当前像素到圆心的距离是否小于半径”,然后决定输出透明还是不透明。这个过程不涉及任何额外的Pass,不需要状态切换,只需要一个物体(通常是Quad或者Image)加上材质就能完成。也就是说,Shader方案把复杂度从“多个渲染组件之间的协作”变成了“一个数学公式对像素的判定”,这个转化是本质性的。对于像素着色器来说,绘制一个圆形遮罩和绘制一个全屏色块,消耗的指令数差距非常小,基本上就是多算一个距离函数和一两次比较指令,单像素开销可以忽略不计。
还有一个更现实的因素是分辨率适配。Mask组件配合Image控件时,裁剪边缘是跟屏幕像素一比一对应的,不同设备上很容易产生一条若隐若现的锯齿白边。而Shader计算出来的圆形边缘是连续的数学值,它天生自带“抗锯齿”的潜力——你可以用一个smoothstep函数让边缘在几像素范围内渐变,这样任何分辨率下都能保持平滑。对于一款要上线到几百种安卓机型的手游来说,这个优势是不可替代的。
当然,我不是说Mask组件一无是处。如果你只是临时在UI编辑器里摆一个圆形遮罩看效果,不改代码,不加动态参数,那Mask肯定更快。但从长期维护、动态效果、多端适配和性能表现这几个角度来评估,Shader方案的综合收益要远高于Mask方案。毕竟一个项目上线后会经历无数次UI改版,改一次Shader参数只需要调整一个材质的Float字段,而不需要重新去调整嵌套Mask的层级关系,这才是真正的省事。
2. 核心原理:UV坐标系与圆形距离场
2.1 UV坐标:Shader里的“地盘地图”
想看懂圆形遮罩,必须先搞明白UV坐标。你可以把UV坐标理解成一张“地盘地图”,它规定了每个像素在被采样的这张材质上位于哪个位置。在Unity里,一个Quad或者一个Image控件被渲染时,它的四个顶点分别对应UV坐标的(0,0)、(1,0)、(0,1)、(1,1)。像素着色器在执行时,会拿到当前像素插值后的UV值,注意是插值后的,这就是为什么一个很小的圆形贴图贴在很大的平面上也不会出现“糊成一片”的原因——GPU会帮你在顶点之间做平滑过渡。
UV坐标的分布规律是:左下角是(0,0),右上角是(1,1)。如果你把整个平面想象成一张1米乘1米的坐标纸,那么每个像素的UV坐标就是在告诉你“我现在位于这张坐标纸的哪个位置”。圆心默认情况下最好取(0.5, 0.5),也就是坐标纸的正中心。但实际使用时,我们通常会在Shader里额外暴露一个“圆心位置”属性,这样可以通过C#脚本动态控制圆心的偏移。典型应用是:当角色移动时,把角色的世界坐标转换到屏幕坐标,再把屏幕坐标归一化成UV坐标值,传到Shader里,这样圆形遮罩就能精确地跟随角色位置走。
这里有一个新手容易犯的误区:以为UV坐标和屏幕坐标是同一个东西。完全不是。屏幕坐标是以像素为单位,不同设备的分辨率不同;UV坐标是归一化的比例值,跟具体分辨率无关。在Shader里做圆形遮罩时,我们要用UV坐标做计算,原因是它天然无视分辨率,在不同屏幕上表现一致。如果有特殊需求需要按屏幕比例拉伸圆形(比如想画一个椭圆而不是正圆),就必须拿到屏幕分辨率的长宽比,手动把UV坐标乘上一个修正系数。
另外要提一个重要的“坐标尺度”问题:当我们在Shader里计算距离时,用的是UV坐标下的“相对距离”。这个距离的真正意义是:“当前像素离圆心有几个UV单位”。如果UV范围是0到1,那么一个圆的半径0.3,意味着它能覆盖整个平面宽度的大约30%,这个比例关系在所有分辨率下都是恒定不变的。这种尺度感非常符合UI美术调参的习惯——美术说“我想要一个直径占屏幕宽度40%的圆”,你直接就填半径0.2就好,无需关心屏幕宽800还是宽1920。
2.2 圆形判定:点到圆心距离的计算与代码表达
有了UV坐标这张“地盘地图”,接下来核心动作就是计算“当前像素点到圆心的距离”,然后拿这个距离跟半径做比较。数学上,距离公式是d = sqrt((uv.x - center.x)^2 + (uv.y - center.y)^2)。这个公式本身没什么难的,但在GPU编程里有个经典的小优化:开平方运算(sqrt)虽然也不是很慢,但在移动端老GPU上还是能省则省。所以实际写代码时,我们通常比较的是“距离的平方”和“半径的平方”,也就是 d2 = (uv.x - center.x)^2 + (uv.y - center.y)^2,最后判断d2 <= radius2。这样一来,整个运算过程完全避开了sqrt,只用了两次乘法和两次加法,单像素开销进一步降低。
用代码来表达就是这样的逻辑:
// 在像素着色器里 float2 offset = uv - _Center; float dist2 = dot(offset, offset); // 等价于offset.x*offset.x + offset.y*offset.y float alpha = (dist2 < _Radius * _Radius) ? 1.0 : 0.0;这里我用了HLSL的dot函数,它本质上就是两个向量对应分量相乘再求和,正好就是距离平方的计算方式。这一行代码放在Fragment中,GPU会为每一个像素并行执行一次,你不需要写任何循环,也不需要处理边界,因为GPU天生就是为这种大量重复单像素运算而生的。
如果你要做的是“硬边圆形遮罩”,也就是圆内部完全不透明、圆外部完全透明,这个“alpha”的值只有两种可能:0或1,直接跟原图的颜色相乘即可。但大多数实际需求都要求“软边”,因为硬边在视觉上会显得很廉价,尤其在UI界面上,一个边缘锋利得像刀切一样的圆跟美术整体柔和风格完全不搭。软边意味着我们要引入一个“边缘宽度”参数,让alpha在圆的外围从1渐变到0,这个渐变过程可以交给smoothstep函数来处理,我下面会展开讲。
2.3 从硬边到软边:smoothstep与边缘宽度的控制在众多Shader函数里,smoothstep是最容易被忽视但实际最有用的一个。它的原型是smoothstep(edge0, edge1, x),当x小于等于edge0时返回0,当x大于等于edge1时返回1,中间部分按照三次Hermite插值做平滑过渡。拿来做圆形边缘,就是把x替换成“当前像素到圆心的距离”,edge0设为“半径减去边缘宽度的一半”,edge1设为“半径加上边缘宽度的一半”。这样,在edge0以内的像素完全显示,edge1以外的像素完全透明,中间宽度内的像素呈现连续渐变。
为什么要用smoothstep而不是简单的lerp或者clamp?因为简单的线性渐变在视觉上会在过渡带两端出现明显的“接缝感”,也就是你能隐约看到一个圆形轮廓在那里突然截断。而smoothstep的三次曲线让过渡在起点和终点处的一阶导数为零,视觉上非常顺滑,真的就像“融入到了背景里”。这个原理跟CSS动画里的ease-in-out缓动是一回事,只是现在作用在了像素的透明度上。
代码示例如下:
// 软边圆形遮罩的核心计算 float dist = length(uv - _Center); // 此时为了smoothstep,需要真实距离 float alpha = 1.0 - smoothstep(_Radius - _SoftEdge * 0.5, _Radius + _SoftEdge * 0.5, dist);注意这里我用了length函数,因为smoothstep需要真实距离判断边界,没法偷懒用距离平方。不过现代GPU对sqrt的实现已经很优化,移动端中高端机型完全跑得起,所以实际项目里我更倾向于直接写length,代码更清晰,性能差异小到可以忽略。只有当边缘极硬、且要同时渲染几千个这类圆形物体时,才值得用距离平方加硬边方式去压性能。
还有一点细节:smoothstep的返回值是从0到1,但它是“距离越小值越小”还是“距离越大值越大”?如果直接写smoothstep(edge0, edge1, dist),那么在圆内(dist小)返回的是0,圆外(dist大)返回的是1,这跟我们想要的“圆内显示、圆外透明”正好相反。所以我在前面加了一个“1.0 -”,把结果翻转过来。很多新手在这儿容易卡住,给自己写个小注释“圆内距离小,要1,圆外距离大,要0”就永远不会搞混了。
3. 两种常用实现路径:Shader Graph与手写ShaderLab代码
3.1 Shader Graph的可视化做法:适合快速验证方案
如果你用的是Unity 2021以上版本或者URP渲染管线,我强烈建议你先在Shader Graph里把这个圆形遮罩搭出来,理由有三点。第一,Shader Graph的节点化编辑方式把数学运算变成了一根根连线,新手可以通过拖拽直观地看到每个中间结果的变化,理解成本比看代码低得多。第二,Shader Graph的节点面板自带实时预览,你调整半径或者软边的Float值,画布上立刻就能看到圆形的边缘变化,不需要编译等待。第三,Shader Graph导出到URP/HDRP之后,在移动端的兼容性其实比很多手写Shader要好,因为Unity引擎会帮你处理平台相关的宏定义和坐标转换。
Shader Graph的具体搭建步骤是这样的:
创建一个Unlit Shader Graph,在Graph的Fragment节点上,我们首先用UV节点(UV节点会输出模型的UV坐标)减去一个Vector2类型的“Center”属性,得到一个偏移向量。然后接一个“Vector2的Length”节点,计算偏移向量的长度,得到距离值。接着创建两个Float属性,“Radius”和“SoftEdge”,用Smoothstep节点把它们组合起来:Edge1接“Radius - SoftEdge * 0.5”,Edge2接“Radius + SoftEdge * 0.5”,In接距离值。Smoothstep节点的输出距离值是圆内为0、圆外为1,所以再接一个“One Minus”节点翻转,得到一个圆内为1、圆外为0的透明系数。最后把这个透明系数接到Fragment的Alpha通道上,同时可以再把一个“Color”属性接到Base Color上,让这个遮罩既能当纯色贴图,又能当带颜色高亮的半透明层。
这里有几个容易操作失误的小地方。第一个是“Surface Type”必须设置成“Transparent”,如果你用的是Unlit/URP Shader Graph,默认是Opaque(不透明),Alpha通道直接会被忽略,明明你计算了半天透明值,输出却什么变化都没有。第二个是“Render Face”选项,如果你希望从背面看也能看到这个圆形遮罩,需要把它设为Both;如果只是一个单面UI元素,保持Front就行,还能省一半像素运算。第三个是“Alpha Clip Value”保持默认0.5不开也行,因为我们走的是常规透明混合,不是剪裁。
Shader Graph适合的场合是项目已经使用了URP管线,同时团队里美术也想参与调效果。它把“Shader是给图形程序写的东西”这个心理门槛降得很低,美术同事拿到Shader Graph资源后,在Inspector里调整参数跟调材质Float是一模一样的体验,配合效率高很多。但它也有个硬伤:一旦你的逻辑复杂起来,比如要同时做多个形状的遮罩叠加、要做参数化动画控制,节点的连线会变得杂乱无章,维护心智负担倍增。此时手写ShaderLab反而更清爽。
3.2 手写ShaderLab代码方案:更自由更可控
手写ShaderLab并不是什么高不可攀的事情,尤其对于圆形遮罩这种入门级效果,写下来也就四五十行。我直接把我项目里用过的一段经典代码贴出来,它支持软边、圆心偏移、环形模式,还能在C#端动态控制参数,几乎覆盖了90%的圆形遮罩需求。
Shader "Custom/CircleMask" { Properties { _MainTex ("Main Texture", 2D) = "white" {} _Color ("Tint Color", Color) = (1,1,1,1) _Center ("Center", Vector) = (0.5, 0.5, 0, 0) _Radius ("Radius", Range(0.01, 1.0)) = 0.3 _SoftEdge ("Soft Edge", Range(0.0, 0.2)) = 0.02 _RingWidth ("Ring Width", Range(0.0, 0.2)) = 0.0 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; float4 _Center; float _Radius; float _SoftEdge; float _RingWidth; 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 { float2 uv = i.uv; float dist = distance(uv, _Center.xy); float alpha = 1.0 - smoothstep(_Radius - _SoftEdge * 0.5, _Radius + _SoftEdge * 0.5, dist); if (_RingWidth > 0.001) { float inner = _Radius - _RingWidth; float ringAlpha = smoothstep(inner - _SoftEdge * 0.5, inner + _SoftEdge * 0.5, dist); ringAlpha *= 1.0 - smoothstep(_Radius - _SoftEdge * 0.5, _Radius + _SoftEdge * 0.5, dist); alpha = ringAlpha; } fixed4 col = tex2D(_MainTex, uv) * _Color; col.a *= alpha; return col; } ENDCG } } }我来逐段解释这段代码的设计思路。Properties块里的参数都是材质面板上可以直接调节的,美术不需要碰代码。_MainTex不用也是可以的,但保留着可以让你在后期给圆形内部叠加纹理,比如给圆形范围加一圈噪点纹理增加质感。_Center用Vector类型是因为我们需要在C#里同时传入U和V两个分量,用Float数组的话反而麻烦。_Radius和_SoftEdge是常规的两个Float,一个控制圆的大小,一个控制边缘的羽化宽度。_RingWidth一开始默认是0,这意味着当前处于“实心填充圆”模式;如果把它设成大于0,会自动切换成“环形”模式,可以实现技能CD转圈或者瞄准圈的光环效果,我就不需要再单独写第二个Shader了。
SubShader里的Tags和Blend设置是透明效果的关键。Tags里的Queue=Transparent让这个物体在所有不透明物体之后渲染,避免半透明效果被后面的物体遮挡产生不正确结果。RenderType=Transparent则是给Unity的着色器替换系统用的,某些后处理特效或者摄像机深度纹理方案需要依赖这个标签。Blend SrcAlpha OneMinusSrcAlpha是标准的Alpha混合,意思是“输出的颜色乘以自身透明度,背景颜色乘以(1-自身透明度)”,这就是半透明物体能透出背景的原理。ZWrite Off是关闭深度写入,如果是半透明物体不开这个,很可能出现看不到的排序问题。Cull Off则是让模型的双面都渲染,做UI或地面光环时很有必要。
3.3 参数暴露与材质面板优化:让美术同事也能顺手调
Shader写出来是为了用的,而“用”的关键在于参数面板设计得够不够顺手。上面那段代码里Properties块的参数排列顺序,就是我在Inspector面板上看到的顺序,这很重要。我习惯把“常用调节项”排在前面,像_Radius和_SoftEdge这种每帧都要调的参数放最上面,_Color这种偶尔改一次的颜色放中间,_MainTex这种很可能根本不换的纹理放最后。这样美术在Inspector上选中材质后,第一眼看到的就是他们最关心的目标。
另外还有一个细节:Range定义的范围要合理。_Radius的范围设为0.01到1.0,下限不能设成0,因为半径为0时整个圆就消失了,在视觉上像是变成了一个点,容易让美术误以为Shader写坏了。_SoftEdge的范围我设成了0.0到0.2,因为在这个范围的数值下,边缘羽化的效果最自然;如果允许美术把它拉到0.5,那么整个半透明渐变的过度范围就会占掉一半半径,看起来就像一个大雾团而不是一个边界清晰的圆形。
关于动态控制参数的方法,我再补充一点C#端的代码。你可以在Update里这样写:
Material mat = renderer.material; mat.SetVector("_Center", new Vector4(uvX, uvY, 0f, 0f)); mat.SetFloat("_Radius", currentRadius);这里有个很关键的点:如果你直接用renderer.material,Unity会为当前渲染器实例化一份材质副本,修改这份副本不会影响场景里其他使用同一材质物体的效果。但如果你希望所有物体共享同一个材质,同时修改生效,就应该用renderer.sharedMaterial。不过要注意,sharedMaterial一旦被修改,会影响所有引用它的物体,你需要在项目逻辑上明确自己到底想要哪种行为。我个人的习惯是:如果只是一个全局的“进度圆环”,用sharedMaterial,因为所有UI界面上的圆环要同步显示同一进度;如果是不同角色脚下各自的范围圈,用material实例化,让每个角色的半径和圆心可以独立控制。
4. 实操过程与核心环节实现
4.1 在场景中搭建测试环境:从Quad到UI的完整流程
理论知识讲得再多,都不如实际在场景里拖一个Cube上去看效果来得快。我来完整走一遍这个搭建流程,你照做一遍基本就能复现出圆形遮罩效果。
第一步,创建一个Shader,从上面的代码直接复制,命名为CircleMask.shader,放到项目的Shader目录下。然后在Project窗口里右键 -> Create -> Material,创建一个新材质,命为Mat_CircleMask。在材质Inspector中,把Shader下拉框切到Custom/CircleMask,看到_RColor、_Radius这些属性暴露出来,说明Shader编译成功,就可以开始调了。
第二步,在场景中创建一个Quad(GameObject -> 3D Object -> Quad),把Mat_CircleMask拖到它的Material插槽上。此时你看到的应该是一个纯白无纹理的圆形物体出现在场景中。如果圆形没有显示,检查一下是不是Quad的Scale被缩放成了非正方形。Quad默认是一米乘一米的正方形,但如果你把它压扁或拉长,圆形就会变成椭圆。这是最初级的错误,但真的经常发生,尤其是从美术手里拿到一个被改过Scale的Prefab时。
第三步,为了让效果更接近UI上的圆形遮罩,我把相机模式改成正交(Orthographic),这样Quad的深度感就消失了,看起来就像一张2D图片贴在屏幕上。你也可以直接用Canvas搭配Image控件,把Image的自定义材质设成Mat_CircleMask,再把Image的Source Image设成一个纯色的Sprite即可。用UI方案时要注意Image的“Raycast Target”选项默认是勾选的,如果这个遮罩不需要响应点击事件,最好关掉,否则它会拦截它下面按钮的点击。
第四步,随便修改_Radius数值,比如从0.3改到0.6。你会看到圆的大小马上变化。这时候你基本验证了Shader的工作正常。如果你在_Radius改为0.6后,圆的外侧溢出到Quad外了,这很正常,因为Quad的UV范围仍然是0到1,圆形半径可以超出这个范围,超出的部分会被Quad边界截断。
4.2 动态效果实现:扩散、收缩与半透明叠加
圆形遮罩静态看没什么意思,真正高频使用场景是动态的。我给几种最常用的动态效果分别写一下C#控制逻辑,方便你直接抄进项目里。
第一种是“从中心扩散”的效果。比如你要做加载完成后的遮罩淡出,或者小地图中的探索范围展开。C#端这样写:
float timeSinceStart = Time.time - startTime; float progress = Mathf.Clamp01(timeSinceStart / duration); // 用EaseOutBack做一个先快后慢的扩散曲线 float eased = 1f - Mathf.Pow(1f - progress, 3); material.SetFloat("_Radius", Mathf.Lerp(0.0f, maxRadius, eased));这里为什么用三次方的ease曲线?因为线性扩散的视觉效果很单调,人眼看着会觉得生硬。实际上,我的经验是扩散效果用“先快后慢”的曲线最自然,因为它在视觉上更像是一个“能量迸发”的过程,而不是机械地撑大一个圆。你可以用Mathf.Pow(1f - progress, 3)获得一个从1降到0的快慢曲线,再用1减去它,就得到从0升到1但先快后慢的eased值。
第二种是“呼吸缩放”效果,常用于技能指示器或者角色脚下光环的持续状态。它本质是半径在目标值附近做正弦振荡:
float breath = Mathf.Sin(Time.time * speed) * 0.5f + 0.5f; material.SetFloat("_Radius", baseRadius + breath * pulseRange);Mathf.Sin的结果范围是-1到1,乘0.5加0.5后映射到0到1,然后乘pulseRange,就会得到一个在baseRadius附近来回振荡的半径。speed参数控制在2到4之间比较合适,太快了观感会变成“抖动”而不是“呼吸”。
第三种是“跟随屏幕坐标”的效果。当角色在3D场景中移动,圆形遮罩要始终覆盖在角色头顶或者脚下时,需要把角色世界坐标转成屏幕坐标,再映射到UI的Shader参数。一个典型的实现是这样的:
// 使用主相机把世界坐标转屏幕像素坐标 Vector3 screenPos = Camera.main.WorldToScreenPoint(target.position); Vector2 uvPos = new Vector2(screenPos.x / Screen.width, screenPos.y / Screen.height); // 注意要保证屏幕坐标原点在左下角,UGUI的原点在左上角,需要翻转Y uvPos.y = 1f - uvPos.y; material.SetVector("_Center", uvPos);这里有一个经典坑:UGUI坐标系的原点在屏幕左上角,而Shader里UV坐标系的原点默认在左下角,所以Y分量需要做一次翻转,否则你会看到圆形遮罩跟着角色移动时上下颠倒。这个坑我在初学阶段踩过,第一次做小地图视野效果时,所有圆都跑到角色对称的另一边去了,排查半天才发现只是Y轴转换漏了。
4.3 渲染状态与批处理:影响UI性能的几个细节
圆形遮罩Shader本身很轻量,但在Unity UI系统里使用半透明Shader有一个大家很容易忽视的问题:UI重建和合批。当你把一个带有自定义透明材质的Image放进Canvas时,Unity会把这个Image独立成一个DrawCall,因为它跟默认的UI着色器状态不一样,无法参与跟其他Image的合批。如果你的Canvas里有几十个这种圆形遮罩Image,就意味着多几十个DrawCall,这在移动端是个不小的负担。
解决思路大致有三种。思路一:尽量减少圆形遮罩Image的数量,能用一个大Image模拟多个圆形的,就不要放多个小Image。比如技能CD转圈,一个角色只需要一个圆环Image,不要为了“外圈深色”和“内圈浅色”各建一个。思路二:把圆形遮罩Shader的ZWrite和Blend设置成跟UGUI默认材质一致,看看能否恢复到合批条件。实际操作中,UGUI的默认UI Shader使用的Blend也是SrcAlpha OneMinusSrcAlpha,理论上你的Shader只要Tags和Blend一致,应该可以参与合批。但如果不行,很可能是Shader里包含了顶点色或者TRANSFORM_TEX而没有对应的指令,导致GPU状态不一致。思路三:把静态的圆形遮罩直接烘焙到纹理上,比如小地图的边界光晕,如果它不每帧变化,就让美术出图,不要用Shader去拼。
如果涉及到大量相同参数的同类型圆形遮罩,更好的做法是使用SRP Batcher(可编程渲染管线批处理)。在URP项目里,只要材质使用的Shader是SRP Batcher兼容的,并且材质之间只是参数不同,Unity就可以把它们合批渲染,性能提升非常可观。为了让手写Shader兼容SRP Batcher,你需要把所有的材质属性都声明在CBUFFER块中,并且结构体布局保持一致。我在上面的示例代码里没有加CBUFFER,是因为它在Built-in管线里没必要,但我建议你在URP项目中使用时,把Properties对应的变量声明放进一个CBUFFER_START(UnityPerMaterial)块中,代码如下:
CBUFFER_START(UnityPerMaterial) float4 _MainTex_ST; fixed4 _Color; float4 _Center; float _Radius; float _SoftEdge; float _RingWidth; CBUFFER_END这样URP就能正确地把材质参数批量上传给GPU,避免每个DrawCall都重新设置参数,DrawCall数量从几十个直接降到一个批次也不在话下。
4.4 圆环模式的实现:一个参数切换两种形态
前面代码里我用了一个_RingWidth参数来切换实心圆和环形,这里单独拿出来说,是因为环形遮罩在组合使用中的价值经常被低估。环形遮罩在技能CD指示器中非常常见:一个圆环包裹住技能图标,环的填充比例表示技能是否冷却完毕。
实现逻辑其实超级简单。我们先按实心圆的方式算出alpha,然后把距离跟一个“内半径”做比较:如果距离小于内半径,alpha变成0,这就把实心圆中间挖空,剩下一个环。内半径等于外半径减去环宽。但要注意,如果环宽太窄,边缘羽化区域会互相重叠,导致环的内外边缘都被羽化得糊成一片。所以你要确保_SoftEdge数值相对于环宽不能超过一半,否则环就变成了一层模糊的灰色雾,消失得无影无踪。
如果你要把环形效果做成“扇形CD”,也就是一个只显示四分之一弧度的进度环,就需要额外传入一个“开始角度”和“结束角度”的参数。计算方法是把UV坐标转换成极坐标,用atan2(uv.y - center.y, uv.x - center.x)获取当前像素的弧度值,然后比较这个弧度值是否在开始和结束角度之间。这个逻辑不算特别复杂,但它只是圆形遮罩的延伸。真正想做得流畅好看,还需要处理角度跨过0度边界时的环绕问题,一般可以用“加上360度再模360”的技巧来归一化角度范围。这部分内容如果展开可以写一整篇,这里先提一嘴,有需要的可以基于这个思路再扩展。
5. 常见问题与排查技巧实录
5.1 圆形边缘粗糙、锯齿明显怎么办
这是圆形遮罩最常遇到的问题。我见到过三种原因,按出现频率排序。原因一:你没有用smoothstep,而是用了step函数做硬边。硬边在低分辨率屏幕上必然会出锯齿,这个没什么好讨论的,解决办法就是把step换成smoothstep。原因二:你用了smoothstep但边缘宽度_SoftEdge设得太小,比如0.001,那么过渡带只有大约千分之一的UV范围,在大多数屏幕上小于一个像素,效果跟硬边没区别。建议_SoftEdge至少设成0.01以上,实际项目中我经常用0.02到0.05。原因三:纹理过滤和相机像素对齐问题。如果圆形遮罩是放在3D场景中并带有缩放旋转,并且相机没有开启MSAA或者后处理抗锯齿,那么即使Shader边界再平滑,最终输出到帧缓冲时仍可能因为采样点位置产生细微锯齿。对于移动端UI,最稳妥的办法是让遮罩边缘的渐变范围不要太窄,稍微带点柔和过渡,视觉效果反而更精致。
5.2 圆形显示成椭圆或者被拉伸
当你发现一个原本标准的圆被拉成椭圆,第一反应是检查渲染器Transform的Scale。Quad和Image如果Scale不相等,圆形自然会变椭圆。但还有一种更隐蔽的情况:你在Shader里直接用了UV坐标,而Quad本身是长方形。比如你有一个宽高比为16:9的UI元素,它的UV范围仍然是0到1,但实际世界坐标的宽度高不同,此时圆形在屏幕上看起来就是横向拉长的椭圆。解决办法是拿到材质的宽高比,把UV的Y分量缩放一下。Shader端可以这样处理:
float aspect = _ScreenParams.y / _ScreenParams.x; // 宽高比修正 float2 correctedUV = float2((uv.x - 0.5) * aspect + 0.5, uv.y);或者是反过来,把X分量除以aspect。具体取哪种要看你希望圆形在何种空间内保持正圆。通常对于全屏UI,用屏幕宽高比修正最靠谱。但要注意_ScreenParams在多个相机渲染时可能不是目标相机的分辨率,如果你用的是特定相机,最好在C#端把相机像素宽高比作为参数传进去。
5.3 透明度不生效,背景全变黑或者完全不透明
这个问题的排查方向非常明确:先看Shader的渲染队列和混合模式。如果你把Queue设成Opaque,或者SubShader里没有写Blend SrcAlpha OneMinusSrcAlpha,那么GPU会直接忽略Alpha通道,输出一个完全不透明的四边形,遮罩就变成了一块没有透明效果的白板。更典型的情况是:你用了URP的Shader Graph,里面设置了Surface Type为Transparent,但忘了在Graph里把Alpha通道连接到Master节点,或者连接了Alpha却把Alpha Clip Threshold设置过大,导致所有像素的透明度都被阈值压到了0。你可以在URP的Material Inspector上勾选“Alpha Clipping”再调整阈值,也可以直接把Alpha Clip Threshold改为0,通常能解决一大片问题。
还有一种情况跟Shader本身没关系,而是你的渲染管线(比如URP)要求Shader必须包含某些Pass。如果你从Built-in管线迁移到URP,代码Shader需要加上URP的专用头文件或者改用Shader Graph。URP本质上会忽略不带UniversalPass标记的旧Shader,导致物体显示为洋红色或干脆不显示。这时候检查Console窗口是否报错,能看到“Shader is not supported on this pipeline”之类的话,然后把Shader改造成URP兼容版本就好。
5.4 圆环中间有杂色或者暗边
如果你用UGUI的Image配合这个Shader,经常会发现圆环中间那块本应是透明的区域,出现一条细细的暗色边缘,或者有一层淡淡的黑色雾。这不是Shader的错,而是UGUI的Image默认在裁剪圆角时会产生一条亚像素的白边。解决办法有两个。方案一:把Image的Sprite设置成“None”(纯色矩形),然后让Shader完全依靠UV坐标绘制,不采样纹理,这样不会出现Sprite本身的边缘杂质。方案二:如果你必须使用Sprite,把Sprite的Pixels Per Unit调到一个较高值,并在Inspector中关闭“Use Sprite Mesh”,这样可以减少网格顶点对采样结果的扭曲。
还有一种“暗边”来源于Alpha混合顺序错误。当多个半透明圆形遮罩叠加在一起,后面的会透过前面的Alpha叠加而产生类似黑边的现象。解决方法是在Shader中加上ZWrite Off,并把Queue设为Transparent,以确保所有透明物体按距离从远到近正确排序。如果同一个UI界面里多个半透明元素还需要强制排序,可以在Canvas的Sorting Order上做调整,或者给Shader添加一个_CustomSort参数来控制混合优先级。
6. 经验与细节补充
6.1 性能优化:从算力到带宽的全链路考量
圆形遮罩的Fragment计算量已经很小,性能瓶颈很多时候不在像素计算,而在带宽和状态切换。如果一个圆形遮罩每帧都在移动和改变半径,C#端每次都要调用SetFloat和SetVector,材质参数的更新会触发GPU的常量缓冲区更新,虽然现代引擎做了很多优化,但频繁更新仍然有开销。一个进阶优化是使用MaterialPropertyBlock,它允许你在不创建材质副本的前提下,为单个渲染器覆盖参数值。配合Renderer.SetPropertyBlock,你可以让多个渲染器共享同一个材质,但各自拥有不同的圆心和半径,这对于几十个角色脚下同时显示范围圈的场合尤其重要。
进一步优化是合并纹理采样。如果你的圆形遮罩同时需要叠加纹理,不要用两张贴图做乘法,而是把纹理合并到一张带有透明通道的贴图上,这样采样次数从两次降为一次。在带宽压力巨大的移动端上,少一次纹理采样就是实打实的GPU时间节省。另外,尽量使用半精度浮点数来存储中间变量,在移动端GPU上half比float执行速度快,虽然现代GPU对两者的差异已经缩小,但 legsy 依然能减少一些寄存器和带宽开销。
6.2 变体管理:不要让Shader在你不注意时膨胀
Shader变体是Unity里一个让人头疼但又躲不掉的坑。如果你给Shader添加了很多特性开关,比如环形模式、纹理开关、是否使用顶点色等,Unity会为每个特性组合生成一个变体,数量呈指数级增长。如果特性总数达到五六个,就有几十个变体,而每个变体都会增加打包时间和包体大小,运行时还会因为Shader加载变体而产生卡顿。我的经验是:一个项目里的圆形遮罩Shader尽量保持一个版本,只暴露弱参数,不要用multi_compile开启宏开关。如果确实有硬需求区分版本,用Shader Graph的“Active Targets”来控制变异范围,或者用自己的shader_feature标志并在C#端手动调用EnableKeyword。绝大多数圆形遮罩场景,用if判断在GPU上也不会太慢,因为GPU的branch和ALU指令的开销差异在这么简单的Shader里压根不明显,没必要为了这点性能牺牲代码简洁度。
6.3 兼容性检查清单:不同设备渲染效果不一致的问题
Shader写得好不好,最终要看它在不同设备上的表现。我自己遇到过不少只在自己的主力开发机上看着正常、发到用户手机上就出问题的案例。排查时可以按这份清单过一遍:第一,确认Shader的GLSL版本要求,老设备上的GLES2对半精度和循环的支持很弱;第二,确认纹理采样器的采样模式,引擎默认的纹理Wrap Mode是Repeat,但UI遮罩通常希望Clamp,否则圆心附近会因为UV溢出出现重复噪点;第三,确认是否开启了MSAA,在移动端后处理链条中MSAA有时会被自动关闭,导致边缘锯齿变明显;第四,如果项目使用了后处理抗锯齿组件,要确认它跟透明物体的Alpha混合没有冲突,有些TAA方案会把透明物体边缘的Alpha值错误地纵向平均,形成光晕或虚线。
在URP项目里,还有一个常见的坑是“中间缓冲区”。URP默认在渲染前会生成一次屏幕空间的深度贴图,如果你的Shader需要查询屏幕空间位置或者深度信息,必须确保在URP Asset里勾选Depth Texture选项。否则你在Shader里调用类似_ScreenParams或者相机深度纹理贴图采样时会得到全黑数据,圆形遮罩跟场景物体的前后关系就错乱了。圆形遮罩本身不依赖深度,但如果它需要跟随角色并遮挡在角色前方,就需要在Shader里加一个深度判断来避免穿透物体。
7. 最后聊几句实在话
CircleMask这个Shader我前前后后写了不下十遍,从最初的硬边圆形到如今的软边可调环形,每次调整都伴随着项目的真实需求。我个人最大的体会是:Shader这种东西,理解UV归一化坐标、距离函数、平滑插值这三板斧,就已经能解锁日常开发里百分之八十的特效需求。圆形遮罩只是这三个概念最简单的组合,它的意义不在于这个圆本身,而在于它帮你建立起了“用数学去描述视觉效果”的思维模式。
如果你看完这篇文章准备自己动手实验,我建议你先别急着改代码,而是花十分钟把Unity的Shader Graph或者代码版本搭起来,手动拖动_Radius看看屏幕上的变化,再拖动_SoftEdge看看边缘羽化如何影响观感。这些直观的反馈,比读任何文章都更能巩固记忆。等你熟练了,不妨再试试把圆形改成六边形、把边缘改成噪声扰动、或者把透明度计算改成“基于角度渐变”的扇形效果,那又是另一个有趣的新篇章了。