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 |
| 5 | HUD(分数、血条) | 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;
}
}
对照要点:
| 能力 | mini2d | Cocos Creator 3.8 |
|---|---|---|
| 排序键 | layer + zIndex + 遍历序 | 节点树遍历序 + setSiblingIndex |
| 顺序属性 | node.zIndex | 无 zIndex;UITransform.priority 已废弃 |
| 矩形裁剪 | ctx.clip() + clipRect | Mask 组件(Mask.Type.GRAPHICS_RECT) |
| 圆形裁剪 | ctx.arc() 后 clip() | Mask.Type.GRAPHICS_ELLIPSE |
| 透明度 | 自行乘 alpha | UIOpacity 组件(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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。