@forgeax/engine-debug-draw 0.0.0-dev.8d955ade1c79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +256 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/errors.test-d.d.ts +2 -0
- package/dist/__tests__/errors.test-d.d.ts.map +1 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/debug-draw.d.ts +64 -0
- package/dist/debug-draw.d.ts.map +1 -0
- package/dist/errors.d.ts +65 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +893 -0
- package/dist/index.mjs.map +1 -0
- package/dist/render-feature.d.ts +12 -0
- package/dist/render-feature.d.ts.map +1 -0
- package/dist/shapes/aabb.d.ts +4 -0
- package/dist/shapes/aabb.d.ts.map +1 -0
- package/dist/shapes/arrow.d.ts +11 -0
- package/dist/shapes/arrow.d.ts.map +1 -0
- package/dist/shapes/axes.d.ts +18 -0
- package/dist/shapes/axes.d.ts.map +1 -0
- package/dist/shapes/frustum.d.ts +7 -0
- package/dist/shapes/frustum.d.ts.map +1 -0
- package/dist/shapes/line.d.ts +4 -0
- package/dist/shapes/line.d.ts.map +1 -0
- package/dist/shapes/sphere.d.ts +7 -0
- package/dist/shapes/sphere.d.ts.map +1 -0
- package/dist/types.d.ts +134 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +62 -0
- package/src/__tests__/errors.test-d.ts +89 -0
- package/src/constants.ts +13 -0
- package/src/debug-draw.ts +709 -0
- package/src/errors.ts +142 -0
- package/src/index.ts +18 -0
- package/src/render-feature.ts +73 -0
- package/src/shapes/aabb.ts +57 -0
- package/src/shapes/arrow.ts +75 -0
- package/src/shapes/axes.ts +60 -0
- package/src/shapes/frustum.ts +182 -0
- package/src/shapes/line.ts +14 -0
- package/src/shapes/sphere.ts +74 -0
- package/src/types.ts +174 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
// @forgeax/engine-debug-draw -- DebugDraw class implementation (feat-20260615-debug-draw M2)
|
|
2
|
+
//
|
|
3
|
+
// This file is the single SSOT for DebugDraw class, GPU resource lifecycle,
|
|
4
|
+
// flush, and destroy semantics.
|
|
5
|
+
//
|
|
6
|
+
// Decision anchors:
|
|
7
|
+
// - plan-strategy D-2: depthMode single-instance single-PSO
|
|
8
|
+
// - plan-strategy D-3: inline WGSL, direct RHI (no ShaderRegistry)
|
|
9
|
+
// - plan-strategy D-4: queue.writeBuffer overwrite + double-resize + hard-cap truncate
|
|
10
|
+
// - plan-strategy D-9: vertex stride 16 B (12 B position + 4 B color)
|
|
11
|
+
// - plan-strategy D-11: destroy-after-shape = no-op + single warn
|
|
12
|
+
|
|
13
|
+
import type { ColorLike, Mat4, Vec3 } from '@forgeax/engine-math';
|
|
14
|
+
import type {
|
|
15
|
+
BindGroup,
|
|
16
|
+
Buffer,
|
|
17
|
+
RenderPipeline,
|
|
18
|
+
RhiCommandEncoder,
|
|
19
|
+
RhiDevice,
|
|
20
|
+
RhiRenderPassEncoder,
|
|
21
|
+
TextureView,
|
|
22
|
+
} from '@forgeax/engine-rhi';
|
|
23
|
+
import type { Result } from '@forgeax/engine-types';
|
|
24
|
+
import { ok } from '@forgeax/engine-types';
|
|
25
|
+
import { INITIAL_VERTEX_CAPACITY, MAX_VERTEX_CAPACITY, VERTEX_STRIDE_BYTES } from './constants';
|
|
26
|
+
import type { DebugDrawError } from './errors';
|
|
27
|
+
import {
|
|
28
|
+
bufferAllocationFailed,
|
|
29
|
+
flushedAfterDestroy,
|
|
30
|
+
pipelineCreateFailed,
|
|
31
|
+
viewProjRequired,
|
|
32
|
+
} from './errors';
|
|
33
|
+
import { aabbVertices } from './shapes/aabb';
|
|
34
|
+
import { arrowVertices } from './shapes/arrow';
|
|
35
|
+
import { axesArrowSets } from './shapes/axes';
|
|
36
|
+
import { frustumVertices } from './shapes/frustum';
|
|
37
|
+
import { lineVertices } from './shapes/line';
|
|
38
|
+
import { sphereVertices } from './shapes/sphere';
|
|
39
|
+
import type { DebugDraw as DebugDrawInterface, DebugDrawOptions } from './types';
|
|
40
|
+
|
|
41
|
+
// ==========================================================================
|
|
42
|
+
// Inline WGSL (plan-strategy D-3: no ShaderRegistry dependency)
|
|
43
|
+
// ==========================================================================
|
|
44
|
+
|
|
45
|
+
const VERTEX_SHADER = /* wgsl */ `
|
|
46
|
+
struct VertexInput {
|
|
47
|
+
@location(0) position: vec3<f32>,
|
|
48
|
+
@location(1) color: vec4<f32>,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
struct VertexOutput {
|
|
52
|
+
@builtin(position) position: vec4<f32>,
|
|
53
|
+
@location(0) color: vec4<f32>,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
struct Uniforms {
|
|
57
|
+
viewProj: mat4x4<f32>,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
|
|
61
|
+
|
|
62
|
+
@vertex
|
|
63
|
+
fn vs_main(in: VertexInput) -> VertexOutput {
|
|
64
|
+
var out: VertexOutput;
|
|
65
|
+
out.position = uniforms.viewProj * vec4<f32>(in.position, 1.0);
|
|
66
|
+
out.color = in.color;
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
const FRAGMENT_SHADER = /* wgsl */ `
|
|
72
|
+
struct FragmentInput {
|
|
73
|
+
@location(0) color: vec4<f32>,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@fragment
|
|
77
|
+
fn fs_main(in: FragmentInput) -> @location(0) vec4<f32> {
|
|
78
|
+
return in.color;
|
|
79
|
+
}
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
// ==========================================================================
|
|
83
|
+
// Internal helpers
|
|
84
|
+
// ==========================================================================
|
|
85
|
+
|
|
86
|
+
/** Access Vec3/Mat4/ColorLike array element, narrow via `as number`. */
|
|
87
|
+
function at(a: { readonly [index: number]: number }, i: number): number {
|
|
88
|
+
return a[i] as number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeCapacity(value: number, fallback: number): number {
|
|
92
|
+
const finiteValue = Number.isFinite(value) ? Math.floor(value) : fallback;
|
|
93
|
+
return Math.max(1, finiteValue);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ==========================================================================
|
|
97
|
+
// DebugDraw (w12 / w13 / w14)
|
|
98
|
+
// ==========================================================================
|
|
99
|
+
|
|
100
|
+
export class DebugDraw implements DebugDrawInterface {
|
|
101
|
+
private stagingArr: Float32Array;
|
|
102
|
+
private stagingLen = 0;
|
|
103
|
+
|
|
104
|
+
private lastFlushedVertexCount = 0;
|
|
105
|
+
|
|
106
|
+
private capVal: number;
|
|
107
|
+
|
|
108
|
+
private gpuVbo: Buffer | null = null;
|
|
109
|
+
|
|
110
|
+
private gpuPipeline: RenderPipeline | null = null;
|
|
111
|
+
|
|
112
|
+
private gpuUniformBuffer: Buffer | null = null;
|
|
113
|
+
|
|
114
|
+
private gpuBindGroup: BindGroup | null = null;
|
|
115
|
+
|
|
116
|
+
private maxCapVal: number;
|
|
117
|
+
|
|
118
|
+
private readonly rhiDevice: RhiDevice;
|
|
119
|
+
|
|
120
|
+
private isDestroyed = false;
|
|
121
|
+
|
|
122
|
+
// Whether the destroy-after-shape warning has been emitted (plan-strategy D-11).
|
|
123
|
+
// private (no underscore, no @internal) — purely class-internal state, not part of
|
|
124
|
+
// package-internal API surface. Biome R-internal-A forbids `_x` on private fields;
|
|
125
|
+
// lint:internal R-internal-C requires `_x` for `@internal`. Drop both markers since
|
|
126
|
+
// there is no package-internal use for this field — accessing it from outside the
|
|
127
|
+
// class is meaningless.
|
|
128
|
+
private destroyedWarnedOnce = false;
|
|
129
|
+
|
|
130
|
+
// Hard-cap diagnostics are per frame: flush() clears this flag with staging.
|
|
131
|
+
private truncationWarned = false;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Depth texture view for less-equal depth mode.
|
|
135
|
+
* Set via {@link _setDepthView} before flush() when depthMode is 'less-equal'.
|
|
136
|
+
* The runtime auto-attach path receives depth from the render-graph context;
|
|
137
|
+
* low-path callers (test harnesses, smoke runners) set this explicitly.
|
|
138
|
+
*/
|
|
139
|
+
private depthView: TextureView | null = null;
|
|
140
|
+
|
|
141
|
+
constructor(
|
|
142
|
+
device: RhiDevice,
|
|
143
|
+
pipeline: RenderPipeline,
|
|
144
|
+
vbo: Buffer,
|
|
145
|
+
uniformBuffer: Buffer,
|
|
146
|
+
bindGroup: BindGroup,
|
|
147
|
+
initialCapacity: number,
|
|
148
|
+
maxCapacity: number,
|
|
149
|
+
) {
|
|
150
|
+
const boundedMaxCapacity = normalizeCapacity(maxCapacity, MAX_VERTEX_CAPACITY);
|
|
151
|
+
const boundedInitialCapacity = Math.min(
|
|
152
|
+
normalizeCapacity(initialCapacity, INITIAL_VERTEX_CAPACITY),
|
|
153
|
+
boundedMaxCapacity,
|
|
154
|
+
);
|
|
155
|
+
this.rhiDevice = device;
|
|
156
|
+
this.gpuPipeline = pipeline;
|
|
157
|
+
this.gpuVbo = vbo;
|
|
158
|
+
this.gpuUniformBuffer = uniformBuffer;
|
|
159
|
+
this.gpuBindGroup = bindGroup;
|
|
160
|
+
this.capVal = boundedInitialCapacity;
|
|
161
|
+
this.maxCapVal = boundedMaxCapacity;
|
|
162
|
+
this.stagingArr = new Float32Array(boundedInitialCapacity * (VERTEX_STRIDE_BYTES / 4));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** @internal CPU staging vertex count (exposed for unit tests). */
|
|
166
|
+
get _stagingVertexCount(): number {
|
|
167
|
+
return this.stagingLen;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @internal Vertex count passed to the most recent non-empty draw call. */
|
|
171
|
+
get _lastFlushVertexCount(): number {
|
|
172
|
+
return this.lastFlushedVertexCount;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** @internal Current GPU vertex buffer capacity in vertex count. */
|
|
176
|
+
get _capacity(): number {
|
|
177
|
+
return this.capVal;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @internal Whether destroy() has been called. */
|
|
181
|
+
get _destroyed(): boolean {
|
|
182
|
+
return this.isDestroyed;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @internal Set the depth texture view for less-equal depth mode.
|
|
187
|
+
* Required before flush() when depthMode is 'less-equal'.
|
|
188
|
+
* Used by test harnesses and smoke runners that don't have a scene
|
|
189
|
+
* depth buffer; the runtime auto-attach path receives depth from
|
|
190
|
+
* the render-graph context.
|
|
191
|
+
*/
|
|
192
|
+
_setDepthView(view: TextureView): void {
|
|
193
|
+
this.depthView = view;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @internal Read position of vertex at `index` in CPU staging (for unit tests). */
|
|
197
|
+
_getVertexPosition(index: number): [number, number, number] {
|
|
198
|
+
const idx = index * 4;
|
|
199
|
+
return [
|
|
200
|
+
this.stagingArr[idx + 0] as number,
|
|
201
|
+
this.stagingArr[idx + 1] as number,
|
|
202
|
+
this.stagingArr[idx + 2] as number,
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** @internal Read color of vertex at `index` as packed u32 (for unit tests). */
|
|
207
|
+
_getVertexPackedColor(index: number): number {
|
|
208
|
+
const byteOff = index * VERTEX_STRIDE_BYTES + 12;
|
|
209
|
+
const bytes = new Uint8Array(this.stagingArr.buffer, byteOff, 4);
|
|
210
|
+
return (
|
|
211
|
+
((bytes[3] as number) << 24) |
|
|
212
|
+
((bytes[2] as number) << 16) |
|
|
213
|
+
((bytes[1] as number) << 8) |
|
|
214
|
+
(bytes[0] as number)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private postDestroyWarnOnce(): void {
|
|
219
|
+
if (!this.destroyedWarnedOnce) {
|
|
220
|
+
this.destroyedWarnedOnce = true;
|
|
221
|
+
console.warn(
|
|
222
|
+
'[DebugDraw] Shape call after destroy() is a no-op. ' +
|
|
223
|
+
'Create a new instance via createDebugDraw().',
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private pushVertex(
|
|
229
|
+
px: number,
|
|
230
|
+
py: number,
|
|
231
|
+
pz: number,
|
|
232
|
+
rc: number,
|
|
233
|
+
gc: number,
|
|
234
|
+
bc: number,
|
|
235
|
+
ac: number,
|
|
236
|
+
): void {
|
|
237
|
+
if (this.isDestroyed) {
|
|
238
|
+
this.postDestroyWarnOnce();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Truncation: silently drop vertices above max cap (warned once in ensureCapacity)
|
|
243
|
+
if (this.stagingLen >= this.maxCapVal) return;
|
|
244
|
+
|
|
245
|
+
const idx = this.stagingLen * 4; // 4 f32s per vertex (stride=16B)
|
|
246
|
+
this.stagingArr[idx + 0] = px;
|
|
247
|
+
this.stagingArr[idx + 1] = py;
|
|
248
|
+
this.stagingArr[idx + 2] = pz;
|
|
249
|
+
|
|
250
|
+
// Write RGBA as u8 bytes via Uint8Array view (Float32Array would
|
|
251
|
+
// corrupt u32 bit-patterns via f32 conversion; the GPU reads raw bytes).
|
|
252
|
+
const u8r = Math.round(Math.max(0, Math.min(1, rc)) * 255);
|
|
253
|
+
const u8g = Math.round(Math.max(0, Math.min(1, gc)) * 255);
|
|
254
|
+
const u8bc = Math.round(Math.max(0, Math.min(1, bc)) * 255);
|
|
255
|
+
const u8a = Math.round(Math.max(0, Math.min(1, ac)) * 255);
|
|
256
|
+
const byteOff = this.stagingLen * VERTEX_STRIDE_BYTES + 12;
|
|
257
|
+
const colorView = new Uint8Array(this.stagingArr.buffer, byteOff, 4);
|
|
258
|
+
colorView[0] = u8r;
|
|
259
|
+
colorView[1] = u8g;
|
|
260
|
+
colorView[2] = u8bc;
|
|
261
|
+
colorView[3] = u8a;
|
|
262
|
+
|
|
263
|
+
this.stagingLen++;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private warnTruncationOnce(): void {
|
|
267
|
+
if (this.truncationWarned) return;
|
|
268
|
+
this.truncationWarned = true;
|
|
269
|
+
console.warn(
|
|
270
|
+
`[DebugDraw] Vertex count would exceed MAX_VERTEX_CAPACITY=${this.maxCapVal}; ` +
|
|
271
|
+
'vertices beyond the limit are discarded.',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private ensureCapacity(needed: number): void {
|
|
276
|
+
if (this.isDestroyed) return;
|
|
277
|
+
if (needed <= this.capVal) return;
|
|
278
|
+
|
|
279
|
+
// Warn for hard-cap truncation once for this frame, before any vertex drops.
|
|
280
|
+
if (needed > this.maxCapVal) {
|
|
281
|
+
this.warnTruncationOnce();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Double up to max cap
|
|
285
|
+
let newCap = this.capVal;
|
|
286
|
+
while (newCap < needed && newCap < this.maxCapVal) {
|
|
287
|
+
newCap = Math.min(newCap * 2, this.maxCapVal);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (newCap > this.capVal) {
|
|
291
|
+
// Bug B fix (feat-20260626 m6-4): the GPU vertex buffer must grow with the
|
|
292
|
+
// CPU staging, or flush() binds a buffer too small for the staged vertex
|
|
293
|
+
// count and the backend rejects the draw ("Vertex range requires a larger
|
|
294
|
+
// buffer than the bound buffer size"). This was latent until the overlay
|
|
295
|
+
// pass actually flushed -- before the glue merge the pass was a no-op stub,
|
|
296
|
+
// so the GPU vbo was never bound past its initial size. Reallocate at the
|
|
297
|
+
// new size + destroy the old; on alloc failure keep the old cap (the draw
|
|
298
|
+
// stays bounded + correct, just truncated) rather than growing the staging
|
|
299
|
+
// past the GPU buffer.
|
|
300
|
+
const newVbo = this.rhiDevice.createBuffer({
|
|
301
|
+
size: newCap * VERTEX_STRIDE_BYTES,
|
|
302
|
+
usage: 8 | 32, // COPY_DST | VERTEX (mirrors createDebugDraw factory)
|
|
303
|
+
label: 'debug-draw-vbo',
|
|
304
|
+
});
|
|
305
|
+
if (!newVbo.ok) {
|
|
306
|
+
console.warn(
|
|
307
|
+
`[DebugDraw] GPU vertex buffer grow to ${newCap} failed (${newVbo.error.code}); ` +
|
|
308
|
+
`keeping ${this.capVal} -- excess vertices are truncated this frame.`,
|
|
309
|
+
);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
console.warn(`[DebugDraw] Resizing vertex buffer from ${this.capVal} to ${newCap} vertices.`);
|
|
313
|
+
if (this.gpuVbo !== null) this.rhiDevice.destroyBuffer(this.gpuVbo);
|
|
314
|
+
this.gpuVbo = newVbo.value;
|
|
315
|
+
this.capVal = newCap;
|
|
316
|
+
const newStaging = new Float32Array(newCap * (VERTEX_STRIDE_BYTES / 4));
|
|
317
|
+
newStaging.set(this.stagingArr.subarray(0, this.stagingLen * 4));
|
|
318
|
+
this.stagingArr = newStaging;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private colorToRGBA(color: ColorLike): [number, number, number, number] {
|
|
323
|
+
if (Array.isArray(color)) {
|
|
324
|
+
return [at(color, 0), at(color, 1), at(color, 2), (color as number[])[3] ?? 1];
|
|
325
|
+
}
|
|
326
|
+
// Float32Array (branded Color or plain)
|
|
327
|
+
return [at(color, 0), at(color, 1), at(color, 2), color[3] ?? 1];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// -- Public shape API --
|
|
331
|
+
|
|
332
|
+
line(a: Vec3, b: Vec3, color: ColorLike): void {
|
|
333
|
+
if (this.isDestroyed) {
|
|
334
|
+
this.postDestroyWarnOnce();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const [r, g, bc, alpha] = this.colorToRGBA(color);
|
|
338
|
+
this.ensureCapacity(this.stagingLen + 2);
|
|
339
|
+
for (const [x, y, z] of lineVertices(a, b)) {
|
|
340
|
+
this.pushVertex(x, y, z, r, g, bc, alpha);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
aabb(min: Vec3, max: Vec3, color: ColorLike): void {
|
|
345
|
+
if (this.isDestroyed) {
|
|
346
|
+
this.postDestroyWarnOnce();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const [r, g, bc, alpha] = this.colorToRGBA(color);
|
|
350
|
+
const verts = aabbVertices(min, max);
|
|
351
|
+
this.ensureCapacity(this.stagingLen + verts.length);
|
|
352
|
+
for (const [x, y, z] of verts) {
|
|
353
|
+
this.pushVertex(x, y, z, r, g, bc, alpha);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
sphere(center: Vec3, radius: number, color: ColorLike, segments = 16): void {
|
|
358
|
+
if (this.isDestroyed) {
|
|
359
|
+
this.postDestroyWarnOnce();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const [r, g, bc, alpha] = this.colorToRGBA(color);
|
|
363
|
+
const verts = sphereVertices(center, radius, segments);
|
|
364
|
+
this.ensureCapacity(this.stagingLen + verts.length);
|
|
365
|
+
for (const [x, y, z] of verts) {
|
|
366
|
+
this.pushVertex(x, y, z, r, g, bc, alpha);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
frustum(viewProj: Mat4, color: ColorLike): void {
|
|
371
|
+
if (this.isDestroyed) {
|
|
372
|
+
this.postDestroyWarnOnce();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const verts = frustumVertices(viewProj);
|
|
376
|
+
if (verts === null) {
|
|
377
|
+
console.warn(
|
|
378
|
+
'[DebugDraw] frustum() received a near-singular viewProj matrix; skipping this frame.',
|
|
379
|
+
);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const [r, g, bc, alpha] = this.colorToRGBA(color);
|
|
383
|
+
this.ensureCapacity(this.stagingLen + verts.length);
|
|
384
|
+
for (const [x, y, z] of verts) {
|
|
385
|
+
this.pushVertex(x, y, z, r, g, bc, alpha);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
arrow(start: Vec3, end: Vec3, color: ColorLike, tipLength?: number): void {
|
|
390
|
+
if (this.isDestroyed) {
|
|
391
|
+
this.postDestroyWarnOnce();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const [r, g, bc, alpha] = this.colorToRGBA(color);
|
|
395
|
+
const verts = arrowVertices(start, end, tipLength);
|
|
396
|
+
this.ensureCapacity(this.stagingLen + verts.length);
|
|
397
|
+
for (const [x, y, z] of verts) {
|
|
398
|
+
this.pushVertex(x, y, z, r, g, bc, alpha);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
axes(worldMat: Mat4, length: number): void {
|
|
403
|
+
if (this.isDestroyed) {
|
|
404
|
+
this.postDestroyWarnOnce();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// Three arrows (X=red, Y=green, Z=blue) along the transform's local axes; each
|
|
408
|
+
// carries its own color, so they cannot share the single-color push path.
|
|
409
|
+
for (const { vertices, color } of axesArrowSets(worldMat, length)) {
|
|
410
|
+
const [r, g, bc, alpha] = this.colorToRGBA(color as unknown as ColorLike);
|
|
411
|
+
this.ensureCapacity(this.stagingLen + vertices.length);
|
|
412
|
+
for (const [x, y, z] of vertices) {
|
|
413
|
+
this.pushVertex(x, y, z, r, g, bc, alpha);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// -- flush (w13) --
|
|
419
|
+
|
|
420
|
+
flush(
|
|
421
|
+
encoder: RhiCommandEncoder,
|
|
422
|
+
view: TextureView,
|
|
423
|
+
viewProj: Mat4,
|
|
424
|
+
): Result<void, DebugDrawError> {
|
|
425
|
+
if (this.isDestroyed) return flushedAfterDestroy();
|
|
426
|
+
if (viewProj === undefined || viewProj === null) return viewProjRequired();
|
|
427
|
+
if (this.stagingLen === 0) {
|
|
428
|
+
this.lastFlushedVertexCount = 0;
|
|
429
|
+
return ok(undefined as void);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Begin render pass with loadOp='load' to preserve scene content.
|
|
433
|
+
// forgeax TextureView is an opaque RHI handle; the underlying WebGPU
|
|
434
|
+
// GPURenderPassDescriptor expects raw GPUTextureView.
|
|
435
|
+
// When depthMode is 'less-equal', a depthStencilAttachment is required
|
|
436
|
+
// matching the PSO's depth format. The caller must provide depthView
|
|
437
|
+
// for less-equal mode; without it, the render pass will fail validation.
|
|
438
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque RHI descriptor (color + optional depth)
|
|
439
|
+
const passDesc: Record<string, any> = {
|
|
440
|
+
colorAttachments: [
|
|
441
|
+
{
|
|
442
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque RHI handle
|
|
443
|
+
view: view as any,
|
|
444
|
+
loadOp: 'load',
|
|
445
|
+
storeOp: 'store',
|
|
446
|
+
},
|
|
447
|
+
],
|
|
448
|
+
};
|
|
449
|
+
if (this.depthView !== null) {
|
|
450
|
+
passDesc.depthStencilAttachment = {
|
|
451
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque depth view
|
|
452
|
+
view: this.depthView as any,
|
|
453
|
+
depthLoadOp: 'load',
|
|
454
|
+
depthStoreOp: 'store',
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque RHI descriptor
|
|
458
|
+
const pass = encoder.beginRenderPass(passDesc as any);
|
|
459
|
+
|
|
460
|
+
const encoded = this.encode(pass, viewProj);
|
|
461
|
+
pass.end();
|
|
462
|
+
return encoded;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
encode(pass: RhiRenderPassEncoder, viewProj: Mat4): Result<void, DebugDrawError> {
|
|
466
|
+
if (this.isDestroyed) return flushedAfterDestroy();
|
|
467
|
+
if (viewProj === undefined || viewProj === null) return viewProjRequired();
|
|
468
|
+
if (this.stagingLen === 0) {
|
|
469
|
+
this.lastFlushedVertexCount = 0;
|
|
470
|
+
return ok(undefined as void);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const vertexCount = Math.min(this.stagingLen, this.maxCapVal);
|
|
474
|
+
const vbo = this.gpuVbo as Buffer;
|
|
475
|
+
const pipeline = this.gpuPipeline as RenderPipeline;
|
|
476
|
+
const uniformBuf = this.gpuUniformBuffer as Buffer;
|
|
477
|
+
const bindGroup = this.gpuBindGroup as BindGroup;
|
|
478
|
+
const byteCount = vertexCount * VERTEX_STRIDE_BYTES;
|
|
479
|
+
this.rhiDevice.queue.writeBuffer(
|
|
480
|
+
vbo,
|
|
481
|
+
0,
|
|
482
|
+
new Uint8Array(this.stagingArr.buffer, 0, byteCount),
|
|
483
|
+
0,
|
|
484
|
+
byteCount,
|
|
485
|
+
);
|
|
486
|
+
const uniformData = new Float32Array(16);
|
|
487
|
+
for (let i = 0; i < 16; i++) uniformData[i] = viewProj[i] as number;
|
|
488
|
+
this.rhiDevice.queue.writeBuffer(uniformBuf, 0, new Uint8Array(uniformData.buffer), 0, 64);
|
|
489
|
+
|
|
490
|
+
pass.setPipeline(pipeline);
|
|
491
|
+
pass.setBindGroup(0, bindGroup);
|
|
492
|
+
pass.setVertexBuffer(0, vbo);
|
|
493
|
+
pass.draw(vertexCount);
|
|
494
|
+
this.lastFlushedVertexCount = vertexCount;
|
|
495
|
+
|
|
496
|
+
// Reset staging for next frame
|
|
497
|
+
this.stagingLen = 0;
|
|
498
|
+
this.truncationWarned = false;
|
|
499
|
+
|
|
500
|
+
return ok(undefined as void);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// -- destroy (w14) --
|
|
504
|
+
|
|
505
|
+
destroy(): void {
|
|
506
|
+
if (this.isDestroyed) return;
|
|
507
|
+
this.isDestroyed = true;
|
|
508
|
+
|
|
509
|
+
if (this.gpuVbo) {
|
|
510
|
+
this.rhiDevice.destroyBuffer(this.gpuVbo);
|
|
511
|
+
this.gpuVbo = null;
|
|
512
|
+
}
|
|
513
|
+
if (this.gpuUniformBuffer) {
|
|
514
|
+
this.rhiDevice.destroyBuffer(this.gpuUniformBuffer);
|
|
515
|
+
this.gpuUniformBuffer = null;
|
|
516
|
+
}
|
|
517
|
+
// BindGroup / Pipeline: WebGPU spec destroys them when the JS reference is lost;
|
|
518
|
+
// we null them to drop GPU resource references.
|
|
519
|
+
this.gpuBindGroup = null;
|
|
520
|
+
this.gpuPipeline = null;
|
|
521
|
+
this.stagingArr = new Float32Array(0);
|
|
522
|
+
this.stagingLen = 0;
|
|
523
|
+
this.lastFlushedVertexCount = 0;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ==========================================================================
|
|
528
|
+
// createDebugDraw factory (w12)
|
|
529
|
+
// ==========================================================================
|
|
530
|
+
|
|
531
|
+
export async function createDebugDraw(
|
|
532
|
+
opts: DebugDrawOptions,
|
|
533
|
+
): Promise<Result<DebugDraw, DebugDrawError>> {
|
|
534
|
+
const device = opts.device;
|
|
535
|
+
const fmt: string = opts.format ?? 'bgra8unorm';
|
|
536
|
+
const depthFormat = opts.depthFormat;
|
|
537
|
+
const depthMode = opts.depthMode ?? 'always';
|
|
538
|
+
const maxCap = normalizeCapacity(
|
|
539
|
+
opts.maxVertexCapacity ?? MAX_VERTEX_CAPACITY,
|
|
540
|
+
MAX_VERTEX_CAPACITY,
|
|
541
|
+
);
|
|
542
|
+
const initialCap = Math.min(
|
|
543
|
+
normalizeCapacity(
|
|
544
|
+
opts.initialVertexCapacity ?? INITIAL_VERTEX_CAPACITY,
|
|
545
|
+
INITIAL_VERTEX_CAPACITY,
|
|
546
|
+
),
|
|
547
|
+
maxCap,
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
// Allocate GPU vertex buffer
|
|
551
|
+
// GPUBufferUsage.COPY_DST=8, VERTEX=32
|
|
552
|
+
const vboByteSize = initialCap * VERTEX_STRIDE_BYTES;
|
|
553
|
+
const vboResult = device.createBuffer({
|
|
554
|
+
size: vboByteSize,
|
|
555
|
+
usage: 8 | 32, // COPY_DST | VERTEX
|
|
556
|
+
label: 'debug-draw-vbo',
|
|
557
|
+
});
|
|
558
|
+
if (!vboResult.ok) {
|
|
559
|
+
return bufferAllocationFailed(
|
|
560
|
+
`createBuffer(COPY_DST|VERTEX, ${vboByteSize}B): ${vboResult.error.code}`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
const vbo = vboResult.value;
|
|
564
|
+
|
|
565
|
+
// Allocate uniform buffer for viewProj (mat4x4<f32> = 64 bytes)
|
|
566
|
+
// GPUBufferUsage.UNIFORM=64 (0x0040), COPY_DST=8 (0x0008)
|
|
567
|
+
const uniformBufResult = device.createBuffer({
|
|
568
|
+
size: 64,
|
|
569
|
+
usage: 64 | 8, // UNIFORM | COPY_DST
|
|
570
|
+
label: 'debug-draw-uniform',
|
|
571
|
+
});
|
|
572
|
+
if (!uniformBufResult.ok) {
|
|
573
|
+
device.destroyBuffer(vbo);
|
|
574
|
+
return bufferAllocationFailed(
|
|
575
|
+
`createBuffer(UNIFORM|COPY_DST, 64B): ${uniformBufResult.error.code}`,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
const uniformBuf = uniformBufResult.value;
|
|
579
|
+
|
|
580
|
+
// Compile WGSL shader modules via injected factory
|
|
581
|
+
const vsResult = await opts.createShaderModule(device, {
|
|
582
|
+
label: 'debug-draw-vs',
|
|
583
|
+
code: VERTEX_SHADER,
|
|
584
|
+
});
|
|
585
|
+
if (!vsResult.ok) {
|
|
586
|
+
device.destroyBuffer(vbo);
|
|
587
|
+
return pipelineCreateFailed(`createShaderModule(vertex): ${vsResult.error.code}`);
|
|
588
|
+
}
|
|
589
|
+
const vsModule = vsResult.value;
|
|
590
|
+
|
|
591
|
+
const fsResult = await opts.createShaderModule(device, {
|
|
592
|
+
label: 'debug-draw-fs',
|
|
593
|
+
code: FRAGMENT_SHADER,
|
|
594
|
+
});
|
|
595
|
+
if (!fsResult.ok) {
|
|
596
|
+
device.destroyBuffer(vbo);
|
|
597
|
+
return pipelineCreateFailed(`createShaderModule(fragment): ${fsResult.error.code}`);
|
|
598
|
+
}
|
|
599
|
+
const fsModule = fsResult.value;
|
|
600
|
+
|
|
601
|
+
const bglResult = device.createBindGroupLayout({
|
|
602
|
+
label: 'debug-draw-bind-group-layout',
|
|
603
|
+
entries: [
|
|
604
|
+
{
|
|
605
|
+
binding: 0,
|
|
606
|
+
visibility: 1,
|
|
607
|
+
buffer: { type: 'uniform', minBindingSize: 64 },
|
|
608
|
+
},
|
|
609
|
+
],
|
|
610
|
+
});
|
|
611
|
+
if (!bglResult.ok) {
|
|
612
|
+
device.destroyBuffer(vbo);
|
|
613
|
+
device.destroyBuffer(uniformBuf);
|
|
614
|
+
return pipelineCreateFailed(`createBindGroupLayout: ${bglResult.error.code}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const pipelineLayoutResult = device.createPipelineLayout({
|
|
618
|
+
label: 'debug-draw-pipeline-layout',
|
|
619
|
+
bindGroupLayouts: [bglResult.value],
|
|
620
|
+
});
|
|
621
|
+
if (!pipelineLayoutResult.ok) {
|
|
622
|
+
device.destroyBuffer(vbo);
|
|
623
|
+
device.destroyBuffer(uniformBuf);
|
|
624
|
+
return pipelineCreateFailed(`createPipelineLayout: ${pipelineLayoutResult.error.code}`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Build render pipeline descriptor.
|
|
628
|
+
// For 'always' mode: no depthStencil — the overlay draws on top regardless of depth.
|
|
629
|
+
// For 'less-equal' mode: depthStencil included so the overlay respects scene depth.
|
|
630
|
+
const depthStencil: GPUDepthStencilState | undefined =
|
|
631
|
+
depthMode === 'less-equal'
|
|
632
|
+
? {
|
|
633
|
+
format: (depthFormat ?? 'depth24plus') as GPUTextureFormat,
|
|
634
|
+
depthWriteEnabled: false,
|
|
635
|
+
depthCompare: 'less-equal',
|
|
636
|
+
}
|
|
637
|
+
: undefined;
|
|
638
|
+
|
|
639
|
+
const vertexBuffers: GPUVertexBufferLayout[] = [
|
|
640
|
+
{
|
|
641
|
+
arrayStride: VERTEX_STRIDE_BYTES,
|
|
642
|
+
stepMode: 'vertex',
|
|
643
|
+
attributes: [
|
|
644
|
+
{
|
|
645
|
+
format: 'float32x3' as GPUVertexFormat,
|
|
646
|
+
offset: 0,
|
|
647
|
+
shaderLocation: 0,
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
format: 'unorm8x4' as GPUVertexFormat,
|
|
651
|
+
offset: 12,
|
|
652
|
+
shaderLocation: 1,
|
|
653
|
+
},
|
|
654
|
+
],
|
|
655
|
+
},
|
|
656
|
+
];
|
|
657
|
+
|
|
658
|
+
const pipelineDesc = {
|
|
659
|
+
label: 'debug-draw-pso',
|
|
660
|
+
layout: pipelineLayoutResult.value,
|
|
661
|
+
vertex: {
|
|
662
|
+
module: vsModule,
|
|
663
|
+
entryPoint: 'vs_main',
|
|
664
|
+
buffers: [...vertexBuffers],
|
|
665
|
+
},
|
|
666
|
+
primitive: {
|
|
667
|
+
topology: 'line-list' as GPUPrimitiveTopology,
|
|
668
|
+
},
|
|
669
|
+
depthStencil,
|
|
670
|
+
fragment: {
|
|
671
|
+
module: fsModule,
|
|
672
|
+
entryPoint: 'fs_main',
|
|
673
|
+
targets: [{ format: fmt as GPUTextureFormat }],
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
/* biome-ignore lint/suspicious/noExplicitAny: forgeax ShaderModule / TextureFormat
|
|
678
|
+
are opaque RHI handles; underlying WebGPU descriptor expects raw GPU types. */
|
|
679
|
+
const psoResult = device.createRenderPipeline(pipelineDesc as any);
|
|
680
|
+
if (!psoResult.ok) {
|
|
681
|
+
device.destroyBuffer(vbo);
|
|
682
|
+
device.destroyBuffer(uniformBuf);
|
|
683
|
+
return pipelineCreateFailed(`createRenderPipeline: ${psoResult.error.code}`);
|
|
684
|
+
}
|
|
685
|
+
const pipeline = psoResult.value;
|
|
686
|
+
|
|
687
|
+
// Create the bind group from the same explicit RHI layout used by the pipeline.
|
|
688
|
+
const bgResult = device.createBindGroup({
|
|
689
|
+
layout: bglResult.value,
|
|
690
|
+
entries: [
|
|
691
|
+
{
|
|
692
|
+
binding: 0,
|
|
693
|
+
resource: {
|
|
694
|
+
kind: 'buffer' as const,
|
|
695
|
+
value: { buffer: uniformBuf, offset: 0, size: 64 },
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
],
|
|
699
|
+
label: 'debug-draw-bindgroup',
|
|
700
|
+
// biome-ignore lint/suspicious/noExplicitAny: forgeax opaque BGL -> createBindGroup descriptor
|
|
701
|
+
} as any);
|
|
702
|
+
if (!bgResult.ok) {
|
|
703
|
+
device.destroyBuffer(vbo);
|
|
704
|
+
device.destroyBuffer(uniformBuf);
|
|
705
|
+
return pipelineCreateFailed(`createBindGroup: ${bgResult.error.code}`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return ok(new DebugDraw(device, pipeline, vbo, uniformBuf, bgResult.value, initialCap, maxCap));
|
|
709
|
+
}
|