《Cocos 游戏开发入门》3.2 绘制顺序、层级与遮罩

场景图只解决了位置,没解决「谁盖住谁」。本节为《方块冲刺》实现绘制顺序:用 layer + zIndex + 遍历序组成稳定排序键,按屏幕空间裁剪框实现滚动视窗与遮罩,并给出 mini2d 的渲染列表、裁剪栈、vitest 测试与 Cocos Creator 3.8 中 Mask、UIOpacity 的对照。

3.2 绘制顺序、层级与遮罩

上一节我们让每个节点都能算出自己的世界矩阵,于是「画在哪里」有了答案。但渲染还有一个同等重要、却常被新手忽略的问题:「谁盖住谁」。Canvas2D 是一条画家流水线——后画的覆盖先画的,没有深度缓冲帮你自动排序。想让 UI 永远压在游戏画面之上、想让玩家永远盖住地面,你必须自己决定绘制顺序。

本节把顺序、层级、裁剪三件事拆开讲:先给每个可绘制对象算出一个排序键,再按屏幕空间矩形做裁剪,最后用一套裁剪栈支撑滚动视窗与圆形遮罩。这三样合起来,就是 mini2d 的「渲染列表」。

本节把《方块冲刺》推进到「画面层次正确」:背景、地面、金币、玩家、粒子、HUD 六层各归其位,右上角的小地图用矩形裁剪出一个滚动视窗。这一步完成后,第 11 章的 UI 与第 15 章的调试绘制都会直接复用这里的渲染列表。

3.2.1 绘制顺序就是 z 序

在 2D 里没有真正的深度,绘制顺序本身就是深度。顺序错误的症状非常直观:玩家被地面盖住、金币浮在 HUD 之上、血条被弹窗挡住。修法只有一条——让渲染列表按你期望的可见层次排好序,再依次提交。

《方块冲刺》的可见层次从下到上大致是:

层内容典型 zIndex
0天空/远景视差0
1地面与平台10
2道具(金币、加速带)20
3玩家与敌人30
4粒子与特效40
5HUD(分数、血条)100
6弹窗与遮罩层1000

把这张表固化成 layer 常量,而不是散落在各处的魔法数字,是避免「层次打架」的第一步。

3.2.2 排序键:layer + zIndex + 遍历序

只靠一个 zIndex 不够用:两个金币的 zIndex 都是 20,谁先画?答案取决于它们在节点树里的先后。所以 mini2d 用三元组做排序键,且排序必须稳定:

// src/render/render-list.ts
import { Node } from '../core/node';

export interface RenderItem {
  node: Node;
  layer: number;
  zIndex: number;
  order: number; // 遍历序号,保证同层同级稳定
}

export function buildRenderList(root: Node): RenderItem[] {
  const items: RenderItem[] = [];
  let order = 0;
  root.visit((node) => {
    if (!node.active || !node.sprite) return; // 只收集可绘制节点
    items.push({ node, layer: node.layer, zIndex: node.zIndex, order: order++ });
  });
  items.sort((a, b) => {
    if (a.layer !== b.layer) return a.layer - b.layer;
    if (a.zIndex !== b.zIndex) return a.zIndex - b.zIndex;
    return a.order - b.order; // 同层同级:保持遍历先后
  });
  return items;
}

三个分量的优先级不能颠倒:layer 决定大层次,zIndex 决定层内微调,order 只在前两者都相等时才起作用。order 由遍历序号提供,天然反映节点树的先后,因此同一帧内稳定。

3.2.3 为什么必须稳定排序

Array.prototype.sort 在 ES2019 之后保证稳定,但依赖它之前,很多老代码会踩坑:两个同 layer 同 zIndex 的节点在不同浏览器里顺序不一致,导致金币闪烁式地互相覆盖。显式带上 order 分量,就是把「稳定」从运行时约定变成数据契约——无论引擎怎么排序,结果都可复现。

3.2.4 裁剪区域:遮罩与滚动视窗

排序解决「谁盖谁」,裁剪解决「谁被切掉」。滚动视窗是最典型的用例:小地图的世界内容在滚动,但只能显示在右上角那个矩形里,超出的部分必须被裁掉。

