《游戏引擎实现原理》6.1 调试绘制与运行时可视化

前三卷把 helio 搭到能渲染,但出了问题时你只能盯着屏幕猜。本节做调试绘制:把线框、包围体、法线、拾取射线累积成一条顶点缓冲,L1 单测断言顶点数与排布,L2 在 headless Chromium 里真画出线框,再回读像素验证边线落在正确的屏幕位置。

6.1 调试绘制与运行时可视化

前三卷把 helio 搭到了能渲染、能动画、能做物理的程度,但一旦屏幕上出现一个不该出现的东西,你手上只有两种手段:读代码,或者 console.log。两者都不告诉你「这个包围盒到底偏了多少」。这一章补上开发期最重要的反馈回路——让引擎把它的内部状态画出来。

这一节只做一件事:给 helio 一个 DebugDraw,它把线框、包围体、法线、拾取射线这些临时几何累积成一条顶点缓冲,交给渲染层一条 line-list 管线画到屏幕上。它不参与发布构建的产物,只在开发档里活着。

本章与站内既有内容的分工

「调试」这个词在站内被不同文章用在不同层次,先把分工划清:

既有文章它负责什么本节只做什么
helio-subsys-11-1 编辑器的 gizmo 与拾取射线编辑器视口里拖拽手柄的射线拾取与命中判定不做编辑器交互,只做运行期「把任意几何画成线框」的顶点缓冲生成
helio-render-11-3 着色器调试与编译信息WGSL 编译诊断的行列还原不碰着色器编译,只把 CPU 侧的调试几何送进一条固定管线
/graphics-gpu-profiling-debugging/讲 GPU 性能分析的方法与工具链不测性能,只做「看得见」的运行时可视化

一句话:别人讲「怎么分析」,本节讲「怎么让引擎把自己画出来」。

调试绘制要解决的三个问题

第一版调试绘制往往写成「每画一条线就 createBuffer 一次」。这在小场景里看不出问题,一进真实关卡就崩——几百条线意味着几百次 GPU 资源创建。正确的形状是把一帧内所有调试几何攒进一条连续缓冲,最后一次性 writeBuffer、一次性 draw。于是 DebugDraw 的职责只有两个:追加顶点、必要时扩容。

第二个问题是图元要可组合。你不想在业务代码里手算包围盒的 12 条边,也不想自己推球的三个大圆。所以 DebugDraw 直接提供 box / sphere / normal / ray 四个图元,每个图元内部拆成一段段 line。这样业务代码只写「在这里画一个盒」,几何细节被关在一个文件里。

第三个问题是错误要挡在开发期。调试绘制的输入来自各种临时计算,很容易出现 min > max 的盒子、退化的方向向量、NaN 端点。这些东西画出来是「看不见」的,比报错更糟。所以每个图元入口都用 assert 断言参数合法——它是 debug 档专属,release 构建里整句被剥掉,零运行时成本。这也正是第一卷 helio-kernel-03-3 断言、日志与 CVar 里定下的规则,本节只是把它用到调试几何上。

可运行产物:DebugDraw 顶点缓冲

顶点布局取「位置 vec3 + 颜色 vec4 = 7 个 float」,与 line-list 管线的顶点描述符一一对应。缓冲按 2 倍增长,vertices 返回已用部分的视图而不是整块,避免把未初始化的尾部喂给 GPU。

// file: src/core/debug-draw.ts
// tier: L1
// verify: npx vitest run src/core/debug-draw.test.ts
//
// helio 内核:调试绘制。把「线框 / 包围体 / 法线 / 拾取射线」这些只在开发期
// 存在的东西,累积成一条顶点缓冲(位置 vec3 + 颜色 vec4),交给渲染层一条
// line-list 管线画出来。纯 CPU 侧数据结构,不碰 GPU,因此可以单测。
//
// 取舍:调试几何每帧重建、绝不留存,所以用「顺序追加 + 自动扩容」而不是对象池;
// 参数非法时用 assert 断言(release 下自动剥掉),把错误挡在开发期。

import { assert } from './diagnostics.ts';
import type { Vec3 } from './vec3.ts';

