最近在调卡牌UI时,策划提了个需求:卡片上时不时有一道高光扫过,看起来要“高级一点”。我最初用的是Tween移动一张预烘焙好的白色渐变条,结果换个角度就要重新烘焙,透明度还容易穿帮。后来决定直接用CocosCreator的Shader来做,于是就有了这个系列的第二篇学习笔记:流光效果。
第一篇文章已经把effect文件的基本结构过了一遍,这篇会从“流光效果为什么能用Shader实现”讲起,从最简单的单条光带开始,逐步加入旋转、宽度控制、多流光、遮罩等扩展,最后把我在实际项目中踩过的坑也一并记录下来。如果你刚接触CocosCreator的Shader,这篇应该能帮你一次跑通。
1. 流光效果的本质:别再用序列帧和Tween硬撑了
1.1 从“移动贴图”到“逐像素计算”
流光效果在游戏UI里太常见了:按钮扫光、卡牌闪光、技能图标高光、列表刷新光晕。大多数人第一反应是做一张带渐变的白色光条图片,然后用Tween来回移动它,或者直接做序列帧动画。
这套做法有个天生的问题:光是二维的,光条按照某个角度扫过,为了适配不同角度的流光,得准备多张不同方向的贴图;如果UI布局变化,光条位置、宽窄又要重新调。更麻烦的是,光条图片一旦遇到不规则形状的图标,你还得做遮罩,不然高光会跑出图形边界。
Shader的做法完全不同:不移动任何贴图,而是在片元着色器里对每个像素计算“当前位置是否处于高光带内”,再决定这个像素该亮多少。你只需要一个普通的纹理贴图,高光带的宽度、角度、速度、颜色全部可以用参数控制。从性能和资源管理角度看,Shader方案都要干净得多。
1.2 光带背后的数学直觉
先抛开Shader,想象有一根长条的亮线经过一张图片。在某个瞬间,图片上离亮线中心越近的像素越亮,离得越远越暗。如果把图片的横坐标看作位置,光带扫描就是“亮线中心随时间改变位置”。
用数学表达就是:
- 位置坐标
x,范围可以归一化到[0, 1] - 亮线中心
c(t)随时间移动,例如c(t) = fract(t * speed) - 每个像素到亮线中心的距离
d = abs(x - c(t)) - 亮度
intensity = 1 - smoothstep(0, width, d)
这就是一个最简单的“扫描线”。再配合UV坐标的旋转,就能实现任意角度的流光。没错,Shader里的流光并不神秘,本质上就是一张“距离衰减图”在动。
1.3 为什么放在片元着色器里最划算
流光需要在每个像素上计算,所以放在Fragment Shader(片元着色器)里最合适。顶点着色器只处理少数几个顶点,给不了“像素级高光”的精细度。GPU本身就是大规模并行做这种简单数学计算的,一个128×128的图标,一帧只需要一万多次浮点运算,完全可以忽略不计。
在CocosCreator中,一个控件往往只有四个顶点,剩下的都是片元着色器的工作。因此只要不是大量复杂函数嵌套,做几十个流光控件毫无压力。
2. CCEffect基础:先写一个能跑的最小Shader
2.1 一个最简流光effect文件
下面这个文件可以直接放到CocosCreator工程里使用。它基于常见的CocosCreator 2.x/3.x effect语法,以CCEffect开头,包含一个technique和一个pass。
CCEffect %{ techniques: - passes: - vert: vs frag: fs properties: - texture: { value: white, type: sampler2D } - u_speed: { value: 0.5, editor: { tooltip: "流光速度" } } - u_width: { value: 0.1, editor: { tooltip: "流光宽度" } } - u_angle: { value: 0.0, editor: { tooltip: "流光角度(弧度)" } } - u_flowColor: { value: [1.0, 1.0, 1.0, 1.0], editor: { type: color, tooltip: "流光颜色" } } - u_flowStrength: { value: 1.0, editor: { tooltip: "流光强度" } } %} CCProgram vs %{ precision highp float; #include <cc-global> #include <cc-local> in vec3 a_position; in vec2 a_uv0; out vec2 v_uv0; void main() { gl_Position = cc_matViewProj * cc_matWorld * vec4(a_position, 1.0); v_uv0 = a_uv0; } }% CCProgram fs %{ precision highp float; #include <cc-global> in vec2 v_uv0; uniform sampler2D texture; uniform float u_speed; uniform float u_width; uniform float u_angle; uniform vec4 u_flowColor; uniform float u_flowStrength; void main() { vec4 texColor = texture2D(texture, v_uv0); // 把UV中心从(0,0)移到(0.5,0.5),方便旋转 vec2 uv = v_uv0 - vec2(0.5, 0.5); // 按角度旋转UV float c = cos(u_angle); float s = sin(u_angle); vec2 ruv = vec2(uv.x * c - uv.y * s, uv.x * s + uv.y * c); // 流光中心随时间在[-0.5, 0.5]之间循环 float bandCenter = fract(cc_time.x * u_speed) - 0.5; // 当前像素到流光中心的距离 float d = abs(ruv.x - bandCenter); // 距离越近越亮,用smoothstep生成柔和的边缘 float light = 1.0 - smoothstep(0.0, u_width, d); // 混合流光颜色 vec4 finalColor = texColor; finalColor.rgb += u_flowColor.rgb * u_flowStrength * light * texColor.a; gl_FragColor = finalColor; } }%这段代码虽然短,但已经把流光的核心逻辑全包含了。接下来拆开讲每一部分。
2.2 关键字段说明:properties、uniform、varying
properties里定义了可在材质面板上调整的参数。texture对应Sprite的贴图,u_speed控制速度,u_width控制光带宽度,u_angle控制扫过角度,u_flowColor控制颜色,u_flowStrength控制额外强度。这些属性最终会生成对应的uniform变量,在CocosCreator运行时自动从材质面板取值。
CCProgram vs和CCProgram fs分别是顶点着色器和片元着色器。顶点着色器只做了一件事:把顶点从模型空间转换到裁剪空间,并把UV传给片元着色器。v_uv0就是UV坐标,范围通常从左上角(0,0)到右下角(1,1)。
片元着色器里我直接用了cc_time.x,这是CocosCreator的内置统一变量,单位是秒。之所以用cc_time.x而不是自己传一个float u_time,是因为省去每帧从脚本更新uniform的麻烦。#include <cc-global>能确保cc_time可用。
2.3 为什么要乘以texColor.a
我把流光加到了颜色上,但乘了一个texColor.a。原因很简单:如果不乘alpha,透明部分也会被加上高光,UI上会出现一圈明显的光框,非常难看。乘上alpha之后,透明区域的流光被过滤掉,只有实际像素存在的地方才有光效。
这段代码里的混合模式用的是默认的透明混合,也就是源颜色乘srcAlpha,再加上目标颜色乘oneMinusSrcAlpha,所以最终透明度仍由texColor.a决定,不会出现“流光把半透明图变实”的问题。
3. 参数怎么调:旋转、宽度、速度背后的几何关系
3.1 中心化与旋转:为什么是vec2(0.5, 0.5)
经常看到Shader里写uv - vec2(0.5),很多人不理解。UI纹理的UV原点在左上角,范围0到1。如果直接拿这个UV去算角度,光带的“旋转中心”会在左上角,而不是图片中心。减去0.5后,图片中心变成了(0,0),这样旋转才是以图片中心为轴。
旋转公式长这样:
ruv.x = uv.x * cos(angle) - uv.y * sin(angle) ruv.y = uv.x * sin(angle) + uv.y * cos(angle)这个公式来源于二维旋转矩阵。为了省去矩阵写法,我直接展开了。当angle = 0时,ruv.x = uv.x,光带就是水平扫过;当angle = π/2时,ruv.x = -uv.y,相当于垂直扫过。你完全可以用角度转弧度去调,比如45度就是0.785。
3.2 光带宽度与平滑边缘
float light = 1.0 - smoothstep(0.0, u_width, d);
smoothstep会在0和u_width之间做一个Hermite插值,把硬直的边缘变成平滑过渡。u_width设成0.05时是很窄的一条光;设成0.2时就是一大片泛光。建议在编辑器里从0.1开始调,差不多相当于图片宽度10%的样子。
为什么不直接写if(d < width) light=1.0?因为硬边缘在移动时会产生明显的锯齿或不自然的切边。用smoothstep后,光带边缘的亮度是连续渐变的,视觉上柔和很多。如果嫌半透明边缘不够锐利,可以再套一层pow或者用smoothstep的三参形式强化曲线。
3.3 用fract实现循环无缝扫过
bandCenter = fract(cc_time.x * u_speed) - 0.5;
fract取小数部分,让bandCenter永远在[0,1)之间循环,减去0.5后变成[-0.5,0.5)。也就是说,流光中心从图片最左边扫到最右边,然后瞬间回到左边。这个跳变肉眼看不见,因为回到左边时,上一轮的d值已经大于u_width,光带完全消失,所以看起来就是无缝循环。
如果想要“从右往左扫”,只需要把speed设成负数。cc_time.x * (-0.5)会让bandCenter递减,等效于反向扫描。
3.4 让流光只在指定范围出现一次
有时候光带不需要循环,比如一个新手引导箭头,只希望光从左边扫到右边,然后停下来。可以把cc_time.x * u_speed用clamp包住:
float progress = clamp(cc_time.x * u_speed, 0.0, 1.0); float bandCenter = progress - 0.5;这样光带到右边后就不动了。如果你想让它扫到右边后淡出,还可以配合mix或者乘以一个fade系数。我一般把这个逻辑封装成第二个effect,留作特定任务用。
4. 在CocosCreator里把流光挂到精灵上
4.1 创建材质面板
先在工程Assets目录下准备好前面那个effect文件,命名为flow-light.effect。然后在编辑器中右键Assets,选择“创建 -> Material”,给它起名FlowLightMat。在Material的属性检查器里,把Effect选成flow-light。
CocosCreator会自动读取CCEffect里的properties,并生成对应的参数输入框。你会看到:
texture:拖入你要加流光的精灵原图u_speed:默认0.5u_width:默认0.1u_angle:默认0u_flowColor:默认白色u_flowStrength:默认1
把材质保存后,拖到任意Sprite节点的CustomMaterial槽位(2.x叫CustomMaterial,3.x叫customMaterial,注意勾选Enable)。如果要整棵树的所有Sprite都使用同一个材质,直接拖到父节点的属性上即可。
4.2 用编辑器调参:一个实时预览的工作流
我强烈建议调参时不要靠猜,把场景编辑器的“动画编辑器”或者Game视图保持在运行状态,手动改材质面板里的参数,每改一个数字都立刻能看效果。
调试顺序一般是这样:
- 先把
u_speed设成0,确认光带静止在某个位置 - 观察光带位置,调整
u_angle让光带方向符合预期 - 调整
u_width到合适的视觉宽度 - 最后把
u_speed恢复到0.3~1.0范围,确认速度合适 - 调整
u_flowStrength控制整体亮度
我的习惯是把u_speed一开始设在0,这样更容易看清当前光带宽度是否均匀。如果你发现光带宽度在图片两端有差异,多半是UV中心或角度问题。
4.3 多Sprite复用时的注意事项
如果多个Sprite使用同一个材质,它们的流光完全同步,因为所有像素读的cc_time.x是一样的。如果你希望不同卡片上的流光有先后错开,就需要为每张卡片设置一个u_offset属性,或者用节点脚本给每个材质实例设置独立的uniform。
方法很简单:在properties里增加一个u_offset:
- u_offset: { value: 0.0, editor: { tooltip: "流光相位偏移" } }然后在fs里改成:
float bandCenter = fract(cc_time.x * u_speed + u_offset) - 0.5;运行时可以通过material.setProperty('u_offset', randomValue)给每个节点设置不同的偏移值。这样就能做出“不同卡片轮流被光扫过”的效果。
5. 实际项目中的踩坑记录:从“能跑”到“能上线”
5.1 非正方形贴图导致的光带宽度不均匀
这是新手最容易踩的坑。UV坐标是归一化的,长宽的纹理在UV空间里都是0~1,但像素密度不同。一个512×128的横幅,横向UV长度和纵向UV长度在像素上差4倍。如果你用旋转后的uv.x作为光带位置,光带宽度在水平扫过和垂直扫过时实际像素宽度完全不同。
解决思路有两个:
- 第一个是给effect传入一个宽高比
u_aspect,在Shader里把UV的x或y方向缩放后再计算 - 第二个是直接使用
textureSize这类函数获取纹理像素尺寸(CocosCreator 3.x的高版本支持,2.x不一定)
以传u_aspect为例,在fs里这样处理:
uniform float u_aspect; // 图片像素宽 / 图片像素高 vec2 uv = (v_uv0 - vec2(0.5, 0.5)) * vec2(u_aspect, 1.0);这样UV在x方向上被等比放大,光带宽度在视觉上更接近“像素均匀”。在编辑器里给材质设置一个合适的u_aspect即可。运行时也可以从Sprite的node.getComponent(UITransform).contentSize拿到宽高,动态设置。
5.2 合图时的流光中心偏移
如果你的图标放在图集(SpriteFrame合图)里,v_uv0不是整个纹理的0~1,而是图集中某个子区域的局部UV。此时再从v_uv0 - 0.5去计算中心,得到的是图片区域局部坐标还是纹理坐标的半个?严格说不再准确,流光中心会偏心。
CocosCreator内置的2D渲染管线和自定义Shader在合图时会给v_uv0传入原始的局部UV,它已经经过图集UV偏移处理,通常指向的是子图内部的UV范围。比如一个精灵图,它的v_uv0可能从(0.2,0.3)到(0.4,0.5)。这时v_uv0 - 0.5就不是子图中心了。
要完美适配,就需要在CPU端把SpriteFrame的uv偏移量传进去,或者把图片去掉图集单独使用。我的做法是:流光材质只给独立纹理的Sprite用,图集里的图标尽量用第二套纯Shader方案,或者直接让美术把要流光的图从图集里分出来。对于卡牌这种高频UI,单独图片的加载成本完全可以接受。
5.3 真机上出现锯齿或闪线
移动端GPU精度有限,highp在某些旧设备上会退化成mediump,导致极窄流光带边缘出现闪烁。解决方案:
- 把
u_width的最小值限制在0.02以上,不要用极细的光带 - 如果追求极细的闪光,可以改用
exp(-pow(d / width, 2))这样的高斯曲线,抗锯齿效果更好 - 必要时在真机上用
precision mediump float测试,观察表现
另外,如果cc_time.x特别大(比如挂了很久的游戏),frac的浮点精度会下降。解决办法是手动做时间模1处理:把cc_time.x * u_speed先做mod(x, 3600.0)再传入,或者每帧在脚本里把时间参数做一个wrap。
5.4 性能开销:真的可以忽略吗
如果只是几十个Sprite用同一个材质,性能没有任何压力。但如果是一屏几百个卡牌同时流光,每个都执行smoothstep和fract,虽然有GPU并行,但片元数量会变得巨大。这时候可以考虑:
- 把流光做成一个“遮罩层”叠加在UI上,而不是让每张卡都跑Shader
- 用顶点着色器做粗略的光带位置处理,片元只做插值
- 限制流光只能在卡牌进入屏幕时启用,离开屏幕则自动关闭材质
我在某项目里做了200张列表卡片同时流光的压力测试,在低端Android上帧率从60掉到50左右,后来把流光的u_width从0.05改成0.08,并降低整体分辨率叠加量,最终恢复流畅。流光看似只是加法,但高强度批量使用仍然要谨慎。
6. 进阶扩展:从一道光到花式流光
6.1 多重流光叠加
单个光带太单调,那就叠两个。把流光计算封装成一个函数,再按不同角度、速度、宽度分别调用,最后把结果累加。
在fs里这样写:
float flowMask(vec2 uv, float angle, float speed, float width) { float c = cos(angle); float s = sin(angle); vec2 ruv = vec2(uv.x * c - uv.y * s, uv.x * s + uv.y * c); float center = fract(cc_time.x * speed) - 0.5; float d = abs(ruv.x - center); return 1.0 - smoothstep(0.0, width, d); } void main() { vec4 texColor = texture2D(texture, v_uv0); vec2 uv = v_uv0 - vec2(0.5, 0.5); float m1 = flowMask(uv, 0.0, 0.4, 0.08); float m2 = flowMask(uv, 0.785, -0.6, 0.06); vec3 flowColor = u_flowColor.rgb * (m1 + m2) * u_flowStrength; vec4 finalColor = texColor; finalColor.rgb += flowColor * texColor.a; gl_FragColor = finalColor; }注意叠加后的总强度可能超过1,导致颜色过曝。如果过曝,用m1 + m2后再乘以0.5,或者用min(m1 + m2, 1.0)限制。美术如果不想过曝,可以保留高光过曝感,看具体需求。
6.2 用第二张纹理做局部遮罩
很多流光希望只在图标的某块区域出现,比如图标上有一个金属边框,流光只沿边框走。这时可以用一张遮罩贴图,在fs里额外采样:
uniform sampler2D maskTex; float mask = texture2D(maskTex, v_uv0).r; finalColor.rgb += flowColor * texColor.a * mask;这个mask可以是一个黑白图,白色区域允许流光通过,黑色区域完全阻挡。这样做的好处是,即使原图是不规则形状,高光也不会跑到透明区或不想照亮的部分。代价是多一次纹理采样,影响很小。
6.3 彩色流光:让RGB三通道分别偏移
默认流光只有一个颜色。做彩色流光时,可以让RGB三个通道各自独立延迟:
float mR = flowMask(uv, u_angle, u_speed, u_width); float mG = flowMask(uv, u_angle, u_speed * 1.1, u_width * 0.9); float mB = flowMask(uv, u_angle, u_speed * 1.2, u_width * 0.8); vec3 color = vec3(mR, mG, mB); finalColor.rgb += color * texColor.a * u_flowStrength;由于RGB三色速度不同,流光会呈现出类似彩虹的拖尾,非常适合科技感UI。如果只想保持一个色调但带渐变,可以直接把u_flowColor替换成一个从左上到右下的渐变向量,利用UV坐标插值:
vec3 gradientColor = mix(vec3(1.0, 1.0, 1.0), u_flowColor.rgb, uv.y + 0.5);6.4 结合进度/技能冷却的流光
如果你在做一个进度条或者技能图标冷却,可以让流光始终停留在进度值的边缘。原理很简单:把流光中心从时间函数改成u_progress:
float bandCenter = u_progress - 0.5; float d = abs(ruv.x - bandCenter); float light = 1.0 - smoothstep(0.0, u_width, d);接着在脚本里每帧更新u_progress,流光就会跟着进度值走。这个技巧在卡牌冷却、雷达扫描、技能CD上非常实用。
写在最后
流光效果是Shader入门里回馈感最强的一类效果。它没有太多复杂的数学,只需要理解UV、时间、距离衰减这三个概念,就可以做出很不错的UI反馈。我在项目里做卡牌高光时,从Tween方案换成Shader方案后,不仅减少了一张光条贴图,还让角度的调整变成纯参数操作,策划自己就能调。
如果你正在学CocosCreator的Shader,建议先亲手把这段代码跑起来,然后改改speed、width、angle,观察光带的变化,再慢慢加多流光、遮罩、渐变。等你可以不看参考就写出不同方向的多重流光时,离真正理解Shader也就不远了。下一篇如果继续写这个系列,我会聊聊边缘发光或者溶解效果。