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 控制台与作弊命令 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。