/** 位置 vec3 + 颜色 vec4 = 7 个 float,与 line-list 管线的顶点布局一一对应。 */
export const FLOATS_PER_VERTEX = 7;

/** RGBA,各分量 0..1。 */
export type Color4 = readonly [number, number, number, number];

/** 常用调色板:一眼能分辨不同类别的调试几何。 */
export const DEBUG_COLORS = {
  box: [0.25, 1.0, 0.35, 1.0],
  sphere: [0.35, 0.7, 1.0, 1.0],
  normal: [1.0, 0.85, 0.2, 1.0],
  ray: [1.0, 0.3, 0.3, 1.0],
  axis: [1.0, 1.0, 1.0, 1.0],
} as const satisfies Record<string, Color4>;

const finite3 = (v: Vec3): boolean => v.every((n) => Number.isFinite(n));
const unit = (v: Vec3): Vec3 => {
  const len = Math.hypot(v[0], v[1], v[2]);
  assert(len > 1e-6, `debug direction is degenerate: ${v}`);
  return [v[0] / len, v[1] / len, v[2] / len];
};

/** 追加式线段累加器。所有图元最终都拆成一段段 line。 */
export class DebugDraw {
  #data: Float32Array;
  #count = 0; // 已写入的顶点数(不是 float 数)

  constructor(capacityVertices = 4096) {
    assert(capacityVertices > 0, `DebugDraw capacity must be > 0, got ${capacityVertices}`);
    this.#data = new Float32Array(capacityVertices * FLOATS_PER_VERTEX);
  }

  get vertexCount(): number {
    return this.#count;
  }
  /** 已使用的顶点数据视图(长度 = vertexCount * 7)。 */
  get vertices(): Float32Array {
    return this.#data.subarray(0, this.#count * FLOATS_PER_VERTEX);
  }
  clear(): void {
    this.#count = 0;
  }

