@forgeax/engine-rhi-debug 0.1.28 → 0.1.30
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/README.md +32 -7
- package/dist/__tests__/query-set-lifecycle.unit.test.d.ts +2 -0
- package/dist/__tests__/query-set-lifecycle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/query-set-replay-fixture.d.ts +39 -0
- package/dist/__tests__/query-set-replay-fixture.d.ts.map +1 -0
- package/dist/__tests__/recorder-canvas-context.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-canvas-context.unit.test.d.ts.map +1 -0
- package/dist/__tests__/timestamp-query-capture.dawn.test.d.ts +2 -0
- package/dist/__tests__/timestamp-query-capture.dawn.test.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +527 -58
- package/dist/index.mjs.map +1 -1
- package/dist/protocol/event-semantics.d.ts +1 -1
- package/dist/protocol/event-semantics.d.ts.map +1 -1
- package/dist/protocol/types.d.ts +1 -1
- package/dist/protocol/types.d.ts.map +1 -1
- package/dist/recorder/closure.d.ts.map +1 -1
- package/dist/recorder/core.d.ts +10 -1
- package/dist/recorder/core.d.ts.map +1 -1
- package/dist/recorder/device.d.ts.map +1 -1
- package/dist/recorder/encoder.d.ts.map +1 -1
- package/dist/recorder/pass.d.ts.map +1 -1
- package/dist/recorder/pipeline.d.ts +5 -0
- package/dist/recorder/pipeline.d.ts.map +1 -0
- package/dist/recorder/proxy.d.ts +3 -1
- package/dist/recorder/proxy.d.ts.map +1 -1
- package/dist/recorder/session.d.ts +1 -1
- package/dist/recorder/session.d.ts.map +1 -1
- package/dist/recorder/shader.d.ts +3 -1
- package/dist/recorder/shader.d.ts.map +1 -1
- package/dist/recorder.d.ts +2 -2
- package/dist/recorder.d.ts.map +1 -1
- package/dist/replay/execute.d.ts.map +1 -1
- package/dist/replay/resources.d.ts +4 -1
- package/dist/replay/resources.d.ts.map +1 -1
- package/dist/replay/session.d.ts +7 -0
- package/dist/replay/session.d.ts.map +1 -1
- package/dist/types.d.ts +45 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/coverage-invariant.test-d.ts +4 -5
- package/src/__tests__/coverage-invariant.unit.test.ts +6 -17
- package/src/__tests__/e2e.browser.test.ts +50 -0
- package/src/__tests__/guard-gates.test.ts +1 -1
- package/src/__tests__/query-set-lifecycle.unit.test.ts +265 -0
- package/src/__tests__/query-set-replay-fixture.ts +553 -0
- package/src/__tests__/readback-format-matrix.dawn.test.ts +9 -2
- package/src/__tests__/recorder-canvas-context.unit.test.ts +67 -0
- package/src/__tests__/recorder-session.integration.test.ts +55 -0
- package/src/__tests__/recorder-session.unit.test.ts +18 -1
- package/src/__tests__/replay-session.dawn.test.ts +171 -0
- package/src/__tests__/replay-session.test-d.ts +1 -0
- package/src/__tests__/rhi-debug-fresh-replay.dawn.test.ts +61 -0
- package/src/__tests__/timestamp-query-capture.dawn.test.ts +61 -0
- package/src/index.ts +1 -0
- package/src/protocol/event-semantics.ts +21 -2
- package/src/protocol/types.ts +1 -0
- package/src/protocol/validation.ts +36 -0
- package/src/recorder/assemble.ts +8 -1
- package/src/recorder/closure.ts +21 -1
- package/src/recorder/core.ts +11 -0
- package/src/recorder/device.ts +33 -6
- package/src/recorder/encoder.ts +60 -6
- package/src/recorder/pass.ts +7 -2
- package/src/recorder/pipeline.ts +31 -0
- package/src/recorder/proxy.ts +58 -2
- package/src/recorder/session.ts +1 -1
- package/src/recorder/shader.ts +29 -22
- package/src/recorder.ts +6 -2
- package/src/replay/execute.ts +143 -1
- package/src/replay/readback.ts +15 -3
- package/src/replay/resources.ts +6 -0
- package/src/replay/session.ts +241 -27
- package/src/types.ts +61 -14
|
@@ -1,9 +1,64 @@
|
|
|
1
1
|
import { createShaderModule, rhi } from '@forgeax/engine-rhi-null';
|
|
2
|
+
import { ok } from '@forgeax/engine-types';
|
|
2
3
|
import { describe, expect, it } from 'vitest';
|
|
3
4
|
import { attachRecorder, openReplay } from '../index';
|
|
4
5
|
import { decodeTape } from '../protocol/codec';
|
|
5
6
|
|
|
6
7
|
describe('RecorderSession real RHI consumer', () => {
|
|
8
|
+
it('preserves synchronous shader creation and replays pipeline-derived layouts', async () => {
|
|
9
|
+
const source = '@compute @workgroup_size(1) fn main() {}';
|
|
10
|
+
const rawDevice = (await (await rhi.requestAdapter()).unwrap().requestDevice()).unwrap();
|
|
11
|
+
const module = (await createShaderModule(rawDevice, { code: source })).unwrap();
|
|
12
|
+
let receivedDevice: unknown;
|
|
13
|
+
const attachment = attachRecorder({
|
|
14
|
+
rhi,
|
|
15
|
+
createShaderModule,
|
|
16
|
+
createShaderModuleImmediate: (device) => {
|
|
17
|
+
receivedDevice = device;
|
|
18
|
+
return ok(module);
|
|
19
|
+
},
|
|
20
|
+
}).unwrap();
|
|
21
|
+
const device = (
|
|
22
|
+
await (await attachment.backend.rhi.requestAdapter()).unwrap().requestDevice()
|
|
23
|
+
).unwrap();
|
|
24
|
+
const factory = attachment.backend.createShaderModuleImmediate;
|
|
25
|
+
expect(factory).toBeTypeOf('function');
|
|
26
|
+
expect(
|
|
27
|
+
(attachment.backend.rhi as unknown as { createShaderModuleImmediate: unknown })
|
|
28
|
+
.createShaderModuleImmediate,
|
|
29
|
+
).toBe(factory);
|
|
30
|
+
const shader = factory?.(device, { code: source });
|
|
31
|
+
expect(shader?.ok).toBe(true);
|
|
32
|
+
expect(receivedDevice).not.toBe(device);
|
|
33
|
+
if (!shader?.ok) throw new Error('synchronous shader factory unavailable');
|
|
34
|
+
const pipeline = device
|
|
35
|
+
.createComputePipeline({
|
|
36
|
+
layout: 'auto',
|
|
37
|
+
compute: { module: shader.value, entryPoint: 'main' },
|
|
38
|
+
})
|
|
39
|
+
.unwrap();
|
|
40
|
+
const layout = (
|
|
41
|
+
pipeline as typeof pipeline & import('@forgeax/engine-rhi').RhiComputePipelineOps
|
|
42
|
+
).getBindGroupLayout(0);
|
|
43
|
+
const bindings = device.createBindGroup({ layout, entries: [] }).unwrap();
|
|
44
|
+
const capture = attachment.captureFrame();
|
|
45
|
+
(await attachment.frameBoundary()).unwrap();
|
|
46
|
+
const encoder = device.createCommandEncoder({}).unwrap();
|
|
47
|
+
const pass = encoder.beginComputePass({});
|
|
48
|
+
pass.setPipeline(pipeline);
|
|
49
|
+
pass.setBindGroup(0, bindings);
|
|
50
|
+
pass.dispatchWorkgroups(1);
|
|
51
|
+
pass.end();
|
|
52
|
+
device.queue.submit([encoder.finish().unwrap()]).unwrap();
|
|
53
|
+
(await attachment.frameBoundary()).unwrap();
|
|
54
|
+
const tape = decodeTape((await capture).unwrap().bytes).unwrap();
|
|
55
|
+
expect(tape.bootstrap.some((item) => item.create.kind === 'getBindGroupLayout')).toBe(true);
|
|
56
|
+
const replayDevice = (await (await rhi.requestAdapter()).unwrap().requestDevice()).unwrap();
|
|
57
|
+
const replay = await openReplay(tape, { device: replayDevice, createShaderModule });
|
|
58
|
+
expect(replay.ok).toBe(true);
|
|
59
|
+
await attachment.dispose();
|
|
60
|
+
});
|
|
61
|
+
|
|
7
62
|
it('captures one steady frame into a strict v7 artifact', async () => {
|
|
8
63
|
const attached = attachRecorder({ rhi, createShaderModule });
|
|
9
64
|
expect(attached.ok).toBe(true);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { RhiInstance } from '@forgeax/engine-rhi';
|
|
1
|
+
import type { RhiInstance, ShaderModule } from '@forgeax/engine-rhi';
|
|
2
|
+
import { ok } from '@forgeax/engine-types';
|
|
2
3
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
4
|
import { attachRecorder, type RecordableBackend } from '../recorder/session';
|
|
4
5
|
|
|
@@ -48,6 +49,22 @@ describe('RecorderSession contract', () => {
|
|
|
48
49
|
).toBe(attached.value.backend.createShaderModule);
|
|
49
50
|
});
|
|
50
51
|
|
|
52
|
+
it('preserves the optional immediate shader factory on both backend seams', () => {
|
|
53
|
+
const createShaderModuleImmediate = vi.fn(() => ok({} as ShaderModule));
|
|
54
|
+
const attached = attachRecorder({ ...backend(), createShaderModuleImmediate });
|
|
55
|
+
expect(attached.ok).toBe(true);
|
|
56
|
+
if (!attached.ok) return;
|
|
57
|
+
|
|
58
|
+
expect(attached.value.backend.createShaderModuleImmediate).toBeDefined();
|
|
59
|
+
expect(
|
|
60
|
+
(
|
|
61
|
+
attached.value.backend.rhi as unknown as {
|
|
62
|
+
createShaderModuleImmediate?: unknown;
|
|
63
|
+
}
|
|
64
|
+
).createShaderModuleImmediate,
|
|
65
|
+
).toBe(attached.value.backend.createShaderModuleImmediate);
|
|
66
|
+
});
|
|
67
|
+
|
|
51
68
|
it('resolves an abort as one terminal structured result', async () => {
|
|
52
69
|
const attached = attachRecorder(backend());
|
|
53
70
|
expect(attached.ok).toBe(true);
|
|
@@ -153,7 +153,122 @@ function triangleTape(): Tape {
|
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
function msaaTriangleTape(): Tape {
|
|
157
|
+
const tape = triangleTape();
|
|
158
|
+
const bootstrapResources = tape.bootstrap.map((resource) => {
|
|
159
|
+
if (resource.handleId === 'texture:rt') {
|
|
160
|
+
const desc = resource.create.desc as Record<string, unknown>;
|
|
161
|
+
return bootstrap('texture:msaa', 'texture', {
|
|
162
|
+
...resource.create,
|
|
163
|
+
handleId: 'texture:msaa',
|
|
164
|
+
desc: { ...desc, sampleCount: 4, usage: 0x10 },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (resource.handleId === 'texture-view:rt') {
|
|
168
|
+
return bootstrap('texture-view:msaa', 'texture-view', {
|
|
169
|
+
...resource.create,
|
|
170
|
+
sourceHandleId: 'texture:msaa',
|
|
171
|
+
resultHandleId: 'texture-view:msaa',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (resource.handleId === 'pipeline:triangle') {
|
|
175
|
+
const desc = resource.create.desc as Record<string, unknown>;
|
|
176
|
+
return bootstrap('pipeline:triangle', 'pipeline', {
|
|
177
|
+
...resource.create,
|
|
178
|
+
desc: { ...desc, multisample: { count: 4 } },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return resource;
|
|
182
|
+
});
|
|
183
|
+
bootstrapResources.push(
|
|
184
|
+
bootstrap('texture:resolve', 'texture', {
|
|
185
|
+
kind: 'createTexture',
|
|
186
|
+
handleId: 'texture:resolve',
|
|
187
|
+
desc: {
|
|
188
|
+
size: { width: WIDTH, height: HEIGHT, depthOrArrayLayers: 1 },
|
|
189
|
+
format: 'rgba8unorm',
|
|
190
|
+
dimension: '2d',
|
|
191
|
+
mipLevelCount: 1,
|
|
192
|
+
sampleCount: 1,
|
|
193
|
+
usage: 0x11,
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
bootstrap('texture-view:resolve', 'texture-view', {
|
|
197
|
+
kind: 'createTextureView',
|
|
198
|
+
sourceHandleId: 'texture:resolve',
|
|
199
|
+
resultHandleId: 'texture-view:resolve',
|
|
200
|
+
desc: {},
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const events = tape.events.map((event) => {
|
|
205
|
+
if (event.kind === 'beginRenderPass') {
|
|
206
|
+
return {
|
|
207
|
+
...event,
|
|
208
|
+
desc: {
|
|
209
|
+
...event.desc,
|
|
210
|
+
colorAttachments: [
|
|
211
|
+
{
|
|
212
|
+
view: null,
|
|
213
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
214
|
+
loadOp: 'clear',
|
|
215
|
+
storeOp: 'store',
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
colorAttachmentViewHandleIds: ['texture-view:msaa'],
|
|
220
|
+
colorAttachmentResolveTargetHandleIds: ['texture-view:resolve'],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return event;
|
|
224
|
+
}) as Tape['events'];
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
...tape,
|
|
228
|
+
header: { ...tape.header, blobCount: 0 },
|
|
229
|
+
bootstrap: bootstrapResources,
|
|
230
|
+
events,
|
|
231
|
+
blobs: [],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
156
235
|
describe.skipIf(SKIP_DAWN)('ReplaySession Dawn contract', () => {
|
|
236
|
+
it.each([
|
|
237
|
+
0x84, 0x184, 0x06,
|
|
238
|
+
])('restores captured buffer bytes without COPY_DST (usage %i)', async (usage) => {
|
|
239
|
+
const pack = await loadDawn();
|
|
240
|
+
const expected = new Uint8Array([17, 31, 63, 127, 5, 9, 13, 21]);
|
|
241
|
+
const tape: Tape = {
|
|
242
|
+
header: { formatVersion: 7, rhiCaps: {}, eventCount: 0, blobCount: 1 },
|
|
243
|
+
bootstrap: [
|
|
244
|
+
bootstrap(
|
|
245
|
+
'buffer:seed',
|
|
246
|
+
'buffer',
|
|
247
|
+
{
|
|
248
|
+
kind: 'createBuffer',
|
|
249
|
+
handleId: 'buffer:seed',
|
|
250
|
+
desc: { size: expected.length, usage },
|
|
251
|
+
},
|
|
252
|
+
[{ hash: 'seed', byteOffset: 0, byteLength: expected.length }],
|
|
253
|
+
),
|
|
254
|
+
],
|
|
255
|
+
events: [],
|
|
256
|
+
blobs: [{ hash: 'seed', bytes: expected, compression: 'none' }],
|
|
257
|
+
};
|
|
258
|
+
const replay = (
|
|
259
|
+
await openReplay(tape, {
|
|
260
|
+
device: await freshDevice(pack),
|
|
261
|
+
createShaderModule: pack.createShaderModule,
|
|
262
|
+
})
|
|
263
|
+
).unwrap();
|
|
264
|
+
try {
|
|
265
|
+
expect((await replay.readResource('buffer:seed')).unwrap().bytes).toEqual(expected);
|
|
266
|
+
expect(tape.bootstrap[0]?.create.desc).toEqual({ size: expected.length, usage });
|
|
267
|
+
} finally {
|
|
268
|
+
(await replay.dispose()).unwrap();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
157
272
|
it('replays a triangle and returns attachment bytes through inspectWork', async () => {
|
|
158
273
|
const pack = await loadDawn();
|
|
159
274
|
const tape = triangleTape();
|
|
@@ -175,6 +290,19 @@ describe.skipIf(SKIP_DAWN)('ReplaySession Dawn contract', () => {
|
|
|
175
290
|
expect(baseline.value.attachment?.provenance.selectedWorkIndex).toBe(0);
|
|
176
291
|
expect(baseline.value.attachment?.provenance.subresource).toBeNull();
|
|
177
292
|
|
|
293
|
+
const selected = await first.value.readResourceAtWork('texture-view:rt', 0);
|
|
294
|
+
expect(
|
|
295
|
+
selected.ok,
|
|
296
|
+
selected.ok ? undefined : `${selected.error.code}: ${JSON.stringify(selected.error.detail)}`,
|
|
297
|
+
).toBe(true);
|
|
298
|
+
if (!selected.ok) throw new Error(selected.error.hint);
|
|
299
|
+
expect(selected.value.provenance.selectedWorkIndex).toBe(0);
|
|
300
|
+
expect(selected.value.bytes).toEqual(baseline.value.attachment?.bytes);
|
|
301
|
+
const bootstrap = await first.value.readResource('texture-view:rt');
|
|
302
|
+
if (!bootstrap.ok) throw new Error(bootstrap.error.hint);
|
|
303
|
+
expect(bootstrap.value.provenance.selectedWorkIndex).toBeUndefined();
|
|
304
|
+
expect(selected.value.bytes).not.toEqual(bootstrap.value.bytes);
|
|
305
|
+
|
|
178
306
|
const second = await openReplay(tape, {
|
|
179
307
|
device: await freshDevice(pack),
|
|
180
308
|
createShaderModule: pack.createShaderModule,
|
|
@@ -189,6 +317,49 @@ describe.skipIf(SKIP_DAWN)('ReplaySession Dawn contract', () => {
|
|
|
189
317
|
expect((await second.value.dispose()).ok).toBe(true);
|
|
190
318
|
}, 60_000);
|
|
191
319
|
|
|
320
|
+
it('resolves an MSAA attachment for pixels and rejects direct MSAA readback', async () => {
|
|
321
|
+
const pack = await loadDawn();
|
|
322
|
+
const replayResult = await openReplay(msaaTriangleTape(), {
|
|
323
|
+
device: await freshDevice(pack),
|
|
324
|
+
createShaderModule: pack.createShaderModule,
|
|
325
|
+
});
|
|
326
|
+
expect(replayResult.ok).toBe(true);
|
|
327
|
+
if (!replayResult.ok) throw new Error(replayResult.error.hint);
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const first = await replayResult.value.inspectWork(0, ['pixels']);
|
|
331
|
+
expect(
|
|
332
|
+
first.ok,
|
|
333
|
+
first.ok ? undefined : `${first.error.code}: ${JSON.stringify(first.error.detail)}`,
|
|
334
|
+
).toBe(true);
|
|
335
|
+
if (!first.ok) throw new Error(first.error.hint);
|
|
336
|
+
const attachment = first.value.attachment;
|
|
337
|
+
expect(attachment?.provenance.resourceId).toBe('texture-view:resolve');
|
|
338
|
+
expect(attachment?.provenance.selectedWorkIndex).toBe(0);
|
|
339
|
+
expect(attachment?.provenance.subresource).toBeNull();
|
|
340
|
+
const centerOffset = (Math.floor(HEIGHT / 2) * WIDTH + Math.floor(WIDTH / 2)) * 4;
|
|
341
|
+
expect(attachment?.bytes.slice(centerOffset, centerOffset + 4)).toEqual(
|
|
342
|
+
new Uint8Array([255, 0, 0, 255]),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const repeated = await replayResult.value.inspectWork(0, ['pixels']);
|
|
346
|
+
expect(repeated.ok).toBe(true);
|
|
347
|
+
if (!repeated.ok) throw new Error(repeated.error.hint);
|
|
348
|
+
expect(repeated.value.attachment?.bytes).toEqual(attachment?.bytes);
|
|
349
|
+
expect(repeated.value.attachment?.provenance.resourceId).toBe('texture-view:resolve');
|
|
350
|
+
expect(repeated.value.attachment?.provenance.selectedWorkIndex).toBe(0);
|
|
351
|
+
expect(repeated.value.attachment?.provenance.subresource).toBeNull();
|
|
352
|
+
|
|
353
|
+
const directMsaa = await replayResult.value.readResource('texture:msaa');
|
|
354
|
+
expect(directMsaa.ok).toBe(false);
|
|
355
|
+
if (directMsaa.ok) throw new Error('MSAA texture readback unexpectedly succeeded');
|
|
356
|
+
expect(directMsaa.error.code).toBe('readback-unsupported');
|
|
357
|
+
expect(directMsaa.error.detail).toMatchObject({ resourceId: 'texture:msaa' });
|
|
358
|
+
} finally {
|
|
359
|
+
expect((await replayResult.value.dispose()).ok).toBe(true);
|
|
360
|
+
}
|
|
361
|
+
}, 60_000);
|
|
362
|
+
|
|
192
363
|
it('reports the first shader factory failure on a real Dawn device', async () => {
|
|
193
364
|
const pack = await loadDawn();
|
|
194
365
|
const device = await freshDevice(pack);
|
|
@@ -14,6 +14,7 @@ const backend: ReplayBackend = {
|
|
|
14
14
|
|
|
15
15
|
expectTypeOf(backend.createShaderModule).toBeFunction();
|
|
16
16
|
expectTypeOf(session.inspectWork(0)).resolves.toHaveProperty('ok');
|
|
17
|
+
expectTypeOf(session.readResourceAtWork('texture:out', 0)).resolves.toHaveProperty('ok');
|
|
17
18
|
expectTypeOf(session.dispose()).resolves.toHaveProperty('ok');
|
|
18
19
|
expectTypeOf<WorkInspection['pipeline']>().not.toBeNever();
|
|
19
20
|
expectTypeOf<WorkInspection['bindings']>().toMatchTypeOf<readonly unknown[] | undefined>();
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from '../recorder';
|
|
13
13
|
import { assembleTape } from '../recorder/assemble';
|
|
14
14
|
import { openReplay, type ReplayBackend } from '../replay/session';
|
|
15
|
+
import { runQuerySetReplayFixture } from './query-set-replay-fixture';
|
|
15
16
|
|
|
16
17
|
interface DawnPack {
|
|
17
18
|
readonly rhi: RhiInstance;
|
|
@@ -175,4 +176,64 @@ describe.skipIf(SKIP_DAWN)('RHI debug v7 fresh-device evidence', () => {
|
|
|
175
176
|
expect(normalizedPixelDelta(baseline, replayPixels)).toBeLessThanOrEqual(0.01);
|
|
176
177
|
expect((await session.value.dispose()).ok).toBe(true);
|
|
177
178
|
}, 60_000);
|
|
179
|
+
|
|
180
|
+
it('captures and replays an occlusion QuerySet with an eight-byte readback', async () => {
|
|
181
|
+
const pack = await loadDawn();
|
|
182
|
+
const recorder = wrap(pack.rhi);
|
|
183
|
+
const recordingShaderModule = wrapCreateShaderModule(pack.createShaderModule, recorder);
|
|
184
|
+
const adapter = await recorder.requestAdapter();
|
|
185
|
+
expect(adapter.ok).toBe(true);
|
|
186
|
+
if (!adapter.ok) throw new Error(`QuerySet Dawn adapter failed: ${adapter.error.code}`);
|
|
187
|
+
const deviceResult = await adapter.value.requestDevice();
|
|
188
|
+
expect(deviceResult.ok).toBe(true);
|
|
189
|
+
if (!deviceResult.ok)
|
|
190
|
+
throw new Error(`QuerySet Dawn device failed: ${deviceResult.error.code}`);
|
|
191
|
+
const armed = recorder.arm(1);
|
|
192
|
+
expect(armed.ok).toBe(true);
|
|
193
|
+
if (!armed.ok) throw new Error(armed.error.hint);
|
|
194
|
+
const evidence = await runQuerySetReplayFixture({
|
|
195
|
+
runner: 'dawn',
|
|
196
|
+
device: deviceResult.value,
|
|
197
|
+
createShaderModule: recordingShaderModule,
|
|
198
|
+
replayCreateShaderModule: pack.createShaderModule,
|
|
199
|
+
finishCapture: async () => {
|
|
200
|
+
recorder.onFrameEnd();
|
|
201
|
+
const assembled = assembleTape(recorder);
|
|
202
|
+
if (!assembled.ok) throw new Error(assembled.error.hint);
|
|
203
|
+
return assembled.value;
|
|
204
|
+
},
|
|
205
|
+
createFreshDevice: async () => {
|
|
206
|
+
const freshAdapter = await pack.rhi.requestAdapter();
|
|
207
|
+
if (!freshAdapter.ok) throw new Error(freshAdapter.error.hint);
|
|
208
|
+
const freshDevice = await freshAdapter.value.requestDevice();
|
|
209
|
+
if (!freshDevice.ok) throw new Error(freshDevice.error.hint);
|
|
210
|
+
return freshDevice.value;
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
expect(evidence, JSON.stringify(evidence.errorReceipts)).toMatchObject({
|
|
214
|
+
status: 'available',
|
|
215
|
+
tapeFormatVersion: 7,
|
|
216
|
+
resolveDestinationOffset: 256,
|
|
217
|
+
originalQueryValues: expect.arrayContaining([expect.any(String), '0']),
|
|
218
|
+
originalResultHalfWords: [15360, 0, 0, 15360, 0, 0, 0, 15360],
|
|
219
|
+
freshQueryValues: ['1', '0'],
|
|
220
|
+
errorReceipts: [],
|
|
221
|
+
deviceLost: null,
|
|
222
|
+
});
|
|
223
|
+
expect(evidence.originalQueryValues[0]).not.toBe('0');
|
|
224
|
+
expect(evidence.originalColorBytes.some((byte) => byte > 0)).toBe(true);
|
|
225
|
+
expect(evidence.eventKinds).toEqual(
|
|
226
|
+
expect.arrayContaining([
|
|
227
|
+
'createQuerySet',
|
|
228
|
+
'beginRenderPass',
|
|
229
|
+
'beginOcclusionQuery',
|
|
230
|
+
'endOcclusionQuery',
|
|
231
|
+
'resolveQuerySet',
|
|
232
|
+
'submit',
|
|
233
|
+
'destroyQuerySet',
|
|
234
|
+
]),
|
|
235
|
+
);
|
|
236
|
+
expect(evidence.querySetHandleId).toBeDefined();
|
|
237
|
+
expect(evidence.resolveDestinationHandleId).toBeDefined();
|
|
238
|
+
}, 60_000);
|
|
178
239
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { rhi } from '@forgeax/engine-rhi-webgpu';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { decodeTape } from '../protocol/codec';
|
|
4
|
+
import { validateTape } from '../protocol/validation';
|
|
5
|
+
import { wrap } from '../recorder';
|
|
6
|
+
import { assembleTape } from '../recorder/assemble';
|
|
7
|
+
|
|
8
|
+
describe('timestamp query capture ownership', () => {
|
|
9
|
+
it.each([
|
|
10
|
+
'render',
|
|
11
|
+
'compute',
|
|
12
|
+
'empty-compute',
|
|
13
|
+
] as const)('retains a pre-capture QuerySet for %s passes', async (kind) => {
|
|
14
|
+
const recorded = wrap(rhi);
|
|
15
|
+
const adapter = (await recorded.requestAdapter()).unwrap();
|
|
16
|
+
const device = (
|
|
17
|
+
await adapter.requestDevice({ requiredFeatures: ['timestamp-query'] })
|
|
18
|
+
).unwrap();
|
|
19
|
+
const querySet = device.createQuerySet({ type: 'timestamp', count: 2 }).unwrap();
|
|
20
|
+
const target = device
|
|
21
|
+
.createTexture({ size: { width: 4, height: 4 }, format: 'rgba8unorm', usage: 0x10 })
|
|
22
|
+
.unwrap();
|
|
23
|
+
const view = device.createTextureView(target, {}).unwrap();
|
|
24
|
+
try {
|
|
25
|
+
recorded.arm(1);
|
|
26
|
+
const encoder = device.createCommandEncoder().unwrap();
|
|
27
|
+
const timestampWrites = { querySet, beginningOfPassWriteIndex: 0, endOfPassWriteIndex: 1 };
|
|
28
|
+
if (kind === 'render')
|
|
29
|
+
encoder
|
|
30
|
+
.beginRenderPass({
|
|
31
|
+
colorAttachments: [{ view, loadOp: 'clear', storeOp: 'store' }],
|
|
32
|
+
timestampWrites,
|
|
33
|
+
})
|
|
34
|
+
.end();
|
|
35
|
+
else if (kind === 'compute') encoder.beginComputePass({ timestampWrites }).end();
|
|
36
|
+
else encoder.encodeEmptyComputePass({ timestampWrites });
|
|
37
|
+
device.queue.submit([encoder.finish().unwrap()]).unwrap();
|
|
38
|
+
await device.queue.onSubmittedWorkDone();
|
|
39
|
+
recorded.onFrameEnd();
|
|
40
|
+
const tape = recorded.getTape();
|
|
41
|
+
if (tape === undefined || !('events' in tape)) throw new Error('Missing captured tape');
|
|
42
|
+
const pass = tape.events.find(
|
|
43
|
+
(event) => event.kind === 'beginRenderPass' || event.kind === 'beginComputePass',
|
|
44
|
+
);
|
|
45
|
+
expect(pass).toHaveProperty('timestampQuerySetHandleId');
|
|
46
|
+
if (pass?.kind !== 'beginRenderPass' && pass?.kind !== 'beginComputePass')
|
|
47
|
+
throw new Error('Missing pass');
|
|
48
|
+
expect(pass.desc?.timestampWrites).toEqual({
|
|
49
|
+
beginningOfPassWriteIndex: 0,
|
|
50
|
+
endOfPassWriteIndex: 1,
|
|
51
|
+
});
|
|
52
|
+
const encoded = assembleTape(recorded).unwrap().bytes;
|
|
53
|
+
const decoded = decodeTape(encoded).unwrap();
|
|
54
|
+
expect(decoded.bootstrap.some((row) => row.kind === 'query-set')).toBe(true);
|
|
55
|
+
expect(validateTape(decoded).ok).toBe(true);
|
|
56
|
+
} finally {
|
|
57
|
+
device.destroyQuerySet(querySet).unwrap();
|
|
58
|
+
device.destroyTexture(target).unwrap();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -14,11 +14,14 @@ export const eventKinds = [
|
|
|
14
14
|
'frameMark',
|
|
15
15
|
'createBuffer',
|
|
16
16
|
'createTexture',
|
|
17
|
+
'createQuerySet',
|
|
17
18
|
'destroyBuffer',
|
|
18
19
|
'destroyTexture',
|
|
20
|
+
'destroyQuerySet',
|
|
19
21
|
'createTextureView',
|
|
20
22
|
'createSampler',
|
|
21
23
|
'createBindGroupLayout',
|
|
24
|
+
'getBindGroupLayout',
|
|
22
25
|
'createBindGroup',
|
|
23
26
|
'createPipelineLayout',
|
|
24
27
|
'createRenderPipeline',
|
|
@@ -30,12 +33,15 @@ export const eventKinds = [
|
|
|
30
33
|
'copyExternalImageToTexture',
|
|
31
34
|
'submit',
|
|
32
35
|
'beginRenderPass',
|
|
36
|
+
'beginOcclusionQuery',
|
|
37
|
+
'endOcclusionQuery',
|
|
33
38
|
'beginComputePass',
|
|
34
39
|
'copyBufferToBuffer',
|
|
35
40
|
'copyBufferToTexture',
|
|
36
41
|
'copyTextureToBuffer',
|
|
37
42
|
'copyTextureToTexture',
|
|
38
43
|
'clearBuffer',
|
|
44
|
+
'resolveQuerySet',
|
|
39
45
|
'pushDebugGroup',
|
|
40
46
|
'popDebugGroup',
|
|
41
47
|
'insertDebugMarker',
|
|
@@ -86,6 +92,8 @@ export function resourceKindForEvent(kind: EventKind): ResourceKind | undefined
|
|
|
86
92
|
return 'buffer';
|
|
87
93
|
case 'createTexture':
|
|
88
94
|
return 'texture';
|
|
95
|
+
case 'createQuerySet':
|
|
96
|
+
return 'query-set';
|
|
89
97
|
case 'createTextureView':
|
|
90
98
|
return 'texture-view';
|
|
91
99
|
case 'createSampler':
|
|
@@ -97,6 +105,7 @@ export function resourceKindForEvent(kind: EventKind): ResourceKind | undefined
|
|
|
97
105
|
return 'pipeline';
|
|
98
106
|
case 'createBindGroup':
|
|
99
107
|
case 'createBindGroupLayout':
|
|
108
|
+
case 'getBindGroupLayout':
|
|
100
109
|
case 'createPipelineLayout':
|
|
101
110
|
return 'binding';
|
|
102
111
|
case 'createCommandEncoder':
|
|
@@ -114,7 +123,9 @@ function semanticsFor(kind: EventKind): EventSemantics {
|
|
|
114
123
|
read: (event) => referencedHandles(event),
|
|
115
124
|
written: (event) => writtenHandles(event),
|
|
116
125
|
destroyed: (event) =>
|
|
117
|
-
event.kind === 'destroyBuffer' ||
|
|
126
|
+
event.kind === 'destroyBuffer' ||
|
|
127
|
+
event.kind === 'destroyTexture' ||
|
|
128
|
+
event.kind === 'destroyQuerySet'
|
|
118
129
|
? stringField(event, 'handleId')
|
|
119
130
|
: [],
|
|
120
131
|
};
|
|
@@ -122,7 +133,12 @@ function semanticsFor(kind: EventKind): EventSemantics {
|
|
|
122
133
|
|
|
123
134
|
function categoryFor(kind: EventKind): EventCategory {
|
|
124
135
|
if (isWorkEvent(kind)) return 'work';
|
|
125
|
-
if (
|
|
136
|
+
if (
|
|
137
|
+
kind.startsWith('create') ||
|
|
138
|
+
kind.startsWith('destroy') ||
|
|
139
|
+
kind === 'initialData' ||
|
|
140
|
+
kind === 'getBindGroupLayout'
|
|
141
|
+
)
|
|
126
142
|
return 'resource';
|
|
127
143
|
if (kind.includes('Pass')) return 'pass';
|
|
128
144
|
if (kind.startsWith('copy') || kind === 'clearBuffer' || kind.startsWith('write')) return 'copy';
|
|
@@ -153,6 +169,9 @@ function referencedHandles(event: RhiCallEvent): readonly string[] {
|
|
|
153
169
|
'resourceHandleIds',
|
|
154
170
|
'bindGroupHandleId',
|
|
155
171
|
'pipelineHandleId',
|
|
172
|
+
'querySetHandleId',
|
|
173
|
+
'occlusionQuerySetHandleId',
|
|
174
|
+
'timestampQuerySetHandleId',
|
|
156
175
|
'indexBufferHandleId',
|
|
157
176
|
'vertexBufferHandleId',
|
|
158
177
|
];
|
package/src/protocol/types.ts
CHANGED
|
@@ -93,6 +93,7 @@ function isResourceKind(value: string): value is ResourceKind {
|
|
|
93
93
|
return [
|
|
94
94
|
'buffer',
|
|
95
95
|
'texture',
|
|
96
|
+
'query-set',
|
|
96
97
|
'texture-view',
|
|
97
98
|
'sampler',
|
|
98
99
|
'shader-module',
|
|
@@ -112,8 +113,10 @@ function eventResources(event: RhiCallEvent): EventResources {
|
|
|
112
113
|
switch (event.kind) {
|
|
113
114
|
case 'createBuffer':
|
|
114
115
|
case 'createTexture':
|
|
116
|
+
case 'createQuerySet':
|
|
115
117
|
case 'createSampler':
|
|
116
118
|
case 'createBindGroupLayout':
|
|
119
|
+
case 'getBindGroupLayout':
|
|
117
120
|
case 'createBindGroup':
|
|
118
121
|
case 'createPipelineLayout':
|
|
119
122
|
case 'createRenderPipeline':
|
|
@@ -123,6 +126,7 @@ function eventResources(event: RhiCallEvent): EventResources {
|
|
|
123
126
|
created: [event.handleId],
|
|
124
127
|
reads: handleRefs(event, [
|
|
125
128
|
'layoutHandleId',
|
|
129
|
+
'pipelineHandleId',
|
|
126
130
|
'vertexShaderModuleHandleId',
|
|
127
131
|
'fragmentShaderModuleHandleId',
|
|
128
132
|
'computeShaderModuleHandleId',
|
|
@@ -135,6 +139,7 @@ function eventResources(event: RhiCallEvent): EventResources {
|
|
|
135
139
|
return { created: [event.cmdHandleId], reads: [], destroyed: [] };
|
|
136
140
|
case 'destroyBuffer':
|
|
137
141
|
case 'destroyTexture':
|
|
142
|
+
case 'destroyQuerySet':
|
|
138
143
|
return { created: [], reads: [event.handleId], destroyed: [event.handleId] };
|
|
139
144
|
case 'writeBuffer':
|
|
140
145
|
case 'writeTexture':
|
|
@@ -156,6 +161,37 @@ function eventResources(event: RhiCallEvent): EventResources {
|
|
|
156
161
|
]),
|
|
157
162
|
destroyed: [],
|
|
158
163
|
};
|
|
164
|
+
case 'resolveQuerySet':
|
|
165
|
+
return {
|
|
166
|
+
created: [],
|
|
167
|
+
reads: handleRefs(event, ['cmdHandleId', 'querySetHandleId', 'destinationHandleId']),
|
|
168
|
+
destroyed: [],
|
|
169
|
+
};
|
|
170
|
+
case 'beginRenderPass':
|
|
171
|
+
return {
|
|
172
|
+
created: [event.passHandleId],
|
|
173
|
+
reads: handleRefs(event, [
|
|
174
|
+
'cmdHandleId',
|
|
175
|
+
'occlusionQuerySetHandleId',
|
|
176
|
+
'timestampQuerySetHandleId',
|
|
177
|
+
'colorAttachmentViewHandleIds',
|
|
178
|
+
'colorAttachmentResolveTargetHandleIds',
|
|
179
|
+
'depthStencilViewHandleId',
|
|
180
|
+
]),
|
|
181
|
+
destroyed: [],
|
|
182
|
+
};
|
|
183
|
+
case 'beginComputePass':
|
|
184
|
+
return {
|
|
185
|
+
created: [event.passHandleId],
|
|
186
|
+
reads: handleRefs(event, ['cmdHandleId', 'timestampQuerySetHandleId']),
|
|
187
|
+
destroyed: [],
|
|
188
|
+
};
|
|
189
|
+
case 'endRenderPass':
|
|
190
|
+
case 'endComputePass':
|
|
191
|
+
return { created: [], reads: [event.passHandleId], destroyed: [event.passHandleId] };
|
|
192
|
+
case 'beginOcclusionQuery':
|
|
193
|
+
case 'endOcclusionQuery':
|
|
194
|
+
return { created: [], reads: handleRefs(event, ['passHandleId']), destroyed: [] };
|
|
159
195
|
default:
|
|
160
196
|
return { created: [], reads: [], destroyed: [] };
|
|
161
197
|
}
|
package/src/recorder/assemble.ts
CHANGED
|
@@ -161,7 +161,11 @@ function createdHandleId(event: RhiCallEvent): HandleId | undefined {
|
|
|
161
161
|
if (event.kind === 'beginRenderPass' || event.kind === 'beginComputePass') {
|
|
162
162
|
return event.passHandleId;
|
|
163
163
|
}
|
|
164
|
-
if (
|
|
164
|
+
if (
|
|
165
|
+
(event.kind.startsWith('create') || event.kind === 'getBindGroupLayout') &&
|
|
166
|
+
'handleId' in event
|
|
167
|
+
)
|
|
168
|
+
return event.handleId;
|
|
165
169
|
return undefined;
|
|
166
170
|
}
|
|
167
171
|
|
|
@@ -171,6 +175,8 @@ function resourceKind(event: RhiCallEvent): BootstrapResource['kind'] | undefine
|
|
|
171
175
|
return 'buffer';
|
|
172
176
|
case 'createTexture':
|
|
173
177
|
return 'texture';
|
|
178
|
+
case 'createQuerySet':
|
|
179
|
+
return 'query-set';
|
|
174
180
|
case 'createTextureView':
|
|
175
181
|
return 'texture-view';
|
|
176
182
|
case 'createSampler':
|
|
@@ -182,6 +188,7 @@ function resourceKind(event: RhiCallEvent): BootstrapResource['kind'] | undefine
|
|
|
182
188
|
return 'pipeline';
|
|
183
189
|
case 'createBindGroup':
|
|
184
190
|
case 'createBindGroupLayout':
|
|
191
|
+
case 'getBindGroupLayout':
|
|
185
192
|
case 'createPipelineLayout':
|
|
186
193
|
return 'binding';
|
|
187
194
|
case 'createCommandEncoder':
|