@flighthq/render-wgpu 0.5.0-next.1944.cd6b6e7 → 0.5.1-edge.638.d4dbd4e

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 (80) hide show
  1. package/README.md +2 -2
  2. package/dist/contract.d.ts +3 -0
  3. package/dist/contract.d.ts.map +1 -1
  4. package/dist/contract.js +3 -0
  5. package/dist/contract.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/wgpuAntialias.d.ts +8 -4
  11. package/dist/wgpuAntialias.d.ts.map +1 -1
  12. package/dist/wgpuAntialias.js +23 -4
  13. package/dist/wgpuAntialias.js.map +1 -1
  14. package/dist/wgpuBackground.d.ts +10 -4
  15. package/dist/wgpuBackground.d.ts.map +1 -1
  16. package/dist/wgpuBackground.js +87 -3
  17. package/dist/wgpuBackground.js.map +1 -1
  18. package/dist/wgpuCompressedTexture.js +9 -9
  19. package/dist/wgpuCompressedTexture.js.map +1 -1
  20. package/dist/wgpuDeviceLoss.d.ts +21 -0
  21. package/dist/wgpuDeviceLoss.d.ts.map +1 -0
  22. package/dist/wgpuDeviceLoss.js +62 -0
  23. package/dist/wgpuDeviceLoss.js.map +1 -0
  24. package/dist/wgpuDraw.d.ts +0 -1
  25. package/dist/wgpuDraw.d.ts.map +1 -1
  26. package/dist/wgpuDraw.js +73 -96
  27. package/dist/wgpuDraw.js.map +1 -1
  28. package/dist/wgpuElement.d.ts +5 -0
  29. package/dist/wgpuElement.d.ts.map +1 -1
  30. package/dist/wgpuElement.js +17 -6
  31. package/dist/wgpuElement.js.map +1 -1
  32. package/dist/wgpuExternalTexture.d.ts.map +1 -1
  33. package/dist/wgpuExternalTexture.js +12 -11
  34. package/dist/wgpuExternalTexture.js.map +1 -1
  35. package/dist/wgpuFullscreenPass.js +1 -1
  36. package/dist/wgpuFullscreenPass.js.map +1 -1
  37. package/dist/wgpuHost.d.ts +7 -0
  38. package/dist/wgpuHost.d.ts.map +1 -0
  39. package/dist/wgpuHost.js +80 -0
  40. package/dist/wgpuHost.js.map +1 -0
  41. package/dist/wgpuMipmap.js +3 -3
  42. package/dist/wgpuMipmap.js.map +1 -1
  43. package/dist/wgpuPipeline.d.ts +5 -0
  44. package/dist/wgpuPipeline.d.ts.map +1 -0
  45. package/dist/wgpuPipeline.js +29 -0
  46. package/dist/wgpuPipeline.js.map +1 -0
  47. package/dist/wgpuRenderState.d.ts +13 -6
  48. package/dist/wgpuRenderState.d.ts.map +1 -1
  49. package/dist/wgpuRenderState.js +220 -271
  50. package/dist/wgpuRenderState.js.map +1 -1
  51. package/dist/wgpuRenderTarget.d.ts.map +1 -1
  52. package/dist/wgpuRenderTarget.js +6 -5
  53. package/dist/wgpuRenderTarget.js.map +1 -1
  54. package/dist/wgpuRenderTexture.js +4 -4
  55. package/dist/wgpuRenderTexture.js.map +1 -1
  56. package/dist/wgpuShader.d.ts.map +1 -1
  57. package/dist/wgpuShader.js +6 -4
  58. package/dist/wgpuShader.js.map +1 -1
  59. package/dist/wgpuSurface.d.ts +5 -5
  60. package/dist/wgpuSurface.d.ts.map +1 -1
  61. package/dist/wgpuSurface.js +2 -2
  62. package/dist/wgpuSurface.js.map +1 -1
  63. package/dist/wgpuTestHelper.d.ts +2 -2
  64. package/dist/wgpuTestHelper.d.ts.map +1 -1
  65. package/dist/wgpuTestHelper.js +6 -2
  66. package/dist/wgpuTestHelper.js.map +1 -1
  67. package/package.json +11 -11
  68. package/src/wgpuAntialias.test.ts +26 -4
  69. package/src/wgpuBackground.test.ts +43 -73
  70. package/src/wgpuDeviceLoss.test.ts +240 -0
  71. package/src/wgpuDraw.test.ts +99 -24
  72. package/src/wgpuElement.test.ts +127 -13
  73. package/src/wgpuHost.test.ts +227 -0
  74. package/src/wgpuMaterialRegistry.test.ts +6 -3
  75. package/src/wgpuMipmap.test.ts +3 -3
  76. package/src/wgpuPipeline.test.ts +52 -0
  77. package/src/wgpuRenderState.test.ts +679 -88
  78. package/src/wgpuRenderTarget.test.ts +12 -8
  79. package/src/wgpuRenderTexture.test.ts +1 -1
  80. package/src/wgpuShader.test.ts +2 -2
@@ -6,7 +6,6 @@ import {
6
6
  withRegistryTableEntry,
7
7
  } from '@flighthq/registry/contract';