Canvas2D 提供了 ctx.clip():调用它之后,后续所有绘制都被限制在当前路径内,直到对应的 ctx.restore()。mini2d 把裁剪框存在节点上,用屏幕空间矩形表示:

// src/render/clip.ts
import { Node } from '../core/node';

export interface ClipRect { x: number; y: number; w: number; h: number; }

// 沿父链向上找最近的非空裁剪框
export function resolveClip(node: Node): ClipRect | null {
  let cur: Node | null = node;
  while (cur) {
    if (cur.clipRect) return cur.clipRect;
    cur = cur.parent;
  }
  return null;
}

用屏幕空间而不是节点局部空间,是因为裁剪框通常代表「一块固定的显示区域」(视窗、面板),它不随内容滚动而移动。若裁剪框需要跟着节点旋转,就得改成局部空间再乘世界矩阵——这属于进阶用法,本节先取最实用的形式。

3.2.5 裁剪栈与绘制循环

把排序和裁剪合到一起,就是 mini2d 的绘制循环。裁剪用 save/clip/restore 配对,天然构成一个栈:

// src/render/draw-list.ts
import type { RenderItem } from './render-list';
import { resolveClip } from './clip';
import { Node } from '../core/node';

export function drawList(
  items: RenderItem[],
  ctx: CanvasRenderingContext2D,
  drawSprite: (ctx: CanvasRenderingContext2D, node: Node) => void,
): void {
  for (const item of items) {
    const clip = resolveClip(item.node);
    if (clip) {
      ctx.save();
      ctx.beginPath();
      ctx.rect(clip.x, clip.y, clip.w, clip.h);
      ctx.clip();
    }

    const m = item.node.worldMatrix;
    ctx.save();
    ctx.setTransform(m.a, m.b, m.c, m.d, m.tx, m.ty);
    drawSprite(ctx, item.node);
    ctx.restore();

    if (clip) ctx.restore(); // 与上面的 save 配对
  }
}

两次 save/restore 的顺序是「先裁后变换、先复变换后复裁剪」。写反了会出现「裁剪作用到下一个节点」的串味 bug——因为 restore 是按栈弹出的,必须严格后进先出。圆形遮罩只是把 rect 换成 arc,其余不变。

3.2.6 用 vitest 验证排序与裁剪

排序键的正确性和裁剪的配对都能用假 ctx 精确验证,不必启动浏览器。

// test/render-list.test.ts
import { describe, it, expect } from 'vitest';
import { Node } from '../src/core/node';
import { buildRenderList } from '../src/render/render-list';

function spriteNode(name: string, layer: number, z: number): Node {
  const n = new Node(name);
  n.layer = layer;
  n.zIndex = z;
  n.sprite = { frame: name };
  return n;
}

describe('render list', () => {
  it('先按 layer 再按 zIndex 排序', () => {
    const root = new Node('root');
    const hud = spriteNode('hud', 5, 0);
    const ground = spriteNode('ground', 1, 0);
    const player = spriteNode('player', 3, 0);
    root.addChild(hud);
    root.addChild(ground);
    root.addChild(player);

    const list = buildRenderList(root).map((i) => i.node.name);
    expect(list).toEqual(['ground', 'player', 'hud']);
  });

  it('同层同级保持遍历先后(稳定)', () => {
    const root = new Node('root');
    const a = spriteNode('coin-a', 2, 20);
    const b = spriteNode('coin-b', 2, 20);
    root.addChild(a);
    root.addChild(b);
    const list = buildRenderList(root).map((i) => i.node.name);
    expect(list).toEqual(['coin-a', 'coin-b']);
  });
});

裁剪的配对用记录式假 ctx 验证,把调用序列记下来做断言:

// test/clip.test.ts
import { describe, it, expect } from 'vitest';
import { Node } from '../src/core/node';
import { buildRenderList } from '../src/render/render-list';
import { drawList } from '../src/render/draw-list';

function fakeCtx(): { calls: string[] } & Record<string, unknown> {
  const calls: string[] = [];
  const rec = (name: string) => (..._args: unknown[]) => { calls.push(name); };
  return {
    calls,
    save: rec('save'), restore: rec('restore'),
    beginPath: rec('beginPath'), rect: rec('rect'), clip: rec('clip'),
    setTransform: rec('setTransform'),
  };
}

