@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.
Files changed (72) hide show
  1. package/README.md +13 -0
  2. package/dist/__tests__/query-set-lifecycle.unit.test.d.ts +2 -0
  3. package/dist/__tests__/query-set-lifecycle.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/query-set-replay-fixture.d.ts +39 -0
  5. package/dist/__tests__/query-set-replay-fixture.d.ts.map +1 -0
  6. package/dist/__tests__/recorder-canvas-context.unit.test.d.ts +2 -0
  7. package/dist/__tests__/recorder-canvas-context.unit.test.d.ts.map +1 -0
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.mjs +465 -53
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/protocol/event-semantics.d.ts +1 -1
  13. package/dist/protocol/event-semantics.d.ts.map +1 -1
  14. package/dist/protocol/types.d.ts +1 -1
  15. package/dist/protocol/types.d.ts.map +1 -1
  16. package/dist/recorder/closure.d.ts.map +1 -1
  17. package/dist/recorder/core.d.ts +10 -1
  18. package/dist/recorder/core.d.ts.map +1 -1
  19. package/dist/recorder/device.d.ts.map +1 -1
  20. package/dist/recorder/encoder.d.ts.map +1 -1
  21. package/dist/recorder/pass.d.ts.map +1 -1
  22. package/dist/recorder/pipeline.d.ts +5 -0
  23. package/dist/recorder/pipeline.d.ts.map +1 -0
  24. package/dist/recorder/proxy.d.ts +3 -1
  25. package/dist/recorder/proxy.d.ts.map +1 -1
  26. package/dist/recorder/session.d.ts +1 -1
  27. package/dist/recorder/session.d.ts.map +1 -1
  28. package/dist/recorder/shader.d.ts +3 -1
  29. package/dist/recorder/shader.d.ts.map +1 -1
  30. package/dist/recorder.d.ts +2 -2
  31. package/dist/recorder.d.ts.map +1 -1
  32. package/dist/replay/execute.d.ts.map +1 -1
  33. package/dist/replay/resources.d.ts +4 -1
  34. package/dist/replay/resources.d.ts.map +1 -1
  35. package/dist/replay/session.d.ts +7 -0
  36. package/dist/replay/session.d.ts.map +1 -1
  37. package/dist/types.d.ts +39 -4
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +6 -6
  40. package/src/__tests__/coverage-invariant.test-d.ts +4 -5
  41. package/src/__tests__/coverage-invariant.unit.test.ts +6 -17
  42. package/src/__tests__/e2e.browser.test.ts +50 -0
  43. package/src/__tests__/guard-gates.test.ts +1 -1
  44. package/src/__tests__/query-set-lifecycle.unit.test.ts +265 -0
  45. package/src/__tests__/query-set-replay-fixture.ts +553 -0
  46. package/src/__tests__/readback-format-matrix.dawn.test.ts +7 -0
  47. package/src/__tests__/recorder-canvas-context.unit.test.ts +67 -0
  48. package/src/__tests__/recorder-session.integration.test.ts +55 -0
  49. package/src/__tests__/recorder-session.unit.test.ts +18 -1
  50. package/src/__tests__/replay-session.dawn.test.ts +49 -0
  51. package/src/__tests__/replay-session.test-d.ts +1 -0
  52. package/src/__tests__/rhi-debug-fresh-replay.dawn.test.ts +61 -0
  53. package/src/index.ts +1 -0
  54. package/src/protocol/event-semantics.ts +20 -2
  55. package/src/protocol/types.ts +1 -0
  56. package/src/protocol/validation.ts +31 -0
  57. package/src/recorder/assemble.ts +8 -1
  58. package/src/recorder/closure.ts +17 -0
  59. package/src/recorder/core.ts +11 -0
  60. package/src/recorder/device.ts +33 -6
  61. package/src/recorder/encoder.ts +22 -4
  62. package/src/recorder/pass.ts +7 -2
  63. package/src/recorder/pipeline.ts +31 -0
  64. package/src/recorder/proxy.ts +58 -2
  65. package/src/recorder/session.ts +1 -1
  66. package/src/recorder/shader.ts +29 -22
  67. package/src/recorder.ts +6 -2
  68. package/src/replay/execute.ts +111 -0
  69. package/src/replay/readback.ts +8 -3
  70. package/src/replay/resources.ts +6 -0
  71. package/src/replay/session.ts +234 -25
  72. package/src/types.ts +53 -12
@@ -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);
@@ -154,6 +154,42 @@ function triangleTape(): Tape {
154
154
  }
