游戏UI跑马灯效果:基于角度渐变与极坐标的Shader实现方案
1. 项目概述从“跑马灯”到“角度渐变”的思维跃迁在游戏UI和特效制作中跑马灯或称流水灯效果是一个高频需求。无论是技能冷却提示、道具稀有度边框、还是菜单的动态高亮这种沿着特定路径循环流动的光效都能极大地提升界面的视觉反馈和动态表现力。过去我们实现这种效果思路往往比较“笨重”要么是美术同学手绘序列帧动画消耗大量资源且不灵活要么是程序同学写脚本控制多个UI元素的透明度或颜色在时间轴上依次变化代码耦合度高调整起来也麻烦。最近在几个项目中我摒弃了这些传统方法转而使用“角度渐变”这个核心图形学概念来重构跑马灯效果。在Unity的UGUI/Shader Graph和Cocos Creator的材质系统里这个思路都能玩得转。简单来说它的核心思想是将“灯光的流动”抽象为“一个渐变图案在极坐标下的旋转”。我们不再需要去控制一堆分散的灯而是定义一条从0°到360°的完整颜色渐变带然后让时间驱动这个渐变带旋转起来。这样一来任何形状的UI元素圆形、圆环、矩形边框只要其UV能够映射到一个角度上就能立刻拥有平滑、可定制且性能极佳的跑马灯效果。这不仅仅是换了个实现方式更是一种设计思维的转变。它把效果从“离散的、基于对象的”变成了“连续的、基于材质的”。对于Unity和Cocos的开发者或技术美术而言掌握这种方法意味着你能用更少的资源、更统一的逻辑去应对项目中各种需要动态流光、边缘扫描的场景从按钮反馈到血条特效都能信手拈来。2. 核心原理拆解极坐标、角度与颜色采样要理解“角度渐变”跑马灯必须从两个基础概念入手极坐标映射和渐变纹理采样。这是整个效果的数学与图形学根基。2.1 极坐标映射将像素位置转换为角度我们屏幕上的UI其每个像素在默认的纹理坐标UV空间里是用二维的(x, y)来表示的范围通常是[0, 1]。为了得到角度我们需要将这片矩形的UV空间转换到极坐标空间。极坐标由两个分量构成半径r和角度θ。对于UI上的一个点其极坐标转换公式可以简化为计算中心点偏移将当前UV坐标减去我们定义的环形中心点比如(0.5, 0.5)。计算角度θ使用atan2(dy, dx)函数。atan2是一个编程和数学中常用的函数它接受y和x两个参数返回的是从正x轴到点(dx, dy)的夹角范围在-π到π或-180°到180°之间。这个角度值就是我们后续一切操作的基础。计算半径rsqrt(dx*dx dy*dy)即点到中心的距离。在跑马灯效果中半径主要用来界定效果发生的区域例如只在半径0.4到0.5的圆环内显示。注意atan2返回的角度范围是[-π, π]而我们的渐变纹理UV通常需要[0, 1]的范围。因此一个关键步骤是将角度值规范化(atan2(dy, dx) / (2 * π)) 0.5。这样-π就映射到了0π映射到了1实现了无缝衔接。2.2 渐变纹理采样定义“灯”的颜色规律“角度渐变”中的“渐变”指的就是一条定义好的颜色带。我们可以用一条一维的纹理比如一张1xN的贴图来代表它也可以用Shader中的Gradient节点如Unity Shader Graph在运行时动态定义。这条渐变纹理的横轴U方向对应着从0°到360°的完整周期。假设我们设计一个“亮-暗-亮”的简单跑马灯那么这条渐变纹理可能就是在0附近是白色在0.5附近是黑色然后逐渐过渡回白色。更复杂的你可以设计七彩流光、火焰边缘等任何颜色规律。实现的精髓在于我们将每个像素计算得到的规范化角度值减去一个随时间均匀增加的值代表旋转然后用这个结果作为UV的x分量去采样那条一维渐变纹理。公式可以表示为finalColor tex1D(GradientTex, frac(angle_normalized - time * speed))这里的frac函数是取小数部分它保证了当角度值超过1即超过360°时能平滑地绕回0从而实现无限循环的流动效果。2.3 与传统方法的对比优势为了更直观地理解这种方法的优势我将其与两种传统方法做了一个对比特性维度传统序列帧动画传统脚本控制多物体角度渐变Shader方案资源消耗高多张纹理中多个Draw Call极低一张小纹理或无需纹理性能表现取决于图集与播放速率差每盏灯都是一个UI元素极佳一个Draw Call纯GPU计算灵活性低改效果需重绘中需改代码逻辑高调参数即可改变颜色、速度、宽度效果平滑度依赖帧数可能有卡顿依赖更新频率可能不连贯完美平滑基于连续函数适用形状固定美术做成啥样就啥样较灵活但布线复杂极灵活任何可映射角度的形状与UI系统集成作为Image组件使用作为多个UI组件管理作为Material或Shader Effect应用从表格可以看出角度渐变方案在性能、灵活性和效果质量上取得了很好的平衡特别适合需要动态生成或频繁调整的现代游戏UI。3. 在Unity UGUI中的实现详解Unity的实现路径非常清晰主要依托于Shader Graph可视化编辑无需编写复杂的CG/HLSL代码对技术美术和程序员都非常友好。下面我以创建一个圆环形跑马灯按钮边框为例拆解每一步。3.1 创建与配置渐变纹理首先我们需要一条一维渐变纹理。最专业的方式是在Photoshop等软件中创建一张1x256或1x512的PNG图片横向绘制你想要的色彩渐变。为了更好的性能和可控性我强烈推荐在Shader Graph内部使用Gradient节点来动态创建。创建Shader Graph在Project窗口右键 - Create - Shader - Universal Render Pipeline - Unlit Shader Graph对于URP项目。命名为“UI_AngleGradient_Radial”。定义Gradient属性在Blackboard中创建一个Gradient类型的属性命名为_ColorRamp。在这里你可以直观地编辑颜色和Alpha的渐变。例如设置两个关键色位置0为亮蓝色RGBA: 0, 0.8, 1, 1位置0.5为深蓝色RGBA: 0, 0.2, 0.5, 0.8位置1又回到亮蓝色。这样就定义了一个蓝-深蓝-蓝的循环。采样Gradient在Graph中添加一个Sample Gradient节点将_ColorRamp属性连上去。这个节点需要一个Time参数这个参数就是我们后续要计算出的“角度偏移量”。3.2 构建极坐标转换网络这是Shader Graph中的核心计算部分。获取UV与中心点添加一个UV节点默认是Tiling And Offset模式下的UV0。添加一个Vector2属性_Center默认值设为(0.5, 0.5)作为极坐标原点。用一个Subtract节点计算UV - _Center得到相对于中心的向量(dx, dy)。计算角度添加一个Arctangent2节点即atan2。将上一步得到的向量的y分量连到Y端口x分量连到X端口。其输出范围是[-π, π]。规范化角度到[0,1]添加一个Divide节点将Arctangent2的输出除以2 * π可以使用Multiply节点将π常量乘以2。然后添加一个Add节点再加上0.5。此时输出范围就是[0, 1]了。我们把这个结果称为BaseAngle。引入时间与速度添加一个Time节点选择Sine Time或Time均可Sine Time自带平滑循环感。添加一个Float属性_Speed。用Multiply节点将Time与_Speed相乘得到累积的时间偏移量TimeOffset。生成流动的采样坐标用Subtract节点计算BaseAngle - TimeOffset。然后连接到一个Fraction节点即frac函数取小数部分。这个结果就是随着时间旋转的、范围在[0,1)循环的最终采样坐标SamplingUV。将SamplingUV连接到Sample Gradient节点的Time输入口。3.3 处理形状与边缘过渡现在我们采样到了基于角度的颜色但它会铺满整个方形UV空间。我们需要将其限制在特定的形状内比如一个圆环。计算半径使用Distance节点计算UV到_Center的距离这就是半径r。定义环状区域添加两个Float属性_InnerRadius和_OuterRadius。例如分别设为0.4和0.5。使用Smoothstep节点来创建平滑的过渡区域。Smoothstep需要三个输入边缘下限、边缘上限、和当前值。我们可以创建两个RingMask Smoothstep(_InnerRadius, _InnerRadius 0.02, r)从内半径开始向外平滑显现。RingMask * 1 - Smoothstep(_OuterRadius - 0.02, _OuterRadius, r)在外半径处向内平滑消失。将两个结果相乘得到一个在指定环带内为1内外侧平滑过渡到0的遮罩FinalMask。这里的0.02是过渡区的宽度可以做成属性_Feather。应用遮罩将Sample Gradient输出的颜色与FinalMask相乘使用Multiply节点。再将结果连接到主Master节点的Color输入。处理透明度确保Sample Gradient节点的渐变中包含了Alpha通道的变化并且最终颜色与遮罩相乘后其Alpha值能正确输出到Master节点的Alpha端口。这样在环带外的区域就完全透明了。3.4 在UI上应用与参数调节创建材质在Project窗口右键基于刚才创建的Shader Graph创建材质命名为“Mat_UI_RingMarquee”。应用到UI在Hierarchy中创建一个UI - Image将其Source Image置空Material选择为“Mat_UI_RingMarquee”。将Image的类型设置为Simple保证UV是标准的[0,1]。动态调节你可以在材质实例上实时调节_Speed改变流速调节_ColorRamp改变光带颜色调节_InnerRadius和_OuterRadius改变光环的粗细和位置。这一切都无需修改代码或重新导入资源。实操心得在Unity中为了让效果在UI上更高效确保Shader的渲染队列是“Transparent”并且检查Shader Graph的Graph Settings将Surface Type设置为TransparentBlend Mode设置为Alpha。另外对于非常简单的单色渐变可以不用Gradient节点而是用Sine函数对时间做运算直接生成明暗变化性能会更好但灵活性稍差。4. 在Cocos Creator中的实现路径Cocos Creator的实现哲学与Unity不同它更倾向于使用脚本组件配合渲染组件的Material系统。虽然Cocos Creator 3.x也引入了类似于Shader Graph的“材质系统”和“Effect”概念但目前更通用、兼容性更好的方式还是通过编写自定义的Effect着色器代码片段来实现。下面以Cocos Creator 3.8版本为例介绍两种主流方法。4.1 方法一使用Sprite组件与自定义Material推荐这是最接近Unity工作流的方式也是效果和性能最可控的方式。准备精灵纹理你需要一张用于显示跑马灯效果的底图。最简单的是用白色圆形或圆环PNG。在PS中创建一个透明背景画一个白色实心圆或圆环保存为PNG。这张图定义了效果的“形状”。创建自定义Effect文件在Assets目录下新建一个.effect文件例如marquee-radial.effect。编辑该文件编写着色器代码。核心代码片段CCProgram fragment如下// 在CCEffect中定义属性 _Speed { value: 1.0 } // 流动速度 _ColorRamp { value: [1.0, 1.0, 1.0, 1.0], target: colorRamp } // 可以是一个颜色数组或纹理这里简化为颜色 _Center { value: [0.5, 0.5] } _InnerRadius { value: 0.4 } _OuterRadius { value: 0.5 } ... // 在片元着色器中 in vec2 v_uv0; uniform sampler2D colorRamp; // 如果使用纹理 uniform float _Speed; uniform vec2 _Center; uniform float _InnerRadius; uniform float _OuterRadius; uniform float _Time; void main () { vec2 uv v_uv0; vec2 d uv - _Center; // 计算角度并归一化到[0, 1] float angle atan(d.y, d.x) / (2.0 * 3.1415926535) 0.5; // 计算半径 float r length(d); // 生成流动的UV float flowUV fract(angle - _Time * _Speed); // 采样渐变这里示例用sin函数模拟渐变实际可用纹理采样 vec4 gradientColor vec4(0.5 0.5 * sin(flowUV * 2.0 * 3.1415926535)); // 计算环状遮罩 float mask smoothstep(_InnerRadius, _InnerRadius 0.02, r); mask * 1.0 - smoothstep(_OuterRadius - 0.02, _OuterRadius, r); // 合成输出 vec4 texColor texture(mainTexture, uv); // 原始精灵颜色 CCOLOR texColor * gradientColor * mask; }注意Cocos Creator的Effect语法是自有的需要参考官方文档。上述代码是一个原理示意实际编写时需要遵循正确的CCEffect结构。创建材质在Assets面板右键 - 创建 - 材质。将新创建的材质球的Effect属性选择为你刚编写的marquee-radial.effect。应用到Sprite场景中创建一个Sprite节点将它的SpriteFrame设置为你准备好的白色圆环纹理。然后将Sprite组件的Material属性拖拽赋值为你创建的材质。脚本控制动态参数你可以写一个TypeScript组件来动态控制材质参数实现更灵活的交互。import { _decorator, Component, Sprite, Material } from cc; const { ccclass, property } _decorator; ccclass(MarqueeController) export class MarqueeController extends Component { property(Sprite) targetSprite: Sprite null; property speed: number 1.0; private _material: Material null; start() { if (this.targetSprite) { this._material this.targetSprite.getMaterial(0); } } update(deltaTime: number) { if (this._material) { // 更新时间或速度参数 // 注意直接修改Material实例会影响所有使用它的Sprite。 // 对于需要独立控制的实例应使用material.clone()。 let currentTime this._material.getProperty(_Time, 0) deltaTime * this.speed; this._material.setProperty(_Time, currentTime); } } }4.2 方法二使用Graphics组件进行动态绘制对于形状简单、需要极简依赖或在小游戏平台对自定义Shader支持有限的情况可以使用Graphics组件进行每帧绘制。思路是用脚本计算一系列点的位置然后沿着圆环路径根据当前时间和角度渐变动态设置每段路径的描边颜色。创建节点与组件创建一个空节点添加Graphics组件。编写绘制脚本import { _decorator, Component, Graphics, Color } from cc; const { ccclass, property } _decorator; ccclass(GraphicsMarquee) export class GraphicsMarquee extends Component { property radius: number 100; // 圆环半径 property lineWidth: number 10; // 线宽 property speed: number 1.0; // 速度 property(Color) brightColor: Color new Color(0, 200, 255, 255); // 亮色 property(Color) darkColor: Color new Color(0, 50, 100, 200); // 暗色 private _graphics: Graphics null; private _timeAccumulator: number 0; start() { this._graphics this.getComponent(Graphics); this._graphics.lineWidth this.lineWidth; } update(deltaTime: number) { this._timeAccumulator deltaTime * this.speed; this._drawRingMarquee(); } private _drawRingMarquee() { this._graphics.clear(); const segments 60; // 分段数影响平滑度 const angleStep (2 * Math.PI) / segments; for (let i 0; i segments; i) { let angle i * angleStep; // 计算当前角度在渐变上的位置0到1 let gradientPos (angle / (2 * Math.PI) - this._timeAccumulator) % 1.0; if (gradientPos 0) gradientPos 1.0; // 简单的线性插值模拟渐变 let color new Color(); Color.lerp(color, this.darkColor, this.brightColor, Math.sin(gradientPos * Math.PI * 2) * 0.5 0.5); let x Math.cos(angle) * this.radius; let y Math.sin(angle) * this.radius; this._graphics.strokeColor color; if (i 0) { this._graphics.moveTo(x, y); } else { this._graphics.lineTo(x, y); } } this._graphics.stroke(); } }优缺点分析这种方法完全由CPU驱动每帧重绘性能开销比GPU Shader大得多只适合简单效果或少量实例。但其优点是完全不依赖Shader兼容性最好且所有逻辑在脚本中调试直观。注意事项在Cocos Creator中如果使用自定义Effect方法一发布到Web平台时需要确保目标平台支持WebGL 2.0或相应的扩展。对于微信小游戏等环境要特别注意着色器代码的兼容性写法避免使用某些高级函数或语法。5. 效果进阶与创意扩展掌握了基础的角度渐变跑马灯后我们可以在此基础上玩出很多花样让效果更加炫酷和实用。5.1 多层叠加与混合模式单一的光带有时显得单调。我们可以在Shader中计算多个不同速度、不同颜色渐变的采样结果然后将它们叠加起来。实现思路复制多份“角度-时间-采样”的计算网络每份使用不同的_Speed比如1.0 -0.7 0.3和不同的_ColorRamp。然后使用Add相加或Screen滤色等混合模式将它们的输出颜色相加。这能创造出更复杂、更有层次感的流光效果。在Unity Shader Graph中可以直接使用Add节点在代码中就是对多个采样结果进行color1 color2 ...操作。5.2 非圆形路径的适配角度渐变的核心是极坐标这天然适合圆形/圆环。但对于矩形边框或其他形状呢我们可以通过“形状距离场”来模拟角度。矩形边框跑马灯对于一个矩形没有全局的中心点。我们可以将UV空间分成四条边分别计算当前像素点到四条边的“最近边”的距离和方向。然后用这条最近边的“切线方向”来模拟角度。更实用的一个技巧是使用屏幕空间导数或预先烘焙的流向图。流向图Flow Map这是一张纹理其RGB通道存储了每个像素点上“流动方向”的向量例如R和G通道存储方向向量的x, y分量。在Shader中我们采样这张流向图得到当前像素的流动方向向量然后用这个向量的角度来代替atan2计算出的角度。这样美术可以在PS中绘制任意形状的流动路径实现S形、螺旋形等复杂跑马灯。5.3 与UI动画系统的结合跑马灯效果不应该是一个孤立的特效而应该融入整体的UI交互中。触发与停止通过脚本控制材质上_Speed参数。默认可以设为0当玩家鼠标悬停或按钮处于激活状态时通过DOTween或LeanTween等动画插件将_Speed从0动画到目标值实现光流的加速启动。离开时再动画回0实现平滑停止。强度控制可以暴露一个_Intensity强度参数与_Speed或最终输出颜色相乘。在需要强调的时候如获得稀有道具可以短暂地提高_Intensity让跑马灯变得更亮。作为状态指示器将跑马灯的颜色与游戏状态绑定。例如技能不可用时跑马灯为灰色且静止技能可用时变为蓝色并流动技能处于激活状态时变为金色并快速流动。这比简单的颜色变化提供了更丰富的信息维度。5.4 性能优化要点虽然GPU Shader方案本身性能很好但在大规模应用时仍需注意合批Batching中断在Unity UGUI中使用自定义Material会打断UI的合批。如果场景中有大量相同的跑马灯UI元素务必确保它们使用的是同一个材质球实例而不是每个UI都单独Material.Instantiate出来一个。可以通过脚本动态共享材质。纹理采样优化如果使用一维渐变纹理确保其尺寸足够小如1x64并设置为合适的压缩格式如RGBA DXT5/BC3。在Unity中可以勾选“sRGB (Color Texture)”并关闭Mipmaps。计算精度在移动端尽量使用half精度在Shader中对应fixed或half进行中间计算特别是在片段着色器中。atan2和fract是相对较重的操作但通常在一个简单的UI Shader中是可以接受的。避免在每帧更新的脚本中如Cocos的Graphics方案进行过于复杂的计算。按需更新如果跑马灯被其他UI元素完全遮挡或者不在摄像机视野内应该通过脚本停止其材质的时间更新以节省不必要的计算。6. 常见问题与排查实录在实际开发中你肯定会遇到一些坑。下面是我和团队在多个项目中总结出来的典型问题及其解决方案。6.1 效果不显示或显示不全问题描述应用了Shader后UI一片空白或只显示一部分。排查步骤检查材质球赋值确保Image或Sprite的Material属性确实指向了你创建的材质而不是空引用。检查Shader编译错误在Unity中查看材质球预览窗口是否正常。在Cocos Creator中查看编辑器Console是否有Effect编译报错。检查Alpha通道这是最常见的原因。确保你的Shader最终输出颜色的Alpha值大于0。在环状遮罩计算中检查smoothstep的参数是否正确导致FinalMask始终为0。可以先将遮罩部分注释掉直接输出gradientColor看是否有颜色。检查UV和中心点将Shader中间变量如BaseAngle、r直接作为颜色输出可视化检查角度和半径的计算是否正确。例如将BaseAngle输出为灰度图应该看到一个从中心向外发散的、平滑的灰度渐变环。6.2 流动效果卡顿或不平滑问题描述跑马灯移动时一顿一顿的不是丝滑的流动。原因与解决时间源问题在Shader中确保使用的是连续、平滑的时间变量。Unity Shader Graph中Time节点比Sine Time节点更线性但Sine Time自带三角函数的平滑性。在脚本中传递给Shader的时间应使用Time.time而非每帧可能波动的deltaTime累加。脚本更新频率如果是Cocos Graphics方案确保update函数在稳定调用。检查是否有性能瓶颈导致帧率下降。数值精度在计算fract(angle - time * speed)时如果time * speed的增长步长过大会导致采样坐标跳变。确保speed参数是一个较小的值如0.1-2.0。对于非常慢的流动可以考虑使用双精度时间累加但传递到Shader时仍需转为单精度。6.3 边缘出现锯齿或硬边问题描述圆环或光带的边缘看起来有锯齿不光滑。解决方案使用smoothstep进行抗锯齿正如我们在遮罩计算中做的使用smoothstep而不是step来创建边缘过渡。smoothstep会在指定的阈值之间进行Hermite插值产生平滑的边缘。增加过渡区宽度_Feather可以进一步柔化边缘。提高渲染分辨率对于UI确保Canvas Scaler的设置合理避免UI在低分辨率下被拉伸显示导致锯齿。MSAA抗锯齿在项目设置中开启多重采样抗锯齿MSAA这对所有基于几何边缘的锯齿都有改善但对纹理内部的渐变锯齿帮助有限。6.4 在Cocos Creator中Effect属性无法动态修改问题描述在Cocos Creator脚本中通过material.setProperty修改了Uniform值但屏幕上效果没变化。排查与解决确认Material实例sprite.getMaterial(0)返回的是共享材质资源。直接修改它会影响所有使用该材质的精灵。如果你需要独立控制必须先克隆一份let uniqueMat material.clone(); sprite.setMaterial(uniqueMat, 0);然后修改uniqueMat。检查Uniform名称确保脚本中setProperty的第一个参数属性名与Effect文件中定义的CCProperty名字完全一致包括大小写。检查更新时机setProperty应在每一帧需要变化时调用如在update中。如果只在一开始设置一次那么效果就是静止的。查看运行时警告在浏览器或模拟器的开发者工具Console中查看是否有WebGL相关的警告或错误例如“uniform location not found”。6.5 效果在设备上异常如黑屏、闪烁问题描述在编辑器里运行正常打包到真机特别是Android/iOS后效果出错。可能原因Shader语法兼容性移动设备GPU特别是低端机对GLSL ESOpenGL for Embedded Systems的支持程度不同。避免使用过于新潮或非标准的语法。在Cocos Creator中确保Effect文件的shader段落正确指定了GLES 2.0或GLES 3.0的版本。精度限定符在移动端Shader中没有指定精度的变量可能会引发问题。在Cocos Creator的Effect中注意变量精度。在Unity中对于URP/内置管线的UI Shader通常引擎已处理好。纹理压缩格式如果使用了渐变纹理其在移动设备上的压缩格式可能导致颜色条带或精度损失。尝试更换纹理压缩格式如ASTC或使用程序化渐变Unity的Gradient节点来避免。驱动Bug极少数情况下特定GPU驱动可能存在Bug。可以尝试简化Shader逻辑或者查找是否有已知的该型号设备的Shader变通方案。通过“角度渐变”这个统一的思想我们在Unity和Cocos Creator两大引擎中都能高效、优雅地实现跑马灯效果。它不仅仅是一个特效的实现更是一种将连续数学函数与视觉表现结合的思路。当你下次再需要任何形式的循环动态边缘光效时不妨先想想能不能用角度来解决