@forgeax/engine-rhi-debug 0.1.27 → 0.1.29
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 +13 -0
- 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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +465 -53
- 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 +39 -4
- 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 +7 -0
- 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 +49 -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/index.ts +1 -0
- package/src/protocol/event-semantics.ts +20 -2
- package/src/protocol/types.ts +1 -0
- package/src/protocol/validation.ts +31 -0
- package/src/recorder/assemble.ts +8 -1
- package/src/recorder/closure.ts +17 -0
- package/src/recorder/core.ts +11 -0
- package/src/recorder/device.ts +33 -6
- package/src/recorder/encoder.ts +22 -4
- 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 +111 -0
- package/src/replay/readback.ts +8 -3
- package/src/replay/resources.ts +6 -0
- package/src/replay/session.ts +234 -25
- package/src/types.ts +53 -12
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/// <reference types="@webgpu/types" />
|
|
2
|
+
|
|
3
|
+
import type { Buffer, MappedBuffer, RhiDevice } from '@forgeax/engine-rhi';
|
|
4
|
+
import { buildFrameModel } from '../frame-model';
|
|
5
|
+
import { decodeTape } from '../protocol/codec';
|
|
6
|
+
import type { Tape } from '../protocol/types';
|
|
7
|
+
import type { CreateShaderModuleFn, EncodedTape } from '../recorder';
|
|
8
|
+
import { openReplay } from '../replay/session';
|
|
9
|
+
|
|
10
|
+
export type QuerySetFixtureRunner = 'browser' | 'dawn';
|
|
11
|
+
|
|
12
|
+
export interface QuerySetFixtureHost {
|
|
13
|
+
readonly runner: QuerySetFixtureRunner;
|
|
14
|
+
readonly device: RhiDevice;
|
|
15
|
+
readonly createShaderModule: CreateShaderModuleFn;
|
|
16
|
+
readonly replayCreateShaderModule: CreateShaderModuleFn;
|
|
17
|
+
readonly finishCapture: () => Promise<EncodedTape>;
|
|
18
|
+
readonly createFreshDevice: () => Promise<RhiDevice>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface QuerySetErrorReceipt {
|
|
22
|
+
readonly operation: string;
|
|
23
|
+
readonly code: string;
|
|
24
|
+
readonly expected: string;
|
|
25
|
+
readonly hint: string;
|
|
26
|
+
readonly detail: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface QuerySetReplayFixtureEvidence {
|
|
30
|
+
readonly runner: QuerySetFixtureRunner;
|
|
31
|
+
readonly status: 'available' | 'unavailable' | 'failed';
|
|
32
|
+
readonly tapeFormatVersion: number | null;
|
|
33
|
+
readonly querySetHandleId: string | null;
|
|
34
|
+
readonly resolveDestinationHandleId: string | null;
|
|
35
|
+
readonly resolveDestinationOffset: number | null;
|
|
36
|
+
readonly originalQueryValues: readonly string[];
|
|
37
|
+
readonly originalResultHalfWords: readonly number[];
|
|
38
|
+
readonly freshQueryValues: readonly string[];
|
|
39
|
+
readonly originalColorBytes: readonly number[];
|
|
40
|
+
readonly freshColorHalfWords: readonly number[];
|
|
41
|
+
readonly eventKinds: readonly string[];
|
|
42
|
+
readonly errorReceipts: readonly QuerySetErrorReceipt[];
|
|
43
|
+
readonly deviceLost: { readonly reason: string; readonly message: string } | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const COLOR_WIDTH = 32;
|
|
47
|
+
const COLOR_HEIGHT = 32;
|
|
48
|
+
const QUERY_RESOLVE_OFFSET = 256;
|
|
49
|
+
const QUERY_BYTES = 8;
|
|
50
|
+
const QUERY_COUNT = 2;
|
|
51
|
+
const QUERY_RESOLVE_BUFFER_SIZE = QUERY_RESOLVE_OFFSET + QUERY_BYTES * QUERY_COUNT;
|
|
52
|
+
const READBACK_SIZE = 256;
|
|
53
|
+
const BUFFER_USAGE_MAP_READ_COPY_DST = 0x9;
|
|
54
|
+
const BUFFER_USAGE_QUERY_RESOLVE_COPY_SRC_STORAGE = 0x284;
|
|
55
|
+
const BUFFER_USAGE_COPY_DST_STORAGE = 0x88;
|
|
56
|
+
const TEXTURE_USAGE_RENDER_ATTACHMENT_COPY_SRC = 0x11;
|
|
57
|
+
const GPU_MAP_MODE_READ = 0x1;
|
|
58
|
+
|
|
59
|
+
const DRAW_VERTEX_SHADER = /* wgsl */ `
|
|
60
|
+
@vertex
|
|
61
|
+
fn main(@builtin(vertex_index) vertexIndex: u32) -> @builtin(position) vec4<f32> {
|
|
62
|
+
var positions = array<vec2<f32>, 3>(
|
|
63
|
+
vec2<f32>(-0.8, -0.8),
|
|
64
|
+
vec2<f32>(0.8, -0.8),
|
|
65
|
+
vec2<f32>(0.0, 0.8),
|
|
66
|
+
);
|
|
67
|
+
return vec4<f32>(positions[vertexIndex], 0.0, 1.0);
|
|
68
|
+
}`;
|
|
69
|
+
|
|
70
|
+
const DRAW_FRAGMENT_SHADER = /* wgsl */ `
|
|
71
|
+
@fragment
|
|
72
|
+
fn main() -> @location(0) vec4<f32> {
|
|
73
|
+
return vec4<f32>(0.0, 0.8, 0.2, 1.0);
|
|
74
|
+
}`;
|
|
75
|
+
|
|
76
|
+
const QUERY_RESULT_FRAGMENT_SHADER = /* wgsl */ `
|
|
77
|
+
@group(0) @binding(0) var<storage, read> queryValues: array<u32>;
|
|
78
|
+
|
|
79
|
+
@fragment
|
|
80
|
+
fn main(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
|
81
|
+
let queryIndex = min(u32(position.x), 1u);
|
|
82
|
+
let covered = select(0.0, 1.0, queryValues[queryIndex * 2u] > 0u);
|
|
83
|
+
return vec4<f32>(covered, 0.0, 0.0, 1.0);
|
|
84
|
+
}`;
|
|
85
|
+
|
|
86
|
+
const RESULT_VERTEX_SHADER = /* wgsl */ `
|
|
87
|
+
@vertex
|
|
88
|
+
fn main(@builtin(vertex_index) vertexIndex: u32) -> @builtin(position) vec4<f32> {
|
|
89
|
+
var positions = array<vec2<f32>, 3>(
|
|
90
|
+
vec2<f32>(-1.0, -1.0),
|
|
91
|
+
vec2<f32>(3.0, -1.0),
|
|
92
|
+
vec2<f32>(-1.0, 3.0),
|
|
93
|
+
);
|
|
94
|
+
return vec4<f32>(positions[vertexIndex], 0.0, 1.0);
|
|
95
|
+
}`;
|
|
96
|
+
|
|
97
|
+
class FixtureFailure extends Error {
|
|
98
|
+
readonly receipt: QuerySetErrorReceipt;
|
|
99
|
+
|
|
100
|
+
constructor(receipt: QuerySetErrorReceipt) {
|
|
101
|
+
super(`${receipt.operation}: ${receipt.code}`);
|
|
102
|
+
this.name = 'FixtureFailure';
|
|
103
|
+
this.receipt = receipt;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function asReceipt(operation: string, error: unknown): QuerySetErrorReceipt {
|
|
108
|
+
const candidate = error as {
|
|
109
|
+
readonly code?: unknown;
|
|
110
|
+
readonly expected?: unknown;
|
|
111
|
+
readonly hint?: unknown;
|
|
112
|
+
readonly detail?: unknown;
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
operation,
|
|
116
|
+
code: typeof candidate.code === 'string' ? candidate.code : 'unknown-error',
|
|
117
|
+
expected:
|
|
118
|
+
typeof candidate.expected === 'string' ? candidate.expected : 'successful RHI operation',
|
|
119
|
+
hint: typeof candidate.hint === 'string' ? candidate.hint : String(error),
|
|
120
|
+
detail: candidate.detail ?? null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function unwrap<T>(
|
|
125
|
+
operation: string,
|
|
126
|
+
result:
|
|
127
|
+
| { readonly ok: true; readonly value: T }
|
|
128
|
+
| { readonly ok: false; readonly error: unknown },
|
|
129
|
+
): T {
|
|
130
|
+
if (!result.ok) throw new FixtureFailure(asReceipt(operation, result.error));
|
|
131
|
+
return result.value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function call(operation: string, action: () => void): void {
|
|
135
|
+
try {
|
|
136
|
+
action();
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw new FixtureFailure(asReceipt(operation, error));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function shader(
|
|
143
|
+
createShaderModule: CreateShaderModuleFn,
|
|
144
|
+
device: RhiDevice,
|
|
145
|
+
operation: string,
|
|
146
|
+
code: string,
|
|
147
|
+
) {
|
|
148
|
+
return unwrap(operation, await createShaderModule(device, { code }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readBytes(mapped: MappedBuffer, operation: string, size: number): Uint8Array {
|
|
152
|
+
const range = unwrap(`${operation}.getMappedRange`, mapped.getMappedRange(0, size));
|
|
153
|
+
return new Uint8Array(range.slice(0));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function readMappedBuffer(buffer: Buffer, operation: string, size: number) {
|
|
157
|
+
const mapped = unwrap(`${operation}.mapAsync`, await buffer.mapAsync(GPU_MAP_MODE_READ, 0, size));
|
|
158
|
+
const bytes = readBytes(mapped, operation, size);
|
|
159
|
+
call(`${operation}.unmap`, () => mapped.unmap());
|
|
160
|
+
return bytes;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function u64Values(bytes: Uint8Array): readonly string[] {
|
|
164
|
+
const values: string[] = [];
|
|
165
|
+
for (let offset = 0; offset + QUERY_BYTES <= bytes.byteLength; offset += QUERY_BYTES) {
|
|
166
|
+
const low = new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, true);
|
|
167
|
+
const high = new DataView(bytes.buffer, bytes.byteOffset + offset + 4, 4).getUint32(0, true);
|
|
168
|
+
values.push(((BigInt(high) << 32n) | BigInt(low)).toString());
|
|
169
|
+
}
|
|
170
|
+
return values;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function tapeEventKinds(tape: Tape): readonly string[] {
|
|
174
|
+
return [
|
|
175
|
+
...tape.bootstrap.map((resource) => {
|
|
176
|
+
const create = resource.create as { readonly kind?: unknown };
|
|
177
|
+
return typeof create.kind === 'string' ? create.kind : 'bootstrap';
|
|
178
|
+
}),
|
|
179
|
+
...tape.events.map((event) => event.kind),
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function eventHandle(tape: Tape, kind: string, field: string): string | null {
|
|
184
|
+
const all = [
|
|
185
|
+
...tape.bootstrap.map((resource) => resource.create as Record<string, unknown>),
|
|
186
|
+
...tape.events,
|
|
187
|
+
];
|
|
188
|
+
const event = all.find((candidate) => candidate.kind === kind) as
|
|
189
|
+
| Record<string, unknown>
|
|
190
|
+
| undefined;
|
|
191
|
+
return typeof event?.[field] === 'string' ? event[field] : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function eventNumber(tape: Tape, kind: string, field: string): number | null {
|
|
195
|
+
const event = tape.events.find((candidate) => candidate.kind === kind) as
|
|
196
|
+
| Record<string, unknown>
|
|
197
|
+
| undefined;
|
|
198
|
+
return typeof event?.[field] === 'number' ? event[field] : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function freshHalfWords(bytes: Uint8Array): readonly number[] {
|
|
202
|
+
const words = new Uint16Array(bytes.buffer, bytes.byteOffset, Math.min(8, bytes.byteLength / 2));
|
|
203
|
+
return Array.from(words);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function executeFrame(
|
|
207
|
+
device: RhiDevice,
|
|
208
|
+
createShaderModule: CreateShaderModuleFn,
|
|
209
|
+
): Promise<{
|
|
210
|
+
readonly queryValues: readonly string[];
|
|
211
|
+
readonly colorBytes: Uint8Array;
|
|
212
|
+
readonly resultHalfWords: readonly number[];
|
|
213
|
+
}> {
|
|
214
|
+
const vertex = await shader(
|
|
215
|
+
createShaderModule,
|
|
216
|
+
device,
|
|
217
|
+
'create.vertexShader',
|
|
218
|
+
DRAW_VERTEX_SHADER,
|
|
219
|
+
);
|
|
220
|
+
const fragment = await shader(
|
|
221
|
+
createShaderModule,
|
|
222
|
+
device,
|
|
223
|
+
'create.fragmentShader',
|
|
224
|
+
DRAW_FRAGMENT_SHADER,
|
|
225
|
+
);
|
|
226
|
+
const queryFragment = await shader(
|
|
227
|
+
createShaderModule,
|
|
228
|
+
device,
|
|
229
|
+
'create.queryResultFragmentShader',
|
|
230
|
+
QUERY_RESULT_FRAGMENT_SHADER,
|
|
231
|
+
);
|
|
232
|
+
const resultVertex = await shader(
|
|
233
|
+
createShaderModule,
|
|
234
|
+
device,
|
|
235
|
+
'create.resultVertexShader',
|
|
236
|
+
RESULT_VERTEX_SHADER,
|
|
237
|
+
);
|
|
238
|
+
const drawLayout = unwrap(
|
|
239
|
+
'create.drawBindGroupLayout',
|
|
240
|
+
device.createBindGroupLayout({ entries: [] }),
|
|
241
|
+
);
|
|
242
|
+
const queryLayout = unwrap(
|
|
243
|
+
'create.queryBindGroupLayout',
|
|
244
|
+
device.createBindGroupLayout({
|
|
245
|
+
entries: [{ binding: 0, visibility: 0x2, buffer: { type: 'read-only-storage' } }],
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
const drawPipelineLayout = unwrap(
|
|
249
|
+
'create.drawPipelineLayout',
|
|
250
|
+
device.createPipelineLayout({ bindGroupLayouts: [drawLayout] }),
|
|
251
|
+
);
|
|
252
|
+
const queryPipelineLayout = unwrap(
|
|
253
|
+
'create.queryPipelineLayout',
|
|
254
|
+
device.createPipelineLayout({ bindGroupLayouts: [queryLayout] }),
|
|
255
|
+
);
|
|
256
|
+
const drawPipeline = unwrap(
|
|
257
|
+
'create.drawPipeline',
|
|
258
|
+
device.createRenderPipeline({
|
|
259
|
+
layout: drawPipelineLayout,
|
|
260
|
+
vertex: { module: vertex, entryPoint: 'main', buffers: [] },
|
|
261
|
+
fragment: { module: fragment, entryPoint: 'main', targets: [{ format: 'rgba8unorm' }] },
|
|
262
|
+
primitive: { topology: 'triangle-list' },
|
|
263
|
+
} as never),
|
|
264
|
+
);
|
|
265
|
+
const queryPipeline = unwrap(
|
|
266
|
+
'create.queryPipeline',
|
|
267
|
+
device.createRenderPipeline({
|
|
268
|
+
layout: queryPipelineLayout,
|
|
269
|
+
vertex: { module: resultVertex, entryPoint: 'main', buffers: [] },
|
|
270
|
+
fragment: { module: queryFragment, entryPoint: 'main', targets: [{ format: 'rgba16float' }] },
|
|
271
|
+
primitive: { topology: 'triangle-list' },
|
|
272
|
+
} as never),
|
|
273
|
+
);
|
|
274
|
+
const colorTexture = unwrap(
|
|
275
|
+
'create.colorTexture',
|
|
276
|
+
device.createTexture({
|
|
277
|
+
size: { width: COLOR_WIDTH, height: COLOR_HEIGHT, depthOrArrayLayers: 1 },
|
|
278
|
+
format: 'rgba8unorm',
|
|
279
|
+
usage: TEXTURE_USAGE_RENDER_ATTACHMENT_COPY_SRC,
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
const colorView = unwrap('create.colorView', device.createTextureView(colorTexture, {}));
|
|
283
|
+
const queryTexture = unwrap(
|
|
284
|
+
'create.queryTexture',
|
|
285
|
+
device.createTexture({
|
|
286
|
+
size: { width: QUERY_COUNT, height: 1, depthOrArrayLayers: 1 },
|
|
287
|
+
format: 'rgba16float',
|
|
288
|
+
usage: TEXTURE_USAGE_RENDER_ATTACHMENT_COPY_SRC,
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
const queryView = unwrap('create.queryView', device.createTextureView(queryTexture, {}));
|
|
292
|
+
const querySet = unwrap(
|
|
293
|
+
'create.querySet',
|
|
294
|
+
device.createQuerySet({ type: 'occlusion', count: QUERY_COUNT }),
|
|
295
|
+
);
|
|
296
|
+
const queryResolveBuffer = unwrap(
|
|
297
|
+
'create.queryResolveBuffer',
|
|
298
|
+
device.createBuffer({
|
|
299
|
+
size: QUERY_RESOLVE_BUFFER_SIZE,
|
|
300
|
+
usage: BUFFER_USAGE_QUERY_RESOLVE_COPY_SRC_STORAGE,
|
|
301
|
+
mappedAtCreation: false,
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
const queryStagingBuffer = unwrap(
|
|
305
|
+
'create.queryStagingBuffer',
|
|
306
|
+
device.createBuffer({
|
|
307
|
+
size: READBACK_SIZE,
|
|
308
|
+
usage: BUFFER_USAGE_MAP_READ_COPY_DST,
|
|
309
|
+
mappedAtCreation: false,
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
const colorReadbackBuffer = unwrap(
|
|
313
|
+
'create.colorReadbackBuffer',
|
|
314
|
+
device.createBuffer({
|
|
315
|
+
size: READBACK_SIZE * COLOR_HEIGHT,
|
|
316
|
+
usage: BUFFER_USAGE_MAP_READ_COPY_DST,
|
|
317
|
+
mappedAtCreation: false,
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
const queryGpuResultBuffer = unwrap(
|
|
321
|
+
'create.queryGpuResultBuffer',
|
|
322
|
+
device.createBuffer({
|
|
323
|
+
size: QUERY_BYTES * QUERY_COUNT,
|
|
324
|
+
usage: BUFFER_USAGE_COPY_DST_STORAGE,
|
|
325
|
+
mappedAtCreation: false,
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
const resultReadbackBuffer = unwrap(
|
|
329
|
+
'create.resultReadbackBuffer',
|
|
330
|
+
device.createBuffer({
|
|
331
|
+
size: READBACK_SIZE,
|
|
332
|
+
usage: BUFFER_USAGE_MAP_READ_COPY_DST,
|
|
333
|
+
mappedAtCreation: false,
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
const queryBindGroup = unwrap(
|
|
337
|
+
'create.queryBindGroup',
|
|
338
|
+
device.createBindGroup({
|
|
339
|
+
layout: queryLayout,
|
|
340
|
+
entries: [
|
|
341
|
+
{
|
|
342
|
+
binding: 0,
|
|
343
|
+
resource: {
|
|
344
|
+
kind: 'buffer',
|
|
345
|
+
value: {
|
|
346
|
+
buffer: queryGpuResultBuffer,
|
|
347
|
+
size: QUERY_BYTES * QUERY_COUNT,
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
} as never),
|
|
353
|
+
);
|
|
354
|
+
const encoder = unwrap('create.commandEncoder', device.createCommandEncoder({}));
|
|
355
|
+
const pass = encoder.beginRenderPass({
|
|
356
|
+
colorAttachments: [
|
|
357
|
+
{
|
|
358
|
+
view: colorView,
|
|
359
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
360
|
+
loadOp: 'clear',
|
|
361
|
+
storeOp: 'store',
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
occlusionQuerySet: querySet,
|
|
365
|
+
} as never);
|
|
366
|
+
call('pass.setPipeline', () => pass.setPipeline(drawPipeline));
|
|
367
|
+
call('pass.beginOcclusionQuery.0', () =>
|
|
368
|
+
unwrap('pass.beginOcclusionQuery.0', pass.beginOcclusionQuery(0)),
|
|
369
|
+
);
|
|
370
|
+
call('pass.draw.0', () => pass.draw(3, 1, 0, 0));
|
|
371
|
+
call('pass.endOcclusionQuery.0', () =>
|
|
372
|
+
unwrap('pass.endOcclusionQuery.0', pass.endOcclusionQuery()),
|
|
373
|
+
);
|
|
374
|
+
call('pass.setScissorRect.zero', () => pass.setScissorRect(0, 0, 0, 0));
|
|
375
|
+
call('pass.beginOcclusionQuery.1', () =>
|
|
376
|
+
unwrap('pass.beginOcclusionQuery.1', pass.beginOcclusionQuery(1)),
|
|
377
|
+
);
|
|
378
|
+
call('pass.draw.1', () => pass.draw(3, 1, 0, 0));
|
|
379
|
+
call('pass.endOcclusionQuery.1', () =>
|
|
380
|
+
unwrap('pass.endOcclusionQuery.1', pass.endOcclusionQuery()),
|
|
381
|
+
);
|
|
382
|
+
call('pass.end', () => pass.end());
|
|
383
|
+
call('encoder.resolveQuerySet', () =>
|
|
384
|
+
unwrap(
|
|
385
|
+
'encoder.resolveQuerySet',
|
|
386
|
+
encoder.resolveQuerySet(querySet, 0, QUERY_COUNT, queryResolveBuffer, QUERY_RESOLVE_OFFSET),
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
call('encoder.copyQueryResolveToStaging', () =>
|
|
390
|
+
encoder.copyBufferToBuffer(
|
|
391
|
+
queryResolveBuffer,
|
|
392
|
+
QUERY_RESOLVE_OFFSET,
|
|
393
|
+
queryStagingBuffer,
|
|
394
|
+
0,
|
|
395
|
+
QUERY_BYTES * QUERY_COUNT,
|
|
396
|
+
),
|
|
397
|
+
);
|
|
398
|
+
call('encoder.copyColorToReadback', () =>
|
|
399
|
+
encoder.copyTextureToBuffer(
|
|
400
|
+
{ texture: colorTexture, mipLevel: 0, origin: { x: 0, y: 0, z: 0 } } as never,
|
|
401
|
+
{
|
|
402
|
+
buffer: colorReadbackBuffer,
|
|
403
|
+
offset: 0,
|
|
404
|
+
bytesPerRow: READBACK_SIZE,
|
|
405
|
+
rowsPerImage: COLOR_HEIGHT,
|
|
406
|
+
} as never,
|
|
407
|
+
{ width: COLOR_WIDTH, height: COLOR_HEIGHT, depthOrArrayLayers: 1 },
|
|
408
|
+
),
|
|
409
|
+
);
|
|
410
|
+
const firstCommand = unwrap('encoder.finish.first', encoder.finish());
|
|
411
|
+
unwrap('queue.submit.first', device.queue.submit([firstCommand]));
|
|
412
|
+
await device.queue.onSubmittedWorkDone();
|
|
413
|
+
|
|
414
|
+
const resultEncoder = unwrap('create.resultCommandEncoder', device.createCommandEncoder({}));
|
|
415
|
+
call('resultEncoder.copyQueryResolveToGpuResult', () =>
|
|
416
|
+
resultEncoder.copyBufferToBuffer(
|
|
417
|
+
queryResolveBuffer,
|
|
418
|
+
QUERY_RESOLVE_OFFSET,
|
|
419
|
+
queryGpuResultBuffer,
|
|
420
|
+
0,
|
|
421
|
+
QUERY_BYTES * QUERY_COUNT,
|
|
422
|
+
),
|
|
423
|
+
);
|
|
424
|
+
const resultPass = resultEncoder.beginRenderPass({
|
|
425
|
+
colorAttachments: [
|
|
426
|
+
{
|
|
427
|
+
view: queryView,
|
|
428
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
429
|
+
loadOp: 'clear',
|
|
430
|
+
storeOp: 'store',
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
} as never);
|
|
434
|
+
call('resultPass.setPipeline', () => resultPass.setPipeline(queryPipeline));
|
|
435
|
+
call('resultPass.setBindGroup', () => resultPass.setBindGroup(0, queryBindGroup, []));
|
|
436
|
+
call('resultPass.draw', () => resultPass.draw(3, 1, 0, 0));
|
|
437
|
+
call('resultPass.end', () => resultPass.end());
|
|
438
|
+
call('resultEncoder.copyResultToReadback', () =>
|
|
439
|
+
resultEncoder.copyTextureToBuffer(
|
|
440
|
+
{ texture: queryTexture, mipLevel: 0, origin: { x: 0, y: 0, z: 0 } } as never,
|
|
441
|
+
{
|
|
442
|
+
buffer: resultReadbackBuffer,
|
|
443
|
+
offset: 0,
|
|
444
|
+
bytesPerRow: READBACK_SIZE,
|
|
445
|
+
rowsPerImage: 1,
|
|
446
|
+
} as never,
|
|
447
|
+
{ width: QUERY_COUNT, height: 1, depthOrArrayLayers: 1 },
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
const resultCommand = unwrap('resultEncoder.finish', resultEncoder.finish());
|
|
451
|
+
unwrap('queue.submit.result', device.queue.submit([resultCommand]));
|
|
452
|
+
await device.queue.onSubmittedWorkDone();
|
|
453
|
+
const queryBytes = await readMappedBuffer(
|
|
454
|
+
queryStagingBuffer,
|
|
455
|
+
'queryStaging',
|
|
456
|
+
QUERY_BYTES * QUERY_COUNT,
|
|
457
|
+
);
|
|
458
|
+
const colorBytes = await readMappedBuffer(
|
|
459
|
+
colorReadbackBuffer,
|
|
460
|
+
'colorReadback',
|
|
461
|
+
READBACK_SIZE * COLOR_HEIGHT,
|
|
462
|
+
);
|
|
463
|
+
const resultBytes = await readMappedBuffer(resultReadbackBuffer, 'resultReadback', READBACK_SIZE);
|
|
464
|
+
for (const [operation, resource, destroy] of [
|
|
465
|
+
['destroy.querySet', querySet, device.destroyQuerySet.bind(device)],
|
|
466
|
+
['destroy.colorTexture', colorTexture, device.destroyTexture.bind(device)],
|
|
467
|
+
['destroy.queryTexture', queryTexture, device.destroyTexture.bind(device)],
|
|
468
|
+
['destroy.queryResolveBuffer', queryResolveBuffer, device.destroyBuffer.bind(device)],
|
|
469
|
+
['destroy.queryStagingBuffer', queryStagingBuffer, device.destroyBuffer.bind(device)],
|
|
470
|
+
['destroy.queryGpuResultBuffer', queryGpuResultBuffer, device.destroyBuffer.bind(device)],
|
|
471
|
+
['destroy.colorReadbackBuffer', colorReadbackBuffer, device.destroyBuffer.bind(device)],
|
|
472
|
+
['destroy.resultReadbackBuffer', resultReadbackBuffer, device.destroyBuffer.bind(device)],
|
|
473
|
+
] as const) {
|
|
474
|
+
call(operation, () => unwrap(operation, destroy(resource as never)));
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
queryValues: u64Values(queryBytes),
|
|
478
|
+
colorBytes,
|
|
479
|
+
resultHalfWords: freshHalfWords(resultBytes),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export async function runQuerySetReplayFixture(
|
|
484
|
+
host: QuerySetFixtureHost,
|
|
485
|
+
): Promise<QuerySetReplayFixtureEvidence> {
|
|
486
|
+
let deviceLost: { reason: string; message: string } | null = null;
|
|
487
|
+
void host.device.lost.then((loss) => {
|
|
488
|
+
deviceLost = loss;
|
|
489
|
+
});
|
|
490
|
+
try {
|
|
491
|
+
const original = await executeFrame(host.device, host.createShaderModule);
|
|
492
|
+
const encoded = await host.finishCapture();
|
|
493
|
+
const decoded = decodeTape(encoded.bytes);
|
|
494
|
+
if (!decoded.ok) throw new FixtureFailure(asReceipt('decodeTape', decoded.error));
|
|
495
|
+
const tape = decoded.value;
|
|
496
|
+
const freshDevice = await host.createFreshDevice();
|
|
497
|
+
const replay = unwrap(
|
|
498
|
+
'openReplay',
|
|
499
|
+
await openReplay(tape, {
|
|
500
|
+
device: freshDevice,
|
|
501
|
+
createShaderModule: host.replayCreateShaderModule,
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
const model = buildFrameModel(tape);
|
|
505
|
+
const queryWork = model.works.at(-1);
|
|
506
|
+
if (queryWork === undefined) {
|
|
507
|
+
throw new FixtureFailure(
|
|
508
|
+
asReceipt('buildFrameModel', new Error('query result draw work is missing')),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
const inspection = unwrap(
|
|
512
|
+
'inspectWork.queryResult',
|
|
513
|
+
await replay.inspectWork(queryWork.workIndex, ['pixels']),
|
|
514
|
+
);
|
|
515
|
+
const freshBytes = inspection.attachment?.bytes ?? new Uint8Array();
|
|
516
|
+
const freshWords = freshHalfWords(freshBytes);
|
|
517
|
+
await replay.dispose();
|
|
518
|
+
return {
|
|
519
|
+
runner: host.runner,
|
|
520
|
+
status: 'available',
|
|
521
|
+
tapeFormatVersion: tape.header.formatVersion,
|
|
522
|
+
querySetHandleId: eventHandle(tape, 'createQuerySet', 'handleId'),
|
|
523
|
+
resolveDestinationHandleId: eventHandle(tape, 'resolveQuerySet', 'destinationHandleId'),
|
|
524
|
+
resolveDestinationOffset: eventNumber(tape, 'resolveQuerySet', 'destinationOffset'),
|
|
525
|
+
originalQueryValues: original.queryValues,
|
|
526
|
+
originalResultHalfWords: original.resultHalfWords,
|
|
527
|
+
freshQueryValues: [freshWords[0] === 15360 ? '1' : '0', freshWords[4] === 15360 ? '1' : '0'],
|
|
528
|
+
originalColorBytes: Array.from(original.colorBytes),
|
|
529
|
+
freshColorHalfWords: freshWords,
|
|
530
|
+
eventKinds: tapeEventKinds(tape),
|
|
531
|
+
errorReceipts: [],
|
|
532
|
+
deviceLost,
|
|
533
|
+
};
|
|
534
|
+
} catch (error) {
|
|
535
|
+
const receipt = error instanceof FixtureFailure ? error.receipt : asReceipt('fixture', error);
|
|
536
|
+
return {
|
|
537
|
+
runner: host.runner,
|
|
538
|
+
status: 'unavailable',
|
|
539
|
+
tapeFormatVersion: null,
|
|
540
|
+
querySetHandleId: null,
|
|
541
|
+
resolveDestinationHandleId: null,
|
|
542
|
+
resolveDestinationOffset: null,
|
|
543
|
+
originalQueryValues: [],
|
|
544
|
+
originalResultHalfWords: [],
|
|
545
|
+
freshQueryValues: [],
|
|
546
|
+
originalColorBytes: [],
|
|
547
|
+
freshColorHalfWords: [],
|
|
548
|
+
eventKinds: [],
|
|
549
|
+
errorReceipts: [receipt],
|
|
550
|
+
deviceLost,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
}
|
|
@@ -231,6 +231,13 @@ describe.skipIf(SKIP_DAWN)('Dawn replay-owned readback matrix', () => {
|
|
|
231
231
|
expect(arrayResult.value.width).toBe(1);
|
|
232
232
|
expect(arrayResult.value.height).toBe(1);
|
|
233
233
|
expect([...arrayResult.value.bytes]).toEqual([96, 97, 98, 99]);
|
|
234
|
+
// Omitting a request means local mip/layer zero, not texture mip/layer zero.
|
|
235
|
+
const defaultResult = await replay.value.readResource('view:array');
|
|
236
|
+
expect(defaultResult.ok).toBe(true);
|
|
237
|
+
if (!defaultResult.ok) throw new Error(defaultResult.error.hint);
|
|
238
|
+
expect(defaultResult.value.width).toBe(1);
|
|
239
|
+
expect(defaultResult.value.height).toBe(1);
|
|
240
|
+
expect([...defaultResult.value.bytes]).toEqual([...arrayResult.value.bytes]);
|
|
234
241
|
const cubeResult = await replay.value.readResource('view:cube', {
|
|
235
242
|
mipLevel: 0,
|
|
236
243
|
arrayLayer: 5,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CanvasConfiguration,
|
|
3
|
+
RhiCanvasContext,
|
|
4
|
+
RhiDevice,
|
|
5
|
+
RhiInstance,
|
|
6
|
+
} from '@forgeax/engine-rhi';
|
|
7
|
+
import { ok } from '@forgeax/engine-types';
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { createRecorderProxy, type RecordableBackend } from '../recorder/proxy';
|
|
10
|
+
|
|
11
|
+
function backend(acquireCanvasContext: ReturnType<typeof vi.fn>): RecordableBackend {
|
|
12
|
+
return {
|
|
13
|
+
rhi: {
|
|
14
|
+
requestAdapter: vi.fn(),
|
|
15
|
+
acquireCanvasContext,
|
|
16
|
+
} as unknown as RhiInstance & RecordableBackend['rhi'],
|
|
17
|
+
createShaderModule: vi.fn(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('recorder canvas context device identity', () => {
|
|
22
|
+
it('unwraps the recorder device before forwarding canvas configure', () => {
|
|
23
|
+
const configure = vi.fn(() => ok(undefined));
|
|
24
|
+
const context = {
|
|
25
|
+
configure,
|
|
26
|
+
unconfigure: vi.fn(),
|
|
27
|
+
getConfiguration: vi.fn(),
|
|
28
|
+
getCurrentTexture: vi.fn(),
|
|
29
|
+
} as unknown as RhiCanvasContext;
|
|
30
|
+
const acquireCanvasContext = vi.fn(() => ok(context));
|
|
31
|
+
const originalDevice = { caps: {} } as unknown as RhiDevice;
|
|
32
|
+
const recorderDevice = { _realDevice: originalDevice } as unknown as RhiDevice;
|
|
33
|
+
const proxy = createRecorderProxy(backend(acquireCanvasContext));
|
|
34
|
+
|
|
35
|
+
const acquired = proxy.backend.rhi.acquireCanvasContext?.({} as HTMLCanvasElement);
|
|
36
|
+
expect(acquired?.ok).toBe(true);
|
|
37
|
+
if (!acquired?.ok) return;
|
|
38
|
+
|
|
39
|
+
const configuration = {
|
|
40
|
+
device: recorderDevice,
|
|
41
|
+
format: 'bgra8unorm',
|
|
42
|
+
} as CanvasConfiguration;
|
|
43
|
+
expect(acquired.value.configure(configuration)).toEqual(ok(undefined));
|
|
44
|
+
expect(acquireCanvasContext).toHaveBeenCalledOnce();
|
|
45
|
+
expect(configure).toHaveBeenCalledWith({
|
|
46
|
+
...configuration,
|
|
47
|
+
device: originalDevice,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('preserves a native device when no recorder identity is present', () => {
|
|
52
|
+
const configure = vi.fn(() => ok(undefined));
|
|
53
|
+
const context = {
|
|
54
|
+
configure,
|
|
55
|
+
unconfigure: vi.fn(),
|
|
56
|
+
getConfiguration: vi.fn(),
|
|
57
|
+
getCurrentTexture: vi.fn(),
|
|
58
|
+
} as unknown as RhiCanvasContext;
|
|
59
|
+
const proxy = createRecorderProxy(backend(vi.fn(() => ok(context))));
|
|
60
|
+
const nativeDevice = { caps: {} } as unknown as RhiDevice;
|
|
61
|
+
const acquired = proxy.backend.rhi.acquireCanvasContext?.({} as HTMLCanvasElement);
|
|
62
|
+
if (!acquired?.ok) throw new Error('canvas context acquisition failed');
|
|
63
|
+
|
|
64
|
+
acquired.value.configure({ device: nativeDevice, format: 'bgra8unorm' } as CanvasConfiguration);
|
|
65
|
+
expect(configure).toHaveBeenCalledWith({ device: nativeDevice, format: 'bgra8unorm' });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -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);
|