之前做项目里的战斗技能时,碰到了一个挺常见又挺烦的需求:要给角色脚下的召唤阵法加一点动态效果。美术那边最开始给的方案是一套序列帧,特效同学做了一个月的8方向循环播放,结果包体大了不少,真机上帧率还波动。后来我换了个思路,直接在Cocos Creator里用shader程序化生成魔法阵,效果比序列帧更灵活,体积却只有几KB。这篇把整个过程整理出来,包括效果拆解、shader怎么写、参数怎么调,以及我踩过的几个坑。如果你正在用Cocos做技能特效、UI特效,或者单纯对程序化shader感兴趣,这篇应该会有点用。
1. 做一个魔法阵之前:先把视觉拆成数学语言
1.1 一个魔法阵拆开看,其实只有四五个元素
魔法阵不是没有规律的东西。拿一个典型的召唤阵来说,通常会有这几层:
- 外环:限定整个法阵的边界,视觉上是最大的一圈;
- 内环:比外环小一圈,稳定内部节奏;
- 符文或纹路:在中间环带上周期性排布,是魔法阵最有辨识度的部分;
- 中心光晕:从圆心向外扩散的光团,是能量聚集点;
- 整体动态:最常见的是匀速旋转、内外反旋、闪烁或随音效脉冲。
从程序角度看,所有元素都可以用两个基础量表达:一个像素离圆心的距离dist,一个像素相对圆心的旋转角度angle。几乎所有魔法阵shader都是在这两个量上做组合。
这里我多说一句为什么把“拆视觉”放在第一步。做shader和做UI不一样,UI是拼图,而shader没有现成的图,得用数学把一个矩形画布划分出来。你要是没想清楚最终画面由什么构成,代码很容易写成一大坨if嵌套,后续想改一个环的半径都得翻半天。
1.2 为什么选shader而不是序列帧
很多团队一开始都会想到序列帧,美术制作也快,往场景里一放就行。但序列帧有几个实际问题:
- 一个2秒、24帧循环的魔法阵序列帧,真机上一张张解码,内存和I/O压力不小;
- 放大缩小、换颜色比较困难,美术得重新出图;
- 想响应实时参数,比如技能爆发时法阵颜色变红,基本做不到。
而shader方案是让GPU在每帧直接画出画面,所有图形来自数学计算或纹理采样,没有序列帧的存储成本。运行时只要改u_speed、u_mainColor、u_glow这几个值,法阵的转速、颜色、辉光就会立刻变化。技能触发瞬间让参数突变,视觉冲击直接上一个台阶。对于包体敏感的项目,这个优势很实在。
| 对比项 | 序列帧 | Shader方案 |
|---|---|---|
| 内存占用 | 高,取决于张数和分辨率 | 极低,只一个effect文件 |
| 动画控制 | 预设帧,无法实时改 | 任意参数动态驱动 |
| 换色/换光效 | 重新出图 | 改一个uniform |
| 性能开销 | 解码+纹理采样 | 几个数学函数 |
| 制作门槛 | 美术套模板 | 需要懂shader原理 |
1.3 两个落地方案:程序化图案与贴图叠加
纯程序化适合做简单清晰、风格统一的法阵,优点是一个effect文件走天下,参数驱动灵活,缺点是太复杂的不对称符文很难用数学表达。
贴图叠加适合做角色定制的复杂高级法阵。流程是shader里先用圆环mask确定展示区域,再采样一张符文贴图,两者相乘。shader负责旋转、光晕和颜色,图案交给美术,这是一个非常成熟的协作方式。我下面先讲纯程序化方式,因为能把shader原理讲透,最后再讲扩展贴图。
2. 环境准备与最小场景搭建
2.1 用到的引擎版本与素材
我这边用的是Cocos Creator 3.8,3.6以上的版本effect语法基本通用。你只需要准备:
- 一个空场景,场景里放一个带Sprite组件的节点;
- 一张任意的纯白精灵图,8x8像素足够,项目内置的白图也行。
为什么强调纯白图?因为shader需要把UV范围内的图像当作方形画布来算,白图本身不携带图案信息,不会干扰后面的颜色乘法。Sprite组件也必须有一个SpriteFrame才能渲染出网格,否则节点虽然存在但屏幕上什么都没有。
2.2 创建Effect文件和材质的完整路径
在Cocos Creator 3.x里新建一个自定义shader的路径是:
- 在资源管理器Assets面板空白处右键,选择Create -> Shader -> Effect;
- 给文件命名,比如MagicCircle;
- 在编辑器中打开这个effect文件,粘贴我后面给出的完整代码;
- 保存后看控制台,确认没有编译报错;
- 右键新建一个Material材质资源;
- 在材质Inspector面板的Effect字段里选择刚才创建的MagicCircle;
- 把材质拖到场景中Sprite组件的Material槽里。
如果右键菜单找不到Effect入口,检查一下是不是点在了文件上而不是文件夹空白处。不同3.x小版本菜单名称略有差异,但都在Shader这个分类下。
2.3 一个容易忽略的坑:宽高比和坐标方向
魔法阵shader默认把UV当成边长1的正方形来算。如果Sprite节点宽高设置成512x256,画出来的“圆环”就已经是椭圆了。最省事的解决方法是让Sprite宽高保持等比,不要单独拉伸。
另一个是坐标方向。在Cocos渲染管线里,如果走的是UI相机流程,v_uv的y轴方向跟传统UV不一定一致。最直接的验证方法,是临时让shader输出一个uv值当颜色,看一眼屏幕上的渐变方向。我个人的习惯是先在shader里做一次v_uv.y = 1.0 - v_uv.y,如果项目里旋转方向反了,再改成取负角度。
3. 魔法阵Shader核心实现拆解
3.1 距离场:shader里画圆环的基础
写魔法阵shader的第一步,是建立UV坐标系。片段着色器拿到的v_uv是0到1的二维坐标,直接画圆是以某个中心为基准算距离,所以先做两件事:
vec2 uv = v_uv - vec2(0.5, 0.5); float dist = length(uv); float angle = atan(uv.y, uv.x);uv减去(0.5,0.5)后,矩形画布的坐标原点移到了中心。dist就是当前像素到圆心的距离,正方形对角线长度是√2/2约等于0.707,所以dist的取值范围大致是0到0.707。angle是当前像素绕圆心的角度,范围是-π到π,这是做符文旋转的基础。
画圆环最直觉的做法是判断dist落在哪个区间:
float ring = step(a, dist) * (1.0 - step(b, dist));但step函数做出来的环边缘会很硬,所以在实际项目里我不用step,改用smoothstep写一个通用圆环函数:
float ringMask(float dist, float radius, float width, float glow) { float d = abs(dist - radius); return 1.0 - smoothstep(width, width + glow, d); }smoothstep(a, b, x)的含义是x在a到b之间从0平滑过渡到1。abs(dist - radius)表示当前像素到目标圆的距离,向圆心内侧和外侧都算。距离小于width时完全可见,在width到width+glow之间渐渐消失。这样做的好处是环的边缘自带一圈柔光,不生硬。
有人会问为什么用abs(dist - radius)而不是区间判断dist > r && dist < r + w。因为用abs写法只用一行就能把环的内外边缘同时算完,而且改半径不影响环宽,改环宽不影响半径,参数互不干扰。
3.2 外环、内环与中心光晕
有了距离场和圆环函数,外环和内环就是两行调用:
float outer = ringMask(dist, u_outerRadius, u_outerWidth, u_glow); float inner = ringMask(dist, u_innerRadius, u_innerWidth, u_glow);这里u_outerRadius控制外环中心半径,u_outerWidth控制环本身宽度,u_glow是柔光范围。三个参数分开调,视觉上就能很自由地组合出不同法阵风格。
中心光晕的做法和环不一样。中心不需要环状的里外边缘,只需要从圆心向外的渐变衰减:
float core = 1.0 - smoothstep(0.0, u_innerRadius * 0.6, dist);从圆心到内环半径的0.6倍处,光晕从1平滑衰减到0。这样魔法阵中心看起来会有一个柔光团,不会像硬切出来的圆那么呆板。
3.3 符文与旋转:用角度函数生成周期性装饰
符文是最考设计感的部分。从数学上看,符文是一段在圆周上周期性出现的图案。Cocos里有个全局时间变量cc_time.x,可以直接在片段着色器里读取,这就是旋转动画的时间来源。
float t = cc_time.x * u_timeScale; float seg = 6.2831853 / u_runeCount; float localAng = mod(angle + t * u_speed, seg); float runeShape = 1.0 - smoothstep(u_runeWidth * 4.0, u_runeWidth * 4.0 + u_glow, abs(localAng - seg / 2.0));这段代码做的事情是:
- 把整个圆2π弧度平均切分成u_runeCount段,每段角度是seg;
- 用mod把当前角度折叠到所在分段内,localAng范围就是0到seg;
- localAng减去seg/2,把每段中心移到0位置;
- smoothstep控制符文两端软边,abs(localAng - seg/2)越大说明当前像素越偏离分段中心,透明度越低。
最后还要让符文限制在特定半径环带上,不然符号会延伸到整张画布:
float runeRing = ringMask(dist, u_runeRadius, u_runeWidth, u_glow); float rune = runeShape * runeRing;角度上的符文形状乘以半径上的圆环mask,一个符文环就成功了。
这里有个参数换算的经验:当u_runeCount取12、u_runeRadius取0.35时,每个符文在圆周上的理论宽度是2π×0.35/12≈0.183格。u_runeWidth取0.015,再乘以4等于0.06,只是0.183的三分之一,整体看起来是细短线。想让符文更夸张,把u_runeWidth改成0.03,或者把u_runeCount改成8,效果立刻不一样。
3.4 颜色混合与最终输出
整个片段着色器的最后一步,是把前面所有mask乘以颜色再叠加:
vec3 col = vec3(0.0); col += u_mainColor.rgb * (outer + inner * 0.8 + rune); col += u_coreColor.rgb * core; float alpha = clamp(outer + inner + rune + core, 0.0, 1.0); gl_FragColor = vec4(col, alpha * v_color.a);这里有个小细节:内环的亮度我特意乘了0.8,不让它和外环一样强,给法阵增加一点层次感。实际项目中这两个环的强弱关系可以随意调。
alpha为什么单独计算而不是直接取col.a?因为发光效果会把颜色叠加到超过1,如果直接拿col的亮度当alpha,发光区域会变成半透明,而魔法阵应该是一个实心图案+外边缘光晕。用clamp把可见区域压到0到1之间,再乘以v_color.a,这样节点透明度还能继续控制,材质不需要额外改。
3.5 完整Effect文件参考
下面是我整理过的完整effect文件。代码基于Cocos Creator 3.8的effect语法,3.6以上版本可以直接用,老版本注意include路径差异。
CCEffect %{ techniques: - passes: - vert: vs frag: fs blendState: targets: - blend: true blendSrc: src_alpha blendDst: one blendSrcAlpha: one blendDstAlpha: one rasterizerState: cullMode: none properties: u_mainColor: { value: [0.85, 0.6, 1.0, 1.0] } u_coreColor: { value: [0.2, 0.05, 0.55, 1.0] } u_outerRadius: { value: 0.46 } u_outerWidth: { value: 0.03 } u_innerRadius: { value: 0.26 } u_innerWidth: { value: 0.012 } u_runeRadius: { value: 0.35 } u_runeWidth: { value: 0.015 } u_runeCount: { value: 12.0 } u_speed: { value: 0.5 } u_glow: { value: 0.12 } u_timeScale: { value: 1.0 } }% CCProgram vs %{ precision highp float; #include <builtin/uniforms/cc-global> in vec3 a_position; in vec4 a_color; in vec2 a_texCoord; out vec4 v_color; out vec2 v_uv; void main() { v_color = a_color; v_uv = a_texCoord; gl_Position = cc_matViewProj * vec4(a_position, 1.0); } }% CCProgram fs %{ precision highp float; #include <builtin/uniforms/cc-global> in vec4 v_color; in vec2 v_uv; uniform MagicCircle { vec4 u_mainColor; vec4 u_coreColor; float u_outerRadius; float u_outerWidth; float u_innerRadius; float u_innerWidth; float u_runeRadius; float u_runeWidth; float u_runeCount; float u_speed; float u_glow; float u_timeScale; }; float ringMask(float dist, float radius, float width, float glow) { float d = abs(dist - radius); return 1.0 - smoothstep(width, width + glow, d); } void main() { vec2 uv = v_uv - vec2(0.5, 0.5); float dist = length(uv); float angle = atan(uv.y, uv.x); float t = cc_time.x * u_timeScale; float outer = ringMask(dist, u_outerRadius, u_outerWidth, u_glow); float inner = ringMask(dist, u_innerRadius, u_innerWidth, u_glow); float seg = 6.2831853 / u_runeCount; float localAng = mod(angle + t * u_speed, seg); float runeShape = 1.0 - smoothstep(u_runeWidth * 4.0, u_runeWidth * 4.0 + u_glow, abs(localAng - seg / 2.0)); float runeRing = ringMask(dist, u_runeRadius, u_runeWidth, u_glow); float rune = runeShape * runeRing; float core = 1.0 - smoothstep(0.0, u_innerRadius * 0.6, dist); vec3 col = vec3(0.0); col += u_mainColor.rgb * (outer + inner * 0.8 + rune); col += u_coreColor.rgb * core; float alpha = clamp(outer + inner + rune + core, 0.0, 1.0); gl_FragColor = vec4(col, alpha * v_color.a); } }%可以看到这段代码刻意没用纹理采样,所以不需要声明sampler2D。后面要叠加符文贴图时再加texture属性和对应的include即可。
4. 实操过程:从素材到跑起来
4.1 把effect挂到Sprite上的流程
按前面说的创建好effect和材质后,实际操作一遍会更直观。
我通常先创建一个空节点,挂上Sprite组件,把一张纯白SpriteFrame赋给Sprite的SpriteFrame槽。这时场景里会显示一个白色方块。然后把材质从Assets面板拖到Sprite组件的Custom Material槽里,法阵就会立即用shader画出来。如果材质是灰的或没有生效,优先检查Inspector里Effect字段是不是选对了,再检查控制台有没有shader编译错误。
一个容易踩的细节是:Cocos导出或预览时default材质和新材质容易混用。如果你的节点之前挂过其他材质,拖新材质上去后要确认Custom Material槽里显示的不是空引用。这个我反复遇到过好几次。
4.2 关键参数怎么调才好看
材质Inspector面板里能看到effect声明的所有属性,实时调整很有用。我整理一张常用参数参照表:
| 参数 | 控制内容 | 推荐区间 | 调整建议 |
|---|---|---|---|
| u_mainColor | 外环/内环/符文主色 | 根据角色配色 | 发紫或发蓝都比较安全 |
| u_coreColor | 中心光晕色 | 比主色深 | 用主色的亮度减半版本 |
| u_outerRadius | 外环半径 | 0.40~0.48 | 离0.5太近会碰到画布边缘 |
| u_outerWidth | 外环粗细 | 0.02~0.04 | 太细在手机上会闪 |
| u_innerRadius | 内环半径 | 0.20~0.30 | 与外环保持距离感 |
| u_innerWidth | 内环粗细 | 0.008~0.02 | 内环通常是细线定位 |
| u_runeRadius | 符文环半径 | 0.30~0.40 | 取内外环平均最匀称 |
| u_runeWidth | 符文长度 | 0.01~0.03 | 值越大符文越长 |
| u_runeCount | 符文数量 | 8~16 | 奇数会有不对称美感 |
| u_speed | 旋转速度 | 0.3~2.0 | 负数控制反向旋转 |
| u_glow | 边缘柔光 | 0.02~0.2 | 0时锯齿会明显 |
注意u_outerRadius离0.5太近会碰到画布边缘,因为uv画布最大半径只有0.707,但圆形要和画布边距至少留出u_outerWidth一半以上才安全。u_runeRadius要跟外环、内环保持适当距离,避免符文和环粘成一团。经验上符文环的半径取外环和内环的平均值,视觉效果最匀称。
如果想要内外环反向旋转,可以简单地把内环的旋转项写成angle - t * u_speed * 0.6,也就是在计算localAng时对内外环用不同正负参数。不过完整代码里我没拆开这个逻辑,实际操作时可以根据需要再增加一个u_innerSpeed。
4.3 运行时动态控制参数
Cocos Creator 3.x里,运行时修改材质参数的常规做法是获取材质实例:
const mat = this.sprite.getMaterialInstance(0); if (mat) { mat.setProperty('u_speed', 2.0); mat.setProperty('u_glow', 0.3); }getMaterialInstance(0)拿到的是这个Sprite节点自己的材质实例,不会污染其他使用同一材质的节点。如果直接改material对象的setProperty,多个节点会串参数,这个要注意。
我做过一个比较满意的技能效果:按下技能键时用Tween把u_glow从0.1拉到0.4,同时u_speed从0.5提到2.0,持续0.3秒再回到正常值。由于shader的帧间计算完全在GPU上,CPU每帧只需要改两个数字,不会增加任何DrawCall。这种实时控制是序列帧完全做不到的。
5. 常见问题与排查实录
5.1 常见表现速查表
| 现象 | 排查方向 |
|---|---|
| 全黑 | 材质Effect没生效、shader编译失败、uniform未接收 |
| 全白 | 混合模式选错,加法混合叠加到过曝 |
| 圆形变椭圆 | Sprite节点宽高不等比 |
| 符文不旋转 | 没读cc_time、include路径不对、时间被锁帧 |
| 旋转方向反了 | 取反v_uv.y或把u_speed设负数 |
| 边缘锯齿严重 | u_glow设了0,或软边范围太小 |
| 低端机花屏 | 用了高精度运算+大量transcendent函数,或材质实例过多 |
5.2 为什么全黑或者全白
全黑最常见的原因是材质还没选对effect。创建材质后,Inspector面板的Effect字段默认可能是空白,必须手动选择。另外Cocos自定义effect里的properties如果没给value默认值,某些版本下uniform可能不会正常初始化,所以代码里每个属性我都写了默认值。
全白则是混合模式问题。我给的effect使用加法混合blendDst: one,适合魔法阵叠加在场景上的发光效果。如果用在普通UI上,加法混合会跟后面的背景叠加变亮,甚至过曝,这时改成blendDst: one_minus_src_alpha。
5.3 魔法阵变成椭圆
这个几乎都是节点宽高不相等引起的。shader计算时uv范围被拉伸到节点实际宽高,正方形UV被拉成了矩形,圆自然就成椭圆。
我踩过最隐蔽的一次是:节点本身是正方形,但父节点加了缩放导致显示比例变化。所以排查时不仅看目标节点,还要看整棵节点树的scale有没有被拉伸。如果必须用非等比节点显示,就需要在shader里额外传入u_aspect参数,用宽高比修正uv坐标。
5.4 旋转方向反了
Cocos的Sprite渲染里,v_uv的y方向跟平台纹理坐标协商有关。如果你的法阵符文旋转方向跟预期相反,直接在fragment shader第二行写v_uv.y = 1.0 - v_uv.y;,或者把angle取负:angle = -atan(uv.y, uv.x);。两个办法效果一样,选哪个取决于你后续还要不要在uv基础上采样贴图。
5.5 移动端性能和兼容性
纯数学生成魔法阵的性能开销很低,整个fs只有几个smoothstep、mod和atan。一般来说老旧手机也没有压力。需要注意三点:
- avoid循环。千万不要为了画符文写for循环展开,GPU擅长并行不擅长循环,循环次数一多编译会非常慢;
- 不要写if分支依赖非uniform值,会打断GPU的并行渲染;
- 移动端shader里不要用太大的浮点精度,precision highp够用就别再往上加。
如果想要更复杂的不规则符文图案,纯数学就不划算了,建议用贴图采样。
贴图叠加的关键代码片段是这样的:
float pattern = texture(texture, uv * 2.0 + vec2(cos(t), sin(t)) * 0.1).r; float finalMask = ringMask(dist, u_runeRadius, u_runeWidth, u_glow) * pattern;贴图采样的pattern值乘到圆环mask上,符文就能有非常复杂的造型。采样时用uv乘以系数可以控制图案密度,加上随时间变化的偏移量还能让符文有流动感。这属于程序化和贴图结合的中间方案,既能保留shader的实时性,又能利用美术资源表达复杂细节。
最后分享一点个人体会
做cocos魔法阵shader这件事,最难的不是加一行函数,而是形成“把视觉拆成距离和角度”的思维习惯。我第一次做的时候,上来就盯着外环怎么画,结果代码改来改去都不对。后来老老实实把法阵拆成外环、内环、符文、光晕四层,每个mask独立算、独立调,再组合到一起,顿时就顺了。
另一个体会是:自定义shader一定要留一个“debug模式”。我平时会在shader里加一个u_debug开关,值为1时直接输出dist或者angle的灰度图,这样能快速判断当前uv坐标系到底对不对。这个习惯帮我省了大量猜疑时间。
如果后面还想扩展,魔法阵也不是只能做静态旋转。让u_outerRadius做曲线动画就能实现召唤时展开、结束时缩小的过程;让u_coreColor随着角色血量变化,就能做出功能性的反馈法阵。这些扩展都只需要改几个参数,shader代码一行不用动。