15.1 调试绘制与可视化
《方块冲刺》做到第 14 章,玩法已经完整:跑、跳、收集、道具、结算都能跑通。但随之而来的是一类靠肉眼极难定位的问题:
- 玩家偶尔穿过地面掉下去,可碰撞检测的代码看着没问题。
- 跳跃高度有时够不着平台,有时又冒出去一截。
- 敌人绕了一大圈才追到玩家,A* 明显没走最短路。
- 明明「站在」平台上,落地判定却晚了一帧才触发。
这些问题的共同点是:逻辑层的数据(碰撞盒、速度、路径、状态)在屏幕上完全不可见。你只能盯着角色贴图猜它现在到底在哪个坐标、速度朝哪、状态机停在哪一格。本节给 mini2d 加一层调试绘制(debug draw),把内部数据直接画到屏幕上。
与站内
game/顶层「引擎概念综述」的分工:那批文章讲概念,本套书亲手把调试层写出来并在浏览器里真看效果。Cocos Creator 3.8 的对照放在本节末。
15.1.1 为什么调试绘制要「先记录、后绘制」
mini2d 的正式渲染器是 immediate mode(立即模式):调用 ctx.fillRect 的瞬间就落到画布上,绘制顺序即调用顺序。调试绘制不能和正式渲染混在一起调用,否则:
- 调试线会被后续正式绘制盖住(比如画在地面下的碰撞盒被地面贴图遮住)。
- 调试绘制的开销会混进正常渲染的性能统计里,污染第 15.2 节的剖析数据。
所以我们把调试层设计成记录式(record-based):更新阶段只把要画的东西记进一个命令缓冲,等正式渲染全部结束后,再一次性 flush 到画布顶层。这样调试图形永远在最上层,且能整体开关。
// mini2d/debug/debug-draw.ts
export type DebugPrimitive =
| { kind: 'line'; x1: number; y1: number; x2: number; y2: number; color: string; width: number }
| { kind: 'rect'; x: number; y: number; w: number; h: number; color: string; width: number }
| { kind: 'circle'; x: number; y: number; r: number; color: string; width: number }
| { kind: 'arrow'; x1: number; y1: number; x2: number; y2: number; color: string }
| { kind: 'text'; x: number; y: number; text: string; color: string };
export class DebugDraw {
private buffer: DebugPrimitive[] = [];
line(x1: number, y1: number, x2: number, y2: number, color = '#00ff66', width = 1): void {
this.buffer.push({ kind: 'line', x1, y1, x2, y2, color, width });
}
rect(x: number, y: number, w: number, h: number, color = '#00ff66', width = 1): void {
this.buffer.push({ kind: 'rect', x, y, w, h, color, width });
}
circle(x: number, y: number, r: number, color = '#00ff66', width = 1): void {
this.buffer.push({ kind: 'circle', x, y, r, color, width });
}
arrow(x1: number, y1: number, x2: number, y2: number, color = '#33aaff'): void {
this.buffer.push({ kind: 'arrow', x1, y1, x2, y2, color });
}
text(x: number, y: number, text: string, color = '#ffffff'): void {
this.buffer.push({ kind: 'text', x, y, text, color });
}
}
flush 时逐条重放,画完必须清空缓冲,否则下一帧会累积成指数级增长:
flush(ctx: CanvasRenderingContext2D): void {
for (const p of this.buffer) {
ctx.strokeStyle = p.color;
ctx.fillStyle = p.color;
switch (p.kind) {
case 'line':
ctx.lineWidth = p.width;
ctx.beginPath();
ctx.moveTo(p.x1, p.y1);
ctx.lineTo(p.x2, p.y2);
ctx.stroke();
break;
case 'rect':
ctx.lineWidth = p.width;
ctx.strokeRect(p.x, p.y, p.w, p.h);
break;
case 'circle':
ctx.lineWidth = p.width;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.stroke();
break;
case 'arrow':
drawArrow(ctx, p.x1, p.y1, p.x2, p.y2);
break;
case 'text':
ctx.font = '12px monospace';
ctx.fillText(p.text, p.x, p.y);
break;
}
}
this.buffer.length = 0;
}
15.1.2 分层开关:把调试信息拆成独立的层
一次性把所有调试信息全画出来,屏幕会糊成一片。更好的做法是按信息类型分层,每层独立开关,默认只开最常用的两层:
// mini2d/debug/flags.ts
export type DebugLayer = 'collider' | 'trigger' | 'velocity' | 'path' | 'grid' | 'state';
export class DebugFlags {
private on = new Set<DebugLayer>(['collider', 'velocity']);
has(layer: DebugLayer): boolean {
return this.on.has(layer);
}
toggle(layer: DebugLayer): void {
if (this.on.has(layer)) this.on.delete(layer);
else this.on.add(layer);
}
}
用按键绑定把「层」和「数字键」对应起来,是游戏调试里非常通用的做法——不用打开任何面板,切到游戏窗口敲数字就能开关:
| 按键 | 层 | 画什么 | 默认 |
|---|---|---|---|
1 | collider | 实体 AABB 碰撞盒 | 开 |
2 | velocity | 速度向量箭头 | 开 |
3 | trigger | 触发器区域(与碰撞盒同色系区分) | 关 |
4 | path | A* 寻路折线与网格 | 关 |
5 | grid | 空间划分格子 | 关 |
6 | state | 实体状态文字标签 | 关 |
15.1.3 碰撞盒与触发器
碰撞盒是最常用的一层。它直接从第 7 章的物理世界取 AABB 数据,不重新计算——调试绘制的铁律是「只读,不改」:
// mini2d/debug/collect.ts
import type { DebugDraw } from './debug-draw';
import type { DebugFlags } from './flags';
import type { World } from '../world';
export function drawColliders(dd: DebugDraw, world: World, flags: DebugFlags): void {
if (!flags.has('collider') && !flags.has('trigger')) return;
for (const e of world.entities) {
const box = e.aabb;
if (!box) continue;
const isTrigger = e.body?.isTrigger === true;
if (isTrigger && !flags.has('trigger')) continue;
if (!isTrigger && !flags.has('collider')) continue;
// 触发器用橙色、实体碰撞盒用绿色,一眼区分
dd.rect(box.x, box.y, box.w, box.h, isTrigger ? '#ffaa00' : '#00ff66');
}
}
这里有个容易踩的坑:AABB 的 x/y 是左上角还是中心必须和渲染层约定一致。mini2d 统一用左上角原点、y 轴向下(与 Canvas2D 一致),所以 rect 直接传 box.x, box.y 即可。若你的物理用「中心点 + 半径」表示,画之前要先换算成左上角。
15.1.4 速度向量与朝向
跳跃高度对不上、冲刺方向诡异,多半是速度本身算错了。把速度画成从实体中心出发的箭头,长度按比例缩放:
export function drawVelocities(dd: DebugDraw, world: World, flags: DebugFlags): void {
if (!flags.has('velocity')) return;
const SCALE = 0.2; // 每 1 单位/秒的速度画 0.2 像素长,避免箭头戳出屏幕
for (const e of world.entities) {
const v = e.velocity;
if (!v || (v.x === 0 && v.y === 0)) continue;
const cx = e.aabb ? e.aabb.x + e.aabb.w / 2 : e.x;
const cy = e.aabb ? e.aabb.y + e.aabb.h / 2 : e.y;
dd.arrow(cx, cy, cx + v.x * SCALE, cy + v.y * SCALE, '#33aaff');
}
}
SCALE 要按游戏尺度调。跑酷游戏里水平速度常在 200400 单位/秒,若直接 1:1 画,箭头会长到画布外。**把它调成「屏幕上 2060 像素」的量级**最好读。
15.1.5 寻路路径与网格
敌人绕远路时,把 A* 算出的路径节点连成折线,一眼就能看出是路径本身绕、还是敌人的「跟随」逻辑没贴合路径:
export function drawPath(
dd: DebugDraw,
path: Array<{ x: number; y: number }>,
flags: DebugFlags,
): void {
if (!flags.has('path') || path.length < 2) return;
for (let i = 0; i < path.length - 1; i++) {
dd.line(path[i].x, path[i].y, path[i + 1].x, path[i + 1].y, '#ff33ff', 2);
}
// 路径节点用小圆点标出,方便数「几步走到」
for (const p of path) dd.circle(p.x, p.y, 3, '#ff88ff');
}
若怀疑是网格本身建错了(比如某个格子被错误地标成障碍),再把空间划分格子也画出来,两相对照即可定位。
15.1.6 状态标签:世界坐标转屏幕
状态机的当前状态、血量、帧号这些「数字信息」用文字画在实体头顶最直观。关键是世界坐标 → 屏幕坐标的换算——这正是第 2.3 节相机变换的逆运算:
export function drawStateLabels(
dd: DebugDraw,
world: World,
camera: { worldToScreen(x: number, y: number): { x: number; y: number } },
flags: DebugFlags,
): void {
if (!flags.has('state')) return;
for (const e of world.entities) {
const box = e.aabb;
if (!box) continue;
const top = camera.worldToScreen(box.x, box.y);
dd.text(top.x, top.y - 4, `${e.state} hp=${e.hp}`, '#ffffff');
}
}
注意 y - 4 是向上偏移:Canvas2D 的 y 轴向下,所以「头顶」是 y 更小的方向。这类细节在调试层里错一次就会让标签和实体错位,务必先在固定坐标上验证一次。
15.1.7 只在开发模式启用
调试绘制绝不能进生产包:它每帧要遍历全部实体、构造一堆临时对象,是实打实的性能负担。用编译期常量把它整段摇树(tree-shaking)掉:
// mini2d/debug/index.ts
import { DebugDraw } from './debug-draw';
import { DebugFlags } from './flags';
// 打包器在构建时把 __DEV__ 替换为字面量;生产构建里该分支被整体消除
export const DEBUG_ENABLED: boolean = (globalThis as { __DEV__?: boolean }).__DEV__ ?? false;
export function installDebugKeys(flags: DebugFlags): void {
if (!DEBUG_ENABLED) return;
const map: Record<string, DebugLayer> = {
'1': 'collider', '2': 'velocity', '3': 'trigger',
'4': 'path', '5': 'grid', '6': 'state',
};
window.addEventListener('keydown', (e) => {
const layer = map[e.key];
if (layer) flags.toggle(layer);
});
}
在游戏循环里,调试收集与 flush 排在正式渲染之后,且整段被 DEBUG_ENABLED 包住:
function frame(ctx: CanvasRenderingContext2D, dt: number): void {
update(dt);
render(ctx);
if (DEBUG_ENABLED) {
const dd = new DebugDraw();
drawColliders(dd, world, flags);
drawVelocities(dd, world, flags);
drawPath(dd, currentPath, flags);
drawStateLabels(dd, world, camera, flags);
dd.flush(ctx);
}
}
用「每帧新建
DebugDraw」而不是复用一个全局实例,是刻意的取舍:调试层本就只在开发模式跑,少一次跨帧状态清理,代码更不容易写出「忘记清空缓冲」的 bug。真到性能敏感时再改成复用 +clear()。
15.1.8 Cocos 对照:Graphics 组件
Cocos Creator 3.8 不提供内置的「调试绘制层」,但可以用 Graphics 组件做同样的事——它是一块可以自由画线、画矩形、画圆的渲染组件。以下为 L2 片段(import { ... } from 'cc'):
import { _decorator, Component, Graphics, Color } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('DebugOverlay')
export class DebugOverlay extends Component {
@property(Graphics)
gfx: Graphics | null = null;
private boxes: Array<{ x: number; y: number; w: number; h: number }> = [];
update(): void {
const g = this.gfx;
if (!g) return;
g.clear();
g.lineWidth = 2;
g.strokeColor = Color.GREEN;
for (const b of this.boxes) g.rect(b.x, b.y, b.w, b.h);
g.stroke();
}
}
对照要点:
Graphics的绘制是记录式的:先moveTo/rect/circle记录路径,再stroke()或fill()一次性提交。这与我们在 15.1.1 里的命令缓冲是同一个思路,只不过缓冲由引擎维护。每帧开头必须clear(),否则路径会累积。- 颜色用
Color(Color.GREEN、Color.RED等常量,或new Color(r, g, b, a)),赋给strokeColor/fillColor。lineWidth控制线宽。 - 坐标是节点本地坐标:
Graphics挂在哪、画在哪。若要把世界坐标的碰撞盒画到 UI 上,需要经UITransform换算,和 15.1.6 的worldToScreen是同一类变换。 - Cocos 没有「按键开关调试层」的内置机制,但可以照搬 15.1.7 的思路:用
input.on监听按键切换各层的可见性(Node.active)。
到这里,mini2d 已经能把「看不见的逻辑」画出来了。但调试绘制只回答「是什么」,不回答「有多慢」。下一节转向性能剖析:用 performance.now() 采样帧耗时,用滚动平均与百分位判断掉帧,再顺着数据找出真凶。
阅读导航:上一节:目录 · 下一节:15.2 帧率、内存与性能剖析 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。