@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
@@ -4,115 +4,85 @@ import {
4
4
  retireWgpuBuffer,
5
5
  retireWgpuTexture,
6
6
  submitWgpuRenderPass,
7
+ withWgpuFrameBorrow,
7
8
  } from './wgpuBackground';
8
- import { getWgpuRenderStateRuntime } from './wgpuRenderState';
9
+ import { createWgpuPipeline } from './wgpuPipeline';
10
+ import { createWgpuOffscreenRenderState, getWgpuRenderStateRuntime } from './wgpuRenderState';
9
11
  import { createWgpuRenderStateForTest, installWgpuMock } from './wgpuTestHelper';
10
12
 
11
- beforeAll(() => {
12
- installWgpuMock();
13
- });
13
+ beforeAll(() => installWgpuMock());
14
14
 
15
15
  describe('beginWgpuFrame', () => {
16
- it('opens an encoder without opening the canvas pass', async () => {
17
- const state = await createWgpuRenderStateForTest();
18
- beginWgpuFrame(state);
19
- const runtime = getWgpuRenderStateRuntime(state);
20
- expect(runtime.commandEncoder).not.toBeNull();
21
- expect(runtime.renderPass).toBeNull();
22
- });
23
-
24
- it('does not replace an active encoder or reset its uniform allocations', async () => {
16
+ it('opens one encoder and preserves it across repeated calls', async () => {
25
17
  const state = await createWgpuRenderStateForTest();
26
18
  beginWgpuFrame(state);
27
- const runtime = getWgpuRenderStateRuntime(state);
28
- const encoder = runtime.commandEncoder;
29
- runtime.uniformOffset = 512;
19
+ const encoder = getWgpuRenderStateRuntime(state).commandEncoder;
30
20
  beginWgpuFrame(state);
31
- expect(runtime.commandEncoder).toBe(encoder);
32
- expect(runtime.uniformOffset).toBe(512);
21
+ expect(getWgpuRenderStateRuntime(state).commandEncoder).toBe(encoder);
33
22
  });
34
23
  });
35
24
 
36
25
  describe('renderWgpuBackground', () => {
37
- it('opens a render pass on the state', async () => {
38
- const state = await createWgpuRenderStateForTest();
39
- const runtime = getWgpuRenderStateRuntime(state);
40
- expect(runtime.renderPass).toBeNull();
41
- renderWgpuBackground(state);
42
- expect(runtime.renderPass).not.toBeNull();
43
- });
44
-
45
- it('resets uniformOffset to zero', async () => {
46
- const state = await createWgpuRenderStateForTest();
47
- const runtime = getWgpuRenderStateRuntime(state);
48
- runtime.uniformOffset = 512;
49
- renderWgpuBackground(state);
50
- expect(runtime.uniformOffset).toBe(0);
51
- });
52
-
53
- it('creates a command encoder', async () => {
54
- const state = await createWgpuRenderStateForTest();
55
- renderWgpuBackground(state);
56
- expect(getWgpuRenderStateRuntime(state).commandEncoder).not.toBeNull();
57
- });
58
-
59
- it('opens the canvas pass on an explicitly begun frame without resetting its uniform allocations', async () => {
26
+ it('opens the presentation pass on the current surface', async () => {
60
27
  const state = await createWgpuRenderStateForTest();
61
- beginWgpuFrame(state);
62
- const runtime = getWgpuRenderStateRuntime(state);
63
- const encoder = runtime.commandEncoder;
64
- runtime.uniformOffset = 512;
65
28
  renderWgpuBackground(state);
66
- expect(runtime.commandEncoder).toBe(encoder);
67
- expect(runtime.uniformOffset).toBe(512);
68
- expect(runtime.renderPass).not.toBeNull();
29
+ expect(getWgpuRenderStateRuntime(state).renderPass).not.toBeNull();
30
+ submitWgpuRenderPass(state);
69
31
  });
70
32
  });
71
33
 
72
34
  describe('retireWgpuBuffer', () => {
73
- it('queues the buffer for post-submit destruction instead of destroying it now', async () => {
35
+ it('adds the buffer to the post-submit destruction ledger', async () => {
74
36
  const state = await createWgpuRenderStateForTest();
75
- const destroy = vi.fn();
76
- const buffer = { destroy } as unknown as GPUBuffer;
77
-
37
+ const buffer = { destroy: vi.fn() } as unknown as GPUBuffer;
78
38
  retireWgpuBuffer(state, buffer);
79
-
80
- expect(destroy).not.toHaveBeenCalled();
81
- expect(getWgpuRenderStateRuntime(state).retiredBuffers).toContain(buffer);
39
+ expect(getWgpuRenderStateRuntime(state).retiredBuffers).toEqual([buffer]);
82
40
  });
83
41
  });
84
42
 
85
43
  describe('retireWgpuTexture', () => {
86
- it('queues the texture for post-submit destruction instead of destroying it now', async () => {
44
+ it('adds the texture to the post-submit destruction ledger', async () => {
87
45
  const state = await createWgpuRenderStateForTest();
88
- const destroy = vi.fn();
89
- const texture = { destroy } as unknown as GPUTexture;
90
-
46
+ const texture = { destroy: vi.fn() } as unknown as GPUTexture;
91
47
  retireWgpuTexture(state, texture);
92
-
93
- // Nothing is freed while the frame is still recording — that is the whole reason this seam exists.
94
- expect(destroy).not.toHaveBeenCalled();
95
- expect(getWgpuRenderStateRuntime(state).retiredTextures).toContain(texture);
48
+ expect(getWgpuRenderStateRuntime(state).retiredTextures).toEqual([texture]);
96
49
  });
97
50
  });
98
51
 
99
52
  describe('submitWgpuRenderPass', () => {
100
- it('clears commandEncoder after submit', async () => {
53
+ it('submits and closes the active frame', async () => {
101
54
  const state = await createWgpuRenderStateForTest();
102
- renderWgpuBackground(state);
55
+ beginWgpuFrame(state);
103
56
  submitWgpuRenderPass(state);
104
57
  expect(getWgpuRenderStateRuntime(state).commandEncoder).toBeNull();
105
58
  });
59
+ });
106
60
 
107
- it('clears renderPass after submit', async () => {
108
- const state = await createWgpuRenderStateForTest();
109
- renderWgpuBackground(state);
110
- submitWgpuRenderPass(state);
111
- expect(getWgpuRenderStateRuntime(state).renderPass).toBeNull();
61
+ describe('withWgpuFrameBorrow', () => {
62
+ it('returns callback values and closes a standalone frame', async () => {
63
+ const screen = await createWgpuRenderStateForTest();
64
+ const offscreen = createWgpuOffscreenRenderState(
65
+ screen.deviceState,
66
+ createWgpuPipeline(getWgpuRenderStateRuntime(screen).registries),
67
+ { format: screen.format },
68
+ );
69
+
70
+ expect(withWgpuFrameBorrow(screen, offscreen, () => 42)).toBe(42);
71
+ expect(getWgpuRenderStateRuntime(screen).commandEncoder).toBeNull();
72
+ expect(getWgpuRenderStateRuntime(offscreen).commandEncoder).toBeNull();
112
73
  });
113
74
 
114
- it('is safe to call without an open pass', async () => {
115
- const state = await createWgpuRenderStateForTest();
116
- expect(() => submitWgpuRenderPass(state)).not.toThrow();
75
+ it('rejects a borrower owned by a different GPU device', async () => {
76
+ const owner = await createWgpuRenderStateForTest();
77
+ const other = await createWgpuRenderStateForTest();
78
+ const borrower = createWgpuOffscreenRenderState(
79
+ other.deviceState,
80
+ createWgpuPipeline(getWgpuRenderStateRuntime(other).registries),
81
+ { format: other.format },
82
+ );
83
+
84
+ expect(() => withWgpuFrameBorrow(owner, borrower, () => {})).toThrow(/same GPU device/);
85
+ expect(getWgpuRenderStateRuntime(owner).commandEncoder).toBeNull();
86
+ expect(getWgpuRenderStateRuntime(borrower).commandEncoder).toBeNull();
117
87
  });
118
88
  });
@@ -0,0 +1,240 @@
1
+ import { connectSignal, createSignal } from '@flighthq/signals/contract';
2
+ import type { WgpuDeviceRuntime, WgpuRenderState } from '@flighthq/types/contract';
3
+ import { EntityRuntimeKey } from '@flighthq/types/contract';
4
+
5
+ import {
6
+ disposeWgpuDeviceSignals,
7
+ enableWgpuDeviceSignals,
8
+ getWgpuDeviceLoss,
9
+ isWgpuDeviceLost,
10
+ observeWgpuDeviceLoss,
11
+ } from './wgpuDeviceLoss';
12
+ import {
13
+ createWgpuDeviceState,
14
+ createWgpuOffscreenRenderState,
15
+ createWgpuRenderStateRuntime,
16
+ getWgpuRenderStateRuntime,
17
+ } from './wgpuRenderState';
18
+
19
+ // A device whose loss the test drives by hand. `lost` is the only member the observer reads, so the
20
+ // rest of GPUDevice stays absent on purpose: a probe that needed a full device would be testing the
21
+ // mock. `resolveLost` stands in for the driver resolving the real terminal promise.
22
+ function makeLosableDevice(): { device: GPUDevice; resolveLost: (reason: GPUDeviceLostReason) => void } {
23
+ let resolveLost!: (reason: GPUDeviceLostReason) => void;
24
+ const lost = new Promise<GPUDeviceLostInfo>((resolve) => {
25
+ resolveLost = (reason) => resolve({ message: `lost: ${reason}`, reason } as GPUDeviceLostInfo);
26
+ });
27
+ return { device: { destroy: () => {}, lost } as unknown as GPUDevice, resolveLost };
28
+ }
29
+
30
+ function makeStateOn(device: GPUDevice): WgpuRenderState {
31
+ const state = { applyBlendMode: null } as unknown as WgpuRenderState;
32
+ state[EntityRuntimeKey] = createWgpuRenderStateRuntime(createWgpuDeviceState(device));
33
+ return state;
34
+ }
35
+
36
+ // A second state over the SAME device tier — the alias relationship a derived pipeline has. Passing
37
+ // the same GPUDevice to makeStateOn twice does NOT produce this: createWgpuDeviceState mints a fresh
38
+ // tier per call, so two such states would share a device and nothing else, and every aliasing probe
39
+ // would pass or fail for the wrong reason.
40
+ function makeAliasOf(state: WgpuRenderState): WgpuRenderState {
41
+ const alias = { applyBlendMode: null } as unknown as WgpuRenderState;
42
+ alias[EntityRuntimeKey] = createWgpuRenderStateRuntime(getWgpuRenderStateRuntime(state));
43
+ return alias;
44
+ }
45
+
46
+ // A resolved `lost` promise still has to reach a listener through a microtask, so every probe that
47
+ // asserts on emission awaits one turn after resolving.
48
+ const settle = (): Promise<void> => Promise.resolve().then(() => undefined);
49
+
50
+ describe('createWgpuOffscreenRenderState', () => {
51
+ it('reports a method-tight device-lost outcome instead of deriving on a dead device', async () => {
52
+ const { device, resolveLost } = makeLosableDevice();
53
+ const screen = makeStateOn(device);
54
+ resolveLost('unknown' as GPUDeviceLostReason);
55
+ await settle();
56
+ const result = createWgpuOffscreenRenderState(screen);
57
+ expect(result.reason).toBe('device-lost');
58
+ if (result.reason !== 'device-lost') throw new Error('expected a device-lost outcome');
59
+ expect(result.info.reason).toBe('unknown');
60
+ });
61
+ });
62
+
63
+ describe('disposeWgpuDeviceSignals', () => {
64
+ it('clears the group so a later enable allocates a fresh one', () => {
65
+ const { device } = makeLosableDevice();
66
+ const state = makeStateOn(device);
67
+ const first = enableWgpuDeviceSignals(state);
68
+ disposeWgpuDeviceSignals(state);
69
+ expect(enableWgpuDeviceSignals(state)).not.toBe(first);
70
+ });
71
+ });
72
+
73
+ describe('enableWgpuDeviceSignals', () => {
74
+ it('is idempotent, returning the same group on every call', () => {
75
+ const { device } = makeLosableDevice();
76
+ const state = makeStateOn(device);
77
+ expect(enableWgpuDeviceSignals(state)).toBe(enableWgpuDeviceSignals(state));
78
+ });
79
+
80
+ it('shares one group between two states over one device', () => {
81
+ const { device } = makeLosableDevice();
82
+ const screen = makeStateOn(device);
83
+ const alias = makeAliasOf(screen);
84
+ expect(enableWgpuDeviceSignals(screen)).toBe(enableWgpuDeviceSignals(alias));
85
+ });
86
+ });
87
+
88
+ describe('getWgpuDeviceLoss', () => {
89
+ // PROBE 1 — unexpected loss emits exactly once. Asserted with a COUNTER: a probe that only checked
90
+ // "did not throw" would survive an implementation that emitted twice, or not at all.
91
+ it('emits onDeviceLost exactly once for an unexpected loss', async () => {
92
+ const { device, resolveLost } = makeLosableDevice();
93
+ const state = makeStateOn(device);
94
+ let emissions = 0;
95
+ connectSignal(enableWgpuDeviceSignals(state).onDeviceLost, () => {
96
+ emissions += 1;
97
+ });
98
+ resolveLost('unknown' as GPUDeviceLostReason);
99
+ await settle();
100
+ expect(emissions).toBe(1);
101
+ expect(getWgpuDeviceLoss(state)?.reason).toBe('unknown');
102
+ });
103
+
104
+ // PROBE 2 — an intentional destroy is NOT a device loss to the caller. `device.lost` resolves for
105
+ // our own `device.destroy()` too (wgpuHost release does exactly that), so without this the suite
106
+ // would pass while reporting every clean teardown as a loss.
107
+ it('records an intentional destroy without emitting onDeviceLost', async () => {
108
+ const { device, resolveLost } = makeLosableDevice();
109
+ const state = makeStateOn(device);
110
+ let emissions = 0;
111
+ connectSignal(enableWgpuDeviceSignals(state).onDeviceLost, () => {
112
+ emissions += 1;
113
+ });
114
+ resolveLost('destroyed' as GPUDeviceLostReason);
115
+ await settle();
116
+ expect(emissions).toBe(0);
117
+ expect(getWgpuDeviceLoss(state)?.reason).toBe('destroyed');
118
+ });
119
+
120
+ // PROBE 3 — two aliases, one device, one loss. Both report it, and the signal fires once in total
121
+ // rather than once per state.
122
+ it('reports one loss to every state sharing the device', async () => {
123
+ const { device, resolveLost } = makeLosableDevice();
124
+ const screen = makeStateOn(device);
125
+ const alias = makeAliasOf(screen);
126
+ let emissions = 0;
127
+ connectSignal(enableWgpuDeviceSignals(screen).onDeviceLost, () => {
128
+ emissions += 1;
129
+ });
130
+ resolveLost('unknown' as GPUDeviceLostReason);
131
+ await settle();
132
+ expect(emissions).toBe(1);
133
+ expect(isWgpuDeviceLost(screen)).toBe(true);
134
+ expect(isWgpuDeviceLost(alias)).toBe(true);
135
+ });
136
+
137
+ // PROBE 4 — a state built AFTER the loss sees the terminal fact immediately. This is the case an
138
+ // event-only model gets wrong: the event already fired, so a late alias would never hear it.
139
+ it('reports the terminal fact to a state created after the loss', async () => {
140
+ const { device, resolveLost } = makeLosableDevice();
141
+ const screen = makeStateOn(device);
142
+ resolveLost('unknown' as GPUDeviceLostReason);
143
+ await settle();
144
+ const late = makeAliasOf(screen);
145
+ expect(isWgpuDeviceLost(late)).toBe(true);
146
+ expect(getWgpuDeviceLoss(late)).toBe(getWgpuDeviceLoss(screen));
147
+ });
148
+
149
+ // PROBE 6 — the observer attaches during device-tier construction, but `lost` may ALREADY be
150
+ // resolved by then. A resolved promise still runs a later handler, and the whole design rests on
151
+ // that, so it is asserted rather than assumed.
152
+ it('records a loss that resolved before the device tier was ever constructed', async () => {
153
+ const { device, resolveLost } = makeLosableDevice();
154
+ resolveLost('unknown' as GPUDeviceLostReason);
155
+ await settle();
156
+ const state = makeStateOn(device);
157
+ let emissions = 0;
158
+ connectSignal(enableWgpuDeviceSignals(state).onDeviceLost, () => {
159
+ emissions += 1;
160
+ });
161
+ await settle();
162
+ expect(getWgpuDeviceLoss(state)?.reason).toBe('unknown');
163
+ expect(emissions).toBe(1);
164
+ });
165
+
166
+ it('returns null while the device is live', () => {
167
+ const { device } = makeLosableDevice();
168
+ expect(getWgpuDeviceLoss(makeStateOn(device))).toBeNull();
169
+ });
170
+
171
+ // A device with no `lost` member is reachable today — `createWgpuDeviceState({} as GPUDevice)` is
172
+ // used by existing tests, and the host seam accepts caller-supplied handles from native hosts.
173
+ it('treats a device with no lost promise as live rather than throwing', () => {
174
+ const state = makeStateOn({ destroy: () => {} } as unknown as GPUDevice);
175
+ expect(isWgpuDeviceLost(state)).toBe(false);
176
+ });
177
+ });
178
+
179
+ describe('isWgpuDeviceLost', () => {
180
+ // PROBE 5 — no state retention. The observer must close over the device tier only; capturing a
181
+ // state would pin every destroyed state for the device's whole life.
182
+ it('does not retain a render state on the device runtime', () => {
183
+ const { device, resolveLost } = makeLosableDevice();
184
+ const state = makeStateOn(device);
185
+ enableWgpuDeviceSignals(state);
186
+ resolveLost('unknown' as GPUDeviceLostReason);
187
+ const deviceRuntime = getWgpuRenderStateRuntime(state).context;
188
+ const holdsState = Object.values(deviceRuntime as unknown as Record<string, unknown>).some(
189
+ (value) => value === state || (Array.isArray(value) && value.includes(state)),
190
+ );
191
+ expect(holdsState).toBe(false);
192
+ });
193
+
194
+ it('is false for a live device and true once lost', async () => {
195
+ const { device, resolveLost } = makeLosableDevice();
196
+ const state = makeStateOn(device);
197
+ expect(isWgpuDeviceLost(state)).toBe(false);
198
+ resolveLost('unknown' as GPUDeviceLostReason);
199
+ await settle();
200
+ expect(isWgpuDeviceLost(state)).toBe(true);
201
+ });
202
+ });
203
+
204
+ describe('observeWgpuDeviceLoss', () => {
205
+ it('records the loss on the runtime it was given', async () => {
206
+ const { device, resolveLost } = makeLosableDevice();
207
+ const deviceRuntime = { device, lost: null, signals: null } as unknown as WgpuDeviceRuntime;
208
+ observeWgpuDeviceLoss(deviceRuntime);
209
+ resolveLost('unknown' as GPUDeviceLostReason);
210
+ await settle();
211
+ expect(deviceRuntime.lost?.reason).toBe('unknown');
212
+ });
213
+
214
+ // Attaching twice must not announce twice. Nothing calls it twice today, but the terminal guard is
215
+ // what makes "exactly once" a property of the code rather than of the current call graph.
216
+ it('announces once even when attached more than once to the same runtime', async () => {
217
+ const { device, resolveLost } = makeLosableDevice();
218
+ const deviceRuntime = {
219
+ device,
220
+ lost: null,
221
+ signals: { onDeviceLost: createSignal<(info: GPUDeviceLostInfo) => void>() },
222
+ } as unknown as WgpuDeviceRuntime;
223
+ let emissions = 0;
224
+ connectSignal(deviceRuntime.signals!.onDeviceLost, () => {
225
+ emissions += 1;
226
+ });
227
+ observeWgpuDeviceLoss(deviceRuntime);
228
+ observeWgpuDeviceLoss(deviceRuntime);
229
+ resolveLost('unknown' as GPUDeviceLostReason);
230
+ await settle();
231
+ await settle();
232
+ expect(emissions).toBe(1);
233
+ });
234
+
235
+ it('is a no-op for a device that exposes no lost promise', () => {
236
+ const deviceRuntime = { device: { destroy: () => {} }, lost: null, signals: null } as unknown as WgpuDeviceRuntime;
237
+ observeWgpuDeviceLoss(deviceRuntime);
238
+ expect(deviceRuntime.lost).toBeNull();
239
+ });
240
+ });
@@ -18,7 +18,6 @@ import {
18
18
  bindWgpuImageResourceTexture,
19
19
  bindWgpuTexture,
20
20
  bindWgpuVideoTexture,
21
- buildWgpuRenderTargetBindGroup,
22
21
  createWgpuTextureEntry,
23
22
  drawWgpuQuad,
24
23
  drawWgpuQuadWithTransform,
@@ -130,6 +129,52 @@ describe('bindWgpuBitmapTexture', () => {
130
129
  });
131
130
  });
132
131
 
132
+ describe('bindWgpuBitmapTexture mip allocation identity', () => {
133
+ // ★ FALSIFIER, defect 6 arm A — mip allocation is part of a resource's identity, not a sampling
134
+ // preference. WebGPU fixes mipLevelCount at creation, so a level-0-only realization can never serve a
135
+ // request that needs a chain. Before the complete upload key, the second call here returned the FIRST
136
+ // caller's single-level texture and the mipmapped request silently sampled a texture with no lower
137
+ // levels. Dropping the mipLevelCount arm from the cache check fails here.
138
+ //
139
+ // This asserts on the ALLOCATION rather than on rendered pixels, which is what makes it immune to the
140
+ // three maskers that keep bitmap-perbitmap-smoothing.webgpu.ts green: it registers the mipmap
141
+ // generator itself rather than relying on the harness, and it never samples, so neither magnification
142
+ // nor a sampler-specific bind group can hide the wrong allocation.
143
+ it('does not hand a no-mip realization to a request that asks for mips', async () => {
144
+ const state = await createWgpuRenderStateForTest();
145
+ registerWgpuMipmapGeneration(state);
146
+ const image = bitmap(4, 1);
147
+
148
+ const withoutMips = bindWgpuBitmapTexture(state, image, false);
149
+ expect(withoutMips.mipLevelCount).toBe(1);
150
+
151
+ const withMips = bindWgpuBitmapTexture(state, image, true);
152
+ expect(withMips.mipLevelCount).toBeGreaterThan(1);
153
+ });
154
+
155
+ // The raw-element cache (bindWgpuTexture) carries the same identity rule. Nothing in-tree passes
156
+ // generateMips through it today, so this is the test that keeps it true rather than accidentally true.
157
+ it('applies the same mip identity rule to the raw-element cache', async () => {
158
+ const state = await createWgpuRenderStateForTest();
159
+ registerWgpuMipmapGeneration(state);
160
+ const canvas = document.createElement('canvas');
161
+ canvas.width = 4;
162
+ canvas.height = 4;
163
+
164
+ expect(requireTextureEntry(bindWgpuTexture(state, canvas, false)).mipLevelCount).toBe(1);
165
+ expect(requireTextureEntry(bindWgpuTexture(state, canvas, true)).mipLevelCount).toBeGreaterThan(1);
166
+ });
167
+
168
+ it('reuses one realization when the mip request matches', async () => {
169
+ const state = await createWgpuRenderStateForTest();
170
+ registerWgpuMipmapGeneration(state);
171
+ const image = bitmap(4, 1);
172
+
173
+ const first = bindWgpuBitmapTexture(state, image, true);
174
+ expect(bindWgpuBitmapTexture(state, image, true)).toBe(first);
175
+ });
176
+ });
177
+
133
178
  describe('bindWgpuCompressedImageTexture', () => {
134
179
  it('routes through the registered compressed upload seam', async () => {
135
180
  const state = await createWgpuRenderStateForTest();
@@ -353,15 +398,6 @@ describe('bindWgpuVideoTexture', () => {
353
398
  });
354
399
  });
355
400
 
356
- describe('buildWgpuRenderTargetBindGroup', () => {
357
- it('returns a bind group for a view', async () => {
358
- const state = await createWgpuRenderStateForTest();
359
- const fakeView = {} as GPUTextureView;
360
- const bindGroup = buildWgpuRenderTargetBindGroup(state, fakeView);
361
- expect(bindGroup).toBeDefined();
362
- });
363
- });
364
-
365
401
  describe('createWgpuTextureEntry', () => {
366
402
  it('creates a texture entry from a canvas', async () => {
367
403
  const state = await createWgpuRenderStateForTest();
@@ -370,7 +406,7 @@ describe('createWgpuTextureEntry', () => {
370
406
  canvas.height = 4;
371
407
  const entry = requireTextureEntry(createWgpuTextureEntry(state, 4, 4, canvas));
372
408
  expect(entry.texture).toBeDefined();
373
- expect(entry.bindGroup).toBeDefined();
409
+ expect(entry.bindings).toBeDefined();
374
410
  });
375
411
  });
376
412
 
@@ -434,21 +470,61 @@ describe('getWgpuRenderProxyColorScaleBias', () => {
434
470
  });
435
471
 
436
472
  describe('resolveWgpuSmoothingBindGroup', () => {
437
- it('returns the default bind group for a null smoothing (no variant built)', async () => {
473
+ it('follows the state smoothing policy for a null smoothing', async () => {
438
474
  const state = await createWgpuRenderStateForTest();
475
+ const runtime = getWgpuRenderStateRuntime(state);
439
476
  const entry = bindWgpuBitmapTexture(state, bitmap(4, 1));
440
- expect(resolveWgpuSmoothingBindGroup(state, entry, null)).toBe(entry.bindGroup);
441
- expect(entry.bindGroupLinear).toBeUndefined();
442
- expect(entry.bindGroupNearest).toBeUndefined();
477
+
478
+ state.allowSmoothing = true;
479
+ expect(resolveWgpuSmoothingBindGroup(state, entry, null)).toBe(entry.bindings.get(runtime.context.linearSampler));
480
+ });
481
+
482
+ // ★ FALSIFIER, defect 6 arm B — a cached bind group must not carry the creating state's smoothing.
483
+ // The entry is realized under one policy and drawn under the other with NO per-bitmap override. Before
484
+ // the resource/binding split the null arm returned a bind group captured at upload time, so this
485
+ // returned the LINEAR group after the policy flipped to nearest: whichever caller realized a shared
486
+ // texture first silently decided how every later sharer sampled it. Deleting the re-read to "cache the
487
+ // default group" fails here.
488
+ it('rebuilds for the current policy when allowSmoothing changes after the texture was realized', async () => {
489
+ const state = await createWgpuRenderStateForTest();
490
+ const runtime = getWgpuRenderStateRuntime(state);
491
+
492
+ state.allowSmoothing = true;
493
+ const entry = bindWgpuBitmapTexture(state, bitmap(4, 1));
494
+ const smoothed = resolveWgpuSmoothingBindGroup(state, entry, null);
495
+
496
+ state.allowSmoothing = false;
497
+ const unsmoothed = resolveWgpuSmoothingBindGroup(state, entry, null);
498
+
499
+ expect(unsmoothed).not.toBe(smoothed);
500
+ expect(unsmoothed).toBe(entry.bindings.get(runtime.context.nearestSampler));
501
+ // Flipping back returns the first group rather than building a third — the policy selects a binding,
502
+ // it does not mint one per draw.
503
+ state.allowSmoothing = true;
504
+ expect(resolveWgpuSmoothingBindGroup(state, entry, null)).toBe(smoothed);
505
+ });
506
+
507
+ it('lets a source-owned sampler override the policy, and an explicit override beat both', async () => {
508
+ const state = await createWgpuRenderStateForTest();
509
+ const runtime = getWgpuRenderStateRuntime(state);
510
+ const entry = bindWgpuBitmapTexture(state, bitmap(4, 1));
511
+
512
+ // External/video sources fix their own filtering; the global policy must not override it.
513
+ entry.sampler = runtime.context.nearestSampler;
514
+ state.allowSmoothing = true;
515
+ expect(resolveWgpuSmoothingBindGroup(state, entry, null)).toBe(entry.bindings.get(runtime.context.nearestSampler));
516
+ // A per-bitmap override still wins, as it always did.
517
+ expect(resolveWgpuSmoothingBindGroup(state, entry, true)).toBe(entry.bindings.get(runtime.context.linearSampler));
443
518
  });
444
519
 
445
520
  it('builds and caches distinct LINEAR and NEAREST variants for true/false', async () => {
446
521
  const state = await createWgpuRenderStateForTest();
522
+ const runtime = getWgpuRenderStateRuntime(state);
447
523
  const entry = bindWgpuBitmapTexture(state, bitmap(4, 1));
448
524
  const createBindGroup = vi.spyOn(state.device, 'createBindGroup');
449
525
 
450
526
  resolveWgpuSmoothingBindGroup(state, entry, true);
451
- expect(entry.bindGroupLinear).toBeDefined();
527
+ expect(entry.bindings.get(runtime.context.linearSampler)).toBeDefined();
452
528
  expect(createBindGroup).toHaveBeenCalledTimes(1);
453
529
 
454
530
  // Second smoothed bind reuses the cached variant — no new bind group.
@@ -457,7 +533,7 @@ describe('resolveWgpuSmoothingBindGroup', () => {
457
533
 
458
534
  // The unsmoothed variant is a separate cached bind group.
459
535
  resolveWgpuSmoothingBindGroup(state, entry, false);
460
- expect(entry.bindGroupNearest).toBeDefined();
536
+ expect(entry.bindings.get(runtime.context.nearestSampler)).toBeDefined();
461
537
  expect(createBindGroup).toHaveBeenCalledTimes(2);
462
538
  });
463
539
 
@@ -467,11 +543,10 @@ describe('resolveWgpuSmoothingBindGroup', () => {
467
543
  const entry = bindWgpuBitmapTexture(state, image);
468
544
  resolveWgpuSmoothingBindGroup(state, entry, true);
469
545
  resolveWgpuSmoothingBindGroup(state, entry, false);
470
- expect(entry.bindGroupLinear).toBeDefined();
546
+ expect(entry.bindings.size).toBe(2);
471
547
  image.version = 2;
472
548
  bindWgpuBitmapTexture(state, image);
473
- expect(entry.bindGroupLinear).toBeUndefined();
474
- expect(entry.bindGroupNearest).toBeUndefined();
549
+ expect(entry.bindings.size).toBe(0);
475
550
  });
476
551
  });
477
552
 
@@ -483,7 +558,7 @@ describe('submitWgpuQuadDraw', () => {
483
558
  canvas.width = 4;
484
559
  canvas.height = 4;
485
560
  const entry = requireTextureEntry(bindWgpuTexture(state, canvas));
486
- expect(() => submitWgpuQuadDraw(state, 0, entry.bindGroup)).not.toThrow();
561
+ expect(() => submitWgpuQuadDraw(state, 0, resolveWgpuSmoothingBindGroup(state, entry, null))).not.toThrow();
487
562
  submitWgpuRenderPass(state);
488
563
  });
489
564
 
@@ -493,7 +568,7 @@ describe('submitWgpuQuadDraw', () => {
493
568
  canvas.width = 4;
494
569
  canvas.height = 4;
495
570
  const entry = requireTextureEntry(bindWgpuTexture(state, canvas));
496
- expect(() => submitWgpuQuadDraw(state, 0, entry.bindGroup)).not.toThrow();
571
+ expect(() => submitWgpuQuadDraw(state, 0, resolveWgpuSmoothingBindGroup(state, entry, null))).not.toThrow();
497
572
  });
498
573
  });
499
574
 
@@ -512,8 +587,8 @@ describe('warmWgpuPipelines', () => {
512
587
  it('pre-populates the pipeline cache', async () => {
513
588
  const state = await createWgpuRenderStateForTest();
514
589
  const runtime = getWgpuRenderStateRuntime(state);
515
- const before = runtime.pipelineCache.size;
590
+ const before = runtime.context.pipelineCache.size;
516
591
  warmWgpuPipelines(state);
517
- expect(runtime.pipelineCache.size).toBeGreaterThanOrEqual(before);
592
+ expect(runtime.context.pipelineCache.size).toBeGreaterThanOrEqual(before);
518
593
  });
519
594
  });