8
8
  import {
9
- copyAllRenderersFromRenderState,
10
9
  enableColorAdjustmentGuards,
11
10
  enableColorAdjustments,
12
11
  getColorAdjustmentUnsupportedGuard,
@@ -21,22 +20,39 @@ import type {
21
20
  RenderState,
22
21
  WgpuColorAdjustmentMaterialFeature,
23
22
  WgpuColorAdjustmentMaterialFeatureGuard,
23
+ WgpuHostAcquisition,
24
+ WgpuHostBackend,
25
+ WgpuPipeline,
26
+ WgpuPresentationRenderState,
27
+ WgpuPresentationSurface,
28
+ WgpuRenderOptions,
29
+ WgpuRenderState,
24
30
  } from '@flighthq/types/contract';
25
- import { RegistryEntryState } from '@flighthq/types/contract';
31
+ import { EntityRuntimeKey, RegistryEntryState } from '@flighthq/types/contract';
26
32
 
27
- import { beginWgpuFrame } from './wgpuBackground';
33
+ import { getWgpuSurfaceRenderExtent } from './wgpuAntialias';
34
+ import { beginWgpuFrame, withWgpuFrameBorrow } from './wgpuBackground';
28
35
  import { registerWgpuCompressedTextureDecoder, registerWgpuCompressedTextureUpload } from './wgpuCompressedTexture';
36
+ import { setWgpuHostBackend } from './wgpuHost';
29
37
  import { registerWgpuMaterialRenderer } from './wgpuMaterialRegistry';
38
+ import { createEmptyWgpuRegistries, createWgpuPipeline } from './wgpuPipeline';
30
39
  import {
31
- copyWgpuRenderStateRegistrations,
32
- createWgpuOffscreenRenderState,
33
- createWgpuRenderStateRuntime,
40
+ createWgpuOffscreenRenderState as createDeviceOnlyWgpuRenderState,
41
+ createWgpuAcquisitionFromCanvasElement,
42
+ createWgpuRenderState as createWgpuRenderStateWithPipeline,
43
+ createWgpuRenderStateFromCanvasElement as createWgpuRenderStateFromCanvasElementWithPipeline,
44
+ releaseWgpuAcquisition,
45
+ createWgpuDeviceState,
46
+ createWgpuRenderStateRuntime as createWgpuRenderStateRuntimeWithPipeline,
34
47
  destroyWgpuRenderState,
35
48
  getWgpuColorAdjustmentMaterialFeature,
36
49
  getWgpuColorAdjustmentMaterialFeatureGuard,
50
+ getWgpuDeviceRuntime,
37
51
  getWgpuRenderStateRuntime,
38
52
  getWgpuSampler,
39
53
  isWgpuSupported,
54
+ registerWgpuDeviceTeardown,
55
+ registerWgpuRenderStateTeardown,
40
56
  resolveWgpuApplyBlendMode,
41
57
  } from './wgpuRenderState';
42
58
  import { createWgpuRenderStateForTest, installWgpuMock } from './wgpuTestHelper';
@@ -46,6 +62,44 @@ beforeAll(() => {
46
62
  installWgpuMock();
47
63
  });
48
64
 
65
+ const _testPipeline = createWgpuPipeline(createEmptyWgpuRegistries());
66
+
67
+ function createWgpuRenderState(acquisition: Readonly<WgpuHostAcquisition>, options: Readonly<WgpuRenderOptions> = {}) {
68
+ return createWgpuRenderStateWithPipeline(acquisition, _testPipeline, options);
69
+ }
70
+
71
+ function createWgpuRenderStateFromCanvasElement(
72
+ canvas: HTMLCanvasElement,
73
+ options: Readonly<WgpuRenderOptions & { powerPreference?: GPUPowerPreference }> = {},
74
+ ) {
75
+ return createWgpuRenderStateFromCanvasElementWithPipeline(canvas, _testPipeline, options);
76
+ }
77
+
78
+ function createWgpuRenderStateRuntime(deviceState: ReturnType<typeof createWgpuDeviceState>) {
79
+ return createWgpuRenderStateRuntimeWithPipeline(deviceState, _testPipeline);
80
+ }
81
+
82
+ function createWgpuOffscreenRenderState(source: WgpuRenderState): WgpuRenderState {
83
+ const pipeline: WgpuPipeline = createWgpuPipeline(getWgpuRenderStateRuntime(source).registries);
84
+ const state = createDeviceOnlyWgpuRenderState(source.deviceState, pipeline, {
85
+ backgroundColor: source.backgroundColor,
86
+ format: source.format,
87
+ imageSmoothingEnabled: source.allowSmoothing,
88
+ pixelRatio: source.pixelRatio,
89
+ roundPixels: source.roundPixels,
90
+ sceneGraphSyncPolicy: source.sceneGraphSyncPolicy,
91
+ });
92
+ const runtime = getWgpuRenderStateRuntime(state);
93
+ const sourceRuntime = getWgpuRenderStateRuntime(source);
94
+ runtime.applyBlendModeParent = source;
95
+ runtime.defaultBitmapShader = sourceRuntime.defaultBitmapShader;
96
+ runtime.mipmapDegradedGuard = sourceRuntime.mipmapDegradedGuard;
97
+ runtime.mipmapGenerator = sourceRuntime.mipmapGenerator;
98
+ runtime.webgpuShaderBindingResolver = sourceRuntime.webgpuShaderBindingResolver;
99
+ runtime.wgpuRenderTextureGuard = sourceRuntime.wgpuRenderTextureGuard;
100
+ return state;
101
+ }
102
+
49
103
  function getPaddingResolver(state: RenderState, kind: string): RenderEffectPaddingResolver | null {
50
104
  const table = getRenderStateRuntime(state).registries.effectPaddingResolvers;
51
105
  return table === undefined ? null : getRegistryTableEntry(table, kind);
@@ -61,72 +115,66 @@ function registerPaddingResolver(state: RenderState, kind: string, resolver: Ren
61
115
  );
62
116
  }
63
117
 
64
- describe('copyWgpuRenderStateRegistrations', () => {
65
- it('copies late Wgpu registrations only when explicitly requested', async () => {
66
- const screen = await createWgpuRenderStateForTest();
67
- const offscreen = createWgpuOffscreenRenderState(screen);
68
- const materialRenderer = { instanceFloatCount: 0, getShaderModule: vi.fn() } as never;
69
- const offscreenMaterialRenderer = { instanceFloatCount: 0, getShaderModule: vi.fn() } as never;
70
- const decoder = vi.fn(() => new Uint8ClampedArray(4));
71
- const resolver = vi.fn(() => null);
72
- registerWgpuCompressedTextureDecoder(screen, decoder);
73
- registerWgpuCompressedTextureUpload(screen);
74
- registerWgpuMaterialRenderer(screen, 'acme.LateMaterial', materialRenderer);
75
- registerWgpuTextureResolver(screen, 'acme.LateTexture', resolver);
118
+ describe('createWgpuAcquisitionFromCanvasElement', () => {
119
+ it('hands back handles the CALLER owns, so no state teardown can release them', async () => {
120
+ const acquisition = await createWgpuAcquisitionFromCanvasElement(document.createElement('canvas'));
76
121
 
77
- expect(
78
- hasRegistryTableEntry(getWgpuRenderStateRuntime(offscreen).registries.materialRenderers, 'acme.LateMaterial'),
79
- ).toBe(false);
80
- expect(
81
- hasRegistryTableEntry(getWgpuRenderStateRuntime(offscreen).registries.textureResolvers, 'acme.LateTexture'),
82
- ).toBe(false);
83
- expect(getWgpuRenderStateRuntime(offscreen).registries.compressedTextureDecoder.entry).toBeNull();
84
- expect(getWgpuRenderStateRuntime(offscreen).registries.compressedTextureUpload.entry).toBeNull();
85
- copyWgpuRenderStateRegistrations(offscreen, screen);
86
- expect(
87
- getRegistryTableEntry(getWgpuRenderStateRuntime(offscreen).registries.materialRenderers, 'acme.LateMaterial'),
88
- ).toBe(materialRenderer);
89
- expect(
90
- getRegistryTableEntry(getWgpuRenderStateRuntime(offscreen).registries.textureResolvers, 'acme.LateTexture'),
91
- ).toBe(resolver);
92
- expect(getWgpuRenderStateRuntime(offscreen).registries.compressedTextureDecoder).toBe(
93
- getWgpuRenderStateRuntime(screen).registries.compressedTextureDecoder,
94
- );
95
- expect(getWgpuRenderStateRuntime(offscreen).registries.compressedTextureDecoder.entry).toEqual({
96
- state: RegistryEntryState.Bound,
97
- value: decoder,
122
+ expect(acquisition).not.toBeNull();
123
+ expect(acquisition!.ownership).toBe('caller');
124
+ expect(acquisition!.surface).toBeDefined();
125
+ releaseWgpuAcquisition(acquisition!);
126
+ });
127
+
128
+ // ★ NULL, NOT THROW. "This environment cannot give me WebGPU" is an expected outcome, not API misuse, so
129
+ // it reports through the return value like every other expected failure in this SDK.
130
+ it('returns null when the host cannot acquire, rather than rejecting', async () => {
131
+ setWgpuHostBackend({
132
+ acquire: vi.fn(async () => {
133
+ throw new Error('no adapter');
134
+ }),
135
+ isSupported: vi.fn(() => false),
136
+ release: vi.fn(),
98
137
  });
99
- expect(getWgpuRenderStateRuntime(offscreen).registries.compressedTextureUpload).toBe(
100
- getWgpuRenderStateRuntime(screen).registries.compressedTextureUpload,
101
- );
102
- registerWgpuMaterialRenderer(offscreen, 'acme.LateMaterial', offscreenMaterialRenderer);
103
- registerWgpuCompressedTextureDecoder(offscreen, null);
104
- registerWgpuCompressedTextureUpload(offscreen, null);
105
- registerWgpuTextureResolver(offscreen, 'acme.LateTexture', null);
106
- expect(
107
- getRegistryTableEntry(getWgpuRenderStateRuntime(offscreen).registries.materialRenderers, 'acme.LateMaterial'),
108
- ).toBe(offscreenMaterialRenderer);
109
- expect(
110
- getRegistryTableEntry(getWgpuRenderStateRuntime(screen).registries.materialRenderers, 'acme.LateMaterial'),
111
- ).toBe(materialRenderer);
112
- expect(
113
- hasRegistryTableEntry(getWgpuRenderStateRuntime(offscreen).registries.textureResolvers, 'acme.LateTexture'),
114
- ).toBe(false);
115
- expect(
116
- getRegistryTableEntry(getWgpuRenderStateRuntime(screen).registries.textureResolvers, 'acme.LateTexture'),
117
- ).toBe(resolver);
118
- expect(getWgpuRenderStateRuntime(offscreen).registries.compressedTextureDecoder.entry).toBeNull();
119
- expect(getWgpuRenderStateRuntime(screen).registries.compressedTextureDecoder.entry?.state).toBe(
120
- RegistryEntryState.Bound,
121
- );
122
- expect(getWgpuRenderStateRuntime(offscreen).registries.compressedTextureUpload.entry).toBeNull();
123
- expect(getWgpuRenderStateRuntime(screen).registries.compressedTextureUpload.entry?.state).toBe(
124
- RegistryEntryState.Bound,
125
- );
138
+
139
+ await expect(createWgpuAcquisitionFromCanvasElement(document.createElement('canvas'))).resolves.toBeNull();
140
+ setWgpuHostBackend(null);
141
+ });
142
+ });
143
+
144
+ describe('createWgpuDeviceState', () => {
145
+ it('allocates distinct owners for repeated calls with the same raw device', () => {
146
+ const device = {} as GPUDevice;
147
+ expect(createWgpuDeviceState(device)).not.toBe(createWgpuDeviceState(device));
148
+ });
149
+
150
+ it('rejects a plain literal at the Entity boundary: EntityRuntimeKey is absent without createEntity', () => {
151
+ const device = {} as GPUDevice;
152
+ const literal = { device };
153
+ expect(EntityRuntimeKey in literal).toBe(false);
154
+
155
+ const entity = createWgpuDeviceState(device);
156
+ expect(EntityRuntimeKey in entity).toBe(true);
157
+ });
158
+
159
+ it('returns a state that shares the device tier across derived runtimes', () => {
160
+ const device = {} as GPUDevice;
161
+ const deviceState = createWgpuDeviceState(device);
162
+ const runtimeA = createWgpuRenderStateRuntime(deviceState);
163
+ const runtimeB = createWgpuRenderStateRuntime(deviceState);
164
+ runtimeA.context.standardMaterialModule = {} as GPUShaderModule;
165
+ expect(runtimeB.context.standardMaterialModule).toBe(runtimeA.context.standardMaterialModule);
126
166
  });
127
167
  });
128
168
 
129
169
  describe('createWgpuOffscreenRenderState', () => {
170
+ it('rejects a device-only state at presentation boundaries', () => {
171
+ const deviceOnlyState = null as WgpuRenderState | null;
172
+ if (deviceOnlyState === null) return;
173
+
174
+ // @ts-expect-error — a device-only state has no presentation surface or canvas context.
175
+ beginWgpuFrame(deviceOnlyState);
176
+ });
177
+
130
178
  it('resolves late screen blend-mode wiring explicitly until locally overridden', async () => {
131
179
  const screen = await createWgpuRenderStateForTest();
132
180
  const offscreen = createWgpuOffscreenRenderState(screen);
@@ -148,7 +196,8 @@ describe('createWgpuOffscreenRenderState', () => {
148
196
  expect(resolveWgpuApplyBlendMode(offscreen)).toBe(offscreenHook);
149
197
  expect(resolveWgpuApplyBlendMode(nested)).toBe(offscreenHook);
150
198
 
151
- copyWgpuRenderStateRegistrations(offscreen, screen);
199
+ offscreen.applyBlendMode = null;
200
+ getWgpuRenderStateRuntime(offscreen).applyBlendModeParent = screen;
152
201
  expect(offscreen.applyBlendMode).toBeNull();
153
202
  expect(resolveWgpuApplyBlendMode(offscreen)).toBe(laterScreenHook);
154
203
  });
@@ -197,18 +246,15 @@ describe('createWgpuOffscreenRenderState', () => {
197
246
  'acme.Effect',
198
247
  effectRunner as never,
199
248
  );
200
- getWgpuRenderStateRuntime(screen).wgpuRenderTextureCache = new WeakMap();
249
+ getWgpuRenderStateRuntime(screen).context.wgpuRenderTextureCache = new WeakMap();
201
250
 
202
251
  const offscreen = createWgpuOffscreenRenderState(screen);
203
252
  const screenRuntime = getWgpuRenderStateRuntime(screen);
204
253
  const offscreenRuntime = getWgpuRenderStateRuntime(offscreen);
205
254
 
206
255
  expect(offscreen.device).toBe(screen.device);
207
- expect(offscreen.canvas).toBe(screen.canvas);
208
- expect(offscreenRuntime.pipelineCache).toBe(screenRuntime.pipelineCache);
209
- expect(offscreenRuntime.textureCache).toBe(screenRuntime.textureCache);
210
- expect(offscreenRuntime.wgpuRenderTextureCache).toBe(screenRuntime.wgpuRenderTextureCache);
211
- expect(offscreenRuntime.uniformBindGroupLayout).toBe(screenRuntime.uniformBindGroupLayout);
256
+ expect('surface' in offscreen).toBe(false);
257
+ expect(offscreenRuntime.context).toBe(screenRuntime.context);
212
258
  expect(offscreenRuntime.uniformBuffer).not.toBe(screenRuntime.uniformBuffer);
213
259
  expect(offscreenRuntime.registries.renderers).toBe(screenRuntime.registries.renderers);
214
260
  expect(offscreenRuntime.registries).not.toBe(screenRuntime.registries);
@@ -284,12 +330,77 @@ describe('createWgpuOffscreenRenderState', () => {
284
330
  registerRenderer(offscreen, root.kind, { createData: () => createEntity({}), submit: vi.fn() });
285
331
  prepareScene2DRender(offscreen, root);
286
332
  beginWgpuFrame(screen);
287
- beginWgpuFrame(offscreen);
288
333
 
289
334
  expect(getRenderStateRuntime(offscreen).renderProxyMap).not.toBe(getRenderStateRuntime(screen).renderProxyMap);
290
- expect(getWgpuRenderStateRuntime(offscreen).commandEncoder).not.toBe(
291
- getWgpuRenderStateRuntime(screen).commandEncoder,
335
+ withWgpuFrameBorrow(screen, offscreen, () => {
336
+ expect(getWgpuRenderStateRuntime(offscreen).commandEncoder).toBe(
337
+ getWgpuRenderStateRuntime(screen).commandEncoder,
338
+ );
339
+ });
340
+ expect(getWgpuRenderStateRuntime(offscreen).commandEncoder).toBeNull();
341
+ });
342
+
343
+ it('borrows only the live frame while retaining its own uniform ring and presentation extent', async () => {
344
+ const screen = await createWgpuRenderStateForTest();
345
+ const offscreen = createWgpuOffscreenRenderState(screen);
346
+ const screenRuntime = getWgpuRenderStateRuntime(screen);
347
+ const offscreenRuntime = getWgpuRenderStateRuntime(offscreen);
348
+ const submit = vi.spyOn(screen.device.queue, 'submit');
349
+ const writeBuffer = vi.spyOn(screen.device.queue, 'writeBuffer');
350
+ beginWgpuFrame(screen);
351
+ const encoder = screenRuntime.commandEncoder;
352
+
353
+ withWgpuFrameBorrow(screen, offscreen, () => {
354
+ expect(offscreenRuntime.commandEncoder).toBe(encoder);
355
+ expect(getWgpuSurfaceRenderExtent(offscreen)).toEqual({ height: 600, width: 800 });
356
+ offscreenRuntime.uniformOffset = offscreenRuntime.uniformStride;
357
+ });
358
+
359
+ expect(screenRuntime.commandEncoder).toBe(encoder);
360
+ expect(offscreenRuntime.commandEncoder).toBeNull();
361
+ expect(writeBuffer).toHaveBeenCalledWith(
362
+ offscreenRuntime.uniformBuffer,
363
+ 0,
364
+ offscreenRuntime.uniformData.buffer,
365
+ 0,
366
+ offscreenRuntime.uniformStride,
367
+ );
368
+ expect(writeBuffer).not.toHaveBeenCalledWith(
369
+ screenRuntime.uniformBuffer,
370
+ expect.anything(),
371
+ expect.anything(),
372
+ expect.anything(),
373
+ expect.anything(),
292
374
  );
375
+ expect(submit).not.toHaveBeenCalled();
376
+ expect(() => getWgpuSurfaceRenderExtent(offscreen)).toThrow(/outside an explicit frame borrow/);
377
+ });
378
+
379
+ it('submits a standalone borrow and restores both states when the callback throws', async () => {
380
+ const screen = await createWgpuRenderStateForTest();
381
+ const offscreen = createWgpuOffscreenRenderState(screen);
382
+ const submit = vi.spyOn(screen.device.queue, 'submit');
383
+ const failure = new Error('bake failed');
384
+
385
+ expect(() =>
386
+ withWgpuFrameBorrow(screen, offscreen, () => {
387
+ throw failure;
388
+ }),
389
+ ).toThrow(failure);
390
+
391
+ expect(submit).toHaveBeenCalledOnce();
392
+ expect(getWgpuRenderStateRuntime(screen).commandEncoder).toBeNull();
393
+ expect(getWgpuRenderStateRuntime(offscreen).commandEncoder).toBeNull();
394
+ expect(() => getWgpuSurfaceRenderExtent(offscreen)).toThrow(/outside an explicit frame borrow/);
395
+ });
396
+
397
+ it('rejects nested borrowing by the same offscreen state', async () => {
398
+ const screen = await createWgpuRenderStateForTest();
399
+ const offscreen = createWgpuOffscreenRenderState(screen);
400
+
401
+ withWgpuFrameBorrow(screen, offscreen, () => {
402
+ expect(() => withWgpuFrameBorrow(screen, offscreen, () => {})).toThrow(/already has an active frame/);
403
+ });
293
404
  });
294
405
 
295
406
  it('destroys derived renderer data and its own uniform ring without freeing the screen ring', async () => {
@@ -313,7 +424,7 @@ describe('createWgpuOffscreenRenderState', () => {
313
424
  expect(screenDestroy).not.toHaveBeenCalled();
314
425
  });
315
426
 
316
- it('requires explicit re-copy for renderers and padding registered after derivation', async () => {
427
+ it('requires an explicit new pipeline for renderers and padding registered after derivation', async () => {
317
428
  const screen = await createWgpuRenderStateForTest();
318
429
  const offscreen = createWgpuOffscreenRenderState(screen);
319
430
  const renderer = { createData: () => null, submit: vi.fn() };
@@ -324,16 +435,78 @@ describe('createWgpuOffscreenRenderState', () => {
324
435
  expect(hasRegistryTableEntry(getRenderStateRuntime(offscreen).registries.renderers, 'acme.LateNode')).toBe(false);
325
436
  expect(getPaddingResolver(offscreen, 'acme.LateEffect')).toBeNull();
326
437
 
327
- copyAllRenderersFromRenderState(offscreen, screen);
328
- copyWgpuRenderStateRegistrations(offscreen, screen);
329
- expect(getRegistryTableEntry(getRenderStateRuntime(offscreen).registries.renderers, 'acme.LateNode')).toBe(
438
+ const refreshed = createWgpuOffscreenRenderState(screen);
439
+ expect(getRegistryTableEntry(getRenderStateRuntime(refreshed).registries.renderers, 'acme.LateNode')).toBe(
330
440
  renderer,
331
441
  );
332
- expect(getPaddingResolver(offscreen, 'acme.LateEffect')).toBe(paddingResolver);
442
+ expect(getPaddingResolver(refreshed, 'acme.LateEffect')).toBe(paddingResolver);
333
443
  });
334
444
  });
335
445
 
336
446
  describe('createWgpuRenderState', () => {
447
+ it('keeps exact caller-owned handles usable through every shared-state teardown', async () => {
448
+ const owner = await createWgpuRenderStateForTest();
449
+ const canvas = document.createElement('canvas');
450
+ const acquisition = {
451
+ context: owner.context,
452
+ device: owner.device,
453
+ format: owner.format,
454
+ ownership: 'caller',
455
+ surface: owner.surface,
456
+ } as const;
457
+ let contextUsable = true;
458
+ let deviceUsable = true;
459
+ const originalCreateBuffer = acquisition.device.createBuffer.bind(acquisition.device);
460
+ const originalGetCurrentTexture = acquisition.context.getCurrentTexture.bind(acquisition.context);
461
+ const createBuffer = vi.spyOn(acquisition.device, 'createBuffer').mockImplementation((descriptor) => {
462
+ if (!deviceUsable) throw new Error('caller device was destroyed');
463
+ return originalCreateBuffer(descriptor);
464
+ });
465
+ const getCurrentTexture = vi.spyOn(acquisition.context, 'getCurrentTexture').mockImplementation(() => {
466
+ if (!contextUsable) throw new Error('caller context was unconfigured');
467
+ return originalGetCurrentTexture();
468
+ });
469
+ const unconfigure = vi.spyOn(acquisition.context, 'unconfigure').mockImplementation(() => {
470
+ contextUsable = false;
471
+ });
472
+ const destroy = vi.spyOn(acquisition.device, 'destroy').mockImplementation(() => {
473
+ deviceUsable = false;
474
+ });
475
+ Object.defineProperty(globalThis.navigator, 'gpu', {
476
+ configurable: true,
477
+ get(): never {
478
+ throw new Error('host getter failed');
479
+ },
480
+ });
481
+
482
+ try {
483
+ const state = createWgpuRenderState(acquisition);
484
+ const offscreen = createWgpuOffscreenRenderState(state);
485
+ expect(state.context).toBe(acquisition.context);
486
+ expect(state.device).toBe(acquisition.device);
487
+ expect(state.format).toBe(acquisition.format);
488
+ expect('context' in offscreen).toBe(false);
489
+ expect(offscreen.device).toBe(acquisition.device);
490
+ expect(offscreen.format).toBe(acquisition.format);
491
+ const createBufferCallsBeforeTeardown = createBuffer.mock.calls.length;
492
+ const getCurrentTextureCallsBeforeTeardown = getCurrentTexture.mock.calls.length;
493
+ destroyWgpuRenderState(state);
494
+ destroyWgpuRenderState(offscreen);
495
+ expect(unconfigure).not.toHaveBeenCalled();
496
+ expect(destroy).not.toHaveBeenCalled();
497
+
498
+ const buffer = acquisition.device.createBuffer({ size: 4, usage: GPUBufferUsage.COPY_DST });
499
+ expect(buffer.size).toBe(4);
500
+ expect(acquisition.context.getCurrentTexture().createView()).toBeDefined();
501
+ expect(createBuffer).toHaveBeenCalledTimes(createBufferCallsBeforeTeardown + 1);
502
+ expect(getCurrentTexture).toHaveBeenCalledTimes(getCurrentTextureCallsBeforeTeardown + 1);
503
+ buffer.destroy();
504
+ } finally {
505
+ installWgpuMock();
506
+ destroyWgpuRenderState(owner);
507
+ }
508
+ });
509
+
337
510
  it('returns a render state with device and context', async () => {
338
511
  const state = await createWgpuRenderStateForTest();
339
512
  expect(state.device).toBeDefined();
@@ -353,9 +526,14 @@ describe('createWgpuRenderState', () => {
353
526
  expect(runtime.uniformOffset).toBe(0);
354
527
  });
355
528
 
356
- it('stores the canvas', async () => {
529
+ // C5: the state exposes the surface as SIZE ONLY. Asserting `instanceof HTMLCanvasElement` here would
530
+ // re-impose the DOM dependency the surface type exists to remove — that the web backend's surface happens
531
+ // to be a canvas is a property of that backend, pinned in wgpuHost.test.ts, not of the state contract.
532
+ it('exposes the presentation surface as live size, carrying no DOM member', async () => {
357
533
  const state = await createWgpuRenderStateForTest();
358
- expect(state.canvas).toBeInstanceOf(HTMLCanvasElement);
534
+ expect(typeof state.surface.width).toBe('number');
535
+ expect(typeof state.surface.height).toBe('number');
536
+ expect(Object.keys(state.surface as object).filter((key) => key !== 'width' && key !== 'height')).toEqual([]);
359
537
  });
360
538
 
361
539
  it('starts with null renderPass and commandEncoder', async () => {
@@ -366,9 +544,46 @@ describe('createWgpuRenderState', () => {
366
544
  });
367
545
  });
368
546
 
547
+ describe('createWgpuRenderStateFromCanvasElement', () => {
548
+ it('acquires flight-owned handles and releases them when the state is destroyed', async () => {
549
+ const owner = await createWgpuRenderStateForTest();
550
+ const acquisition = { ...ownerAcquisition(owner), ownership: 'flight' } as const;
551
+ const released: Readonly<WgpuHostAcquisition>[] = [];
552
+ setWgpuHostBackend({
553
+ acquire: vi.fn(async () => acquisition),
554
+ isSupported: vi.fn(() => true),
555
+ release: vi.fn((held: Readonly<WgpuHostAcquisition>) => released.push(held)),
556
+ });
557
+
558
+ const state = await createWgpuRenderStateFromCanvasElement(document.createElement('canvas'));
559
+ destroyWgpuRenderState(state);
560
+
561
+ expect(released).toEqual([acquisition]);
562
+ setWgpuHostBackend(null);
563
+ destroyWgpuRenderState(owner);
564
+ });
565
+
566
+ it('forwards the acquisition options, which the render options no longer carry', async () => {
567
+ const owner = await createWgpuRenderStateForTest();
568
+ const acquire = vi.fn(async () => ({ ...ownerAcquisition(owner), ownership: 'flight' }) as const);
569
+ setWgpuHostBackend({ acquire, isSupported: vi.fn(() => true), release: vi.fn() });
570
+ const canvas = document.createElement('canvas');
571
+
572
+ const state = await createWgpuRenderStateFromCanvasElement(canvas, {
573
+ format: 'rgba8unorm',
574
+ powerPreference: 'low-power',
575
+ });
576
+
577
+ expect(acquire).toHaveBeenCalledWith(canvas, { format: 'rgba8unorm', powerPreference: 'low-power' });
578
+ destroyWgpuRenderState(state);
579
+ setWgpuHostBackend(null);
580
+ destroyWgpuRenderState(owner);
581
+ });
582
+ });
583
+
369
584
  describe('createWgpuRenderStateRuntime', () => {
370
585
  it('returns a runtime with the base binding slot and empty named registration tables', () => {
371
- const runtime = createWgpuRenderStateRuntime();
586
+ const runtime = createWgpuRenderStateRuntime(createWgpuDeviceState({} as GPUDevice));
372
587
  expect(runtime.binding).toBeNull();
373
588
  expect(runtime.registries.customMaterialShaders).toMatchObject({
374
589
  onMiss: 'Unregistered',
@@ -432,9 +647,47 @@ describe('createWgpuRenderStateRuntime', () => {
432
647
  });
433
648
  expect(runtime.registries.velocityWriters.entries.size).toBe(0);
434
649
  });
650
+
651
+ it('shares the device tier when two runtimes are built from the same WgpuDeviceState', () => {
652
+ const deviceState = createWgpuDeviceState({} as GPUDevice);
653
+ const runtimeA = createWgpuRenderStateRuntime(deviceState);
654
+ const runtimeB = createWgpuRenderStateRuntime(deviceState);
655
+
656
+ const sampler = {} as GPUSampler;
657
+ runtimeA.context.linearSampler = sampler;
658
+ expect(runtimeB.context.linearSampler).toBe(sampler);
659
+
660
+ const sampler2 = {} as GPUSampler;
661
+ runtimeB.context.linearSampler = sampler2;
662
+ expect(runtimeA.context.linearSampler).toBe(sampler2);
663
+ });
664
+
665
+ it('keeps separate device tiers when two runtimes are built from different WgpuDeviceStates', () => {
666
+ const runtimeA = createWgpuRenderStateRuntime(createWgpuDeviceState({} as GPUDevice));
667
+ const runtimeB = createWgpuRenderStateRuntime(createWgpuDeviceState({} as GPUDevice));
668
+
669
+ expect(runtimeA.context).not.toBe(runtimeB.context);
670
+ });
435
671
  });
436
672
 
437
673
  describe('destroyWgpuRenderState', () => {
674
+ it('releases a Flight-owned acquisition once after its last derived state', async () => {
675
+ const state = await createWgpuRenderStateForTest();
676
+ const offscreen = createWgpuOffscreenRenderState(state);
677
+ const unconfigure = vi.spyOn(state.context, 'unconfigure');
678
+ const destroy = vi.spyOn(state.device, 'destroy');
679
+
680
+ destroyWgpuRenderState(state);
681
+ destroyWgpuRenderState(state);
682
+ expect(unconfigure).not.toHaveBeenCalled();
683
+ expect(destroy).not.toHaveBeenCalled();
684
+
685
+ destroyWgpuRenderState(offscreen);
686
+ destroyWgpuRenderState(offscreen);
687
+ expect(unconfigure).toHaveBeenCalledOnce();
688
+ expect(destroy).toHaveBeenCalledOnce();
689
+ });
690
+
438
691
  it('destroys the state-owned uniform buffer', async () => {
439
692
  const state = await createWgpuRenderStateForTest();
440
693
  const runtime = getWgpuRenderStateRuntime(state);
@@ -445,10 +698,60 @@ describe('destroyWgpuRenderState', () => {
445
698
  expect(destroy).toHaveBeenCalled();
446
699
  });
447
700
 
701
+ it('runs state-owned teardown callbacks exactly once', async () => {
702
+ const state = await createWgpuRenderStateForTest();
703
+ const teardown = vi.fn();
704
+ registerWgpuRenderStateTeardown(state, teardown);
705
+
706
+ destroyWgpuRenderState(state);
707
+ destroyWgpuRenderState(state);
708
+
709
+ expect(teardown).toHaveBeenCalledOnce();
710
+ expect(teardown).toHaveBeenCalledWith(state);
711
+ });
712
+
448
713
  it('does not throw on a fresh state with no lazily-created buffers', async () => {
449
714
  const state = await createWgpuRenderStateForTest();
450
715
  expect(() => destroyWgpuRenderState(state)).not.toThrow();
451
716
  });
717
+
718
+ it('shares the device tier across screen and offscreen states', async () => {
719
+ const state = await createWgpuRenderStateForTest();
720
+ const offscreen = createWgpuOffscreenRenderState(state);
721
+ const runtime = getWgpuRenderStateRuntime(state);
722
+ const offscreenRuntime = getWgpuRenderStateRuntime(offscreen);
723
+
724
+ const sampler = {} as GPUSampler;
725
+ runtime.context.linearSampler = sampler;
726
+ expect(offscreenRuntime.context.linearSampler).toBe(sampler);
727
+
728
+ destroyWgpuRenderState(offscreen);
729
+ destroyWgpuRenderState(state);
730
+ });
731
+
732
+ it('invokes registered teardown callbacks when the last reference is destroyed', async () => {
733
+ const state = await createWgpuRenderStateForTest();
734
+ const offscreen = createWgpuOffscreenRenderState(state);
735
+ const teardown = vi.fn();
736
+ registerWgpuDeviceTeardown(state, teardown);
737
+
738
+ destroyWgpuRenderState(state);
739
+ expect(teardown).not.toHaveBeenCalled();
740
+
741
+ destroyWgpuRenderState(offscreen);
742
+ expect(teardown).toHaveBeenCalledOnce();
743
+ expect(teardown).toHaveBeenCalledWith(state.device);
744
+ });
745
+
746
+ it('does not invoke teardowns when references remain', async () => {
747
+ const state = await createWgpuRenderStateForTest();
748
+ createWgpuOffscreenRenderState(state);
749
+ const teardown = vi.fn();
750
+ registerWgpuDeviceTeardown(state, teardown);
751
+
752
+ destroyWgpuRenderState(state);
753
+ expect(teardown).not.toHaveBeenCalled();
754
+ });
452
755
  });
453
756
 
454
757
  describe('getWgpuColorAdjustmentMaterialFeature', () => {
@@ -500,6 +803,22 @@ describe('getWgpuColorAdjustmentMaterialFeatureGuard', () => {
500
803
  });
501
804
  });
502
805
 
806
+ describe('getWgpuDeviceRuntime', () => {
807
+ it('returns the device runtime attached by createWgpuDeviceState', () => {
808
+ const device = {} as GPUDevice;
809
+ const deviceState = createWgpuDeviceState(device);
810
+ const runtime = getWgpuDeviceRuntime(deviceState);
811
+ expect(runtime).toBeDefined();
812
+ expect(runtime.device).toBe(device);
813
+ expect(runtime.teardowns).toEqual([]);
814
+ });
815
+
816
+ it('resolves the same runtime object on repeated calls', () => {
817
+ const deviceState = createWgpuDeviceState({} as GPUDevice);
818
+ expect(getWgpuDeviceRuntime(deviceState)).toBe(getWgpuDeviceRuntime(deviceState));
819
+ });
820
+ });
821
+
503
822
  describe('getWgpuRenderStateRuntime', () => {
504
823
  it('returns the runtime attached by createWgpuRenderState', async () => {
505
824
  const state = await createWgpuRenderStateForTest();
@@ -521,8 +840,10 @@ describe('getWgpuSampler', () => {
521
840
  const b = getWgpuSampler(state, 'linear', 'linear', 'repeat', 'repeat');
522
841
  expect(a).toBe(b);
523
842
  // The cache is keyed by a packed NUMBER (no per-call string allocation), and one config caches once.
524
- expect([...getWgpuRenderStateRuntime(state).samplerCache.keys()].every((k) => typeof k === 'number')).toBe(true);
525
- expect(getWgpuRenderStateRuntime(state).samplerCache.size).toBe(1);
843
+ expect([...getWgpuRenderStateRuntime(state).context.samplerCache.keys()].every((k) => typeof k === 'number')).toBe(
844
+ true,
845
+ );
846
+ expect(getWgpuRenderStateRuntime(state).context.samplerCache.size).toBe(1);
526
847
  });
527
848
 
528
849
  it('returns a distinct sampler for a different wrap or filter', async () => {
@@ -539,7 +860,7 @@ describe('getWgpuSampler', () => {
539
860
  const noMip = getWgpuSampler(state, 'linear', 'linear', 'repeat', 'repeat');
540
861
  const trilinear = getWgpuSampler(state, 'linear', 'linear', 'repeat', 'repeat', 'linear');
541
862
  expect(noMip).not.toBe(trilinear);
542
- expect(getWgpuRenderStateRuntime(state).samplerCache.size).toBe(2);
863
+ expect(getWgpuRenderStateRuntime(state).context.samplerCache.size).toBe(2);
543
864
  });
544
865
 
545
866
  it('forces linear filtering and a linear mip filter when anisotropy exceeds 1', async () => {
@@ -556,7 +877,7 @@ describe('getWgpuSampler', () => {
556
877
  const a = getWgpuSampler(state, 'linear', 'linear', 'repeat', 'repeat', 'linear', 4.9);
557
878
  const b = getWgpuSampler(state, 'linear', 'linear', 'repeat', 'repeat', 'linear', 4);
558
879
  expect(a).toBe(b);
559
- expect(getWgpuRenderStateRuntime(state).samplerCache.size).toBe(1);
880
+ expect(getWgpuRenderStateRuntime(state).context.samplerCache.size).toBe(1);
560
881
  });
561
882
 
562
883
  it('keeps independent minification and magnification filters', async () => {
@@ -573,6 +894,66 @@ describe('isWgpuSupported', () => {
573
894
  it('returns true when navigator.gpu is present', () => {
574
895
  expect(isWgpuSupported()).toBe(true);
575
896
  });
897
+
898
+ it('returns false when navigator.gpu throws', () => {
899
+ Object.defineProperty(globalThis.navigator, 'gpu', {
900
+ configurable: true,
901
+ get(): never {
902
+ throw new Error('host getter failed');
903
+ },
904
+ });
905
+ try {
906
+ expect(isWgpuSupported()).toBe(false);
907
+ } finally {
908
+ installWgpuMock();
909
+ }
910
+ });
911
+ });
912
+
913
+ describe('registerWgpuDeviceTeardown', () => {
914
+ it('pushes a callback that fires on device teardown', async () => {
915
+ const state = await createWgpuRenderStateForTest();
916
+ const teardown = vi.fn();
917
+ registerWgpuDeviceTeardown(state, teardown);
918
+ destroyWgpuRenderState(state);
919
+ expect(teardown).toHaveBeenCalledOnce();
920
+ expect(teardown).toHaveBeenCalledWith(state.device);
921
+ });
922
+ });
923
+
924
+ describe('registerWgpuRenderStateTeardown', () => {
925
+ it('runs a state-owned callback exactly once on teardown', async () => {
926
+ const state = await createWgpuRenderStateForTest();
927
+ const teardown = vi.fn();
928
+ registerWgpuRenderStateTeardown(state, teardown);
929
+
930
+ destroyWgpuRenderState(state);
931
+ destroyWgpuRenderState(state);
932
+
933
+ expect(teardown).toHaveBeenCalledOnce();
934
+ expect(teardown).toHaveBeenCalledWith(state);
935
+ });
936
+ });
937
+
938
+ describe('releaseWgpuAcquisition', () => {
939
+ // Unconditional on purpose: this is the CALLER asking. Flight's own paths refuse to release caller-owned
940
+ // handles, so if this verb deferred to the same policy the caller would have no way to end their life.
941
+ it('releases caller-owned handles, which Flight itself never does', async () => {
942
+ const owner = await createWgpuRenderStateForTest();
943
+ const acquisition = { ...ownerAcquisition(owner), ownership: 'caller' } as const;
944
+ const released: Readonly<WgpuHostAcquisition>[] = [];
945
+ setWgpuHostBackend({
946
+ acquire: vi.fn(async () => acquisition),
947
+ isSupported: vi.fn(() => true),
948
+ release: vi.fn((held: Readonly<WgpuHostAcquisition>) => released.push(held)),
949
+ });
950
+
951
+ releaseWgpuAcquisition(acquisition);
952
+
953
+ expect(released).toEqual([acquisition]);
954
+ setWgpuHostBackend(null);
955
+ destroyWgpuRenderState(owner);
956
+ });
576
957
  });
577
958
 
578
959
  describe('resolveWgpuApplyBlendMode', () => {
@@ -583,3 +964,213 @@ describe('resolveWgpuApplyBlendMode', () => {
583
964
  expect(resolveWgpuApplyBlendMode(state)).toBe(hook);
584
965
  });
585
966
  });
967
+
968
+ // ★ OWNERSHIP LIFECYCLE. Flight decides whether an acquisition is released; the backend only carries out
969
+ // the teardown. Each backend here would really destroy the handles if asked, so "not released" is a fact
970
+ // about Flight's decision rather than about a spy that could not have destroyed anything.
971
+ describe('wgpu acquisition lifecycle', () => {
972
+ const destroyingBackend = (
973
+ acquisition: Readonly<WgpuHostAcquisition>,
974
+ ): { backend: WgpuHostBackend; released: Readonly<WgpuHostAcquisition>[] } => {
975
+ const released: Readonly<WgpuHostAcquisition>[] = [];
976
+ return {
977
+ backend: {
978
+ acquire: vi.fn(async () => acquisition),
979
+ isSupported: vi.fn(() => true),
980
+ release: vi.fn((held: Readonly<WgpuHostAcquisition>) => {
981
+ released.push(held);
982
+ held.device.destroy();
983
+ }),
984
+ },
985
+ released,
986
+ };
987
+ };
988
+
989
+ it('L1: releases a flight-owned acquisition exactly once, after the last sharer, in either order', async () => {
990
+ for (const destroyOffscreenFirst of [false, true]) {
991
+ const owner = await createWgpuRenderStateForTest();
992
+ const acquisition = { ...ownerAcquisition(owner), ownership: 'flight' } as const;
993
+ const { backend, released } = destroyingBackend(acquisition);
994
+ setWgpuHostBackend(backend);
995
+
996
+ const state = createWgpuRenderState(acquisition);
997
+ const offscreen = createWgpuOffscreenRenderState(state);
998
+ const first = destroyOffscreenFirst ? offscreen : state;
999
+ const second = destroyOffscreenFirst ? state : offscreen;
1000
+
1001
+ destroyWgpuRenderState(first);
1002
+ expect(released, 'released before the last sharer was destroyed').toEqual([]);
1003
+ destroyWgpuRenderState(second);
1004
+ expect(released).toEqual([acquisition]);
1005
+
1006
+ setWgpuHostBackend(null);
1007
+ destroyWgpuRenderState(owner);
1008
+ }
1009
+ });
1010
+
1011
+ it('L2: never releases a caller-owned acquisition, in either destroy order', async () => {
1012
+ for (const destroyOffscreenFirst of [false, true]) {
1013
+ const owner = await createWgpuRenderStateForTest();
1014
+ const acquisition = { ...ownerAcquisition(owner), ownership: 'caller' } as const;
1015
+ const { backend, released } = destroyingBackend(acquisition);
1016
+ setWgpuHostBackend(backend);
1017
+
1018
+ const state = createWgpuRenderState(acquisition);
1019
+ const offscreen = createWgpuOffscreenRenderState(state);
1020
+ destroyWgpuRenderState(destroyOffscreenFirst ? offscreen : state);
1021
+ destroyWgpuRenderState(destroyOffscreenFirst ? state : offscreen);
1022
+
1023
+ expect(released).toEqual([]);
1024
+ // L5: the handles still work, which is what the caller actually depends on.
1025
+ expect(acquisition.device.createBuffer({ size: 4, usage: GPUBufferUsage.COPY_DST }).size).toBe(4);
1026
+
1027
+ setWgpuHostBackend(null);
1028
+ destroyWgpuRenderState(owner);
1029
+ }
1030
+ });
1031
+
1032
+ it('L3: destroying the same state twice releases once', async () => {
1033
+ const owner = await createWgpuRenderStateForTest();
1034
+ const acquisition = { ...ownerAcquisition(owner), ownership: 'flight' } as const;
1035
+ const { backend, released } = destroyingBackend(acquisition);
1036
+ setWgpuHostBackend(backend);
1037
+
1038
+ const state = createWgpuRenderState(acquisition);
1039
+ destroyWgpuRenderState(state);
1040
+ destroyWgpuRenderState(state);
1041
+
1042
+ expect(released).toEqual([acquisition]);
1043
+ setWgpuHostBackend(null);
1044
+ destroyWgpuRenderState(owner);
1045
+ });
1046
+
1047
+ it('L4: routes release to the backend that acquired, not whichever is installed at destroy time', async () => {
1048
+ const owner = await createWgpuRenderStateForTest();
1049
+ const acquisition = { ...ownerAcquisition(owner), ownership: 'flight' } as const;
1050
+ const acquiring = destroyingBackend(acquisition);
1051
+ const replacement = destroyingBackend(acquisition);
1052
+ setWgpuHostBackend(acquiring.backend);
1053
+
1054
+ const state = createWgpuRenderState(acquisition);
1055
+ setWgpuHostBackend(replacement.backend);
1056
+ destroyWgpuRenderState(state);
1057
+
1058
+ expect(acquiring.released).toEqual([acquisition]);
1059
+ expect(replacement.released, 'released through the backend installed later').toEqual([]);
1060
+
1061
+ setWgpuHostBackend(null);
1062
+ destroyWgpuRenderState(owner);
1063
+ });
1064
+ });
1065
+
1066
+ function ownerAcquisition(owner: WgpuPresentationRenderState): Omit<WgpuHostAcquisition, 'ownership'> {
1067
+ return { context: owner.context, device: owner.device, format: owner.format, surface: owner.surface };
1068
+ }
1069
+
1070
+ describe('WgpuPipeline snapshots', () => {
1071
+ it('captures late Wgpu registrations only when an explicit pipeline is created', async () => {
1072
+ const screen = await createWgpuRenderStateForTest();
1073
+ const materialRenderer = { instanceFloatCount: 0, getShaderModule: vi.fn() } as never;
1074
+ const offscreenMaterialRenderer = { instanceFloatCount: 0, getShaderModule: vi.fn() } as never;
1075
+ const decoder = vi.fn(() => new Uint8ClampedArray(4));
1076
+ const resolver = vi.fn(() => null);
1077
+ registerWgpuCompressedTextureDecoder(screen, decoder);
1078
+ registerWgpuCompressedTextureUpload(screen);
1079
+ registerWgpuMaterialRenderer(screen, 'acme.LateMaterial', materialRenderer);
1080
+ registerWgpuTextureResolver(screen, 'acme.LateTexture', resolver);
1081
+ const offscreen = createWgpuOffscreenRenderState(screen);
1082
+
1083
+ expect(
1084
+ getRegistryTableEntry(getWgpuRenderStateRuntime(offscreen).registries.materialRenderers, 'acme.LateMaterial'),
1085
+ ).toBe(materialRenderer);
1086
+ expect(
1087
+ getRegistryTableEntry(getWgpuRenderStateRuntime(offscreen).registries.textureResolvers, 'acme.LateTexture'),
1088
+ ).toBe(resolver);
1089
+ expect(getWgpuRenderStateRuntime(offscreen).registries.compressedTextureDecoder).toBe(
1090
+ getWgpuRenderStateRuntime(screen).registries.compressedTextureDecoder,
1091
+ );
1092
+ expect(getWgpuRenderStateRuntime(offscreen).registries.compressedTextureDecoder.entry).toEqual({
1093
+ state: RegistryEntryState.Bound,
1094
+ value: decoder,
1095
+ });
1096
+ expect(getWgpuRenderStateRuntime(offscreen).registries.compressedTextureUpload).toBe(
1097
+ getWgpuRenderStateRuntime(screen).registries.compressedTextureUpload,
1098
+ );
1099
+ registerWgpuMaterialRenderer(offscreen, 'acme.LateMaterial', offscreenMaterialRenderer);
1100
+ registerWgpuCompressedTextureDecoder(offscreen, null);
1101
+ registerWgpuCompressedTextureUpload(offscreen, null);
1102
+ registerWgpuTextureResolver(offscreen, 'acme.LateTexture', null);
1103
+ expect(
1104
+ getRegistryTableEntry(getWgpuRenderStateRuntime(offscreen).registries.materialRenderers, 'acme.LateMaterial'),
1105
+ ).toBe(offscreenMaterialRenderer);
1106
+ expect(
1107
+ getRegistryTableEntry(getWgpuRenderStateRuntime(screen).registries.materialRenderers, 'acme.LateMaterial'),
1108
+ ).toBe(materialRenderer);
1109
+ expect(
1110
+ hasRegistryTableEntry(getWgpuRenderStateRuntime(offscreen).registries.textureResolvers, 'acme.LateTexture'),
1111
+ ).toBe(false);
1112
+ expect(
1113
+ getRegistryTableEntry(getWgpuRenderStateRuntime(screen).registries.textureResolvers, 'acme.LateTexture'),
1114
+ ).toBe(resolver);
1115
+ expect(getWgpuRenderStateRuntime(offscreen).registries.compressedTextureDecoder.entry).toBeNull();
1116
+ expect(getWgpuRenderStateRuntime(screen).registries.compressedTextureDecoder.entry?.state).toBe(
1117
+ RegistryEntryState.Bound,
1118
+ );
1119
+ expect(getWgpuRenderStateRuntime(offscreen).registries.compressedTextureUpload.entry).toBeNull();
1120
+ expect(getWgpuRenderStateRuntime(screen).registries.compressedTextureUpload.entry?.state).toBe(
1121
+ RegistryEntryState.Bound,
1122
+ );
1123
+ });
1124
+ });
1125
+
1126
+ // ★ THE PRESENTATION-SURFACE CONTRACT. `WgpuPresentationSurface` exists so the WGPU path can size itself
1127
+ // without an `HTMLCanvasElement`, and its whole risk is a provider that captures the size once. A snapshot
1128
+ // would satisfy every construction-time assertion in this repo and fail only on a resize, so liveness is
1129
+ // tested here through a NON-DOM provider whose values change behind getters — which also proves the second
1130
+ // half of the contract, that nothing on the path reads a DOM member.
1131
+ describe('WgpuPresentationSurface', () => {
1132
+ it('C1/C2: drives every size consumer from a live, non-DOM, size-only provider', async () => {
1133
+ const owner = await createWgpuRenderStateForTest();
1134
+ let width = 800;
1135
+ let height = 600;
1136
+ const surface: WgpuPresentationSurface = {
1137
+ get height() {
1138
+ return height;
1139
+ },
1140
+ get width() {
1141
+ return width;
1142
+ },
1143
+ };
1144
+ const acquisition = {
1145
+ context: owner.context,
1146
+ device: owner.device,
1147
+ format: owner.format,
1148
+ ownership: 'caller',
1149
+ surface,
1150
+ } as const;
1151
+ const canvas = document.createElement('canvas');
1152
+
1153
+ const state = createWgpuRenderState(acquisition);
1154
+ expect(getWgpuSurfaceRenderExtent(state)).toEqual({ width: 800, height: 600 });
1155
+
1156
+ width = 320;
1157
+ height = 240;
1158
+
1159
+ // Every consumer re-reads: a captured `{ width, height }` would still answer 800x600 here.
1160
+ expect(state.surface.width).toBe(320);
1161
+ expect(state.surface.height).toBe(240);
1162
+ expect(getWgpuSurfaceRenderExtent(state)).toEqual({ width: 320, height: 240 });
1163
+
1164
+ destroyWgpuRenderState(state);
1165
+ destroyWgpuRenderState(owner);
1166
+ });
1167
+
1168
+ it('C4: accepts an HTMLCanvasElement structurally, so the web path needs no wrapper', () => {
1169
+ const canvas = document.createElement('canvas');
1170
+ canvas.width = 128;
1171
+ canvas.height = 64;
1172
+ const surface: WgpuPresentationSurface = canvas;
1173
+ expect(surface.width).toBe(128);
1174
+ expect(surface.height).toBe(64);
1175
+ });
1176
+ });