155
155
 
156
156
  describe.skipIf(SKIP_DAWN)('ReplaySession Dawn contract', () => {
157
+ it.each([
158
+ 0x84, 0x184, 0x06,
159
+ ])('restores captured buffer bytes without COPY_DST (usage %i)', async (usage) => {
160
+ const pack = await loadDawn();
161
+ const expected = new Uint8Array([17, 31, 63, 127, 5, 9, 13, 21]);
162
+ const tape: Tape = {
163
+ header: { formatVersion: 7, rhiCaps: {}, eventCount: 0, blobCount: 1 },
164
+ bootstrap: [
165
+ bootstrap(
166
+ 'buffer:seed',
167
+ 'buffer',
168
+ {
169
+ kind: 'createBuffer',
170
+ handleId: 'buffer:seed',
171
+ desc: { size: expected.length, usage },
172
+ },
173
+ [{ hash: 'seed', byteOffset: 0, byteLength: expected.length }],
174
+ ),
175
+ ],
176
+ events: [],
177
+ blobs: [{ hash: 'seed', bytes: expected, compression: 'none' }],
178
+ };
179
+ const replay = (
180
+ await openReplay(tape, {
181
+ device: await freshDevice(pack),
182
+ createShaderModule: pack.createShaderModule,
183
+ })
184
+ ).unwrap();
185
+ try {
186
+ expect((await replay.readResource('buffer:seed')).unwrap().bytes).toEqual(expected);
187
+ expect(tape.bootstrap[0]?.create.desc).toEqual({ size: expected.length, usage });
188
+ } finally {
189
+ (await replay.dispose()).unwrap();
190
+ }
191
+ });
192
+
157
193
  it('replays a triangle and returns attachment bytes through inspectWork', async () => {
158
194
  const pack = await loadDawn();
159
195
  const tape = triangleTape();
@@ -175,6 +211,19 @@ describe.skipIf(SKIP_DAWN)('ReplaySession Dawn contract', () => {
175
211
  expect(baseline.value.attachment?.provenance.selectedWorkIndex).toBe(0);
176
212
  expect(baseline.value.attachment?.provenance.subresource).toBeNull();
177
213
 
214
+ const selected = await first.value.readResourceAtWork('texture-view:rt', 0);
215
+ expect(
216
+ selected.ok,
217
+ selected.ok ? undefined : `${selected.error.code}: ${JSON.stringify(selected.error.detail)}`,
218
+ ).toBe(true);
219
+ if (!selected.ok) throw new Error(selected.error.hint);
220
+ expect(selected.value.provenance.selectedWorkIndex).toBe(0);
221
+ expect(selected.value.bytes).toEqual(baseline.value.attachment?.bytes);
222
+ const bootstrap = await first.value.readResource('texture-view:rt');
223
+ if (!bootstrap.ok) throw new Error(bootstrap.error.hint);
224
+ expect(bootstrap.value.provenance.selectedWorkIndex).toBeUndefined();
225
+ expect(selected.value.bytes).not.toEqual(bootstrap.value.bytes);
226
+
178
227
  const second = await openReplay(tape, {
179
228
  device: await freshDevice(pack),
180
229
  createShaderModule: pack.createShaderModule,
@@ -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
  });
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ export {
66
66
  attachRecorder,
67
67
  type CaptureFrameOptions,
68
68
  type CreateShaderModuleFn,
69
+ type CreateShaderModuleImmediateFn,
69
70
  type EncodedTape,
70
71
  type RecordableBackend,
71
72
  type RecorderAttachment,
@@ -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' || event.kind === 'destroyTexture'
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 (kind.startsWith('create') || kind.startsWith('destroy') || kind === 'initialData')
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,8 @@ function referencedHandles(event: RhiCallEvent): readonly string[] {
153
169
  'resourceHandleIds',
154
170
  'bindGroupHandleId',
155
171
  'pipelineHandleId',
172
+ 'querySetHandleId',
173
+ 'occlusionQuerySetHandleId',
156
174
  'indexBufferHandleId',
157
175
  'vertexBufferHandleId',
158
176
  ];
@@ -11,6 +11,7 @@ export type TapeBlobCompression = 'none' | 'gzip';
11
11
  export type ResourceKind =
12
12
  | 'buffer'
13
13
  | 'texture'
14
+ | 'query-set'
14
15
  | 'texture-view'
15
16
  | 'sampler'
16
17
  | 'shader-module'
@@ -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,32 @@ 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
+ 'colorAttachmentViewHandleIds',
177
+ 'colorAttachmentResolveTargetHandleIds',
178
+ 'depthStencilViewHandleId',
179
+ ]),
180
+ destroyed: [],
181
+ };
182
+ case 'beginComputePass':
183
+ return { created: [event.passHandleId], reads: [event.cmdHandleId], destroyed: [] };
184
+ case 'endRenderPass':
185
+ case 'endComputePass':
186
+ return { created: [], reads: [event.passHandleId], destroyed: [event.passHandleId] };
187
+ case 'beginOcclusionQuery':
188
+ case 'endOcclusionQuery':
189
+ return { created: [], reads: handleRefs(event, ['passHandleId']), destroyed: [] };
159
190
  default:
160
191
  return { created: [], reads: [], destroyed: [] };
161
192
  }
@@ -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 (event.kind.startsWith('create') && 'handleId' in event) return event.handleId;
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':
@@ -135,12 +135,14 @@ export function _collectFrameReferencedHandleIds(events: readonly RhiCallEvent[]
135
135
  cmdHandleId: HandleId;
136
136
  colorAttachmentViewHandleIds: readonly (HandleId | undefined)[];
137
137
  depthStencilViewHandleId?: HandleId;
138
+ occlusionQuerySetHandleId?: HandleId;
138
139
  };
139
140
  refs.add(we.cmdHandleId);
140
141
  for (const vhId of we.colorAttachmentViewHandleIds) {
141
142
  if (vhId !== undefined) refs.add(vhId);
142
143
  }
143
144
  if (we.depthStencilViewHandleId !== undefined) refs.add(we.depthStencilViewHandleId);
145
+ if (we.occlusionQuerySetHandleId !== undefined) refs.add(we.occlusionQuerySetHandleId);
144
146
  break;
145
147
  }
146
148
  case 'beginComputePass': {
@@ -204,6 +206,7 @@ export function _collectFrameReferencedHandleIds(events: readonly RhiCallEvent[]
204
206
  break;
205
207
  }
206
208
  case 'createBindGroup':
209
+ case 'getBindGroupLayout':
207
210
  case 'createPipelineLayout':
208
211
  case 'createRenderPipeline':
209
212
  case 'createComputePipeline':
@@ -223,17 +226,28 @@ export function _collectFrameReferencedHandleIds(events: readonly RhiCallEvent[]
223
226
  }
224
227
  case 'destroyBuffer':
225
228
  case 'destroyTexture':
229
+ case 'destroyQuerySet':
226
230
  refs.add(e.handleId);
227
231
  break;
228
232
  case 'frameMark':
229
233
  case 'createBuffer':
230
234
  case 'createTexture':
235
+ case 'createQuerySet':
231
236
  case 'createSampler':
232
237
  case 'createBindGroupLayout':
233
238
  case 'createShaderModule':
234
239
  case 'createCommandEncoder':
235
240
  // Leaf declaration events — no backward references to collect.
236
241
  break;
242
+ case 'resolveQuerySet':
243
+ refs.add(e.cmdHandleId);
244
+ refs.add(e.querySetHandleId);
245
+ refs.add(e.destinationHandleId);
246
+ break;
247
+ case 'beginOcclusionQuery':
248
+ case 'endOcclusionQuery':
249
+ refs.add(e.passHandleId);
250
+ break;
237
251
  default: {
238
252
  // Exhaustiveness guard: if a new RhiCallEvent member is added to the
239
253
  // union without a corresponding handle-collection case, tsc fails here.
@@ -292,8 +306,11 @@ export function _getCreateEventReferencedHandleIds(event: RhiCallEvent): HandleI
292
306
  const e = event as { sourceHandleId: HandleId };
293
307
  return [e.sourceHandleId];
294
308
  }
309
+ case 'getBindGroupLayout':
310
+ return [(event as { pipelineHandleId: HandleId }).pipelineHandleId];
295
311
  case 'createBuffer':
296
312
  case 'createTexture':
313
+ case 'createQuerySet':
297
314
  case 'createSampler':
298
315
  case 'createBindGroupLayout':
299
316
  case 'createShaderModule':
@@ -5,6 +5,7 @@
5
5
  import type {
6
6
  Result,
7
7
  RhiDevice,
8
+ RhiError,
8
9
  RhiInstance,
9
10
  ShaderModule,
10
11
  TextureView,
@@ -557,6 +558,16 @@ export type CreateShaderModuleFn = (
557
558
  desc: { code: string; label?: string | undefined },
558
559
  ) => Promise<Result<ShaderModule, import('@forgeax/engine-rhi').RhiError>>;
559
560
 
561
+ /**
562
+ * Synchronous shader-module factory used by the render path's immediate
563
+ * adapter. Keep it in the recordable backend contract so attaching RHI-debug
564
+ * does not silently remove the renderer's first-use fast path.
565
+ */
566
+ export type CreateShaderModuleImmediateFn = (
567
+ device: RhiDevice,
568
+ desc: { code: string; label?: string | undefined },
569
+ ) => Result<ShaderModule, RhiError>;
570
+
560
571
  export type { RecorderInternal, SnapshotProgress };
561
572
  export {
562
573
  addSwapchainViewFormat,
@@ -40,6 +40,7 @@ import {
40
40
  TEXTURE_USAGE_COPY_SRC,
41
41
  } from './core';
42
42
  import { createCommandEncoderProxy } from './encoder';
43
+ import { wrapPipeline } from './pipeline';
43
44
  import { createQueueProxy } from './queue';
44
45
 
45
46
  export function createDeviceProxy(s: RecorderInternal, realDevice: RhiDevice): RhiDevice {
@@ -338,9 +339,9 @@ export function createDeviceProxy(s: RecorderInternal, realDevice: RhiDevice): R
338
339
  vertexShaderModuleHandleId,
339
340
  fragmentShaderModuleHandleId,
340
341
  };
341
- registerHandle(s, res.value as object, 'renderPipeline', event);
342
+ const pipelineId = registerHandle(s, res.value as object, 'renderPipeline', event);
342
343
  pushEvent(s, event);
343
- return res;
344
+ return makeOk(wrapPipeline(s, res.value, pipelineId));
344
345
  },
345
346
 
346
347
  createComputePipeline(desc: ComputePipelineDescriptor) {
@@ -364,13 +365,26 @@ export function createDeviceProxy(s: RecorderInternal, realDevice: RhiDevice): R
364
365
  layoutHandleId: layoutId,
365
366
  computeShaderModuleHandleId,
366
367
  };
367
- registerHandle(s, res.value as object, 'computePipeline', event);
368
+ const pipelineId = registerHandle(s, res.value as object, 'computePipeline', event);
368
369
  pushEvent(s, event);
369
- return res;
370
+ return makeOk(wrapPipeline(s, res.value, pipelineId));
370
371
  },
371
372
 
372
373
  createQuerySet(desc: QuerySetDescriptor) {
373
- return realDevice.createQuerySet(desc);
374
+ const res = realDevice.createQuerySet(desc);
375
+ if (!res.ok) return res;
376
+ const event: RhiCallEvent = {
377
+ kind: 'createQuerySet',
378
+ handleId: '' as HandleId,
379
+ desc: {
380
+ ...(desc.label === undefined ? {} : { label: desc.label }),
381
+ type: desc.type ?? 'occlusion',
382
+ count: desc.count ?? 0,
383
+ },
384
+ };
385
+ registerHandle(s, res.value as object, 'querySet', event);
386
+ pushEvent(s, event);
387
+ return res;
374
388
  },
375
389
 
376
390
  destroyBuffer(buf: Buffer) {
@@ -419,7 +433,20 @@ export function createDeviceProxy(s: RecorderInternal, realDevice: RhiDevice): R
419
433
  },
420
434
 
421
435
  destroyQuerySet(querySet: QuerySet) {
422
- return realDevice.destroyQuerySet(querySet);
436
+ const hId = s.handleMap.get(querySet as object);
437
+ const res = realDevice.destroyQuerySet(querySet);
438
+ if (!res.ok) return res;
439
+ if (hId !== undefined) {
440
+ if (
441
+ !retainsCaptureBootstrap(s) &&
442
+ !s.snapshotSeededHandles.has(hId) &&
443
+ !hasBootstrapDependency(s, hId)
444
+ ) {
445
+ s.bootstrapCreates.delete(hId);
446
+ }
447
+ pushEvent(s, { kind: 'destroyQuerySet', handleId: hId });
448
+ }
449
+ return res;
423
450
  },
424
451
 
425
452
  destroyTexture(tex: Texture) {
@@ -61,15 +61,21 @@ export function createCommandEncoderProxy(
61
61
  ...(desc.depthStencilAttachment === undefined
62
62
  ? {}
63
63
  : { depthStencilAttachment: { ...desc.depthStencilAttachment } }),
64
- ...(desc.occlusionQuerySet === undefined
65
- ? {}
66
- : { occlusionQuerySet: desc.occlusionQuerySet }),
67
64
  ...(desc.timestampWrites === undefined ? {} : { timestampWrites: desc.timestampWrites }),
68
65
  ...(desc.maxDrawCount === undefined ? {} : { maxDrawCount: desc.maxDrawCount }),
69
66
  },
70
67
  colorAttachmentViewHandleIds,
71
68
  colorAttachmentResolveTargetHandleIds,
72
69
  depthStencilViewHandleId,
70
+ ...(desc.occlusionQuerySet === undefined
71
+ ? {}
72
+ : {
73
+ occlusionQuerySetHandleId: getHandleId(
74
+ s,
75
+ desc.occlusionQuerySet as object,
76
+ 'querySet',
77
+ ),
78
+ }),
73
79
  });
74
80
  const realPass = realEnc.beginRenderPass(desc);
75
81
  return createRenderPassProxy(s, realPass, passHId);
@@ -220,13 +226,25 @@ export function createCommandEncoderProxy(
220
226
  realEnc.clearBuffer(buffer, offset, size);
221
227
  },
222
228
  resolveQuerySet(querySet, firstQuery, queryCount, destination, destinationOffset) {
223
- return realEnc.resolveQuerySet(
229
+ const result = realEnc.resolveQuerySet(
224
230
  querySet,
225
231
  firstQuery,
226
232
  queryCount,
227
233
  destination,
228
234
  destinationOffset,
229
235
  );
236
+ if (result.ok) {
237
+ pushEvent(s, {
238
+ kind: 'resolveQuerySet',
239
+ cmdHandleId: cmdHId,
240
+ querySetHandleId: getHandleId(s, querySet as object, 'querySet'),
241
+ firstQuery,
242
+ queryCount,
243
+ destinationHandleId: getHandleId(s, destination as object, 'buffer'),
244
+ destinationOffset,
245
+ });
246
+ }
247
+ return result;
230
248
  },
231
249
  pushDebugGroup(groupLabel) {
232
250
  pushEvent(s, { kind: 'pushDebugGroup', cmdHandleId: cmdHId, groupLabel });
@@ -177,10 +177,15 @@ export function createRenderPassProxy(
177
177
  return realPass.executeBundles(bundles);
178
178
  },
179
179
  beginOcclusionQuery(queryIndex) {
180
- return realPass.beginOcclusionQuery(queryIndex);
180
+ const result = realPass.beginOcclusionQuery(queryIndex);
181
+ if (result.ok)
182
+ pushEvent(s, { kind: 'beginOcclusionQuery', passHandleId: passHId, queryIndex });
183
+ return result;
181
184
  },
182
185
  endOcclusionQuery() {
183
- return realPass.endOcclusionQuery();
186
+ const result = realPass.endOcclusionQuery();
187
+ if (result.ok) pushEvent(s, { kind: 'endOcclusionQuery', passHandleId: passHId });
188
+ return result;
184
189
  },
185
190
  end() {
186
191
  pushEvent(s, { kind: 'endRenderPass', passHandleId: passHId });
@@ -0,0 +1,31 @@
1
+ import type { ComputePipeline, RenderPipeline, RhiRenderPipelineOps } from '@forgeax/engine-rhi';
2
+ import type { HandleId } from '../types';
3
+ import { pushEvent, type RecorderInternal, registerHandle } from './core';
4
+
5
+ export function wrapPipeline<T extends RenderPipeline | ComputePipeline>(
6
+ state: RecorderInternal,
7
+ pipeline: T,
8
+ pipelineHandleId: HandleId,
9
+ ): T {
10
+ // Preserve native pipeline identity: renderer-owned command paths may pass
11
+ // the opaque handle directly to WebGPU, which rejects a JavaScript Proxy.
12
+ const operation = (pipeline as T & Partial<RhiRenderPipelineOps>).getBindGroupLayout;
13
+ if (operation === undefined) return pipeline;
14
+ const getLayout = operation.bind(pipeline);
15
+ Object.defineProperty(pipeline, 'getBindGroupLayout', {
16
+ configurable: true,
17
+ value: (index: number) => {
18
+ const layout = getLayout(index);
19
+ const event = {
20
+ kind: 'getBindGroupLayout' as const,
21
+ handleId: '' as HandleId,
22
+ pipelineHandleId,
23
+ index,
24
+ };
25
+ registerHandle(state, layout, 'bindGroupLayout', event);
26
+ pushEvent(state, event);
27
+ return layout;
28
+ },
29
+ });
30
+ return pipeline;
31
+ }