《Cocos 游戏开发高级编程》3.1 Cocos Effect 语法与编译

Effect 文件是 Cocos 渲染定制的入口。本节拆开它的两半——描述管线与属性的 CCEffect 块、写 GLSL 的 CCProgram 块,逐段解读内置 sprite 着色器,讲清从 Effect 源码到 EffectAsset、Material、Pass 与 Shader 变体的编译链路,并给出可编译的材质初始化与宏切换代码。

3.1 Cocos Effect 语法与编译

第 2 章看的是「渲染管线怎么组织」,从这一节起看管线里每个 draw 到底怎么着色。在 Cocos 里,着色的描述单元是一个 .effect 文件——它同时是一份 YAML 配置和若干段 GLSL。读懂它的语法,是后面「改内置着色器」(3.2)和「写全屏后处理」(3.3)的前置。

站内 graphics/ 专题讲的是着色器与图形 API 的通用理论;本节只讲 Cocos 自己那套 Effect 语法与编译链路,所有源码引用都标了文件路径与 3.8.8 版本,可对照 cocos/cocos-engine 仓库逐行核对。

3.1.1 Effect 文件的两半

一个 .effect 文件由两类块组成:

  • CCEffect %{ ... }%:一段 YAML,声明这条 Effect 有几种技术(technique)、每种技术下有哪些 pass、每个 pass 用哪对顶点/片元入口、混合与深度状态,以及可在编辑器里调的 properties。
  • CCProgram <名字> %{ ... }%:一段 GLSL(Cocos 方言),每个块是一个「着色器程序」,通过 vert() / frag() 这类入口函数被 CCEffect 里的 vert: / frag: 引用。

分工很清晰:CCEffect 负责「怎么画」,CCProgram 负责「算出什么颜色」。前者被引擎的 Effect 编译器解析成结构化数据,后者拼上 #include 展开后交给图形后端编译。两者之间靠名字绑定:CCEffect 的属性名、CCProgram 的 uniform 成员名、入口函数名,任何一处对不上都会静默失效。

3.1.2 CCEffect:声明「怎么画」

一个最小的 CCEffect 骨架如下。.effect 由引擎的 Effect 编译器处理,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现:

CCEffect %{
  techniques:
  - name: opaque
    passes:
    - vert: my-vs:vert
      frag: my-fs:frag
      depthStencilState:
        depthTest: false
        depthWrite: false
      blendState:
        targets:
        - blend: true
          blendSrc: src_alpha
          blendDst: one_minus_src_alpha
      rasterizerState:
        cullMode: none
      properties:
        tintColor: { value: [1.0, 1.0, 1.0, 1.0] }
}%

逐项说明:

字段含义
techniques一条 Effect 可以有多种技术,按 technique 索引选用;2D 通常只有一条
passes同一条技术里的多趟绘制,按顺序执行;后处理链就靠多个 pass
vert / frag程序名:入口函数名,指向某个 CCProgram 块
blendState混合开关与源/目标因子;2D 透明必须开 blend
depthStencilState2D 一般关掉深度测试与写入
rasterizerStatecullMode: none 表示不剔除背面(2D 面片正反都要)
properties暴露给材质/编辑器的 uniform,名字必须与 GLSL 里的 uniform 成员一致

properties 是 Effect 与脚本之间的契约:脚本里 material.setProperty('tintColor', ...) 能生效,前提正是这里声明了 tintColor,且某个 CCProgram 里有同名 uniform 成员。改完属性名后,一定要在编辑器里点一下材质面板确认没有报「属性不存在」。

3.1.3 CCProgram:GLSL 的入口与 include

CCProgram 块里写的是 Cocos 方言 GLSL。以片元程序为例:

CCProgram my-fs %{
  precision highp float;

  #include <builtin/uniforms/cc-global>

  in vec4 color;
  in vec2 uv0;

  uniform TintData {
    vec4 tintColor;
  };

  #pragma builtin(local)
  layout(set = 2, binding = 12) uniform sampler2D cc_spriteTexture;

  vec4 frag () {
    vec4 tex = texture(cc_spriteTexture, uv0);
    return tex * color * tintColor;
  }
}%

几处关键语法:

  • precision highp float;:默认精度。移动端不加会报警或精度不足,每个 CCProgram 顶部都该写。
  • #include <...>:把引擎提供的公共代码块展开进来。<builtin/uniforms/cc-global> 展开后提供 cc_matView / cc_matProj / cc_matViewProj / cc_time / cc_screenSize 等全局 uniform——这些名字在 editor/assets/chunks/builtin/uniforms/cc-global.chunk(tag 3.8.8)里能逐行核对。
  • in / out:顶点程序用 out 把 uv0、color 传给片元程序,片元程序用 in 接收;名字必须一一对应。
  • uniform 名字 { ... }:一个 uniform 块。块内成员名要和 CCEffect 的 properties 名字对得上,编译器据此把属性值绑到 uniform 上。可以对照 editor/assets/chunks/builtin/internal/alpha-test.chunk(tag 3.8.8):它声明 uniform ALPHA_TEST_DATA { float alphaThreshold; };,正好对应内置 sprite effect 里的 alphaThreshold 属性。
  • #pragma builtin(local):标记这是「每个材质实例自己的」纹理槽,配合固定的 layout(set = 2, binding = 12) 绑定到精灵纹理 cc_spriteTexture。
  • 入口函数:片元程序写 vec4 frag () 返回最终颜色,顶点程序写 vec4 vert () 返回裁剪空间坐标;引擎会把它们包成真正的 main。

3.1.4 拆一个真的内置 Effect

理论讲完,看一个真实文件。内置 2D 精灵着色器在 editor/assets/effects/for2d/builtin-sprite.effect(tag 3.8.8)。它的 CCEffect 段是:

