如果你玩过一些游戏加载页或者角色释放技能时的特效,一定见过那种彩色光带绕着一个中心点不断旋转的画面——红橙黄绿青蓝紫依次切换,像是把彩虹拧成了一根麻花。这种效果在 Cocos 里做其实有好几条路:硬编码贴图序列帧、引擎自带的 Tween 旋转节点、或者直接上自定义 Shader。我自己的结论是:贴图序列帧最笨重,Tween 旋转只有形没有色,最值得学习的方案是在 Cocos 里写一个彩色旋转 Shader,一条代码同时搞定旋转、换色、流动,还能让美术直接调参数。
这篇东西不是贴一段代码就完事,我想把整个思考过程、数学原理、Cocos Creator 3.x 和 2.x 的具体落地差异、真机上的性能坑都讲清楚。无论你是想给加载界面加个动态背景,还是给角色技能做光环特效,或者单纯想入门 Cocos Shader,这篇文章都够用。
1. 先拆解需求:“彩色旋转”到底包含了哪几层
1.1 一个看似简单但很容易做丑的小效果
我第一次接到这个需求是在做一个消除类游戏的开场界面,策划想要一个“彩色旋转光效”当背景装饰。当时第一反应是找美术出一张彩虹螺旋贴图,然后用 Cocos 的node.rotation接口每帧转一下。结果有两个问题:一是贴图是静止的,只是整体旋转,看不出“色彩流动”;二是贴图稍微放大一点边缘就有锯齿,观感特别廉价。
后来换了一种思路:把彩虹做成一条从中心向外扩散的色带,色带本身绕中心旋转,同时色相还在不断变化。这其实就是“彩色旋转 Shader”的完整含义,它不是简单的图片旋转,而是颜色空间里的动态旋转 + 屏幕 UV 空间的旋转,两个旋转叠加在一起,视觉上才会又花又爽。
1.2 拆开看:UV 旋转、时间驱动、颜色映射三个要素
一旦想清楚了目标效果,拆起来就很简单。任何一个“彩色旋转”效果,本质上由三件事组成:
- UV 旋转:把采样坐标(或者说颜色生成坐标)绕某个中心点旋转。旋转的幅度由引擎时间驱动,这样画面才会动。
- 颜色映射:旋转后的坐标不能直接作为 UV 去采样图片,而是要映射成一个颜色值。最理想的做法是把坐标转换到 HSV 色环上,让不同角度对应不同的色相。
- 随时间流动:除了旋转本身,色相也可以随时间做偏移,这样同样一个色环,每帧看到的颜色都在变,动态感更强。
这三个要素不是必须捆绑的,但如果你想要效果足够“魔性”,三者缺一不可。很多新手写 Shader 时只做了第一件事,旋转是转了,但颜色根本没变化,看起来就是一个静态贴图在转,效果大打折扣。
1.3 为什么不用引擎自带的材质或动画系统
Cocos Creator 的编辑器确实提供了内置材质和 Tween 动画系统,比如把一张彩虹贴图的rotation属性打关键帧,确实能转。但它的上限很低:你没法让多个颜色带朝向不同方向旋转,没法让色相在旋转过程中自动渐变,也没法通过一个参数从“彩色旋转”平滑过渡到“原图呈现”。
Shader 最大的优势在于:所有变化都是数学函数算出来的,不需要额外资源,也不需要关键帧动画。同一个材质可以挂给任意 Sprite、任意粒子纹理,甚至挂在 UI 上,只需要微调几个 uniform 参数就能适配不同场景。所以我坚持用自定义 Effect 文件来做,这也是 Cocos 里做特效的正确姿势。
2. 核心数学部分:UV 旋转矩阵、时间驱动和 HSV 颜色映射
2.1 给 Shader 一个“心跳”:统一使用 cc_time
Cocos Creator 内置了一个全局时间变量cc_time,在 3.x 里它是vec4类型,cc_time.x是从引擎启动到当前的秒数,cc_time.y是增量时间,cc_time.z是总时间的 sin 值包装,cc_time.w是另一个包装值。我们最常用的就是cc_time.x,它天然就是不断增长的浮点数,适合乘系数当角度。
要特别说明一点:不要在 Shader 里自己写sin(uTime)或者float time += 0.016这种逻辑。片元着色器是并行执行的,所有像素需要拿到同一个时间值,自己维护一个“每秒加点”的变量在 Shader 里是不可行的。让引擎把时间传进来,我们只用它来驱动角度变化,这是 Cocos Shader 的基本共识。
2.2 在 UV 坐标系里做旋转,然后映射到色环
UV 坐标通常就是(0,0)到(1,1)的矩形区域,Tonemapping 之前你先要判断一个很关键的问题:你希望旋转中心在哪里。对于一张加载页背景,中心大概率是(0.5, 0.5)。所以第一步是把 uv 平移一下,让中心对齐到坐标原点,也就是uv - center。
然后就是二维旋转矩阵。绕原点的逆时针旋转可以写成:
x' = x * cos(angle) - y * sin(angle) y' = x * sin(angle) + y * cos(angle)这个矩阵用在 Shader 里就是一次mat2乘法。旋转完之后,这个坐标已经带着旋转信息了。接下来别急着拿它去采样贴图,先看它的极坐标:atan(y', x')能算出当前点相对于中心的角度,length(vec2(x', y'))能算出它离中心多远。
角度映射到色相,就是整个效果的灵魂。把弧度换算到0~1的范围,然后丢给 HSV 转 RGB 的函数,不同角度就会呈现不同颜色。想让颜色旋转,只需要让角度加上一个cc_time.x的倍数;想让色相流动,只需要在 HSV 的 H 通道上再加一个时间偏移。
2.3 HSV 比 RGB 更适合做彩虹色
写 Shader 的人都有个经验:想要彩虹色,一定要用 HSV,不要直接硬编码 RGB 分段。用 RGB 做渐变,中间过渡全是脏色,而且颜色不连续。HSV 的思路是:色相 H 从 0 转到 1,饱和度 S 固定,明度 V 固定,直接转成 RGB,出来的就是从红到紫的完整光谱。相当于你什么都不用管,H 通道就是彩虹。
HSV 转 RGB 的 GLSL 函数网上有很多版本,我习惯用这一个,短小精悍,效率也不错:
vec3 hsv2rgb(vec3 c) { vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); }到这里,“彩色旋转”的数学骨架就完整了:旋转矩阵负责让坐标转起来,极坐标的角度负责给 H 通道提供输入,HSV 转 RGB 负责把色相变成真正的颜色。剩下的都是工程落地问题。
3. 在 Cocos Creator 中落地:完整 Effect 代码与挂载流程
3.1 3.x 版本的 Effect 文件可以直接抄
Cocos Creator 3.x 里自定义 Shader 的载体是.effect文件。文件里面同时包含顶点着色器、片元着色器和属性定义。下面这个是我常用的彩色旋转材质,直接在资源管理器里新建 Effect 文件,把代码粘进去就能用:
// colorRotate.effect CCEffect %{ techniques: - passes: - vert: vs frag: fs blendState: targets: - blend: true rasterizerState: cullMode: none properties: rotationSpeed: { value: 0.6 } cycleSpeed: { value: 0.8 } saturation: { value: 0.9 } brightness: { value: 1.0 } center: { value: [0.5, 0.5] } mixFactor: { value: 0.4 } }% 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 () { vec4 pos = vec4(a_position, 1.0); v_uv0 = a_uv0; gl_Position = cc_matViewProj * pos; } }% CCProgram fs %{ precision highp float; #include <cc-global> #include <alpha-test> #include <texture> in vec2 v_uv0; out vec4 fragColor; uniform Params { float rotationSpeed; float cycleSpeed; float saturation; float brightness; vec2 center; float mixFactor; }; vec3 hsv2rgb(vec3 c) { vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); } void main () { vec2 uv = v_uv0 - center; float angle = cc_time.x * rotationSpeed; float cosA = cos(angle); float sinA = sin(angle); uv = mat2(cosA, -sinA, sinA, cosA) * uv; float theta = atan(uv.y, uv.x) / (2.0 * 3.14159265) + 0.5; float radius = length(uv); float hue = fract(theta + cc_time.x * cycleSpeed); vec3 rainbow = hsv2rgb(vec3(hue, saturation, brightness)); vec4 texColor = texture(texture, v_uv0); vec3 finalColor = mix(texColor.rgb, rainbow, mixFactor); fragColor = vec4(finalColor, texColor.a); ALPHA_TEST(texColor.a); } }%这段代码里有一个细节值得注意:我用mixFactor把原始贴图颜色和彩虹色做了混合。这样做是故意的,因为实际项目中很少真的需要把纹理彻底丢掉,更多时候是让彩色旋转叠加在原图上,既能看到原图内容,又有动态效果。如果你想做成完全纯色的彩虹光环,把mixFactor改成1.0就行。
3.2 2.x 版本的主要差异
如果你还在用 Cocos Creator 2.4.x 或更早版本,思路是一样的,但语法有几个差异:
- 2.x 的 Effect 文件是
.effect结尾,结构类似,但 uniform 不要包在uniform Params {}块里,直接在片元着色器顶层声明uniform float rotationSpeed;这种单变量即可。 - properties 里的名字要和 fs 里声明的 uniform 名字一致,2.x 对名称的匹配要求更严格。
cc_time在 2.x 里也是引擎全局变量,可以直接用,但要确保在#include <cc-global>之后。
2.x 的片元着色器大概长这样:
uniform float rotationSpeed; uniform float cycleSpeed; uniform float saturation; uniform float brightness; uniform vec2 center; uniform float mixFactor;其余数学逻辑完全一致。说实话,如果新项目建议直接上 3.x,官方对 Effect 的支持和维护更积极,2.x 的项目如果没有历史包袱,迁移过来统一用 3.x 风格更省心。
3.3 从 Effect 到材质到 Sprite 的挂载步骤
光有一个.effect文件还不行,你得先创建材质,再把材质赋给精灵。操作流程很简单,在资源管理器里右键一个 2D 精灵,然后:
- 新建一个 Material 资源。
- 在 Material 资源上把 Effect 属性选成你刚创建的
colorRotate。 - 如果用的是纯色或者彩虹不需要纹理,清掉纹理槽;如果要叠加原图,把 SpriteFrame 拖进去。
- 把材质拖到场景里任意 Sprite 组件的
CustomMaterial槽位中。 - 在编辑器右上角就能看到 rotationSpeed、cycleSpeed、saturation 这些参数,拖动就能实时预览。
这里有一个很常见的误解:有人把材质拖到 Sprite 的SpriteMaterial或者直接改了Canvas的全局材质,结果效果没出来。实际上 2D 渲染下你要找的是 Sprite 组件的CustomMaterial,只有这个槽位才会走自定义 Effect,否则引擎会强行用内置的builtin-sprite材质把你的 Effect 覆盖掉。
3.4 代码里的 uniform 命名坑
Cocos Creator 对 uniform 的命名和类型容忍度比较低。比如你在 properties 里写的value: 0.6,在 fs 里如果声明成了float rotationSpeed,名字不一致会导致编译报错或者参数不生效。最典型的就是center属性,类型是vec2,你在 properties 里要写value: [0.5, 0.5],少一个数都不行。
另外,Params这个 uniform 块名在 3.x 里写法很严格,一定不要漏掉分号。很多新手把uniform Params {}写成了uniform Params;,结果整个 effect 编译失败,这个错误极其常见。
4. 从“能看”到“好用”:参数化技巧和扩展玩法
4.1 把参数交给美术,比写死效果好得多
第一个版本我习惯把所有参数都写死在 Shader 里,后来被打击了:不同场景需求完全不同,加载页希望旋转慢一点、色环疏一点;技能特效希望转得快、鲜艳一点;UI 过场又希望透明度低、别太抢眼。与其每次改代码,不如把参数全部抽出来。
现在的 properties 里实际上给了你一套完整的参数卡片:
| 参数 | 含义 | 推荐范围 |
|---|---|---|
| rotationSpeed | 整体旋转速度 | 0.2 ~ 2.0 |
| cycleSpeed | 色相流动速度 | 0.2 ~ 1.5 |
| saturation | 饱和度 | 0.6 ~ 1.0 |
| brightness | 明度 | 0.5 ~ 1.2 |
| center | 旋转中心 | 默认 0.5, 0.5 |
| mixFactor | 彩虹与原图混合度 | 0 ~ 1 |
用编辑器微调这些参数,比一遍遍改代码重新构建有效率得多。重点是让美术在编辑模式下直接拖,他们能实时看到变化,这样合版本的时候就不用老来烦你。
4.2 玩出差异化:扭曲、波浪、局部彩色旋转
基础版足够应付大多数场景,但如果你想要更有辨识度的效果,可以在这个骨架上做加法。
- 波浪扭曲:在计算 theta 之前,给 uv 加一个正弦扰动,比如
uv.x += sin(uv.y * 10.0 + cc_time.x) * 0.05,彩色带就会扭得像一条蛇一样。 - 径向渐变:还原本来的色相映射,把
hue = fract(theta + time)改成hue = fract(radius * flowSpeed - time),彩虹就会从中心向外圈扩散,类似发射流光的效果。 - 局部区域:在 Shader 里判断一下 uv 是否在某个圆环范围内,然后只在圆环内部显示彩色旋转,外部透出原图。这样可以用作角色脚下的光环,而不是全屏特效。
这些玩法不需要改整个 Shader 的结构,只是在坐标变换和颜色映射之间多塞几行数学运算而已。我自己最喜欢的组合是“波浪扭曲 + 径向扩散 + 低饱和度”,用它做了一个粒子纹理,整个粒子系统放出一种软绵绵的彩色烟雾,效果比静态粒子好太多。
4.3 混合模式的思路:不是只能叠在 Sprite 上
如果你把这个 Effect 挂到粒子纹理上,那么 blendState 就格外重要。当前代码里 blend 是 true,配合默认的 SrcAlpha 和 OneMinusSrcAlpha,适合大多数半透明材质。如果你想要发光的感觉,可以把 blend 改成加法混合,彩虹叠加后会高亮,特别适合技能爆发瞬间的冲击特效。
但要注意,加法混合会让黑色区域也变亮,如果你的彩虹色饱和度拉满,叠加后很容易过曝。实际操作中建议把 brightness 控制在 1.0 左右,别超过 1.3,不然真机上颜色会非常刺眼。
5. 真机性能观察与踩坑记录
5.1 片元着色器里的三角函数不是免费的
我知道很多教程让你放心大胆地写sin、cos、atan,但真实移动端项目里,我们的 Fragment Shader 是逐像素执行的,哪怕一张 256x256 的贴图全屏铺开,也会有几十万个像素同时调用这个函数。每个像素多算一次atan和两次三角函数,粒子和 UI 叠加在一起,帧率下降很快。
优化手段分两种。如果旋转速度不需要特别精细,可以把角度计算里的一部分挪到顶点着色器里去,比如旋转矩阵只和角度有关,角度和时间相关,这部分可以在顶点阶段算好传下去。另一种方案是预计算一张颜色查找表,把色相映射结果存成一张 256x1 的纹理,在片元里采样它,替代hsv2rgb的数学计算。我实际测过,后者在低端机上收益明显。
5.2 cc_time 的精度与 Android 兼容性
Cocos Creator 3.x 在 Android 真机上,cc_time.x是高精度浮点数,理论上没问题。但如果你在某个机型上发现旋转速度忽快忽慢,或者颜色跳动不连续,很可能是项目里某个地方修改了全局时间缩放。比如你用了director.getDeltaTime()做了慢动作,但 Shader 里直接用的是cc_time.x,这俩是不同的时间体系,会明显不一致。
解决办法是:如果你希望旋转速度受游戏暂停或时间缩放控制,就不要直接用cc_time.x,而是在代码里把时间值通过 uniform 传进来,由逻辑层统一控制。对于 UI 装饰类特效,直接用cc_time.x倒是没问题,因为不会暂停更新。
5.3 我踩过的一个大坑:UV 原点方向搞反了
很多 2D 美术同事习惯把图片左上角当(0,0),但 OpenGL 系渲染里纹理坐标的(0,0)在左下角。我第一版写的中心旋转,在编辑器看着是对的,一到真机上就发现旋转中心偏了半个屏幕,就是因为我在心里默认了左上角原点。
调试这个问题的经验是:准备一张带明显对角颜色的测试图,左上角红色、右下角蓝色,然后看旋转效果。如果发现中心点对应的是图片的右上角而不是正中心,把center参数的 vec2 的两个分量反过来试一下,多半就是原点方向的问题。
5.4 与动画状态机和 UI 一起用时的注意事项
彩色旋转 Shader 很适合做角色状态切换时的底光,也适合挂给 UI 面板做动态边框。但如果挂到 UI 上,有几点要特别注意:UI 节点本身就带合批系统,如果给大量 UI 节点挂不同参数的同一材质,会导致 DrawCall 暴涨,因为合批要求材质参数一致。我的做法是同一界面里最多两个节点使用这个材质,其余背景效果用序列帧或粒子替代。
另外,如果你在动画状态机里切换多个精灵帧,最好保证所有帧的纹理尺寸一致,否则 UV 映射会拉伸变形。我在技能系统里就是专门准备了一张 512x512 的背景纹理,把所有状态切换都放在这一张图上,最终效果稳定,DrawCall 也低。
最后分享一个小技巧:给这个 Shader 的mixFactor接上一个从 0 到 1 的动画曲线,彩色旋转就会从原图慢慢浮现出来,非常适合做“魔化”“觉醒”这类变身演出。你不需要额外做任何特效资源,只要一个 Tween 控制参数即可,这也是 Shader 参数化最迷人的地方——一个材质可以泛生出无数种玩法。