
做 Cocos Creator 游戏时经常会需要一个“彩色旋转”的动态效果——加载界面的彩虹圆环、技能CD的彩色转盘、地图雷达扫过的渐变光晕或者结算画面里那个不断转动的背景色轮。 我之前一直用序列帧硬做后来发现一个十几帧的序列图就要占好几MB空间改个颜色还得重新出图最终下定决心换成shader实现。其实这个效果的核心逻辑并不复杂让UV围绕某个中心点旋转再根据旋转角度映射成颜色输出到屏幕上。这篇记录就围绕这个彩色旋转shader把从数学原理到Cocos Creator 3.x实操的完整过程讲清楚适合那些想让2D画面多一点“动态感”、又不想被美术资源拖住的开发朋友参考。1. 彩色旋转Shader能做什么为什么值得自己写1.1 这个效果的真实应用场景先说说我在项目里实际用到的地方免得你觉得这只是个花哨的玩具。第一个是加载转圈。现在很多游戏喜欢用彩色渐变的花环代替普通的菊花图旋转起来比静态图生动得多。用shader实现的话不仅转得快慢可以平滑调节颜色渐变的方向、饱和度和亮度都能实时调不需要美术单独出图。第二个是技能冷却倒计时。把彩色旋转的角度当作进度角度从0到360度对应冷却从满到空配合一个圆弧遮罩就能直观地表现“正在恢复中”。这种需求如果用序列帧帧数少了不流畅帧数多了包体爆炸而shader只需要修改一个角度变量。第三个是战斗特效里的辅助光效。比如英雄脚下出现彩色旋转的光环、传送门前的地面波纹这些效果往往是短暂出现的用shader做不用加载任何纹理内存占用几乎为零在战斗场景里频繁创建销毁也毫无压力。第四个是背景氛围。主界面的宇宙星云、地下城的魔法能量池都可以用这个彩色旋转套路加一点动态感。当时我接了一个休闲小游戏的单子客户就要求主界面要有一种“会流动的彩虹感”我直接用这个shader挂在一个大Sprite上搞定全程没麻烦美术。1.2 用Shader实现的核心优势很多人第一反应是动画嘛我用Tween改颜色不就行了或者我干脆做一张彩虹图片用UV偏移去做旋转这两种方案我都试过最终都放弃了。用代码改颜色的问题在于每秒要驱动上百个像素的颜色变化CPU压力大而且容易和其他逻辑耦合。用预烘焙图片加UV偏移的问题在于图片的渐变精度受纹理尺寸限制旋转到边缘容易露出接缝而且要做一圈完整闭环渐变图片边缘的颜色必须严格一致否则旋转起来能肉眼看见一个跳变。最重要的是这两种方式都无法做到“逐像素控制”而shader天生就是干这个的。Shader的核心优势是“按像素并行运算”。你在片元着色器里写的每条指令都会在GPU上同时对成千上万个像素执行所以哪怕计算看起来很复杂实际耗时也远低于在CPU里一层层循环改色。另一个优势是参数化——中心点位置、旋转速度、饱和度、亮度全部做成可调参数同一个effect文件既能做转圈又能做呼吸灯还能做传送门不需要改一行代码。还有一个容易忽略的点跨平台一致性。Cocos Creator的effect会被编译成适配不同后端WebGL1、WebGL2、GLES3、Metal等的版本我在编辑器里看到的效果到手机上基本一致。序列帧方案反而容易出现压缩格式导致颜色偏差的问题。2. 从零读懂彩色旋转的数学原理与实现思路2.1 让UV围绕一个中心点旋转坐标旋转公式彩色旋转的本质是把“采样坐标”绕着一个点旋转。这个说法听起来抽象但你完全可以把它想象成在一张纸上画一个点然后转动整张纸——只不过我们不是物理转动整张图片而是反过来对于屏幕上每一个像素去计算它如果“转过某个角度”之后原本应该在哪个位置。假设我们想让每个像素围绕坐标center旋转angle弧度标准旋转公式是newX (oldX - center.x) * cos(angle) - (oldY - center.y) * sin(angle) center.xnewY (oldX - center.x) * sin(angle) (oldY - center.y) * cos(angle) center.y为什么先减中心再乘旋转矩阵又加回来因为旋转是绕原点进行的所以必须先把中心点平移到原点旋转完成后再平移回去。这一步不理解的话后面调center参数时很容易懵。但在彩色旋转shader里我要的不是“转了之后长什么样”而是“处在这个位置的像素应该显示什么颜色”。所以我换了一个思路不直接旋转UV坐标而是根据像素位置计算出它相对于中心点的极坐标——也就是距离radius和角度angle。角度angle atan(uv.y, uv.x)这里atan是GLSL里的atan(y, x)它比atan(x/y)要稳定得多能区分四个象限返回范围是 -π 到 π。正是因为有了这个逐帧变化的角度我才能把角度映射成颜色。为了让角度随时间不断增大只需要做angle angle cc_time.x * speed。这一步是整个效果的灵魂时间做加法角度做颜色位置做旋转。理解了这个后面写代码就是顺水推舟。2.2 从角度到颜色HSV到RGB的关键转换角度有了怎么变成彩色很多人会想到直接rgb(angle值, 0, 0)这种写法那只会得到一片红黑根本不是彩虹。正确做法是用HSV色彩模型。HSV把颜色拆成三个维度色相HHue、饱和度SSaturation、明度VValue。其中色相H天然就是一个环形结构——0度和360度颜色相同正好对应旋转一圈的效果。把刚才算出的角度除以2π得到0到1的色相值再把饱和度设为1、明度设为1就可以得到一个“每个角度都不同、且首尾衔接顺滑”的彩色序列。那为什么不能直接在RGB空间里生成彩虹呢因为在RGB空间里R、G、B三个通道都是独立线性变化的没有一个“环状”的概念。你很难写出一段公式让颜色沿着一个圆环平滑过渡而不发灰。而HSV到RGB的转换是数学上设计好的标准映射你只需要套公式。在实际的GLSL代码里我用的是一段非常经典的高效转换函数vec3 hsv2rgb(vec3 c) { vec4 K vec4(1.0, 2.0 / 3.0, 1.0 / 4.0, 3.0 / 5.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); }初看会有点懵它的思路其实很巧妙fract负责把色相切成三段循环mix根据饱和度在灰色和彩色之间插值最后用明度c.z整体缩放。不需要if判断也没有分支GPU执行起来非常快。2.3 数据从哪来Cocos Creator的uniform参数体系Shader里有一些数据是每个像素都一样的比如中心点、旋转速度、饱和度、当前时间它们被统称为uniform。Cocos Creator 3.x的effect文件里uniform有两种来源一类是内置的比如cc_time引擎每帧自动传入不需要你手动赋值。cc_time是一个vec4其中cc_time.x是自场景开始运行以来的总秒数带小数点精确到毫秒级别。我们在shader里就取cc_time.x来驱动旋转。另一类是自己定义的属性需要在effect文件的properties段声明。声明之后Cocos Creator的编辑器会生成对应的材质属性面板你可以在Inspector里手动调也可以在脚本里用材质接口动态设置。主程序与shader的分工很清楚主程序负责提供“状态参数”时间由引擎给、速度由策划配、中心点由节点位置决定Shader负责把参数渲染成颜色。两者不直接通信而是通过uniform这个“邮箱”中转。理解了这个管道后面调bug时才能快速定位数据是在哪一环节断掉的。3. Cocos Creator中的实操从Effect文件到挂在Sprite上3.1 创建Effect文件与Cocos Creator 3.x语法结构在Cocos Creator 3.x中shader文件的后缀是.effect它不是裸的GLSL而是一个带配置头的文本文件。配置头用%{ }%包裹里面声明了顶点着色器入口、片元着色器入口、渲染状态以及暴露给外部的属性。我的做法是在资源管理器中右键 → Create → Shader → Effect得到一个模板文件然后整个替换成下面这段基础版本。CCEffect %{ techniques: - passes: - vert: vs frag: fs rasterizerState: cullMode: none properties: center: value: [0.5, 0.5] editor: displayName: 旋转中心 speed: value: 1.0 editor: displayName: 旋转速度 saturation: value: 1.0 editor: displayName: 饱和度 brightness: value: 1.0 editor: displayName: 亮度 }% CCProgram vs %{ precision highp float; in vec3 a_position; in vec2 a_uv0; out vec2 v_uv; uniform mat4 cc_matViewProj; void main() { v_uv a_uv0; gl_Position cc_matViewProj * vec4(a_position, 1.0); } }% CCProgram fs %{ precision highp float; in vec2 v_uv; out vec4 o_fragColor; uniform vec2 center; uniform float speed; uniform float saturation; uniform float brightness; uniform vec4 cc_time; vec3 hsv2rgb(vec3 c) { vec4 K vec4(1.0, 2.0 / 3.0, 1.0 / 4.0, 3.0 / 5.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_uv - center; float radius length(uv); float angle atan(uv.y, uv.x); angle angle cc_time.x * speed; angle mod(angle, 6.28318); float hue angle / 6.28318; vec3 color hsv2rgb(vec3(hue, saturation, brightness)); float fade smoothstep(0.5, 0.47, radius); o_fragColor vec4(color, 1.0 - fade); } }%有个细节需要提醒CCProgram vs和CCProgram fs这两个块在effect文件里是并排定义的但引擎实际拼接时会先处理顶点着色器再处理片元着色器。v_uv作为out/in变量把UV坐标从顶点阶段传递到片元阶段这是GPU渲染管线里固定的流程。如果你看到v_uv报未声明十有八九是某个precision声明写错了位置。3.2 顶点着色器和片元着色器逐行拆解很多初学者看到两个着色器就头大其实顶点着色器这里极其简单它收到引擎传进来的模型位置a_position和UV坐标a_uv0把位置转换成裁剪空间坐标输出把UV原样传给下一阶段。片元着色器才是真正的舞台。我从上到下梳理一遍v_uv是一个范围在0到1之间的二维坐标。对一个标准的Sprite节点来说左上角是(0,0)右下角是(1,1)正中心就是(0.5,0.5)。第一步vec2 uv v_uv - center;等于把坐标原点挪到你想要的旋转中心上。如果你把center设为(0.5,0.5)那UV的正中心就变成了(0,0)四周变成负值没关系atan能处理所有象限。下一步是计算极坐标的半径和角度。radius被用作边缘透明度的依据——距离中心越远就越接近节点边缘。我在代码末尾用了一个smoothstep(0.5, 0.47, radius)意思是半径大于0.5的区域透明度渐变为0这样节点边缘不会生硬地切掉彩虹环。如果你想要一个完整的圆形色块可以把这行去掉或者把阈值调大。角度计算是核心。atan(uv.y, uv.x)返回值范围是 -π 到 π不是0到2π所以后面加了一个mod(angle, 6.28318)把它规范到0到2π区间。为什么先加时间再取模因为随着时间增长角度会累积到几百上千弧度取模既能保证精度不丢失又不会影响后续的色相计算。cc_time.x * speed这里就启动了旋转。speed是角速度单位是弧度/秒。如果你希望2秒转一圈speed就设为2π/2≈3.14159如果希望1秒转一圈speed设为2π≈6.28318。我有一次犯蠢直接把speed设成1结果十几秒才转一圈还以为是代码写错了。hsv2rgb函数我上面已经解构过不再重复。最后输出vec4(color, 1.0 - fade)前三个分量是RGB最后一个分量是透明度。注意我没有用任何纹理采样这个shader是完全程序化生成的图案。3.3 从Effect到Material再挂到Sprite上写好了effect文件接下来把它变成一个可用的材质。在资源管理器选中刚才创建的effect右键 → Create → MaterialCocos Creator会自动为它生成一个材质资源。选中这个材质你会看到Inspector底部出现了我们在properties里声明的四个参数旋转中心、旋转速度、饱和度和亮度。此时可以直接拖到场景中任意Sprite组件的Custom Material插槽上。如果只是做静态演示到这里就够了。但实际项目中speed往往需要动态变化比如加载快完成时旋转加速。这时需要在脚本里设置材质参数import { _decorator, Component, Sprite, Material } from cc; const { ccclass, property } _decorator; ccclass(RainbowRotator) export class RainbowRotator extends Component { property(Sprite) private targetSprite: Sprite | null null; property private rotateSpeed: number 1.0; private _material: Material | null null; start() { if (this.targetSprite) { this._material this.targetSprite.getMaterial(0); if (this._material) { this._material.setProperty(speed, this.rotateSpeed); } } } update(deltaTime: number) { // 这里可以按需动态改speed、center等 if (this._material) { const angle (Date.now() / 1000) * this.rotateSpeed; this._material.setProperty(speed, angle * 0.01); } } }有两点必须遵守第一setProperty的参数名字一定要和effect里properties里面声明的一致拼错不会报错但材质参数不会生效表现就是画面旋转速度不变排查时很隐蔽。第二多个Sprite共用一个Material时修改参数会同时影响所有使用该材质的节点。如果想要不同节点不同速度需要用material.initialize克隆一个独立材质实例或者在加载时就为每个节点生成单独材质。3.4 变体增强带纹理的混合、圆环遮罩、扇形CD样式纯彩色旋转看久了会觉得单调实际项目里往往需要把彩虹和美术素材结合。最实用的变体是“叠加原图纹理”——在片元着色器里采样Sprite原本的贴图然后与彩虹颜色混合。在effect文件中加一行属性声明properties: texture: value: white type: sampler2D然后在片元着色器里增加uniform sampler2D texture; vec4 textureColor texture(texture, v_uv); o_fragColor vec4(mix(color, textureColor.rgb, textureColor.a), 1.0 - fade);这样就是带图案的彩虹旋转了。比如你先给美术一张白色Logo图shader就能动态给它染上流动的彩色。另一种常见变体是“圆环样式”而不是实心圆盘。把末尾的透明度计算改成float ring smoothstep(0.45, 0.43, radius) * (1.0 - smoothstep(0.55, 0.57, radius)); o_fragColor vec4(color, ring);这样只会渲染一圈彩色环带中心透明。加载圈就是这么做的把speed调慢还能当呼吸灯。还有“扇形CD”变体让角度小于某个值时完全透明大于时显示颜色就能表现冷却转圈。float progress 0.7; float angleDeg degrees(angle 3.14159); float mask step(progress * 360.0, angleDeg); o_fragColor vec4(color, mask);这几个变体我都实际跑过原理上只是对原有shader做加减法没有改变核心的旋转逻辑推给美术同事时他们也很容易理解——把参数当成“旋钮”就行。4. 踩坑记录与排查技巧4.1 为什么画面全黑或者全白全黑和全白是最常见的问题我一度以为自己的effect语法写错了反复复制模板对比也没发现差异。后来才发现原因各有不同。全黑的头号原因是顶点着色器里gl_Position的变换矩阵写错了。在Cocos Creator 3.x中如果你跳过了cc_matViewProj直接gl_Position vec4(a_position, 1.0)画面会完全不可预知——因为模型坐标没有经过视图和投影变换几何体可能落在屏幕外的任何位置。排查手段很简单把片元着色器的输出临时改成固定颜色比如o_fragColor vec4(1.0, 0.0, 0.0, 1.0)。如果这时候画面出现红色说明渲染管线和顶点阶段没问题问题出在片元逻辑如果依然是黑色就去检查顶点着色器。全白通常与透明度有关。如果你输出o_fragColor vec4(color, 1.0)本来就没问题但有透明通道的地方被Blend叠加成了白色很可能是混合模式没有设为透明混合。在effect文件的pass里加一行blendState: targets: - blend: true srcBlendFactor: src_alpha dstBlendFactor: one_minus_src_alpha如果是挂在UI的Sprite上需要确保节点本身不勾选“Is Gray”之类的灰度化组件否则颜色会被引擎后处理强制压灰。4.2 颜色只在某个区域显示旋转中心不对有读者跟我反馈说效果出来了但彩色只在节点的右下角一小块出现。我一看代码他把center设成了(0.0, 0.0)。在UV坐标系里(0,0)是左上角不是屏幕中心也不是节点中心。节点中心要写(0.5, 0.5)。如果你在脚本里用node.position之类的世界坐标去设置center那基本都会错——因为shader工作在UV空间不知道你的节点在屏幕的哪个位置。正确做法是要么固定写0.5要么通过UIConvertToWindowPoint把世界坐标转换后手动计算UV比例。实际项目里固定写0.5的情况占了九成因为Sprite的UV范围本身就是0到1。还有一个容易踩的如果Sprite开启了九宫格Sliced它的UV不是简单的0到1而是根据格子划分后的局部坐标。这种节点直接套彩色旋转shader会出现诡异的扭曲。我当时是在一个对话框背景上挂这个shader出来一圈彩虹切得乱七八糟。解决办法是要么不用九宫格要么在shader里额外传一个UV偏移和缩放参数把九宫格的内部区域映射回0到1。这个问题查了我大半天最后在Cocos官方社区里看到有人提了一嘴才醒悟。4.3 时间变量在不同平台的表现差异cc_time.x在不同平台都是总秒数这一点没问题。但要注意它的精度在移动端低精度模式precision mediump float下当游戏运行时间很长——比如超过一个小时——秒数变成几千再乘上speed角度的数值会非常大这时用mod取模后依然可能因为浮点误差出现颜色抖动的现象。解决办法有两个方向一是把秒数取模后再乘速度比如float t mod(cc_time.x, 3600.0);然后再乘speed二是干脆在脚本里周期性地重置传参但大多数场景用mod就够了。另外cc_time在2.x和3.x的uniform布局有细微差别。3.x里是uniform vec4 cc_time;2.x里同样是这个名字但需要放在cc-global相关的头文件里。如果你从2.x项目拷shader到3.x记得把#include cc-global改成显式声明uniform vec4 cc_time;不然编译会报undefined。4.4 性能表现与包体控制很多人一听到GPU逐像素运算就担心卡顿。以彩色旋转shader为例它的运算量对于一个普通Sprite来说完全可以忽略——片元着色器的核心指令不到20条现代手机的GPU每秒能执行几十亿次这样的运算。真正需要担心的不是计算量而是绘制批次和Overdraw。如果你把彩色旋转shader挂到一个全屏大节点上并且背后还有大量UI等于整个屏幕都先画了一层透明但是经过复杂计算的颜色再叠加上面的内容会徒增GPU负担。我的经验是旋转特效尽量用尽量小的节点并且控制在屏幕上同时存在的数量——同时三四个全屏旋转光效哪怕计算量再小也会因为填充率问题让低端机发烫。包体方面一个effect文件通常只有几KB对比序列帧动辄几MB的优势不用再多说。但要注意每次新建Material都会把effect编译成对应后端的着色器程序如果变体太多第一次运行时的编译时间会变长。这时候可以通过Cocos Creator的“Effect预编译”选项来提前把包进首场景的effect编译好避免游戏启动时卡顿一下。还有一个小经验Unity那边常见的“双面材质shader”和“震动效果”思路和Cocos Creator是相通的——都是通过调整片元着色器的输出或顶点的位移来实现。如果你在Cocos里遇到“为什么这个shader在Unity里能用搬到Cocos就失效”的问题别怀疑算法重点检查uniform名字和坐标系的约定比如v_uv0还是a_uv0这样的命名差异。5. 把彩色旋转Shader扩展成更多玩法虽然标题是彩色旋转但掌握了这个套路之后改几个参数就能衍生出一整套特效。我给美术导出过一个“特效参数表”里面列了常用的几个组合speed设负值能反向旋转saturation设为0就变成黑白旋转配合radius可以做从中心向外的波纹扩散如果把angle的计算公式改成angle radius * multiplier cc_time.x * speed就变成经典的“彩虹瀑布”效果——沿半径方向的颜色带会向外流动。之前提到的扇形CD样式我把progress参数绑定到技能的真实冷却时间上上线后主美还特意问了一句这是不是用了新序列帧。其实底层还是这个效果文件只是多了一个uniform变量而已。现在再看最开始的问题当初为了一个不到两秒的加载动画折腾了半宿值不值得我的答案是值得因为这一次彻底打通了“数学原理 → GLSL语法 → Cocos Creator材质链路 → 真机调试”的整个流程。后续再做任何shader相关需求都不会再觉得那是黑盒了。对了写这个shader时我还有个习惯每次改参数都先在材质面板上手动拖一遍确认视觉效果后再回脚本固化参数——因为shader的表现受数学公式影响很直接手动拖能让你立刻看到“值”和“效果”之间的关系比在代码里盲猜效率高得多。希望这份记录能帮你绕开我踩过的那些坑。