2.2 材质与 Effect 体系
上一节讲了「谁驱动渲染」,这一节回答「一次绘制用什么数据」。在 Cocos 里,这个问题有三个层次的答案,而它们经常被混着说:EffectAsset 是模板,Material 是模板的一份配置,Pass 才是真正提交给 GPU 的单位。
把这三者分清,很多「为什么改了材质没生效」「为什么改一个精灵把全场都改了」的困惑会自动消失。本节的所有接口都先在 cc.d.ts 里核对过,涉及引擎内部实现的部分会给出 cocos/cocos-engine tag 3.8.8 的文件路径与行号。
2.2.1 三者的关系:模板、实例、提交单元
先用一句话定义,再用一张图收口:
- EffectAsset:描述「怎么画」——顶点 / 片元着色器、渲染状态(混合、深度、剔除)、可配置属性(
properties)的默认值。它是一个Asset,在资源管理器里就是.effect文件。 - Material:描述「这次画用什么参数」——选定 Effect 的第几个 technique、覆盖哪些宏、覆盖哪些渲染状态、每个属性的具体取值。它也是
Asset,对应.mtl文件。 - Pass:真正被渲染管线读写的对象——持有 uniform 缓冲、描述符集、管线状态与着色器。一个 Material 通常持有一个或多个 Pass。
它们的数据流是单向的:
.effect ──编译──▶ EffectAsset(模板)
│ technique.passes
▼
.mtl ──加载──▶ Material(配置) ──创建──▶ Pass[](提交单元) ──▶ GPU
▲
setProperty ──┘
关键点:Material 不直接持有 uniform 数值,它把数值下发给 Pass。 这条结论下面会用源码证明。
2.2.2 EffectAsset:着色器与状态的模板
cc.d.ts 里 EffectAsset extends Asset,暴露的字段只有四个:techniques、shaders、combinations、hideInEditor。前三个都是数据——techniques 是「有哪些可用的渲染技法」,shaders 是「每个技法里的着色器程序」,combinations 是「需要预编译的宏组合」。
它还提供了三个静态方法:EffectAsset.get(name) 按名字取已注册的 effect、EffectAsset.register(asset) / EffectAsset.remove(asset) 手动注册与注销。内置 effect 是引擎启动时自动注册的,所以你不需要在代码里注册 builtin-sprite,直接用名字引用即可。
一个 effect 文件的头部(不是完整文件,着色器主体见 3.1 Cocos Effect 语法与编译 )大致长这样。本段需引擎 Effect 编译器 / 编辑器导入,本节不可命令行复现。
CCEffect %{
techniques:
- passes:
- vert: tint-vs:vert
frag: tint-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, 1, 1, 1], editor: { type: color } }
alphaThreshold: { value: 0.5 }
}%
CCProgram tint-vs %{
precision highp float;
in vec3 a_position;
in vec4 a_color;
out vec4 v_color;
vec4 vert () {
v_color = a_color;
return vec4(a_position, 1.0);
}
}%
在编辑器的 Effect 面板里打开它,你会看到什么:上方是 Techniques 列表,每一项下挂着若干 Passes;展开一个 Pass,能依次看到 Properties(这里有两项 tintColor 与 alphaThreshold,后者带一个 0.5 的默认值)、Blend State、Rasterizer State;面板下方则是 CCProgram 折叠区,每个 %{ ... }% 对应一段着色器。properties 里声明的名字,就是运行时 setProperty 能改的名字——这是本节最重要的一条对应关系。
(以上需引擎 Effect 编译器 / 编辑器导入,本节不可命令行复现。)
2.2.3 Material:Effect 的一份可配置实例
Material extends Asset,它的构造是「新建空材质 → 用 initialize(info) 填充」。info 的类型是 IMaterialInfo,cc.d.ts 里只有五个可选字段:
| 字段 | 类型 | 说明 |
|---|---|---|
effectAsset | EffectAsset | null | 直接用资源引用指定 effect |
effectName | string | 用名字指定 effect,与 effectAsset 二选一 |
technique | number | 用第几个 technique,默认 0 |
defines | MacroRecord | 覆盖宏定义 |
states | PassOverrides | 覆盖管线状态 |
注意这里没有「初始属性值」字段——属性不走 initialize,而是初始化完成后再用 setProperty 逐项设置。下面这段代码创建一个材质并打印它的 Pass 数量:
import { _decorator, Component, Material, EffectAsset } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('MatSetup')
export class MatSetup extends Component {
@property(EffectAsset)
effect: EffectAsset | null = null;
start(): void {
if (!this.effect) return;
const mat = new Material();
mat.initialize({
effectAsset: this.effect,
technique: 0,
defines: { USE_TEXTURE: true },
});
console.log(`[mat] effect=${mat.effectName} passes=${mat.passes.length}`);
}
}
对照 cocos/asset/assets/material.ts(tag 3.8.8)第 207 行的 initialize:它先检查 this._passes.length,非空就直接 warnID(12005) 返回——一个材质只能初始化一次,重复初始化是错误用法。随后 _fillInfo(info) 记录 effect 与 technique,_update() 才真正按 technique 创建出 _passes 数组。所以 mat.passes.length 等于所选 technique 的 pass 数,而不是 1。
2.2.4 Pass:真正提交 GPU 的单位
Pass 是 renderer 命名空间下的类(cc.d.ts 里写作 export class Pass,类型引用写作 renderer.Pass)。它对外暴露的核心是句柄 + 写值这一对操作:
getHandle(name, offset?, targetType?):把属性名解析成一个句柄(number)。名字不在 UBO 里就返回0。setUniform(handle, value):往句柄对应的位置写一个向量 / 数值。这是每帧更新推荐用的接口。setUniformArray(handle, value[]):写数组类 uniform。bindTexture(binding, texture)/bindSampler(binding, sampler):绑定纹理与采样器。update():把这一帧写进缓冲的数据上传。
getHandle 的 offset 与 targetType 参数是为「只改一个分量」准备的。cc.d.ts 的注释里给了一个例子:若 albedoScale 是 vec4 而你只想改 .w,可以 getHandle('albedoScale', 3, gfx.Type.FLOAT) 拿到第 3 个分量的句柄,再 setUniform(h, 0.5)。这个能力在每帧更新单个标量时能省掉一次 Vec4 构造。
Pass 还有一个不那么显眼但决定合批行为的属性:batchingScheme。cocos/render-scene/core/pass.ts(tag 3.8.8)第 794 行的 _syncBatchingScheme() 逻辑是:只有着色器里开了 USE_INSTANCING 宏、且设备支持 instancing 时,才把 batchingScheme 设为 BatchingSchemes.INSTANCING;否则一律是 NONE。
这解释了一个常见误解:2D 精灵的「合批」不是 GPU instancing。Sprite 用的 builtin-sprite 没有开 USE_INSTANCING,所以它的 batchingScheme 是 NONE,引擎走的是「把顶点数据合并进同一个 DrawBatch」的路子——那正是 2.3 渲染批次与合批机制
要展开的内容。
2.2.5 setProperty 背后到底改了什么
现在把调用链追到底。Material.setProperty(name, val, passIdx?) 的实现在 cocos/asset/assets/material.ts(tag 3.8.8)第 303 行。它的主干是:
- 若不传
passIdx,遍历所有 Pass,逐个尝试;若传了,只改第passIdx个(越界会warnID(16372))。 - 对每个 Pass 调用
_uploadProperty(pass, name, val)。 - 只要有一个 Pass 成功,就缓存进
this._props;全都失败则warnID(16373, name)。
再看 _uploadProperty 本身,它才是真正对接 Pass 的地方:
_uploadProperty(pass, name, val):
handle = pass.getHandle(name)
if handle == 0: return false // 名字不在这个 Pass 的 UBO 里
if 类型 < SAMPLER1D: // 数值 / 向量
pass.setUniform(handle, val) // 或 setUniformArray
else: // 纹理 / 采样器
pass.bindTexture(handle, val)
于是那条结论可以坐实:Material.setProperty 本身不存渲染数据,它只是把值转发给 Pass 的 uniform 缓冲。 三个推论:
- 属性名必须来自 effect 的
properties。 写错名字不会报错崩溃,只会静默走warnID(16373),运行时属性不变——这是「改了没生效」的头号原因。 - 同一个名字要在所有 Pass 上都存在才会「成功」。 若一个材质有两个 Pass,而你只想改其中一个,必须显式传
passIdx。 getProperty只能取回通过setProperty设过的值。 想读渲染时的真实数据,得用Pass.getUniform(handle, out),cc.d.ts在Material.getProperty的注释里特意点明了这一点。
一段把它用起来的组件——注意这里只改 builtin-sprite 真实存在的属性 alphaThreshold:
import { _decorator, Component, Sprite } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('ThresholdTuner')
export class ThresholdTuner extends Component {
@property
speed = 0.5;
private t = 0;
update(dt: number): void {
this.t = (this.t + dt * this.speed) % 1;
const sprite = this.getComponent(Sprite);
if (!sprite) return;
const mat = sprite.getRenderMaterial(0);
if (!mat) return;
// alphaThreshold 来自 builtin-sprite 的 properties,名字必须逐字一致
mat.setProperty('alphaThreshold', 0.1 + this.t * 0.8);
}
}
2.2.6 材质实例:改一个节点而不影响别人
上面那段代码有个隐患:getRenderMaterial(0) 拿到的是共享材质资源。如果这个材质被多个精灵共用,改它等于全场一起改。要只改一个节点,用材质实例。
cc.d.ts 在 Renderer 上给了三组方法,语义各不相同:
| 方法 | 返回 | 语义 |
|---|---|---|
getMaterial(idx) | Material | null | 取该子模型的材质资源 |
getRenderMaterial(idx) | Material | null | 取实际用于渲染的材质(有实例则返回实例) |
getMaterialInstance(idx) | renderer.MaterialInstance | null | 取实例,没有就现建一个 |
setMaterialInstance(mat, idx) | void | 换掉实例 |
renderer.MaterialInstance 继承自 Material,所以 setProperty 照样可用;区别在于它的 _passes 是 PassInstance[],且只影响自己。它还有一个 owner 属性指向所属渲染组件,便于反查:
import { _decorator, Component, Sprite, renderer } from 'cc';
const { ccclass } = _decorator;
@ccclass('FlashOnHit')
export class FlashOnHit extends Component {
onLoad(): void {
const sprite = this.getComponent(Sprite);
if (!sprite) return;
// 只为这一个节点创建材质实例,不影响共享材质资源
const inst = sprite.getMaterialInstance(0) as renderer.MaterialInstance | null;
if (!inst) return;
inst.setProperty('alphaThreshold', 0.15);
const ownerName = inst.owner ? inst.owner.node.name : 'none';
console.log(`[mat] instance owner=${ownerName}`);
}
}
实例化的代价是多一份 uniform 数据、多一个 Pass 对象,并且会打断合批——因为合批判定里有一条是「材质对象引用相同」(见 2.3)。所以规则很清晰:整类节点统一的参数调共享材质,单个节点临时的参数用实例。
2.2.7 工程决策:三种改法,三档代价
把本节的选择收成一张表:
| 需求 | 正确做法 | 代价 |
|---|---|---|
| 整类对象统一参数(所有金币同色) | 改共享 .mtl 或共享 Material | 最低,仍可合批 |
| 单个节点临时参数(受击闪白) | getMaterialInstance 后 setProperty | 多一份实例数据,断合批 |
| 每帧变化的参数(进度、扫描线) | 取 Pass,getHandle + setUniform | 每次更新最省,但需自己管句柄 |
三条经验:
- 能改共享材质就别建实例。 实例是「为了一个节点的特例牺牲一次合批」的交易,只在该节点真的特殊时才划算。
- 每帧更新走
Pass,不要走Material.setProperty。cc.d.ts在setProperty的注释里明确写了「如果需要每帧更新 uniform,建议使用renderer.Pass.setUniform以获得更好的性能」。差别在于setProperty会顺带做属性缓存与名字解析。 - 先确认属性名存在。 调试时最省事的办法是在编辑器里选中材质资源,展开它的 Pass 看
Properties列表;列表里没有的名字,setProperty一定静默失败。
下一节把本节埋下的伏笔收掉:Pass 的 batchingScheme 为什么是 NONE、顶点数据是怎么被合并进一个 DrawBatch、以及卷二那个「180 → 42 Draw Call」在引擎内部究竟发生了什么。
阅读导航:上一节:2.1 渲染管线与自定义管线 · 下一节:2.3 渲染批次与合批机制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。