《Cocos 游戏开发高级编程》2.2 材质与 Effect 体系

EffectAsset、Material、Pass 是 Cocos 渲染里最容易被混为一谈的三样东西。这一节把它们的关系拆清楚:谁是模板、谁是配置、谁是真正提交 GPU 的单位,再用 cc.d.ts 与 cocos-engine 3.8.8 源码追一遍 setProperty 的调用链,最后给出改材质、改单个节点材质、每帧改 uniform 三种场景的正确写法与代价。

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 里只有五个可选字段:

字段类型说明
effectAssetEffectAsset | null直接用资源引用指定 effect
effectNamestring用名字指定 effect,与 effectAsset 二选一
techniquenumber用第几个 technique,默认 0
definesMacroRecord覆盖宏定义
statesPassOverrides覆盖管线状态

注意这里没有「初始属性值」字段——属性不走 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 行。它的主干是:

  1. 若不传 passIdx,遍历所有 Pass,逐个尝试;若传了,只改第 passIdx 个(越界会 warnID(16372))。
  2. 对每个 Pass 调用 _uploadProperty(pass, name, val)。
  3. 只要有一个 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 渲染批次与合批机制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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