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 |
depthStencilState | 2D 一般关掉深度测试与写入 |
rasterizerState | cullMode: 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(tag3.8.8)里能逐行核对。in/out:顶点程序用out把uv0、color传给片元程序,片元程序用in接收;名字必须一一对应。uniform 名字 { ... }:一个 uniform 块。块内成员名要和 CCEffect 的properties名字对得上,编译器据此把属性值绑到 uniform 上。可以对照editor/assets/chunks/builtin/internal/alpha-test.chunk(tag3.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(tag3.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 变体:
Material.initialize({ effectName, defines, states })用名字取到EffectAsset,按defines里的宏组合挑一个变体;- 一个
Material的passes是一个renderer.Pass[](cc.d.ts中get passes()); - 改一个 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 改造内置着色器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。