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

资讯详情

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

用Cocos Creator Shader打造光球流光效果:从UV坐标到加色混合的完整拆解

用Cocos Creator Shader打造光球流光效果:从UV坐标到加色混合的完整拆解

做游戏特效这几年,我越来越觉得,很多看起来高大上的效果,其实都是几个数学函数叠出来的。就拿这次要聊的“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 面板右键创建材质:

  1. 在 Assets 窗口右键Create -> Material,命名LightBallMat。
  2. 点击材质,在右侧 Inspector 的Effect属性里拖入刚才创建的light-balleffect。
  3. 展开Uniforms列表,能看到centerColor、edgeColor、flowColor等属性,直接调颜色即可。
  4. 把材质拖到场景中一个 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.0ADD 混合下太亮会过曝
边缘辉光系数球体外圈光晕强度0.3 ~ 0.6太高会看起来像雾气

调参有一条铁律:一次只改一个参数。很多人对着 Shader 一顿乱调,颜色却又灰又糊,最后根本不知道哪个参数导致的。先固定速度和颜色,把密度调到自己想要的数量感;再动速度,找到“流动感”最好的区间;最后微调颜色,因为加色混合下颜色对整体观感的影响最大。

5. 实战中容易踩的坑和排查思路

5.1 坑 1:刚写好 effect,画面一片白

这个 90% 是 Shader 编译失败或者混合模式全开导致。排查顺序:

  1. 打开 Console 面板看有没有shader compile error之类的日志,有错先解决语法。
  2. 找到材质,把混合模式临时改成普通不透明,确认 Shader 本身能出图。
  3. 如果还是一团白,把输出颜色固定成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 后效果消失

这个问题在真机上比编辑器里更容易暴露。常见的诱发因素有三个:

  1. 使用了discard。部分移动 GPU 对discard的处理效率低,极端情况下会出现整片区域渲染异常。我的代码里可以删掉discard,替代方案是让 mask 乘进最终颜色。
  2. 精度问题。默认写了highp float,在部分旧 GPU 上支持不好,可以改成mediump验证。大多数 2D 光球效果用mediump足够。
  3. 编译失败。真机 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 方向行走动画的朝向切换,本质上都是“时间驱动状态”的变体,只是换了一个载体而已。

返回列表