ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unity Shader实现UI流光效果:原理、参数调优与移动端性能优化

Unity Shader实现UI流光效果:原理、参数调优与移动端性能优化 你可能在游戏里见过这种效果一张卡牌上有一道亮光从左下扫到右上然后过几秒又来一次或者一个武器图标上高光条不停从表面滑过。这个就是典型的“流光效果”在UI界面、装备图标、技能按钮上非常常见。很多人第一反应是用序列帧动画去播放但如果你是做Unity开发的我更推荐用UnityShader直接在材质层把流光做出来。相比序列帧Shader实现的流光完全不占贴图空间、不增加DrawCall而且亮度、方向、速度全部参数化调起来特别顺手。这篇文章我就把自己在项目中常用的流光Shader写法、背后原理、踩过的坑一次讲清楚适合刚接触Shader的Unity开发者也适合想把手上的UI质感再提一档的从业者。1. 为什么用Shader做流光效果而不是序列帧1.1 流光效果的本质与常见应用场景流光的本质其实很简单在一张静态贴图上叠加一层“位置随时间变化的亮条”。这个亮条可以是斜着的、横着的、竖着的也可以是一条渐变带。它本身不改变贴图的图案只是让观察者感觉“光在表面流动”。我见过最典型的应用场景有三种。第一种是UI卡牌尤其是稀有度高的卡牌边框常常在金色描边上做一圈缓慢流动的斜光用来强调等级和稀有度。第二种是游戏里的装备图标、道具图标比如强化过的武器会有微弱的流光告诉玩家“这不是普通装备”。第三种是活动入口按钮、商城入口、抽卡按钮这些地方为了让玩家注意力集中往往会有节奏性的流光扫过配合呼吸灯效果。这三种场景对流光的要求有差异。卡牌和图标需要流光柔和、不抢主体按钮上则需要更明显、节奏更快。如果都用序列帧来实现你得为不同UI分别准备一套UV动画贴图资源量不小而且颜色、速度改起来也要重新出图。Shader方案就把这些全部压缩成了一个材质参数。1.2 与序列帧、粒子效果相比Shader方案的优势先说结论能用Shader解决的动效我基本不会用序列帧。原因很直接。第一不占运行内存和包体。一套序列帧如果10帧就要10张图哪怕是图集也需要额外内存和带宽。而Shader流光只是GPU上一段数学计算零贴图开销同一套Shader用在几十个UI上完全没问题。第二参数可控、迭代快。美术改需求说“流光再粗一点、角度再斜一点、颜色偏金黄一点”序列帧你得重新出图Shader方案我直接改滑动条秒出效果。这在开发期美术反复调的时候非常重要。第三运行时动态组合能力强。同一个材质可以挂在一个UI上也能用在3D模型的某个部位。你甚至可以把多个流光叠加在一起做更花哨的效果序列帧这种动态组合就很难做。当然Shader也不是万能的后面我会单独说它在移动端和半透明排序上的坑。整体而言做UI动效Shader流光的性价比是最高的。2. 一个可以直接抄走的流光Shader代码2.1 完整Shader代码片元着色器实现下面这个是我在UI项目中常用的一套写法思路清晰、代码量不大而且做了基础参数化。你直接新建一个Shader贴进去再拖到Image或RawImage的自定义材质上就能看到效果。Shader Custom/UIFlowLight { Properties { _MainTex (主纹理, 2D) white {} _FlowColor (流光颜色, Color) (1, 1, 1, 1) _FlowSpeed (流光速度, Float) 2.0 _FlowWidth (流光宽度, Range(0.01, 1.0)) 0.3 _FlowAngle (流光角度, Range(0.0, 360.0)) 30.0 _FlowIntensity (流光强度, Range(0.0, 5.0)) 1.5 _FlowOffset (流光初始偏移, Range(0.0, 1.0)) 0.0 } SubShader { Tags { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; fixed4 _FlowColor; float _FlowSpeed; float _FlowWidth; float _FlowAngle; float _FlowIntensity; float _FlowOffset; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; fixed4 color : COLOR; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.color v.color; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * i.color; float rad radians(_FlowAngle); float2 dir float2(cos(rad), sin(rad)); float t dot(i.uv - 0.5, dir); t _Time.y * _FlowSpeed; t _FlowOffset; t frac(t); float d abs(t - 0.5) * 2.0; float mask 1.0 - smoothstep(0.0, _FlowWidth, d); float3 flowColor _FlowColor.rgb * mask * _FlowIntensity; col.rgb flowColor; col.a max(col.a, _FlowColor.a * mask); return col; } ENDCG } } }这段代码我是怎么一点点抠出来的下面拆开讲。2.2 核心原理从UV坐标到流光mask的推导很多新手看流光Shader会懵感觉代码不长但不知道每一行在干什么。其实整个效果的核心就三步确定方向、让时间动起来、把位置变成亮度。第一步确定流光方向。UV坐标是二维的我想让亮条沿着某个方向移动就需要把二维UV投影到一维直线上。这里用到的是点积dot(i.uv - 0.5, dir)。为什么要减0.5因为UV原点在贴图左下角减0.5之后整张贴图的中心就变成了(0,0)这样投影出来的值在贴图中心附近是0向两边正负延伸。这个处理能让后续的周期计算更对称流光在贴图上移动也更均匀。dir则是由角度算出来的单位向量比如_FlowAngle 30就代表流光方向与U轴成30度角。第二步让时间动起来。_Time.y是Unity内置的秒级时间是一个持续增长的数值。将点积结果加上_Time.y * _FlowSpeed投影位置就会随时间均匀增大对应到贴图上亮条就会匀速移动。注意这里我终于踩到了第一个坑直接用%对浮点数取模在HLSL里对负数结果是负的而且有些移动端GPU对浮点取模支持不好。所以我换成了frac(t)只取小数部分让投影值永远落在0到1的范围内这样流光就会循环往复不会越跑越远。第三步把位置变成亮度。这一步是整个Shader的精华。frac(t)得到的值是一个锯齿波在0处是0然后线性升到1再瞬间跳回0。如果直接拿这个值去当亮度你会看到一道从暗到亮的硬边界而不是一个柔和的亮带。所以我把锯齿波转成了三角波d abs(t - 0.5) * 2.0。这行的意思是当t在0.5时也就是亮条扫到贴图中线时d为0当t在0或1时d为1。于是我们得到了一个中间高、两边低的三角形波。再用smoothstep(0.0, _FlowWidth, d)去截取这个三角形波的顶部只有当d很小、也就是亮条中心附近时才输出1再配合1.0 -翻转就得到了中间亮、边缘渐隐的流光mask。说到smoothstep我相信很多新手也容易搞混它的参数顺序。它的签名是smoothstep(min, max, x)当x小于min时返回0x大于max时返回1中间是平滑过渡。我这里的1.0 - smoothstep(0.0, _FlowWidth, d)意思是d越小返回值越大流光中心最亮向两侧逐渐衰减宽度由_FlowWidth控制。这里有个隐藏福利因为用了三角波流光在时间上循环到边界时恰好是d1、mask0的位置也就是说亮条消失后再从另一侧出现肉眼几乎看不到“跳变”看起来就是连续循环。这一点是很多人问“为什么我的流光总是闪一下”的答案其实就是没有用三角波直接用了锯齿波导致循环点出现硬切。3. 参数化调整与玩法扩展3.1 五个关键参数怎么调才自然Shader写出来只是第一步真正让效果融入项目的是参数调整。我项目里反复用的其实就五个参数每个都有自己的逻辑。参数名作用我的默认值调整心得_FlowSpeed流光移动速度2.0UI图标上我通常开到1.5到3之间。太慢会显得呆板太快会让玩家觉得图标一直在闪容易视觉疲劳。_FlowWidth流光带宽度0.3卡牌边框这类细长区域调到0.15左右比较精致按钮和图标可以到0.4越宽越像扫光。_FlowAngle流光方向角度30斜向45度最常用30度更柔和。注意UV的V轴是向下的所以实际方向要实测确认我通常先设为0看横竖再调角度。_FlowIntensity流光亮度叠加强度1.5在Unity线性色彩空间下加法叠加很容易过曝我一般不会超过2.0宁可让流光淡一点。_FlowOffset流光初始相位0.0多个UI共享同一材质时用这个参数错开流光相位避免所有图标同步扫光显得很机械。这里单独说一下_FlowAngle的方向问题。因为UV坐标系里V轴是向下增长的所以float2(cos(rad), sin(rad))在正角度时实际移动方向可能和你直觉相反。我早期做过一个装备界面美术要求“从右下往左上扫”我按直觉调成135度结果怎么调都不对。后来我直接在材质面板拖动_FlowAngle看着预览图来确定方向而不是凭空算角度。你可以把它当成一个经验UV相关的角度问题靠肉眼验证比靠数学推导快得多。还有一个很容易忽略的问题就是Color Space。如果你的项目用的是Linear色彩空间而流光颜色是用Color字段直接定义的要注意Shader里计算出来的颜色是线性空间下的数值上会比Gamma空间下暗一点。表现为调好一个亮度后切到真机上效果变亮了或者变暗了这种时候直接微调_FlowIntensity就行不用纠结色彩空间的换算。3.2 单流光到多流光玩法扩展很多场景下一道流光不够用。比如抽卡按钮上我见过同时有斜向高光、上下扫光、还有边缘流动光带的效果其实都是同一个Shader逻辑复制几遍叠加出来的。多流光的实现思路非常简单在片元着色器里重复计算几次mask然后把颜色叠加到主颜色上。我在项目中常用的做法是写一个类似下面的辅助函数把单条流光封装起来传不同的角度、速度、宽度循环累加。float BandMask(float2 uv, float angle, float speed, float width, float offset) { float rad radians(angle); float2 dir float2(cos(rad), sin(rad)); float t dot(uv - 0.5, dir); t _Time.y * speed; t offset; t frac(t); float d abs(t - 0.5) * 2.0; return 1.0 - smoothstep(0.0, width, d); }有了这个函数想要双流光就调用两次然后col.rgb mask1 * color1 mask2 * color2即可。如果你想要更高级的效果比如流光带有彩虹色可以把_FlowColor替换成按UV坐标采样的一维渐变纹理_FlowRampTex这样流光扫过的时候颜色会随之变化。不过我要提醒一句多流光不等于越多越好。在移动端每多一条流光片元着色器里就多了一组三角函数、点积和smoothstep计算尤其当这个材质用在整屏UI上时性能压力是成倍增加的。项目里我一般控制在两条以内除非这个UI是在静态页面上可以接受一点Overdraw。3.3 用Alpha通道限制流光区域有时候你不想让流光铺满整张贴图比如卡牌边框只有描边区域需要流光而中间的角色立绘不要被光扫过。这种需求在美术那边很常见Shader里处理起来也不算麻烦。最简单的方式是让美术在贴图的Alpha通道里画好“允许流光出现的范围”然后在计算完mask后用主纹理的采样结果去截断流光。具体做法是在取得主颜色col后拿col.a去乘mask让流光只出现在不透明区域。如果美术不想动贴图Alpha也可以用一张额外的遮罩贴图用同一套UV采样取它的R通道或A通道当作权重。我个人的习惯是如果是UI图标就用主贴图的Alpha来限制如果是复杂卡面就让美术出一张单独的流光遮罩因为主贴图的Alpha可能还承担着描边半透明的任务混在一起会出问题。这个思路和PBR里的Metallic/Smoothness纹理共享通道是一个道理灵活利用通道能省不少贴图内存。4. 移动端实战性能优化与渲染细节4.1 半透明混合、OverDraw与排序我在前面提到过Shader方案在性能上优于序列帧但如果你以为它完全零成本那就天真了。流光Shader要用半透明混合而半透明物体在Unity渲染管线里是要单独排序的尤其是UI界面半透明材质叠加多了以后Overdraw会非常严重。Overdraw通俗点说就是同一个像素被多个半透明物体反复绘制。你在UI里放了十个带流光Shader的图标这些图标互相重叠的区域片元着色器就要执行很多次。手机上GPU的填充率是有限的Overdraw一高帧率马上掉给你看。我做过一个商店界面整个页面十几个道具图标都挂了流光材质测试时中端安卓机直接掉到40帧后来把流光材质缩减到只有“稀有度超过紫色”的图标才挂帧率才恢复正常。降低Overdraw有几个实用办法。第一让流光区域尽量小能只做在边框就绝不铺满整图。第二减少同时使用该材质的UI数量用代码动态控制哪些图标需要流光不需要时恢复成普通材质。第三如果流光和底图在视觉上没有遮挡关系可以把流光的Blend改成Blend One One这种加法混合在某些GPU上比标准透明混合更快一点但要注意加法混合会让黑色区域也变得半透明发光视觉上需要测试。另一个和排序相关的坑是ZWrite。半透明Shader必须关闭ZWrite否则半透明物体会挡住后面的半透明物体。我代码里已经写了ZWrite Off但如果你是从UIMask相关的Shader改过来的很容易漏掉这一行导致流光图标遮挡后面的UI文字看起来像“隔了一层玻璃”。4.2 精度问题与几个优化技巧移动端GPU和PC GPU最大的区别之一就是浮点精度的处理。很多安卓机的片元着色器只支持最低精度的half浮点而你如果直接在Shader里用float在部分老机型上会降级为半精度计算导致时间变量_Time.y在数值变大以后出现精度不足表现就是流光跑着跑着开始抖动或者颜色带出现条带感。我遇到过最典型的案例是同一个流光Shader在PC上完美运行在某个两年前的安卓机上流光每隔几秒就闪一下。排查了很久才发现问题不在逻辑而在精度。因为_Time.y这种世界时间数值会持续增长到很大超过半精度能表达的整数范围后小数位就丢了。解决办法有两个方向一是把fragment shader里参与时间计算的变量都标注成half并让时间归一到小范围二是用一个脚本每间隔一段时间重置偏移避免时间变量无限增长。对于大多数项目最简单的方案是把流光的移动位移拆成_Time.y % 一个大常量再参与计算把数值范围缩小。优化技巧方面我可以分享几个实测有效的点。第一能算一次的就不要每像素算一次。比如dir向量是在每个像素里用cos和sin算出来的其实它在同一个DrawCall里是常量完全可以在顶点着色器里算好然后传给片元每个顶点算一次而不是每个像素算一次。这套Shader我为了简明易懂没有这样拆分但实际项目里我会把这类不依赖UV的量先算好。第二用pow和smoothstep做渐变时注意smoothstep内部有一次浮点除法如果不要求高质量可以自己用clamp加lerp代替能省一点寄存器压力。第三如果流光只需要叠加在固定颜色上可以只在UI的某个Layer启用这个Shader或者用Render Texture预先烘焙一张流光动画再采样它这样片元着色器计算量降到最低。不过这个属于换了一种实现方案只有在你真的被性能逼到墙角时才值得做。5. 流光Shader常见问题与排查实录5.1 常见问题速查表我在多个项目里折腾过流光Shader把最常出现的问题整理成了一张表遇到问题可以先对照排查。现象可能原因解决办法流光不动_Time.y没有正确参与计算或者材质球没设置速度检查_FlowSpeed是否大于0确认frag里确实加了_Time.y * _FlowSpeed流光方向反了UV坐标的V轴方向与预期相反将_FlowAngle加90或者180度用预览图确认流光闪烁/跳变直接用%取模或者没有用三角波处理循环点出现硬切改成frac(t)并保持d abs(t - 0.5) * 2.0的三角波写法流光像一整坨亮斑_FlowWidth太大或者smoothstep参数顺序写反把_FlowWidth调到0.3以下检查smoothstep的min和max顺序UI图片变黑/显示异常Shader没有配合UI的Stencil模板测试如果是UGUI的Image使用自定义材质时要保留UI的模板逻辑或者改用继承自UI/Default的写法安卓机上部分GPU不显示流光移动端Shader里用了不支持的操作或者精度问题把取模、负数值等操作换成frac和clamp检查日志中Shader编译报错流光让贴图过曝颜色加法叠加后超出范围降低_FlowIntensity或者使用lerp(col.rgb, _FlowColor.rgb, mask)代替加法相邻UI的流光同步扫描很机械所有UI用了同一个材质实例相位一致用_FlowOffset给每个实例设置不同初始偏移5.2 两个真实项目排查案例讲一个我印象深刻的案例。某次做活动页面策划要求按钮流光从左边斜向上扫到右边我调好参数后在本机预览一切正常但打包给测试之后有一个安卓平板出现了流光只显示一半的情况。当时我一度以为是贴图UV的问题查了很久才发现是那个平板的分辨率比较特殊UI整体被CanvasScaler缩放后我那个Shader里用了取整操作来算流光位置导致一些像素的UV坐标被截断。后来我把所有涉及位置的浮点计算都改成完整的float运算不中间取整问题就消失了。还有一个案例是关于UI模板测试的。UGUI的Image默认会用一套模板缓冲来处理UI裁切如果我把Shader直接替换成自定义Shader在ScrollView里面的图标会显示不出来或者被裁切成奇怪形状。我记得当时查了好几天最后发现是Shader里缺少了UnityUI.cginc里的UI_CLIP_RECT相关处理逻辑。如果你的流光Shader也是用在ScrollView、Dropdown这类裁剪容器里建议基于UI/Default的源码进行扩展把那套模板和裁剪逻辑保留下来而不是像我那样从零搭一个不带模板的Shader。这也是为什么很多商业项目里UI流光Shader不是从空SubShader写起而是从Unity内置的UI Shader改出来的。最后分享一点我的实际体会我在项目里调流光Shader最重视的其实是“克制”两个字。流光这东西一旦调顺了很容易上瘾参数一加就停不下来最后把一个精致的小图标做成霓虹灯招牌美术和策划都会有意见。我现在的习惯是先做一版最简效果亮度偏低、宽度偏窄、速度偏慢让流光像一层若隐若现的“呼吸”然后再根据实际的UI层级一点点往上加强度。另外无论你的Shader逻辑多完美真机上一定要测尤其是那些中低端安卓机。PC上看流畅、看精致真机上可能又闪又糊。如果你在项目里也被流光效果困扰过不妨把我这套代码拿去先跑一遍再按自己的需求改参数应该能帮你省下不少排查的时间。
返回列表