《Cocos 游戏开发入门》15.1 调试绘制与可视化

碰撞检测明明写了却总穿模、跳跃高度对不上、A* 路径绕远路,光看画面根本看不出原因。本节给 mini2d 加一层记录式调试渲染器,把碰撞盒、触发器、速度向量、寻路路径与状态标签按层画出来,用数字键独立开关,只在开发模式启用、生产构建整段摇树消除。

15.1 调试绘制与可视化

《方块冲刺》做到第 14 章,玩法已经完整:跑、跳、收集、道具、结算都能跑通。但随之而来的是一类靠肉眼极难定位的问题:

  • 玩家偶尔穿过地面掉下去,可碰撞检测的代码看着没问题。
  • 跳跃高度有时够不着平台,有时又冒出去一截。
  • 敌人绕了一大圈才追到玩家,A* 明显没走最短路。
  • 明明「站在」平台上,落地判定却晚了一帧才触发。

这些问题的共同点是:逻辑层的数据(碰撞盒、速度、路径、状态)在屏幕上完全不可见。你只能盯着角色贴图猜它现在到底在哪个坐标、速度朝哪、状态机停在哪一格。本节给 mini2d 加一层调试绘制(debug draw),把内部数据直接画到屏幕上。

与站内 game/ 顶层「引擎概念综述」的分工:那批文章讲概念,本套书亲手把调试层写出来并在浏览器里真看效果。Cocos Creator 3.8 的对照放在本节末。

15.1.1 为什么调试绘制要「先记录、后绘制」

mini2d 的正式渲染器是 immediate mode(立即模式):调用 ctx.fillRect 的瞬间就落到画布上,绘制顺序即调用顺序。调试绘制不能和正式渲染混在一起调用,否则:

  1. 调试线会被后续正式绘制盖住(比如画在地面下的碰撞盒被地面贴图遮住)。
  2. 调试绘制的开销会混进正常渲染的性能统计里,污染第 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);
  }
}

用按键绑定把「层」和「数字键」对应起来,是游戏调试里非常通用的做法——不用打开任何面板,切到游戏窗口敲数字就能开关:

按键层画什么默认
1collider实体 AABB 碰撞盒开
2velocity速度向量箭头开
3trigger触发器区域(与碰撞盒同色系区分)关
4pathA* 寻路折线与网格关
5grid空间划分格子关
6state实体状态文字标签关

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 帧率、内存与性能剖析 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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