  /** 容量不足时按 2 倍增长,旧数据原样保留。 */
  #reserve(extra: number): void {
    const need = (this.#count + extra) * FLOATS_PER_VERTEX;
    if (need <= this.#data.length) return;
    let cap = this.#data.length * 2;
    while (cap < need) cap *= 2;
    const grown = new Float32Array(cap);
    grown.set(this.#data.subarray(0, this.#count * FLOATS_PER_VERTEX));
    this.#data = grown;
  }

  #vertex(p: Vec3, c: Color4): void {
    const o = this.#count++ * FLOATS_PER_VERTEX;
    this.#data.set(p, o);
    this.#data.set(c, o + 3);
  }

  /** 一段线段:两个顶点。端点必须是有限值,否则整帧的调试绘制都不可信。 */
  line(a: Vec3, b: Vec3, c: Color4): void {
    assert(finite3(a) && finite3(b), `debug line has non-finite endpoint: ${a} -> ${b}`);
    this.#reserve(2);
    this.#vertex(a, c);
    this.#vertex(b, c);
  }

  /** 轴对齐包围盒线框:8 个角(位编码 x/y/z)、12 条边、24 个顶点。 */
  box(min: Vec3, max: Vec3, c: Color4 = DEBUG_COLORS.box): void {
    assert(
      min[0] <= max[0] && min[1] <= max[1] && min[2] <= max[2],
      `debug box min must be <= max on every axis: ${min} vs ${max}`,
    );
    const corner = (i: number): Vec3 => [
      (i & 1) === 0 ? min[0] : max[0],
      (i & 2) === 0 ? min[1] : max[1],
      (i & 4) === 0 ? min[2] : max[2],
    ];
    const edges: readonly (readonly [number, number])[] = [
      [0, 1], [1, 3], [3, 2], [2, 0], // z = min 面
      [4, 5], [5, 7], [7, 6], [6, 4], // z = max 面
      [0, 4], [1, 5], [2, 6], [3, 7], // 连接边
    ];
    this.#reserve(edges.length * 2);
    for (const [i, j] of edges) this.line(corner(i), corner(j), c);
  }

  /** 球体线框:三个互相垂直的大圆(XY / XZ / YZ),每个圆 segments 段。 */
  sphere(center: Vec3, radius: number, c: Color4 = DEBUG_COLORS.sphere, segments = 16): void {
    assert(radius > 0, `debug sphere radius must be > 0, got ${radius}`);
    assert(segments >= 3, `debug sphere needs >= 3 segments, got ${segments}`);
    const [cx, cy, cz] = center;
    for (let i = 0; i < segments; i++) {
      const a0 = (2 * Math.PI * i) / segments;
      const a1 = (2 * Math.PI * (i + 1)) / segments;
      const [c0, s0, c1, s1] = [Math.cos(a0), Math.sin(a0), Math.cos(a1), Math.sin(a1)];
      this.line([cx + c0 * radius, cy + s0 * radius, cz], [cx + c1 * radius, cy + s1 * radius, cz], c);
      this.line([cx + c0 * radius, cy, cz + s0 * radius], [cx + c1 * radius, cy, cz + s1 * radius], c);
      this.line([cx, cy + c0 * radius, cz + s0 * radius], [cx, cy + c1 * radius, cz + s1 * radius], c);
    }
  }

  /** 法线箭头:一段杆 + 两段箭头,共 3 段线、6 个顶点。方向会被归一化。 */
  normal(origin: Vec3, dir: Vec3, length: number, c: Color4 = DEBUG_COLORS.normal): void {
    assert(length > 0, `debug normal length must be > 0, got ${length}`);
    const u = unit(dir);
    const tip: Vec3 = [origin[0] + u[0] * length, origin[1] + u[1] * length, origin[2] + u[2] * length];
    // 在垂直于 u 的平面里取一条侧轴,做两片箭头
    const h: Vec3 = Math.abs(u[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
    const side = unit([u[1] * h[2] - u[2] * h[1], u[2] * h[0] - u[0] * h[2], u[0] * h[1] - u[1] * h[0]]);
    const back = length * 0.25;
    const wing = (s: number): Vec3 => [
      tip[0] - u[0] * back + side[0] * back * s,
      tip[1] - u[1] * back + side[1] * back * s,
      tip[2] - u[2] * back + side[2] * back * s,
    ];
    this.line(origin, tip, c);
    this.line(tip, wing(1), c);
    this.line(tip, wing(-1), c);
  }

  /** 拾取射线:从 origin 沿归一化方向走 length。 */
  ray(origin: Vec3, dir: Vec3, length: number, c: Color4 = DEBUG_COLORS.ray): void {
    assert(length > 0, `debug ray length must be > 0, got ${length}`);
    const u = unit(dir);
    this.line(origin, [origin[0] + u[0] * length, origin[1] + u[1] * length, origin[2] + u[2] * length], c);
  }
}

单测盯三件事:每个图元的顶点数(box 恒为 24、sphere 为 3 * segments * 2)、断言的 debug/release 行为、以及扩容时旧数据不丢。关键几条:

// snippet: src/core/debug-draw.test.ts 的三条关键断言
it('box 产生 12 条边 = 24 个顶点', () => {
  const d = new DebugDraw(64);
  d.box([-1, -1, -1], [1, 1, 1]);
  expect(d.vertexCount).toBe(24);
});

it('box 的 min > max 触发断言(debug 档)', () => {
  setBuildMode('debug');
  const d = new DebugDraw(64);
  expect(() => d.box([1, 0, 0], [0, 1, 1])).toThrow(AssertionError);
  setBuildMode('release');
  expect(() => d.box([1, 0, 0], [0, 1, 1])).not.toThrow(); // release 下断言被剥掉
});

it('容量不足时自动扩容且旧数据不丢', () => {
  const d = new DebugDraw(2); // 只够 2 个顶点
  d.line([0, 0, 0], [1, 0, 0], [1, 1, 1, 1]);
  d.box([-1, -1, -1], [1, 1, 1]); // 触发扩容
  expect(d.vertexCount).toBe(2 + 24);
  expect(Array.from(d.vertices.slice(0, 3))).toEqual([0, 0, 0]); // 第一段的起点还在
});

本机实跑:

$ npx tsc --noEmit
TypeScript: No errors found
$ npx vitest run src/core/debug-draw.test.ts
 Test Files  1 passed (1)
      Tests  7 passed (7)

把它真画出来(L2)

顶点缓冲对不对,只有真画一遍才知道。着色器极简:位置乘 viewProj,颜色原样透传,不做光照也不做雾。

// file: shaders/debug-lines.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/scene/debug-draw-l2.ts
//
// 6.1 的调试线着色器:位置 vec3 + 颜色 vec4 进,line-list 出。
// viewProj 由 CPU 侧给;颜色直接透传到片元,不做光照、不做雾。

struct Cam { vp: mat4x4<f32> };
@group(0) @binding(0) var<uniform> cam: Cam;

struct VSOut {
  @builtin(position) pos: vec4<f32>,
  @location(0) color: vec4<f32>,
};

@vertex
fn vs(@location(0) p: vec3<f32>, @location(1) c: vec4<f32>) -> VSOut {
  var o: VSOut;
  o.pos = cam.vp * vec4<f32>(p, 1.0);
  o.color = c;
  return o;
}

@fragment
fn fs(i: VSOut) -> @location(0) vec4<f32> {
  return i.color;
}

L2 的关键技巧是用单位矩阵当 viewProj:顶点坐标直接当 clip 坐标,于是「某个顶点落在哪个像素」可以纯手算。盒子边长取 1、以原点为中心,四条竖直/水平边正好落在 clip 的 x,y = ±0.5,对应 128 像素画布上的第 32 与第 96 列。回读像素后,这几条断言就能精确到位。

// snippet: src/render/scene/debug-draw-l2.ts 的渲染与断言
const pipeline = device.createRenderPipeline({
  layout: 'auto',
  vertex: { module, entryPoint: 'vs', buffers: [{
    arrayStride: 7 * 4,
    attributes: [
      { shaderLocation: 0, offset: 0, format: 'float32x3' },
      { shaderLocation: 1, offset: 3 * 4, format: 'float32x4' },
    ],
  }] },
  fragment: { module, entryPoint: 'fs', targets: [{ format: 'rgba8unorm' }] },
  primitive: { topology: 'line-list' },
});
// ... 绑定 vbuf、clear 成场景底色、draw(verts.length / 7)、copyTextureToBuffer 回读

const dd = new DebugDraw(256);
dd.box([-0.5, -0.5, -0.5], [0.5, 0.5, 0.5], DEBUG_COLORS.box);
dd.ray([-0.9, 0.9, 0], [1, -1, 0], 0.35, DEBUG_COLORS.ray); // 左上角一条红射线
// 沿中间一行找线框的左右两条竖直边
const rowHits: number[] = [];
for (let x = 0; x < SIZE; x++) if (near(at(x, 64), boxPix, 40)) rowHits.push(x);
const left = rowHits[0] ?? -1;
const right = rowHits[rowHits.length - 1] ?? -1;
push('左右竖直边落在 x=32 / x=96(±3)', Math.abs(left - 32) <= 3 && Math.abs(right - 96) <= 3, ...);

headless Chromium(SwiftShader)实跑结果:

$ node tools/run-l2.mjs src/render/scene/debug-draw-l2.ts
PASS  线框像素数 > 200  boxHits=255
PASS  盒子内部(中心像素)仍是场景底色  [13,13,31,255]
PASS  左右竖直边落在 x=32 / x=96(±3)  left=31 right=95
PASS  拾取射线是另一种颜色  rayHits=16
PASS  射线落在左上象限  rayTopLeft=16
=== run-l2 src/render/scene/debug-draw-l2.ts: ALL PASS ===

26 个顶点(盒 24 + 射线 2)被画成 255 个绿色像素,四条边分别落在预期的 31/95 列;盒子内部仍是 [13,13,31] 的场景底色,说明调试几何只画线、不填充;左上角的红色射线独立成色。这三条合起来证明的不是「代码没报错」,而是「顶点缓冲里的每个数都变成了屏幕上正确位置的像素」——这正是调试绘制能当工具用的前提。

有了画线框的能力,下一步是让开发者能在运行时输入命令去开关这些可视化。下一节做控制台、CVar 与作弊码。

阅读导航:上一节:回到目录 · 下一节:6.2 控制台与作弊命令 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径