describe('clip stack', () => {
  it('带裁剪框的节点会配对 save/restore', () => {
    const root = new Node('root');
    const view = new Node('minimap');
    view.clipRect = { x: 500, y: 20, w: 120, h: 80 };
    view.sprite = { frame: 'map' };
    root.addChild(view);

    const ctx = fakeCtx();
    drawList(buildRenderList(root), ctx as never, () => {});
    expect(ctx.calls).toEqual([
      'save', 'beginPath', 'rect', 'clip', 'save',
      'setTransform', 'restore', 'restore',
    ]);
  });
});

save/restore 各出现两次且首尾配对,正是「裁剪栈」这个抽象的可执行定义。一旦有人把某次 restore 漏掉,这条断言会立刻变红。

3.2.7 Cocos Creator 3.8 对照:层级、Mask 与 UIOpacity

在 Cocos Creator 3.8 里,上面三件事都有引擎内建支持,但实现路径和直觉不太一样。

层级顺序。 这是最容易踩的坑:Cocos 3.x 没有 node.zIndex 这个属性——cc.d.ts 里搜不到 zIndex,而 UITransform.priority 在 cc.d.ts 的注释里被明确标注 @deprecated Since v3.1。2D 的绘制顺序由节点树遍历顺序决定,同级节点用 setSiblingIndex 调先后:

import { _decorator, Component, Node, Mask, UIOpacity, Graphics, Color } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('HudRoot')
export class HudRoot extends Component {
  @property(Mask)
  minimapMask: Mask | null = null;

  start(): void {
    // 顺序 = 兄弟下标,越大越晚画、越靠上
    const n: Node = this.node;
    const parent = n.parent;
    if (parent) n.setSiblingIndex(parent.children.length - 1);
  }

  fadeIn(): void {
    const op = this.node.getComponent(UIOpacity);
    if (op) op.opacity = 255;
  }

  drawFrame(): void {
    const g = this.node.getComponent(Graphics);
    if (!g) return;
    g.lineWidth = 2;
    g.strokeColor = new Color(255, 255, 255, 255);
    g.clear();
    g.rect(0, 0, 120, 80);
    g.stroke();
  }
}

遮罩。 Cocos 用 Mask 组件(cc.d.ts 中由 MaskComponent 导出为别名 Mask)。它的 type 取 Mask.Type 枚举:矩形、椭圆、Graphics 模板、SpriteFrame 模板四类。设置 inverted = true 可反转遮罩,spriteFrame / alphaThreshold 支持基于贴图透明度的镂空:

import { _decorator, Component, Mask } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('RoundAvatar')
export class RoundAvatar extends Component {
  @property(Mask)
  mask: Mask | null = null;

  start(): void {
    if (!this.mask) return;
    this.mask.type = Mask.Type.GRAPHICS_ELLIPSE; // 椭圆裁剪
    this.mask.inverted = false;
  }
}

对照要点:

能力mini2dCocos Creator 3.8
排序键layer + zIndex + 遍历序节点树遍历序 + setSiblingIndex
顺序属性node.zIndex无 zIndex;UITransform.priority 已废弃
矩形裁剪ctx.clip() + clipRectMask 组件(Mask.Type.GRAPHICS_RECT)
圆形裁剪ctx.arc() 后 clip()Mask.Type.GRAPHICS_ELLIPSE
透明度自行乘 alphaUIOpacity 组件(0–255)
2D 渲染根场景根节点RenderRoot2D(Canvas 继承自它)

三点差异。其一,Cocos 的裁剪、透明度、绘制都拆成独立组件挂在节点上,而不是节点自身的字段——这正是第 6 章「组合优于继承」的延续。其二,Mask 内部靠模板缓冲或 Graphics 实现,你只声明 type,不必手写 save/clip/restore。其三,RenderRoot2D 是 2D 渲染的挂载点,Canvas 继承自它;脱离 RenderRoot2D 的 2D 组件不会被渲染。

下一节我们讨论渲染列表交给 GPU 之后的开销:为什么同样的画面,画 100 次和画 3 次的帧率天差地别,以及怎么把 Draw Call 数下来。

阅读导航:上一节:3.1 场景图与节点树 · 下一节:3.3 批处理与 Draw Call 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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