news 2026/9/18 11:16:19

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } 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时返回0,x大于max时返回1,中间是平滑过渡。我这里的1.0 - smoothstep(0.0, _FlowWidth, d),意思是d越小返回值越大,流光中心最亮,向两侧逐渐衰减,宽度由_FlowWidth控制。这里有个隐藏福利:因为用了三角波,流光在时间上循环到边界时,恰好是d=1、mask=0的位置,也就是说亮条消失后再从另一侧出现,肉眼几乎看不到“跳变”,看起来就是连续循环。这一点是很多人问“为什么我的流光总是闪一下”的答案,其实就是没有用三角波,直接用了锯齿波导致循环点出现硬切。

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的量先算好。第二,用powsmoothstep做渐变时,注意smoothstep内部有一次浮点除法,如果不要求高质量,可以自己用clamplerp代替,能省一点寄存器压力。第三,如果流光只需要叠加在固定颜色上,可以只在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上看流畅、看精致,真机上可能又闪又糊。如果你在项目里也被流光效果困扰过,不妨把我这套代码拿去先跑一遍,再按自己的需求改参数,应该能帮你省下不少排查的时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 11:16:02

深入Storybook架构:Monorepo设计与模块化系统

深入Storybook架构:Monorepo设计与模块化系统 Storybook采用先进的Monorepo架构设计,通过精心组织的目录结构和现代化的工具链,实现了高度模块化和可扩展的开发模式。该架构使用Yarn Workspaces和Nx构建系统管理多个包,包含core/核…

作者头像 李华
网站建设 2026/9/18 11:14:46

CSS Grid 核心概念与实战:从网格线到响应式布局一文讲透

做前端这几年,如果让我评一个“文档看过、真到用时就怂”的 CSS 模块,CSS Grid 网格布局绝对排第一。不少人都在教程里见它炫技,什么九宫格、双飞翼、瀑布流,看起来无所不能,可真到自己写页面,手指头还是习…

作者头像 李华
网站建设 2026/9/18 11:11:51

ESP32-P4 USB Host 实战:HID 鼠标枚举与报告解析

鼠标插上 DNESP32P4 的 Host 口,串口只蹦出一行 device descriptor 就彻底安静了——这是我做 USB 鼠标(Host)实验时遇到的第一个画面。当时我以为是驱动没装好,折腾了半天才发现,问题根本不在代码,而在 VB…

作者头像 李华
网站建设 2026/9/18 11:11:45

web停车场管理系统开发:数据模型、计费接口与前后端实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 11:11:12

【ComfyUI】WanVACE 视频扩展重绘

今天给大家演示的是一个基于 Wan2.1 VACE 系列的 ComfyUI 视频生成工作流,整个流程通过加载扩散模型与 LoRA 结合文本提示,生成高质量的视频片段,并支持对输入视频进行扩展与再创作。 在效果层面,这个工作流能够将一段简单的输入视频素材,转化为带有艺术感和电影氛围的动…

作者头像 李华
网站建设 2026/9/18 11:11:11

伺服通信协议选型实战:从RS-485到EtherCAT的避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华