做游戏特效这几年,我越来越觉得,很多看起来高大上的效果,其实都是几个数学函数叠出来的。就拿这次要聊的“cocos shader 光球流光”来说,拆开看就是径向渐变、极坐标偏移、时间驱动,再加一层加色混合。这篇文章我会把从思路到代码、从材质配置到真机避坑的完整过程讲清楚,适合正在用 Cocos Creator 做 2D/UI 特效或者 3D 能量球的人,也适合想入门 shader 但不知道从哪里下手的同学。
光球流光这类效果在项目里特别常见:技能图标、能量球、buff 提示、传送门、圆形进度条的高光,甚至副本入口的光效都能用。它的核心不是美术资源,而是 Shader 片元着色器里那一小段 UV 运算。把原理吃透之后,你改几个参数就能换颜色、换速度、换光带形态,比做序列帧省太多事了。
1. 先把“光球流光”拆成三层
1.1 第一层:球体本体
光球本质上不是“圆”,而是“从中心到边缘颜色连续变化的球体”。在 UI 上通常表现为一个圆形图标区域,Shader 拿到 UV 坐标后,首先计算当前像素到中心的距离。
距离计算用 GLSL 内置的distance()或者自己用length()都行:
float d = distance(uv, vec2(0.5, 0.5));这里的0.5就是 UV 坐标系的中心点。有了距离d,就可以用smoothstep()做圆形遮罩,也可以用来在“中心色”和“边缘色”之间插值。
我给的实现里,主体颜色是这么混出来的:
vec3 body = mix(centerColor.rgb, edgeColor.rgb, clamp(d * 2.0, 0.0, 1.0));意思很直白:越靠近中心越接近centerColor,越靠近边缘越接近edgeColor。这层决定了光球是“白炽球”还是“蓝火球”。如果你做的是熔岩风格,中心色用橙黄、边缘色用暗红,效果一下子就出来了。
这里有个容易被忽略的点:光球边缘不能硬切。smoothstep(0.40, 0.5, d)会让边缘有大约 0.1 个 UV 单位的过渡带,视觉上就是一圈柔和的渐变。边缘如果切得太硬,放大了会有明显的锯齿,我在第 5 节还会专门讲这个。
1.2 第二层:流光带
流光带是“光球流光”这个名字的由来。常见做法有三种:环形旋转、径向扩散、斜向扫光。三种本质完全一样,都是让某个波形函数随时间偏移。
举环形旋转的例子。普通平面坐标不好表达“绕着圆心转”,所以需要把 UV 坐标转换成极坐标,取角度:
float angle = atan(uv.y - 0.5, uv.x - 0.5) / 6.2831853;得到的就是一圈 0 到 1 的角度值。然后让这个角度加上时间偏移:
float stripe = fract(angle * flowDensity + cc_time.x * flowSpeed);fract()会把结果限制在 0 到 1 之间,于是角度一圈里会出现重复的“条纹”。再用pow(1.0 - stripe, 6.0)把一条条光带压成锐利的脉冲状,就像有道光在球面上转圈。
关键点在于:流光一定要被“遮罩”约束在球体内部。如果忘了乘遮罩,光带会扩散到整个屏幕矩形外,看起来就是一道道横穿屏幕的亮线,特别出戏。我一般会再做一层边缘约束:
flow *= smoothstep(0.32, 0.42, d) * (1.0 - smoothstep(0.46, 0.5, d));这样流光只会出现在球体靠近边缘的环带上,视觉上更像能量在球壳上流动。
1.3 第三层:核心高光与辉光修正
光球如果只是一个均匀渐变圆,看久了会觉得很“平”。给它加一个小高光点,立体感立刻不一样。高光本质上也是一个径向渐变,只不过半径很小、衰减很快,可以用高斯形函数:
float core = exp(-pow((uv.x - 0.53) * 6.0, 2.0) - pow((uv.y - 0.47) * 6.0, 2.0));exp(-x^2 - y^2)能做到中心最大、四周指数衰减。把高光位置往右上或者左上偏 0.02 到 0.03,光球就有了球面反射的真实感。
最后还要考虑“辉光”。我的做法是在球体主体里加一个假的边缘辉光,用pow(1.0 - clamp(d / 0.5, 0.0, 1.0), 2.0)模拟从边缘向外扩散的光晕。这个数学形式和菲涅尔近似,但思路是一样的:边缘区域对光的贡献更大,看起来就像球体在发光。
2. 写 Shader 前必须搞懂的 6 个基础点
2.1 Cocos Creator 3.x 的 effect 文件骨架
Cocos Creator 3.x 的 Shader 文件后缀是.effect,整体分成两大块:CCEffect和CCProgram。
CCEffect部分负责声明渲染状态(比如混合模式、剔除)和自定义参数(uniform)。CCProgram则是真正的 GLSL 代码,包含顶点着色器和片元着色器。
你可以把CCEffect理解成“配料单”:里面写着这道菜需要什么原料(颜色、速度、密度)、用什么烹饪方式(混合、深度测试、剔除模式)。而CCProgram是“实际做菜的过程”。
官方模板里两个CCProgram分别叫vs和fs,在passes里通过vert: vs、frag: fs引用。新手最容易犯的错误是改了CCProgram的名字却忘了同步改引用,结果编译报错半天找不到原因。
2.2 UV 坐标系到底是什么
UV 坐标是 Shader 里最基础也最重要的概念。对 Sprite 或被渲染的平面网格来说,UV 左下角是(0,0),右上角是(1,1)。
光球效果里的“圆心”就是(0.5, 0.5)。如果你想验证自己对 UV 的理解,可以先把片元颜色直接设为vec4(v_uv, 0.0, 1.0),你会看到红绿渐变,这就说明 UV 读取没问题。
有个很常见的坑:如果 Sprite 节点的宽高比不是 1:1,UV 空间依然覆盖整个矩形,光球就会被拉伸成一个椭圆。解决思路有两个,要么保证节点矩形是正方形,要么在 Shader 里乘以宽高比修正。详细处理方法我在第 5 节的坑 3 里写。
2.3 时间 uniform:cc_time
要让流光“动”起来,必须有时间变量。Cocos Creator 3.x 内置了cc_time,它是个vec4类型,其中cc_time.x是从游戏开始到当前的累计秒数。
使用前一定要在片元着色器里加上:
#include <cc-global>我遇到过好几次这种情况:Shader 写完了,静态效果正常,流光就是不动。排查到最后发现是复制代码时漏了#include <cc-global>,导致cc_time编译不过,整个片段程序都出错。这个问题在 Cocos Creator 2.x 里尤其常见,因为 2.x 的全局 uniform 名称和引入方式跟 3.x 不同,后面会细说。
2.4 混合模式:光球发光的底层逻辑
光球流光常用加色混合,也就是src: one, dst: one。它的意思是:当前像素颜色加上目标像素颜色,最终结果会比背景亮,很适合火焰、能量、激光这类“发光体”。
但加色混合有个副作用:颜色累加之后容易过曝,尤其是中心色、流光色都用亮色的时候,画面会白成一片。我通常把颜色值调得相对保守,比如中心色[0.85, 0.95, 1.0],流光色不要超过1.0太多。如果美术反馈“太刺眼”,优先把混合模式改成半透明:
blend: src: src_alpha dst: one这样既保留发光感,又不会彻底丢失暗部细节。
2.5 顶点着色器和片元着色器各管什么
很多入门资料喜欢把 Shader 说得绕来绕去,其实就两句话:顶点着色器决定“形状”,片元着色器决定“颜色”。
在光球流光的效果里,顶点着色器做的事情非常简单:把模型顶点坐标转换到裁剪空间,再把 UV 转发给片元着色器:
vec4 vert() { vec4 pos = vec4(a_position, 1.0); v_uv = a_uv; return cc_matViewProj * pos; }所有光球、流光、高光的计算都在片元着色器里完成。这是因为片元着色器是逐像素执行的,非常适合做复杂的距离运算和波形叠加。换句话说,你只需要关心顶点着色器有没有正确传 UV,剩下的精力都放在frag()函数里。
2.6 Cocos Creator 2.x 和 3.x 的差异
2.x 和 3.x 在 Shader 上的差异主要体现在“壳”上,核心的 GLSL 算法是通用的。2.x 用的是.shader文件,自定义 uniform 通常写在properties里,但要手写uniform的声明,而且变量的命名空间跟 3.x 不同。3.x 的.effect结构更规范,CCEffect里的properties声明会自动映射到CCProgram的uniform constant {}中,省了不少事。
如果你要从 2.x 迁移到 3.x,重点是检查三点:precision声明是否完整、#include <cc-global>是否引入、in/out关键字是否替换了 2.x 的attribute/varying。算法部分基本不用动。
3. 从零开始实现光球流光 Shader
3.1 实现球体主体和边缘辉光
球体主体我用了一个封装函数,里面做了两件事:生成圆形实心遮罩,生成边缘辉光。
float bodyColor(vec2 uv) { float d = distance(uv, vec2(0.5, 0.5)); float base = 1.0 - smoothstep(0.30, 0.48, d); float edgeGlow = pow(1.0 - clamp(d / 0.5, 0.0, 1.0), 2.0); return base * 0.7 + edgeGlow * 0.5; }base控制内部亮区,范围从里到外递减;edgeGlow控制边缘一圈的辉光。两个值叠加之后,球体的明暗层次就出来了。这里的0.7和0.5是强度系数,建议先按这个跑起来,再根据美术风格调整。
为什么不用一个单纯的smoothstep圆形遮罩?因为那只能画出一个“圆片”,没有立体感。光球的“球感”来自中心亮、边缘暗、最外圈又有一层辉光这三层关系的叠加。就好比现实里发光的灯泡,你看到的不只是灯丝,还有周围一大圈光晕。
3.2 实现核心高光
核心高光用高斯函数生成,这是从很多图形学资料里借鉴的经典做法。高斯函数的优点是形状圆润、衰减自然,不会出现锯齿边缘:
float core = exp(-pow((uv.x - 0.53) * 6.0, 2.0) - pow((uv.y - 0.47) * 6.0, 2.0));这里0.53和0.47是高光中心偏移量。我习惯把高光放在右上偏一点,模拟光源从左上打下来的感觉。6.0控制高光半径,数值越大光斑越小。
这个高光最后要乘以一个不超过 1 的强度系数,比如1.2。注意别乘太大,否则中心会过曝。如果你觉得高光太硬,可以把6.0降到4.0,让它更柔和。
3.3 实现流光层
我在完整代码里提供了三种流光模式,用flowMode切换,分别是:
0:径向扩散,波纹从中心向外扩散。1:环形旋转,光带绕球心旋转。2:斜向扫光,光带沿斜线扫过球面。
代码核心如下:
float t = cc_time.x * flowSpeed; if (flowMode < 0.5) { float r = d * 2.0; float wave = 0.5 + 0.5 * sin((r - t) * flowDensity * 6.2831853); flow = pow(wave, 4.0) * mask; } else if (flowMode < 1.5) { float angle = atan(uv.y - 0.5, uv.x - 0.5) / 6.2831853; float stripe = fract(angle * flowDensity + t); flow = pow(1.0 - stripe, 6.0); flow *= smoothstep(0.32, 0.42, d) * (1.0 - smoothstep(0.46, 0.5, d)); } else { float line = uv.x + uv.y - t * 0.8; float wave = 0.5 + 0.5 * sin(line * flowDensity * 6.2831853); flow = pow(wave, 4.0) * mask; }注意0.5 + 0.5 * sin()和pow()的组合:前者把正弦值从[-1,1]映射到[0,1],避免出现负数颜色;后者把波形压窄,让光带更锐利。pow的指数越大,光带越细,冲击感越强,但也越容易闪烁。一般取3到6比较合适。
环形旋转分支里的flow *= smoothstep(...)很关键,它把流光限制在球体边缘一圈。如果不乘,流光会覆盖整个球面,效果会更“花”,但缺少那种“能量在球壳上环绕”的立体感,看具体需求取舍。
3.4 完整 effect 代码
把上面所有内容整合进一个.effect文件,可以直接复制到 Cocos Creator 3.x 项目里使用:
CCEffect %{ techniques: - passes: - vert: vs frag: fs blend: src: one dst: one rasterizerState: cullMode: none properties: centerColor: { value: [0.85, 0.95, 1.0, 1.0], editor: { type: color } } edgeColor: { value: [0.10, 0.45, 1.0, 1.0], editor: { type: color } } flowColor: { value: [1.0, 0.95, 0.8, 1.0], editor: { type: color } } flowSpeed: { value: 0.6 } flowDensity: { value: 3.0 } flowMode: { value: 1 } }% CCProgram vs %{ precision highp float; #include <cc-global> in vec3 a_position; in vec2 a_uv; out vec2 v_uv; vec4 vert() { vec4 pos = vec4(a_position, 1.0); v_uv = a_uv; return cc_matViewProj * pos; } }% CCProgram fs %{ precision highp float; #include <cc-global> in vec2 v_uv; uniform constant { vec4 centerColor; vec4 edgeColor; vec4 flowColor; float flowSpeed; float flowDensity; float flowMode; }; float sphereMask(vec2 uv) { float d = distance(uv, vec2(0.5, 0.5)); return 1.0 - smoothstep(0.40, 0.5, d); } float bodyColor(vec2 uv) { float d = distance(uv, vec2(0.5, 0.5)); float base = 1.0 - smoothstep(0.30, 0.48, d); float edgeGlow = pow(1.0 - clamp(d / 0.5, 0.0, 1.0), 2.0); return base * 0.7 + edgeGlow * 0.5; } vec4 frag() { vec2 uv = v_uv; float d = distance(uv, vec2(0.5, 0.5)); float mask = sphereMask(uv); if (mask < 0.01) { discard; } vec3 body = mix(centerColor.rgb, edgeColor.rgb, clamp(d * 2.0, 0.0, 1.0)); vec3 col = body * bodyColor(uv); float t = cc_time.x * flowSpeed; float flow = 0.0; if (flowMode < 0.5) { float r = d * 2.0; float wave = 0.5 + 0.5 * sin((r - t) * flowDensity * 6.2831853); flow = pow(wave, 4.0) * mask; } else if (flowMode < 1.5) { float angle = atan(uv.y - 0.5, uv.x - 0.5) / 6.2831853; float stripe = fract(angle * flowDensity + t); flow = pow(1.0 - stripe, 6.0); flow *= smoothstep(0.32, 0.42, d) * (1.0 - smoothstep(0.46, 0.5, d)); } else { float line = uv.x + uv.y - t * 0.8; float wave = 0.5 + 0.5 * sin(line * flowDensity * 6.2831853); flow = pow(wave, 4.0) * mask; } col += flowColor.rgb * flow * 1.4; vec2 hi = vec2(0.5, 0.5); float core = exp(-pow((uv.x - hi.x - 0.03) * 6.0, 2.0) - pow((uv.y - hi.y + 0.03) * 6.0, 2.0)); col += centerColor.rgb * core * 1.2; return vec4(col, 1.0); } }%代码里有一行discard,作用是丢弃圆形遮罩之外的像素。加了这行之后,在 3D 场景里不会看到矩形残留;但在某些移动端 GPU 上,discard可能影响半透明物体排序性能。如果你的光球只用 2D UI 且 Blend 都用 ADD,可以删掉discard,直接让颜色乘 0 即可。
3.5 在编辑器里创建材质并验证
把上面的代码保存为light-ball.effect后,接下来在 Assets 面板右键创建材质:
- 在 Assets 窗口右键
Create -> Material,命名LightBallMat。 - 点击材质,在右侧 Inspector 的
Effect属性里拖入刚才创建的light-balleffect。 - 展开
Uniforms列表,能看到centerColor、edgeColor、flowColor等属性,直接调颜色即可。 - 把材质拖到场景中一个 Sprite 上,或者拖到一个 3D Sphere 的 MeshRenderer 上。
这里有个常见坑:在 3.x 里,Sprite 默认使用的是 Sprite 自带的 InternalMaterial,你直接拖材质可能没反应。需要先选中 Sprite,在属性检查器里找到CustomMaterial插槽,把材质拖进去。如果光球没有显示,记得把这个 Sprite 的Color属性置为白色,避免颜色相乘把效果压暗。
运行时动态调参可以用代码:
this.sprite.customMaterial.setProperty('flowSpeed', 1.2); this.sprite.customMaterial.setProperty('flowMode', 2);注意setProperty的名字必须和 effect 里properties声明的名字完全一致,包括大小写。对材质对象调用setProperty会影响所有使用该材质的节点,如果你希望每个光球独立变色,需要为每个光球单独实例化材质,或者用material.getSharedMaterial复制一份再设置。
4. 三种流光模式怎么选怎么调
4.1 径向扩散模式
模式0模拟的是“波纹从中心向外扩散”,类似水面涟漪或者能量爆发的冲击波。它的数学核心是让波纹函数跟“中心距离”挂钩:
float r = d * 2.0; float wave = 0.5 + 0.5 * sin((r - t) * flowDensity * 6.2831853);d从 0 到 0.5,乘以 2 后变成 0 到 1。flowDensity决定一圈上有几道波纹。比如flowDensity = 3,意味着从中心到边缘一共有 3 个完整的正弦波周期。flowSpeed越大波纹扩散越快。
这个模式很适合做“充能”效果:把光球当技能冷却图标,波纹扩散速度可以用flowSpeed映射到充能进度。如果要做“吸收能量”的感觉,把(r - t)改成(r + t),波纹就会从外向中心收拢。
4.2 环形旋转模式
模式1就是很多游戏里“能量球环绕”的效果。先通过atan拿到角度,再在角度上叠加时间。它是三种模式里最接近“流光”直觉的一种,因为光带是绕着球心转的。
调参时注意两个地方:
flowDensity控制一圈多少条光带。默认 3,表现就是三条亮带转圈;改成 1 就是一道非常明显的扫光,适合做雷达扫描。pow(1.0 - stripe, 6.0)的指数控制光带尾迹长度。指数越大,光带越窄越锐利;指数越小,光带拖尾越长越柔和。
如果你要配合“圆形进度”这种功能,比如热词里有人搜kccprogresstimertyperadial那种径向进度效果,思路其实是相通的:把角度和时间偏移跟进度值相乘,再取smoothstep,就能让光球的流光亮度跟进度耦合,变成一个既有进度又有流光的技能指示器。
4.3 斜向扫光模式
模式2是用uv.x + uv.y构造一条斜线,再让斜线随时间平移:
float line = uv.x + uv.y - t * 0.8;这个模式最适合做“宝石被光照掠过”的高光动画。它的优点是简单、性能好,几乎不会出问题;缺点是方向固定,如果你需要任意方向的扫光,可以把方向改成uv.x * dir.x + uv.y * dir.y,其中dir是二维方向向量。
我经常用这个模式给纯色的圆形按钮做“呼吸高光”,让原本很呆板的 UI 多一层微动效果。注意这里我把速度乘了0.8,是为了在面板上让flowSpeed的直觉值更顺手,不然手动调太快。
4.4 调参经验表
| 参数 | 作用 | 推荐范围 | 效果说明 |
|---|---|---|---|
flowSpeed | 流光运动速度 | 0.2 ~ 1.2 | 低于 0.2 会显得凝滞,高于 1.2 容易眼花 |
flowDensity | 光带数量/波纹密度 | 1 ~ 6 | 数值越大光带越多越碎 |
pow指数 | 光带宽度 | 3 ~ 6 | 指数越大光带越窄、越锐利 |
| 颜色亮度 | 整体发光强度 | 0.6 ~ 1.0 | ADD 混合下太亮会过曝 |
| 边缘辉光系数 | 球体外圈光晕强度 | 0.3 ~ 0.6 | 太高会看起来像雾气 |
调参有一条铁律:一次只改一个参数。很多人对着 Shader 一顿乱调,颜色却又灰又糊,最后根本不知道哪个参数导致的。先固定速度和颜色,把密度调到自己想要的数量感;再动速度,找到“流动感”最好的区间;最后微调颜色,因为加色混合下颜色对整体观感的影响最大。
5. 实战中容易踩的坑和排查思路
5.1 坑 1:刚写好 effect,画面一片白
这个 90% 是 Shader 编译失败或者混合模式全开导致。排查顺序:
- 打开 Console 面板看有没有
shader compile error之类的日志,有错先解决语法。 - 找到材质,把混合模式临时改成普通不透明,确认 Shader 本身能出图。
- 如果还是一团白,把输出颜色固定成
vec4(1.0, 0.0, 0.0, 1.0),看有没有红色方块。没有,说明顶点着色器或属性绑定有问题;有,说明问题在混合模式或后面的算法。
最后一步很多人跳过,但这一下就能把问题范围缩小一半。
5.2 坑 2:流光不流动
流光不动的排查顺序:
- 确认是否写了
#include <cc-global>,没有cc_time直接用不到。 - 确认
flowSpeed不是 0。 - 确认你把参数设置在“材质”上,而不是在某个节点上只改了
flowMode然后没刷新。 - 确认不是代码里
setProperty拼写错了,控制台会报属性不存在。 - 确认效果在编辑器和真机表现一致,编辑器的 Shader 编译器和原生 GLES 编译器对部分写法支持不同,建议早点做原生预览。
5.3 坑 3:明明是圆形光球,渲染成椭圆
这个最核心的原因是 Sprite 节点的宽高比不是 1:1。UV 永远覆盖整个矩形,光球的遮罩按 UV 距离计算,矩形越扁圆就越扁。
解决思路有几种:
- 最简单的是把 Sprite 节点宽高设为相等。
- 如果必须保持任意宽高,就在 Shader 里传入宽高比修正,在
CCEffect的properties加一个aspectRatio属性,然后在计算d时乘以修正值。 - 也可以读取
cc_spriteTexture的尺寸,但那样会耦合 Sprite 特性,通用性不好。
我个人倾向于第一种,因为美术做图的时候本来就应该按正方形做光球贴图区域。强行在 Shader 里做宽高比修正会让参数变多,增加排查成本。
5.4 坑 4:编辑器里正常,打包 APK 后效果消失
这个问题在真机上比编辑器里更容易暴露。常见的诱发因素有三个:
- 使用了
discard。部分移动 GPU 对discard的处理效率低,极端情况下会出现整片区域渲染异常。我的代码里可以删掉discard,替代方案是让 mask 乘进最终颜色。 - 精度问题。默认写了
highp float,在部分旧 GPU 上支持不好,可以改成mediump验证。大多数 2D 光球效果用mediump足够。 - 编译失败。真机 GLES 的 error log 不一定在 Cocos 编辑器控制台完整显示,建议先用 Creator 的“构建预览”功能跑一遍原生预览,或者用 Chrome 远程调试看 web 版。
打包 APK 之前在编辑器里能跑只是第一步,我吃过好几次“编辑器正常、真机白屏”的亏。现在养成习惯:写自定义 Shader 后,第一时间做一次原生构建预览,成本极低但能省很多时间。
5.5 坑 5:UI 上光球把后面的文字糊掉
原因多半是 Blend 模式和渲染顺序的问题。光球用了加色混合,在很多情况下会捞到后面的 UI 内容一起提亮,视觉上像“糊成一片”。
处理办法有两种:
- 如果光球是独立节点,把它放到单独的 UI 节点层级,并且给材质单独设一个更靠后的
renderQueue。 - 把混合模式从
src: one改成src: src_alpha,只让半透明区域参与混合,对后面的干扰会小很多。
还要注意 2D UI 的光球如果和 3D 场景物体交错,不要打开深度测试,否则光球会被场景物体挡掉。
5.6 问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 整个屏幕发白 | 编译失败或过曝 | 改混合模式、检查编译日志 |
| 流光不动 | cc_time未引入 | 补#include <cc-global> |
| 光球变椭圆 | 节点宽高比不是 1:1 | 正方形节点或在 Shader 中修正 |
| 真机效果消失 | discard或精度问题 | 去掉discard、改用mediump |
| UI 文字被糊掉 | 混合顺序不对 | 调整renderQueue或混合模式 |
| 颜色刺眼 | ADD 叠加过曝 | 降低颜色上限,换半透明混合 |
最后分享一个我自己的使用习惯:凡是这种纯数学函数驱动的 Shader,我都会先在 Shadertoy 或者 Cocos 的独立 Demo 项目里搭一个最简单的单 Sprite 场景,把所有参数暴露成 uniform,跑通了再移植到正式项目。移植的时候只改“壳”,不动“核”。Shader 这东西,思路通了就是一马平川,思路没通,抄一百遍代码也只会改错觉。光球流光做完之后,你会发现像画面震动、消星星的破碎粒子、甚至 16 方向行走动画的朝向切换,本质上都是“时间驱动状态”的变体,只是换了一个载体而已。