光球流光这种效果,在卡牌大招、技能爆发、抽卡转场里几乎天天见。很多项目第一反应是做序列帧,但换一套配色就要重新出图,改个速度还要反复调动画。我自己的习惯是直接上 Shader:一张白模、一个材质,颜色、流速、条纹密度全部实时调,出效果的时间能压缩到序列帧的十分之一。这篇就用 Cocos Creator 3.x 手写一个光球流光 Shader,从数学拆解到完整 Effect 文件,再到调参和避坑,一步步看完就能直接搬到项目里。
这篇文章适合两类人:一类是刚接触 Shader,想知道流光效果到底由哪几层合成的新手;另一类是已经在项目里用序列帧做这类特效,想换 Shader 方案但又怕踩坑的实操派。我会把最终代码放在第二部分,但强烈建议先把第一部分的原理看完,不然遇到效果不对,你连该调哪一行都不知道。
1. 效果拆解:一个看似复杂的光球,其实只有四层逻辑
1.1 先想清楚:为什么流光必须用 Shader
先解决一个根本问题:光球流光到底能不能用序列帧做?能做,但代价很高。假设你需要 10 帧循环,每个技能 3 种颜色变体,那就是 30 张图。再遇到不同屏幕分辨率,特效图还得单独处理。而 Shader 方案只需要一张纯白图(甚至直接用 Sprite 的原始 UV),所有变化都在运行时计算。
更关键的是,Shader 能跟游戏逻辑联动。比如角色释放技能时瞬爆,流光速度从 0.5 加速到 3.0,序列帧很难做到这种连续响应,但 Shader 里就是一个 uniform 的事。框架层接入Material.setProperty即可实时改。这也解释了为什么现在中等以上品质的项目,这类光效几乎全走 Shader。
1.2 第一层:用距离场画一个"软边圆"
任何特效的第一步都是确定形状。光球得是一个圆形,而且边缘要柔和,不能是硬邦邦的圆盘。这就用到 UV 平面上的距离场概念。
假设纹理的 UV 范围是 0 到 1,球心放在center = vec2(0.5, 0.5)。像素到球心的距离就是:
float d = distance(uv, center);d 最小是 0(球心),最大能到约 0.707(顶点)。接下来用smoothstep把距离变成遮罩(Mask),让圆内是 1,圆外是 0,中间过渡带是 0 到 1:
float ballMask = 1.0 - smoothstep(radius - softness, radius + softness * 0.5, d);radius控制球的大小,softness控制边缘过渡宽度。softness越大,边缘越虚,光球看起来越像"气态";调小了就是一颗硬边实心球。这一步多用生活化类比理解:就像拿软橡皮擦铅笔画的圆,擦得越用力,边缘越虚。
1.3 第二层:径向渐变,让球"鼓"起来
光球不是均匀的色块,它应该中心亮、边缘暗,这样才有立体感。径向渐变的公式我习惯写成:
float radial = pow(clamp(1.0 - d / radius, 0.0, 1.0), 2.0);这个式子好懂:d 等于 0 时,1 - d/radius是 1,亮度最高;d 接近 radius 时,数值趋向 0。再做一个平方运算,是为了让亮度衰减得更快,中心更集中。
指数可以继续调大,比如pow(..., 3.0),球心会变成一个很亮的光核,边缘迅速暗下来。这个光核再往下叠加做出"光球受光"的感觉,非常够用。很多新手以为光球要有 3D 光照模型,其实 2D 特效里用这种径向渐变就能骗过眼睛。
1.4 第三层:流光条纹,数学上就是一个"移动的波浪"
流光是最有意思的一层。它的本质是:沿某个方向,把一个周期函数切出来,再让相位随时间移动。
先把 UV 坐标投影到流光的运动方向dir上:
vec2 dir = vec2(cos(angle), sin(angle)); float proj = dot(uv - center, dir);这个proj可以理解成"每个像素在流光方向上的位置"。如果dir是水平方向(1, 0),proj就是uv.x - center.x。斜向流光就是把坐标系旋转了一下。
然后引入正弦函数生成条纹:
float phase = (proj + time) * density; float stripeRaw = 0.5 + 0.5 * sin(phase * 6.2831853); float stripe = pow(stripeRaw, width);相位随时间整体平移,条纹就会朝dir方向滚动。density控制条纹条数,width是指数,控制条纹粗细。这个操作的本质是:先把连续的正弦波叠出来,再用指数把波峰附近以外的部分压暗,形成"一条亮带"的视觉。指数越大,亮带越窄越锐利。
别忘了最关键的一步:流光必须被限制在球内。所以最终要乘以ballMask,否则条纹会延伸到矩形纹理外面,非常难看。
1.5 第四层:边缘辉光,让光球"溢出"边界
球本身有了,还差点氛围感。光球应该像能量源一样,在边缘往外渗光。这个效果我用一段独立的边缘光公式:
float edge = pow(1.0 - smoothstep(radius * 0.7, radius + softness, d), 3.0);它在圆半径附近形成一个窄窄的亮环,向外逐渐衰减。配合加色混合,会让光球边缘有一圈泛光。这一圈辉光处理得好不好,直接决定了特效是"干净的光球"还是"廉价贴图"。
2. 手写一个可用的 Cocos Creator 3.x Effect 文件
2.1 Effect 文件的结构:YAML 声明 + 两段 GLSL
Cocos Creator 3.x 的 Shader 文件沿用.effect格式,核心是三部分:CCEffect 声明段(YAML)、顶点着色器 CCProgram vs、片段着色器 CCProgram fs。
YAML 段里最重要的是properties。它定义了材质面板上能调整的参数,并且要求每个属性名必须在 fs 的 uniform 块里能找到对应成员。如果把stripeParams写进 YAML,但 GLSL 的 uniform 块里没有这个名字,材质编译会直接报错。我习惯把所有参数打包成vec4,因为引擎对 vec4 的传输和缓存效率最好,这也是项目里常见的做法。
2.2 完整代码:直接复制即可运行
下面给出我验证过可用的版本。新建一个.effect文件,把内容整个贴进去,再创建材质拖到 Sprite 上就能看到效果。
// LightBallFlow.effect CCEffect %{ techniques: - passes: - vert: vs frag: fs blend: src: one dst: one rasterizerState: cullMode: none properties: center: { value: [0.5, 0.5, 0.0, 0.0], editor: { type: vec4, tooltip: "球心位置(UV坐标)" } } radiusSoft: { value: [0.42, 0.06, 0.0, 0.0], editor: { type: vec4, tooltip: "x=半径 y=边缘柔化" } } coreColor: { value: [1.0, 0.85, 0.6, 1.0], editor: { type: vec4, tooltip: "球心高光色" } } glowColor: { value: [0.15, 0.45, 1.0, 1.0], editor: { type: vec4, tooltip: "边缘辉光色" } } stripeColor: { value: [1.0, 1.0, 1.0, 1.0], editor: { type: vec4, tooltip: "流光主色" } } stripeParams: { value: [0.6, 5.0, 6.0, 0.6], editor: { type: vec4, tooltip: "x=速度 y=条纹密度 z=条纹宽度指数 w=流光方向角度" } } }% CCProgram vs %{ precision highp float; #include <cc-global> in vec3 a_position; in vec2 a_uv0; out vec2 v_uv0; vec4 vert () { vec4 pos = vec4(a_position, 1.0); v_uv0 = a_uv0; return cc_matViewProj * pos; } }% CCProgram fs %{ precision highp float; #include <cc-global> in vec2 v_uv0; uniform LightBall { vec4 center; vec4 radiusSoft; vec4 coreColor; vec4 glowColor; vec4 stripeColor; vec4 stripeParams; }; vec4 frag () { vec2 uv = v_uv0; vec2 c = center.xy; float radius = radiusSoft.x; float soft = radiusSoft.y; // 距离场与球体遮罩 float d = distance(uv, c); float ballMask = 1.0 - smoothstep(radius - soft, radius + soft * 0.5, d); // 径向渐变(球体高光) float radial = pow(clamp(1.0 - d / radius, 0.0, 1.0), 2.0); // 流光条纹 float t = cc_time.x * stripeParams.x; vec2 dir = vec2(cos(stripeParams.w), sin(stripeParams.w)); float proj = dot(uv - c, dir); float phase = (proj + t) * stripeParams.y; float stripeRaw = 0.5 + 0.5 * sin(phase * 6.2831853); float stripe = pow(stripeRaw, stripeParams.z); // 边缘辉光 float edge = pow(1.0 - smoothstep(radius * 0.7, radius + soft, d), 3.0); // 颜色叠加 vec3 col = vec3(0.0); col += glowColor.rgb * edge * 1.2; col += coreColor.rgb * radial * ballMask; col += stripeColor.rgb * stripe * ballMask * 1.8; return vec4(col, 1.0); } }%cc_time.x是引擎内置的时间变量,单位秒,无需自己传,但必须在 fs 里#include <cc-global>才能访问。这里我用加色混合(src: one, dst: one),核心原因是流光和辉光属于"亮色叠加",加色混合可以让多层光效相加,暗部不会发灰发脏。如果你希望光球有半透明边缘,可以改用src: src_alpha, dst: one,但注意此时返回的 alpha 必须正确,否则边缘会发黑。
2.3 从文件到画面:创建材质和挂载 Sprite
代码写完还不能立刻看到效果,需要走一遍资产流程,步骤其实很快:
- 在资源管理器里新建 Effect,把上面的代码粘贴进去。
- 新建 Material,在 Effect 属性里选择 LightBallFlow。
- 场景里新建一个 Sprite,把 SpriteFrame 设成任意纯白图(没有的话可以用内置默认 SpriteFrame)。
- 选择 Sprite 节点,在 Sprite 组件的 Custom Material 中挂上刚才创建的材质。
建议场景背景放一张深色图,黑色或深蓝色都行。因为加色混合在浅色背景上会泛白,看不清层次。如果手头没有纯白图,可以先用一个 1x1 的白色 PNG,效果完全一样。
3. 关键参数与调优:让效果真正适配你的项目
3.1 每个参数的调节思路和三档手感
我把材质 Inspector 面板里的参数全部列成一张速查表,这是我在项目里调特效时最常参照的东西。别小看这张表,很多新手拿到代码乱调一通,最后效果不对,其实是参数之间互相影响。
| 参数 | 范围建议 | 手感说明 |
|---|---|---|
| center.x / center.y | 0.35 ~ 0.65 | 球心位置,配合图集裁切时使用 |
| radiusSoft.x | 0.35 ~ 0.48 | 半径越大球越大,超过 0.5 会超出纹理边界 |
| radiusSoft.y | 0.02 ~ 0.12 | 柔化值越小边越硬,越大越像气态光团 |
| stripeParams.x | 0.3 ~ 1.0 | 流光滚动速度,攻击演出前段建议偏快 |
| stripeParams.y | 3 ~ 8 | 条纹密度,数字越大条数越多 |
| stripeParams.z | 4 ~ 12 | 条纹粗细,数字越大条纹越细越锐利 |
| stripeParams.w | 0 ~ 3.14 | 流光方向,0 是水平方向,1.57 是垂直方向 |
我最常用的三档预设是这样:
- 大招蓄力:半径 0.45、柔化 0.10、速度 0.4、密度 4、宽度 6、角度 0.6。
- 技能爆发的瞬间:半径 0.48、柔化 0.08、速度 2.0、密度 6、宽度 8、角度 1.2。
- 抽卡转场装饰:半径 0.38、柔化 0.12、速度 0.8、密度 3、宽度 4、角度 0.3。
注意,条纹密度和宽度指数是关联的。密度调大以后,条纹数量变多,如果宽度指数没有跟着提高,整个球会糊成一片。反过来密度小的时候宽度指数调太大,球面可能几乎看不到条纹。所以调参时先把密度固定,再动宽度指数,最后回头微调速度。
3.2 混合模式的选择:加色还是半透明
Effect 里的blend块决定最终颜色怎么画到屏幕上。我用的是:
blend: src: one dst: one这是最简单的加色混合,最终颜色等于源颜色加上目标颜色。它的好处是亮色叠加后更亮,暗部几乎不残留,特别适合深色背景上的能量系特效。
如果游戏界面是浅色调,比如某些日系卡牌的白色抽卡界面,加色混合会把整个区域冲得很白。这种场景建议改成:
blend: src: src_alpha dst: one这样颜色输出前乘以 alpha,透明度能够控制叠加强度,避免过曝。代码里return vec4(col, 1.0)的 alpha 也要跟着调,或者直接给 col 的亮度乘以一个强度系数。我项目里习惯再暴露一个intensity参数,调 0.8 到 1.2 之间,比临时改混合模式方便得多。
3.3 配色的底层逻辑:别把同一颜色全堆进去
光球好不好看,七成靠配色。最常见也最稳的结构是:中心高光用暖色,边缘辉光用冷色,流光用介于两者之间的过渡色。
举个例子:中心coreColor给乳白色偏金(1.0, 0.9, 0.7),边缘glowColor给能量蓝(0.2, 0.5, 1.0),流光stripeColor给淡蓝白(0.9, 0.95, 1.0)。这样球心是亮核,边缘透出冷光,视觉层次立刻拉开。如果三个颜色都是冷的或者都是暖的,效果会平。这是做材质配色时一个非常实用的经验。
4. 从光球到完整技能特效:三个常用扩展
4.1 让流光自动旋转,模拟蓄力能量
很多技能里的光球,条纹是围绕球心旋转的。现在的代码里角度是固定 uniform,改起来有两种方式:一种是在 Shader 内部写stripeParams.w += cc_time.x * rotateSpeed,这样流光方向会自动转,但这样做会破坏原方向参数的可控性,不太推荐;更好的方式是每帧在脚本里更新材质属性。
import { _decorator, Component, Material, Vec4 } from 'cc'; const { ccclass, property } = _decorator; @ccclass('LightBallCtrl') export class LightBallCtrl extends Component { @property(Material) mat: Material = null; angle = 0; update(dt: number) { this.angle += dt * 0.8; const cur = this.mat.getProperty('stripeParams') as Vec4; this.mat.setProperty('stripeParams', new Vec4(cur.x, cur.y, cur.z, this.angle)); } }每次把新的Vec4写回去会有一点开销,但光球数量一般不多,性能可以接受。如果同屏光球非常多,比如几十个,建议每个球用一个独立的 Material 实例,避免共享材质导致全部一起被改。
4.2 用噪声扰动条纹边缘,去掉"塑料感"
正弦条纹做出来的流光边缘非常光滑,看久了会觉得有点"塑料"。想要更自然的能量流动感,可以在投影计算里加入一点点噪声扰动。先在 fs 里加一个简单的哈希噪声函数:
float hash21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); float a = hash21(i); float b = hash21(i + vec2(1.0, 0.0)); float c = hash21(i + vec2(0.0, 1.0)); float d = hash21(i + vec2(1.0, 1.0)); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); }然后修改条纹相位计算:
float projNoise = proj + (noise(uv * 4.0 + vec2(t * 0.5, t * 0.3)) - 0.5) * 0.08; float phase = (projNoise + t) * stripeParams.y;uv * 4.0控制噪声的密度,数字越大扰动越碎;乘以 0.08 控制扰动幅度,幅度太大会让条纹变形到认不出来。这个技巧同样适用于粒子特效里的扭曲扰动,核心思路在这里都是通用的。
4.3 叠加粒子与多重流光,做出爆发感
光球 Shader 本身解决了"球"的形态和"光流"的动态,但要撑起一个完整的技能演出,还需要外部组合。我实际项目里的做法分三层:
- 第一层用这个光球 Shader 做核心能量体,放在技能中心。
- 第二层叠加一个粒子系统,粒子用同色系但更亮的纹理,从球心向外喷射,用来补充动态爆发感。
- 第三层在球体上方叠加一个环形光圈,用另一个带圆环的 Shader,或者直接用拉伸过的 Sprite,制造"能量环锁定"的感觉。
这三层都用同一个主色调,但要拉开透明度。核心球和粒子是主角,光圈做点缀,层次就出来了。千万不要三层都一样亮,那是新手最容易犯的错误。
5. 常见问题与排查实录
5.1 问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 物体渲染全黑 | fs 里变量未初始化,或 uniform 块成员名与 properties 不一致 | 检查uniform LightBall内的变量名是否和 YAML properties 完全一致 |
| 完全看不到球 | 材质没挂上、SpriteFrame 是空的、混合模式不对 | 确认自定义材质已赋给 Sprite,并给一个有效的 SpriteFrame |
| 流光不动 | 没有在 fs 里 include cc-global,或cc_time.x用错 | 确保 fs 顶部有#include <cc-global> |
| 条纹细到闪烁 | 条纹密度过高,在低分辨率真机上采样不足 | 降低 density,或改用 smoothstep 宽度控制,必要时提高纹理分辨率 |
| 白色背景上整个区域发白 | 加色混合导致过曝 | 换src_alpha, one,或把亮度统一压低 |
| 真机颜色跟编辑器差很多 | 部分 GPU 对 highp 精度运算的取舍不同 | 关键计算保持 highp,纹理采样时额外做一次 clamp |
5.2 最值得记住的两个坑
第一个坑是 uniform 命名不一致。properties里的键要和uniform LightBall {}块里的成员名对得上,否则 Cocos Creator 编译材质时会报错或者输出错误结果。我见过很多情况是手滑把stripeParams写成stripeParam,结果编辑器里改参数完全没反应。
第二个坑是光球尺寸很容易超过纹理边界。radius如果大于 0.5,球会被 Sprite 的矩形裁掉,边缘出现一个生硬的切面。解决方法是把半径压在 0.48 以内,或者给球心位置预留边界。还有一种办法是改 Sprite 的sizeMode放大节点,但节点大小和 Shader 里的 UV 是两套体系,别混了。
5.3 Cocos Creator 2.x 项目怎么办
这篇文章的代码基于 Cocos Creator 3.x 语法。如果你的项目还在用 2.4.x,effect 文件的框架结构基本一样,CCEffect、CCProgram、vert、frag 都还在,但有三点差异需要注意:一是内置变量和 include 路径在不同版本有个别差异,建议以你本地安装的引擎官方示例为准;二是代码里in/out关键字在 2.4 的某些版本支持良好,但个别老项目需要改成varying风格;三是 Material 的 setProperty 传参格式在 2.4 中是通过setProperty(name, value)访问,参数类型和 3.x 也有细节区别。遇到问题时,最可靠的方法是打开引擎自带的光照示例 effect 文件对标一下。
6. 最后聊两句调试习惯
做 Shader 调试,我最推荐的方法是"分层可视化"。拿到一个新 Shader,先把最终颜色输出拆成几段临时返回值,一段只看遮罩,一段只看条纹,一段只看辉光,确认各自正常后再合成。比如我把 frag 里return vec4(col, 1.0)临时改成return vec4(vec3(ballMask), 1.0),就能直接看到球体遮罩长什么样。这一步能节省大量排查时间。
还有一个藏在细节里的习惯:Shder 调亮度过高导致看不清层次时,别急着改参数,先把整体颜色除以一个常量,比如col *= 0.25,看清楚结构后再恢复。一旦习惯了这种分段验证的方式,你会发现 Shader 调试比想象中容易得多,光球流光这种级别的问题,几乎都能在半小时内解决。