《Cocos 游戏开发高级编程》3.2 改造内置着色器

上一节读了 Effect 语法,这一节动真格:把内置 sprite 着色器复制出来,改出一条描边。本节给出改前与改后的完整可读片段,逐段解释 texel 计算、邻域采样与 alpha 判据,再给出可编译的材质装载代码,并说清五次采样的性能代价与合批影响。

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 工程决策

改造内置着色器的判断链,按顺序问自己:

  1. **内置有没有属性/宏能直接做到?**有就用,绝不复制。灰度、染色、混合模式都属这一类。
  2. **需要新采样或新数学吗?**要,就复制 Effect 出来改,并接受「从此自己维护这份 GLSL」。
  3. **这个效果是全场统一的,还是个别对象?**统一就用一份共享材质;个别对象才每实例一份材质。
  4. **它在帧预算里占多少?**沿用卷二 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 后处理与全屏特效 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画