CCEffect %{
  techniques:
  - passes:
    - vert: sprite-vs:vert
      frag: sprite-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:
        alphaThreshold: { value: 0.5 }
}%

片元程序则是:

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;
  }
}%

值得注意的三点:

  • USE_TEXTURE / IS_GRAY 是编译期宏,不是运行时 if。同一份 .effect 会在不同宏组合下编译出不同的 Shader 变体,运行时按材质上设的宏挑选。IS_GRAY 打开就是灰度图——所以「把精灵变灰」根本不用自己写着色器,改一个宏即可(下一节展开)。
  • CCSampleWithAlphaSeparated 来自 editor/assets/chunks/builtin/internal/embedded-alpha.chunk(tag 3.8.8),它按是否启用内嵌 alpha 决定取一张还是两张纹理。
  • ALPHA_TEST(o) 来自 alpha-test.chunk,在 USE_ALPHA_TEST 打开时执行 if (color.a < alphaThreshold) discard;,实现「按透明度裁边」。

顺带记一下内置 2D Effect 的家底:builtin-sprite.effect(普通精灵)、builtin-sprite-renderer.effect(带自定义装配的精灵)、builtin-spine.effect(Spine 骨骼)——都在 editor/assets/effects/for2d/ 下(tag 3.8.8)。下一节就从第一个开刀。

3.1.5 编译流程:从源码到变体

.effect 不是运行时读的文本,而是被编辑器/构建流程预处理后序列化成 EffectAsset。cc.d.ts 里 EffectAsset 暴露了三个关键字段(tag 3.8.8):

字段含义
techniques解析后的技术 / pass 结构
shaders每个 CCProgram 展开 #include 后的 GLSL 源码
combinations需要预编译的宏组合列表

combinations 是理解性能的关键:每一个被用到的宏组合,都对应一份要单独编译、单独上传的 GPU Shader。宏越多、组合越全,包体与首帧编译时间越大。所以内置 Effect 用宏做分支,但会控制组合数量——它们只为真正会用到的组合预编译。

运行时装载的路径是 EffectAsset → Material → Pass → Shader 变体:

  1. Material.initialize({ effectName, defines, states }) 用名字取到 EffectAsset,按 defines 里的宏组合挑一个变体;
  2. 一个 Material 的 passes 是一个 renderer.Pass[](cc.d.ts 中 get passes());
  3. 改一个 uniform 走 Material.setProperty,需要每帧高频更新时改走更底层的 Pass.setUniform。

Material.initialize 的入参类型是 IMaterialInfo,字段固定为 effectAsset / effectName / technique / defines / states——effectAsset 与 effectName 至少要给一个。

3.1.6 L2:用代码装载材质并切换宏

下面这段可在 Node 26 + @cocos/creator-types@3.8.8 下通过 tsc --noEmit(L2)。它把内置 sprite 材质变成灰度:

import { _decorator, Component, Material, Sprite } from 'cc';

const { ccclass, property } = _decorator;

@ccclass('GraySprite')
export class GraySprite extends Component {
  @property
  useGray = true;

  start(): void {
    const sprite = this.getComponent(Sprite);
    if (!sprite) return;

    // 直接给内置 Effect 指定宏组合,编译出灰度变体
    const mat = new Material();
    mat.initialize({
      effectName: 'builtin-sprite',
      defines: { IS_GRAY: this.useGray },
    });
    sprite.customMaterial = mat;
  }

  /** 运行时切宏:只允许在材质实例上重编 */
  setGray(on: boolean): void {
    const mat = this.getComponent(Sprite)?.customMaterial;
    if (!mat) return;
    mat.recompileShaders({ IS_GRAY: on });
  }
}

两个 API 要记牢:initialize 是「一次性装载」,recompileShaders 是「运行时换宏组合」。后者会触发一次 Shader 重编,属于昂贵操作——不要在每帧里调,最多在切场景、切品质档时调一次。

3.1.7 工程决策与常见陷阱

陷阱后果对策
属性名与 uniform 成员名不一致setProperty 静默无效两边逐字对齐,改完在编辑器验证
用宏做高频切换每切一次重编一次 Shader,卡顿高频变化用 uniform,不用宏
combinations 组合爆炸包体与首帧编译时间上升只预编译真正用到的宏组合
忘记 precision移动端精度不足、画面异常每个 CCProgram 顶部声明
直接改内置 .effect升级引擎时被覆盖复制一份到 assets/effects/ 再改

最后一行尤其重要。引擎自带的 .effect 位于只读的引擎资产目录,直接改会在升级时丢失;正确做法是复制到工程的 assets/effects/ 下改名再改,这也是 3.2 的做法。

combinations 那行最容易忽略。宏是编译期的,好处是「分支零运行时开销」,代价是「每个组合一份二进制」。判断标准很简单:**这个开关会不会在运行时频繁变?**会变就用 uniform,不会变(平台差异、品质档)才用宏。

这一节把 Effect 的「语法与编译」讲清了:CCEffect 描述管线、CCProgram 写 GLSL、宏决定变体。下一节不再纸上谈兵,而是拿内置 sprite effect 开刀,给它加一条描边,走完一次完整的「改前 / 改后」改造。

3.1.8 本节自检清单

  • 能说清 CCEffect 与 CCProgram 各自的职责
  • 知道 properties 名必须与 GLSL uniform 成员名一致
  • 会用 #include <builtin/uniforms/cc-global> 拿到 cc_matViewProj
  • 明白宏(USE_TEXTURE / IS_GRAY)是编译期分支,会产生多份变体
  • 知道 Material.initialize 与 recompileShaders 的区别与代价
  • 改内置 Effect 前先复制一份,不直接动引擎资产

阅读导航:上一节:目录 · 下一节:3.2 改造内置着色器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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