3.2 改造内置着色器
上一节把 Effect 的语法读透了。这一节动真格:拿内置的 builtin-sprite.effect 开刀,改出一条描边。这是最常见的「改造内置着色器」需求——UI 选中态、角色受击高亮、卡牌稀有度外发光,本质都是同一件事。
改造分两个层次,这一节两条都会走到:能用宏解决的绝不写 GLSL(比如灰度),宏解决不了的才复制出来改(比如描边)。
3.2.1 第一步:把内置 Effect 复制出来
内置 2D 精灵着色器在 editor/assets/effects/for2d/builtin-sprite.effect(tag 3.8.8)。它位于引擎的只读资产目录,绝对不能直接改——一升级引擎就没了,而且改坏了很难定位。
正确姿势:把它复制到工程的 assets/effects/ 下,改个名,比如 sprite-outline.effect。复制过来的是完整的两半(CCEffect + 两个 CCProgram),改完后它就是工程自己的资产,和引擎版本解耦。落盘后的目录结构(沿用卷二工程设定)是:
assets/effects/sprite-outline.effect # 自定义 Effect
assets/materials/sprite-outline.mtl # 引用它的材质(编辑器生成)
assets/scripts/game/SpriteOutline.ts # 装载与调参的组件
3.2.2 改前:内置 sprite 的片元程序
先把要改的对象读清楚。内置 sprite-fs 的片元程序如下(editor/assets/effects/for2d/builtin-sprite.effect,tag 3.8.8):
CCProgram sprite-fs %{
precision highp float;
#include <builtin/internal/embedded-alpha>
#include <builtin/internal/alpha-test>
in vec4 color;
#if USE_TEXTURE
in vec2 uv0;
#pragma builtin(local)
layout(set = 2, binding = 12) uniform sampler2D cc_spriteTexture;
#endif
vec4 frag () {
vec4 o = vec4(1, 1, 1, 1);
#if USE_TEXTURE
o *= CCSampleWithAlphaSeparated(cc_spriteTexture, uv0);
#if IS_GRAY
float gray = 0.2126 * o.r + 0.7152 * o.g + 0.0722 * o.b;
o.r = o.g = o.b = gray;
#endif
#endif
o *= color;
ALPHA_TEST(o);
return o;
}
}%
逐段读:
in vec4 color是从顶点程序传下来的顶点色,对Sprite来说就是组件的颜色与透明度,所以改sprite.color能影响最终颜色。o *= CCSampleWithAlphaSeparated(cc_spriteTexture, uv0)取精灵纹理的颜色,CCSampleWithAlphaSeparated会处理内嵌 alpha 的图集。o *= color把顶点色乘进去。注意乘法顺序:先纹理后顶点色,最终o = 纹理色 × 顶点色。ALPHA_TEST(o)在宏打开时按alphaThreshold裁边。
问题在于:它只采样了 uv0 这一个点。要画描边,就得看「当前像素周围有没有不透明像素」——这需要额外采样。这正是改造要加的东西。
3.2.3 改后:加描边的完整 Effect
下面是改造后的完整文件。.effect 由引擎的 Effect 编译器处理,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。先看 CCEffect 段——比内置多了四个属性:
CCEffect %{
techniques:
- passes:
- vert: sprite-vs:vert
frag: sprite-outline-fs:frag
depthStencilState:
depthTest: false
depthWrite: false
blendState:
targets:
- blend: true
blendSrc: src_alpha
blendDst: one_minus_src_alpha
blendDstAlpha: one_minus_src_alpha
rasterizerState:
cullMode: none
properties:
outlineColor: { value: [1.0, 1.0, 1.0, 1.0] }
outlineWidth: { value: 1.0 }
alphaThreshold: { value: 0.1 }
texSize: { value: [512.0, 512.0] }
}%
顶点程序几乎照抄内置,只删掉了 2D 用不到的像素对齐分支;片元程序才是改动的重点:
CCProgram sprite-vs %{
precision highp float;
#include <builtin/uniforms/cc-global>
#if USE_LOCAL
#include <builtin/uniforms/cc-local>
#endif
in vec3 a_position;
in vec2 a_texCoord;
in vec4 a_color;
out vec4 color;
out vec2 uv0;
vec4 vert () {
vec4 pos = vec4(a_position, 1);
#if USE_LOCAL
pos = cc_matWorld * pos;
#endif
pos = cc_matViewProj * pos;
uv0 = a_texCoord;
color = a_color;
return pos;
}
}%
CCProgram sprite-outline-fs %{
precision highp float;
#include <builtin/internal/embedded-alpha>
in vec4 color;
in vec2 uv0;
#pragma builtin(local)
layout(set = 2, binding = 12) uniform sampler2D cc_spriteTexture;
uniform OutlineData {
vec4 outlineColor;
float outlineWidth;
float alphaThreshold;
vec2 texSize;
};
vec4 frag () {
vec4 tex = CCSampleWithAlphaSeparated(cc_spriteTexture, uv0);
vec2 step = outlineWidth / texSize;
float aL = CCSampleWithAlphaSeparated(cc_spriteTexture, uv0 - vec2(step.x, 0.0)).a;
float aR = CCSampleWithAlphaSeparated(cc_spriteTexture, uv0 + vec2(step.x, 0.0)).a;
float aD = CCSampleWithAlphaSeparated(cc_spriteTexture, uv0 - vec2(0.0, step.y)).a;
float aU = CCSampleWithAlphaSeparated(cc_spriteTexture, uv0 + vec2(0.0, step.y)).a;
float neighbor = max(max(aL, aR), max(aD, aU));
if (tex.a < alphaThreshold && neighbor >= alphaThreshold) {
tex = vec4(outlineColor.rgb, outlineColor.a * neighbor);
}
tex *= color;
return tex;
}
}%
3.2.4 描边算法逐段解释
改动集中在 frag() 里,逐行拆:
vec2 step = outlineWidth / texSize;:把「描边宽度(像素)」换算成「UV 步长」。texSize是纹理的像素尺寸,由脚本传进来(见 3.2.6)。这一步是描边宽度的关键——直接用固定 UV 偏移会让不同尺寸的精灵描边粗细不一。- 四邻域采样:分别取左(
-x)、右(+x)、下(-y)、上(+y)四个方向的 alpha。.a只取透明度通道,因为描边只关心「这里是不是实心」。 float neighbor = max(max(aL, aR), max(aD, aU));:取四者最大值——只要任意一个方向是实心的,neighbor就接近 1。- 判据
tex.a < alphaThreshold && neighbor >= alphaThreshold:当前像素透明、但邻域不透明,说明它落在图形的外轮廓上,于是填成描边色。两个条件缺一不可:只判neighbor会把图形内部也涂满。 outlineColor.a * neighbor:用邻域的 alpha 当描边透明度,边缘更平滑。
为什么用 alpha 判据而不是颜色判据?因为 2D 精灵的「形状」是由透明通道定义的,颜色可能千变万化(受顶点色、灰度宏影响),只有 alpha 是稳定的几何信号。
3.2.5 为什么选描边而不是改灰度
改造内置有两条路,选哪条取决于「内置有没有现成的钩子」:
| 需求 | 做法 | 要不要动 GLSL |
|---|---|---|
| 精灵变灰 | 材质设 defines: { IS_GRAY: true } | 不用,内置已用宏实现 |
| 精灵整体染色 | sprite.color 或 Material.setProperty 改 tint | 不用,内置属性已够 |
| 加描边 / 外发光 | 复制 Effect 加邻域采样 | 要,内置没有这个逻辑 |
| 改混合模式 | 材质 states 覆盖 blendState | 不用,改管线状态即可 |
第一行要特别记住:builtin-sprite.effect 里那段 #if IS_GRAY 就是给灰度准备的。改造的第一原则是「先找内置钩子,钩子不够再复制」——复制意味着从此要自己维护这份 GLSL,引擎升级带来的修复也不会自动同步。
3.2.6 L2:装载自定义材质并传纹理尺寸
下面这段可在 @cocos/creator-types@3.8.8 下通过 tsc --noEmit(L2)。它装载 3.2.3 的自定义 Effect,并把精灵纹理尺寸传给 texSize:
import { _decorator, Color, Component, Material, Sprite, Vec2 } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('SpriteOutline')
export class SpriteOutline extends Component {
@property
outlineWidth = 1.0;
@property(Color)
outlineColor = new Color(255, 255, 255, 255);
start(): void {
const sprite = this.getComponent(Sprite);
if (!sprite) return;
// effectName 就是 .effect 的文件名(不含扩展名)
const mat = new Material();
mat.initialize({ effectName: 'sprite-outline' });
sprite.customMaterial = mat;
mat.setProperty('outlineWidth', this.outlineWidth);
mat.setProperty('outlineColor', this.outlineColor);
// 把纹理像素尺寸喂给着色器,用于换算 UV 步长
const frame = sprite.spriteFrame;
if (frame) {
mat.setProperty('texSize', new Vec2(frame.rect.width, frame.rect.height));
}
}
}
两个细节:effectName 是 .effect 文件名去掉扩展名;texSize 必须用纹理实际像素尺寸(SpriteFrame.rect 的宽高),而不是节点尺寸——描边宽度的单位是「纹理像素」。
3.2.7 性能代价与边界
描边不是免费的。它把一次纹理采样变成了五次(1 次中心 + 4 次邻域),并且引入了一次分支。三笔账:
- 采样次数 ×5:在低端移动 GPU 上,纹理采样往往是片元着色器的主要开销。全屏铺满描边精灵时,这笔开销会直接反映在帧时间上。
- 破坏合批:用了自定义材质的精灵,与用内置材质的精灵不在同一批次,会产生额外的 Draw Call(机制见卷二 13.1 与本书 2.3)。如果一屏几十个精灵都各挂一份材质实例,Draw Call 会迅速上去。
- 必须留边:描边是往「图形外」画的,而精灵的绘制矩形恰好贴着不透明区域时,外面根本没有像素可采,描边会被裁掉。解决办法是给纹理留出透明 padding(比如四周各留 4 像素),或让精灵的 mesh 比图形大一圈。
还有一条容易踩的坑:如果一屏里有大量同类描边精灵,让它们共享同一个材质实例(而不是每个 new Material()),这样至少材质一致、有可能合批。只有需要各自不同描边颜色时才分裂材质。
3.2.8 工程决策
改造内置着色器的判断链,按顺序问自己:
- **内置有没有属性/宏能直接做到?**有就用,绝不复制。灰度、染色、混合模式都属这一类。
- **需要新采样或新数学吗?**要,就复制 Effect 出来改,并接受「从此自己维护这份 GLSL」。
- **这个效果是全场统一的,还是个别对象?**统一就用一份共享材质;个别对象才每实例一份材质。
- **它在帧预算里占多少?**沿用卷二 13.3 的帧预算(60 FPS → 16.6 ms/帧)。描边、发光这类全屏大量出现的后处理式效果,要用真机复测,别只看编辑器里流畅。
不该用的场合:纯 UI 高亮优先考虑换一张带描边的图集,或叠一个放大一像素的深色底图——它们的成本是零着色器开销,只有在「描边颜色/宽度要动态变」时,自定义着色器才真正划算。
这一节我们走完了一次完整的「改前 / 改后」改造:复制内置、加邻域采样、传纹理尺寸、装载材质。下一节把镜头从「单个精灵」拉远到「整帧」——后处理与全屏特效,那里采样的不再是精灵纹理,而是整个屏幕。
3.2.9 本节自检清单
- 知道改内置前要复制到
assets/effects/,不动引擎资产 - 能说清
CCSampleWithAlphaSeparated与ALPHA_TEST的来源 chunk - 明白描边宽度用「纹理像素 ÷ 纹理尺寸」换算 UV 步长
- 知道用 alpha 而不是颜色判断图形轮廓
- 记得给纹理留透明 padding,否则描边被裁
- 同类描边精灵尽量共享材质实例,避免 Draw Call 上升
阅读导航:上一节:3.1 Cocos Effect 语法与编译 · 下一节:3.3 后处理与全屏特效 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。