拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Cocos Creator 3.x 光球流光 Shader 手写教程:从原理拆解到工程落地

Cocos Creator 3.x 光球流光 Shader 手写教程:从原理拆解到工程落地

光球流光这种效果,在卡牌大招、技能爆发、抽卡转场里几乎天天见。很多项目第一反应是做序列帧,但换一套配色就要重新出图,改个速度还要反复调动画。我自己的习惯是直接上 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

代码写完还不能立刻看到效果,需要走一遍资产流程,步骤其实很快:

  1. 在资源管理器里新建 Effect,把上面的代码粘贴进去。
  2. 新建 Material,在 Effect 属性里选择 LightBallFlow。
  3. 场景里新建一个 Sprite,把 SpriteFrame 设成任意纯白图(没有的话可以用内置默认 SpriteFrame)。
  4. 选择 Sprite 节点,在 Sprite 组件的 Custom Material 中挂上刚才创建的材质。

建议场景背景放一张深色图,黑色或深蓝色都行。因为加色混合在浅色背景上会泛白,看不清层次。如果手头没有纯白图,可以先用一个 1x1 的白色 PNG,效果完全一样。

3. 关键参数与调优:让效果真正适配你的项目

3.1 每个参数的调节思路和三档手感

我把材质 Inspector 面板里的参数全部列成一张速查表,这是我在项目里调特效时最常参照的东西。别小看这张表,很多新手拿到代码乱调一通,最后效果不对,其实是参数之间互相影响。

参数范围建议手感说明
center.x / center.y0.35 ~ 0.65球心位置,配合图集裁切时使用
radiusSoft.x0.35 ~ 0.48半径越大球越大,超过 0.5 会超出纹理边界
radiusSoft.y0.02 ~ 0.12柔化值越小边越硬,越大越像气态光团
stripeParams.x0.3 ~ 1.0流光滚动速度,攻击演出前段建议偏快
stripeParams.y3 ~ 8条纹密度,数字越大条数越多
stripeParams.z4 ~ 12条纹粗细,数字越大条纹越细越锐利
stripeParams.w0 ~ 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 调试比想象中容易得多,光球流光这种级别的问题,几乎都能在半小时内解决。

返回列表