@flighthq/render-wgpu 0.5.1-next.1069.554a2ac → 0.5.1-next.1201.9b49dee

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 (105) hide show
  1. package/README.md +2 -2
  2. package/dist/contract.d.ts +9 -6
  3. package/dist/contract.d.ts.map +1 -1
  4. package/dist/contract.js +9 -6
  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/wgpuDraw.js +1 -1
  11. package/dist/wgpuDraw.js.map +1 -1
  12. package/dist/wgpuFrame.d.ts +23 -0
  13. package/dist/wgpuFrame.d.ts.map +1 -0
  14. package/dist/{wgpuBackground.js → wgpuFrame.js} +67 -121
  15. package/dist/wgpuFrame.js.map +1 -0
  16. package/dist/wgpuFullscreenPass.d.ts +2 -2
  17. package/dist/wgpuFullscreenPass.d.ts.map +1 -1
  18. package/dist/wgpuFullscreenPass.js +1 -2
  19. package/dist/wgpuFullscreenPass.js.map +1 -1
  20. package/dist/wgpuHost.d.ts +6 -6
  21. package/dist/wgpuHost.d.ts.map +1 -1
  22. package/dist/wgpuHost.js +17 -3
  23. package/dist/wgpuHost.js.map +1 -1
  24. package/dist/wgpuRenderPass.d.ts +9 -0
  25. package/dist/wgpuRenderPass.d.ts.map +1 -0
  26. package/dist/wgpuRenderPass.js +222 -0
  27. package/dist/wgpuRenderPass.js.map +1 -0
  28. package/dist/wgpuRenderState.d.ts +3 -4
  29. package/dist/wgpuRenderState.d.ts.map +1 -1
  30. package/dist/wgpuRenderState.js +17 -88
  31. package/dist/wgpuRenderState.js.map +1 -1
  32. package/dist/wgpuRenderTarget.d.ts +12 -29
  33. package/dist/wgpuRenderTarget.d.ts.map +1 -1
  34. package/dist/wgpuRenderTarget.js +19 -252
  35. package/dist/wgpuRenderTarget.js.map +1 -1
  36. package/dist/wgpuRenderTargetPool.d.ts +5 -5
  37. package/dist/wgpuRenderTargetPool.d.ts.map +1 -1
  38. package/dist/wgpuRenderTargetPool.js +7 -7
  39. package/dist/wgpuRenderTargetPool.js.map +1 -1
  40. package/dist/wgpuRenderTexture.d.ts +5 -4
  41. package/dist/wgpuRenderTexture.d.ts.map +1 -1
  42. package/dist/wgpuRenderTexture.js +14 -16
  43. package/dist/wgpuRenderTexture.js.map +1 -1
  44. package/dist/wgpuRenderTexturePool.js +1 -3
  45. package/dist/wgpuRenderTexturePool.js.map +1 -1
  46. package/dist/wgpuScissor.d.ts +1 -1
  47. package/dist/wgpuScissor.d.ts.map +1 -1
  48. package/dist/wgpuScissor.js +7 -4
  49. package/dist/wgpuScissor.js.map +1 -1
  50. package/dist/wgpuScreenAntialias.d.ts +3 -0
  51. package/dist/wgpuScreenAntialias.d.ts.map +1 -0
  52. package/dist/wgpuScreenAntialias.js +119 -0
  53. package/dist/wgpuScreenAntialias.js.map +1 -0
  54. package/dist/wgpuScreenCapture.d.ts +5 -0
  55. package/dist/wgpuScreenCapture.d.ts.map +1 -0
  56. package/dist/{wgpuSurface.js → wgpuScreenCapture.js} +55 -73
  57. package/dist/wgpuScreenCapture.js.map +1 -0
  58. package/dist/wgpuScreenRenderTarget.d.ts +9 -0
  59. package/dist/wgpuScreenRenderTarget.d.ts.map +1 -0
  60. package/dist/wgpuScreenRenderTarget.js +123 -0
  61. package/dist/wgpuScreenRenderTarget.js.map +1 -0
  62. package/dist/wgpuShader.js +2 -2
  63. package/dist/wgpuShader.js.map +1 -1
  64. package/dist/wgpuTestHelper.d.ts +8 -2
  65. package/dist/wgpuTestHelper.d.ts.map +1 -1
  66. package/dist/wgpuTestHelper.js +38 -3
  67. package/dist/wgpuTestHelper.js.map +1 -1
  68. package/dist/wgpuTextureRenderTarget.d.ts +18 -0
  69. package/dist/wgpuTextureRenderTarget.d.ts.map +1 -0
  70. package/dist/wgpuTextureRenderTarget.js +122 -0
  71. package/dist/wgpuTextureRenderTarget.js.map +1 -0
  72. package/package.json +11 -11
  73. package/src/wgpuDraw.test.ts +8 -8
  74. package/src/{wgpuBackground.test.ts → wgpuFrame.test.ts} +34 -20
  75. package/src/wgpuFullscreenPass.test.ts +7 -7
  76. package/src/wgpuHost.test.ts +28 -82
  77. package/src/wgpuRenderPass.test.ts +258 -0
  78. package/src/wgpuRenderState.test.ts +34 -313
  79. package/src/wgpuRenderTarget.test.ts +44 -219
  80. package/src/wgpuRenderTargetPool.test.ts +21 -21
  81. package/src/wgpuRenderTexture.test.ts +17 -15
  82. package/src/wgpuScissor.test.ts +43 -13
  83. package/src/wgpuScreenAntialias.test.ts +60 -0
  84. package/src/wgpuScreenCapture.test.ts +156 -0
  85. package/src/wgpuScreenRenderTarget.test.ts +152 -0
  86. package/src/wgpuShader.test.ts +5 -6
  87. package/src/wgpuTextureRenderTarget.test.ts +129 -0
  88. package/src/wgpuTextureResolver.test.ts +5 -5
  89. package/dist/wgpuAntialias.d.ts +0 -14
  90. package/dist/wgpuAntialias.d.ts.map +0 -1
  91. package/dist/wgpuAntialias.js +0 -155
  92. package/dist/wgpuAntialias.js.map +0 -1
  93. package/dist/wgpuBackground.d.ts +0 -20
  94. package/dist/wgpuBackground.d.ts.map +0 -1
  95. package/dist/wgpuBackground.js.map +0 -1
  96. package/dist/wgpuElement.d.ts +0 -7
  97. package/dist/wgpuElement.d.ts.map +0 -1
  98. package/dist/wgpuElement.js +0 -20
  99. package/dist/wgpuElement.js.map +0 -1
  100. package/dist/wgpuSurface.d.ts +0 -6
  101. package/dist/wgpuSurface.d.ts.map +0 -1
  102. package/dist/wgpuSurface.js.map +0 -1
  103. package/src/wgpuAntialias.test.ts +0 -239
  104. package/src/wgpuElement.test.ts +0 -145
  105. package/src/wgpuSurface.test.ts +0 -141
@@ -1,14 +1,7 @@
1
- import {
2
- beginWgpuFrame,
3
- renderWgpuBackground,
4
- retireWgpuBuffer,
5
- retireWgpuTexture,
6
- submitWgpuRenderPass,
7
- withWgpuFrameBorrow,
8
- } from './wgpuBackground';
1
+ import { beginWgpuFrame, retireWgpuBuffer, retireWgpuTexture, submitWgpuFrame, withWgpuFrameBorrow } from './wgpuFrame';
9
2
  import { createWgpuPipeline } from './wgpuPipeline';
10
3
  import { createWgpuOffscreenRenderState, getWgpuRenderStateRuntime } from './wgpuRenderState';
11
- import { createWgpuRenderStateForTest, installWgpuMock } from './wgpuTestHelper';
4
+ import { beginWgpuScreenRenderPassForTest, createWgpuRenderStateForTest, installWgpuMock } from './wgpuTestHelper';
12
5
 
13
6
  beforeAll(() => installWgpuMock());
14
7
 
@@ -22,15 +15,6 @@ describe('beginWgpuFrame', () => {
22
15
  });
23
16
  });
24
17
 
25
- describe('renderWgpuBackground', () => {
26
- it('opens the presentation pass on the current surface', async () => {
27
- const state = await createWgpuRenderStateForTest();
28
- renderWgpuBackground(state);
29
- expect(getWgpuRenderStateRuntime(state).renderPass).not.toBeNull();
30
- submitWgpuRenderPass(state);
31
- });
32
- });
33
-
34
18
  describe('retireWgpuBuffer', () => {
35
19
  it('adds the buffer to the post-submit destruction ledger', async () => {
36
20
  const state = await createWgpuRenderStateForTest();
@@ -49,13 +33,43 @@ describe('retireWgpuTexture', () => {
49
33
  });
50
34
  });
51
35
 
52
- describe('submitWgpuRenderPass', () => {
36
+ describe('submitWgpuFrame', () => {
53
37
  it('submits and closes the active frame', async () => {
54
38
  const state = await createWgpuRenderStateForTest();
55
39
  beginWgpuFrame(state);
56
- submitWgpuRenderPass(state);
40
+ submitWgpuFrame(state);
57
41
  expect(getWgpuRenderStateRuntime(state).commandEncoder).toBeNull();
58
42
  });
43
+
44
+ it('closes a pass the caller left open rather than carrying its encoder into the next frame', async () => {
45
+ const state = await createWgpuRenderStateForTest();
46
+ beginWgpuFrame(state);
47
+ beginWgpuScreenRenderPassForTest(state);
48
+ expect(getWgpuRenderStateRuntime(state).renderPass).not.toBeNull();
49
+
50
+ submitWgpuFrame(state);
51
+
52
+ const runtime = getWgpuRenderStateRuntime(state);
53
+ expect(runtime.renderPass).toBeNull();
54
+ expect(runtime.passStack).toEqual([]);
55
+ expect(runtime.currentRenderTarget).toBeNull();
56
+ expect(runtime.renderTargetViewport).toBeNull();
57
+ });
58
+ // A frame that only drew into texture targets has no screen to resolve or capture. Skipping that work
59
+ // is not an optimization: the capture texture still holds the LAST VISIBLE frame, so encoding a copy
60
+ // here would overwrite the readback buffer with a stale picture that a caller would read as current.
61
+ it('resolves and captures nothing when the frame never touched a screen target', async () => {
62
+ const state = await createWgpuRenderStateForTest();
63
+ const runtime = getWgpuRenderStateRuntime(state);
64
+ beginWgpuFrame(state);
65
+ const encoder = runtime.commandEncoder!;
66
+ const copyToBuffer = vi.spyOn(encoder, 'copyTextureToBuffer');
67
+
68
+ expect(runtime.frameScreenTarget).toBeNull();
69
+ submitWgpuFrame(state);
70
+
71
+ expect(copyToBuffer).not.toHaveBeenCalled();
72
+ });
59
73
  });
60
74
 
61
75
  describe('withWgpuFrameBorrow', () => {
@@ -1,12 +1,12 @@
1
- import { renderWgpuBackground, submitWgpuRenderPass } from './wgpuBackground';
1
+ import { submitWgpuFrame } from './wgpuFrame';
2
2
  import {
3
3
  createWgpuFullscreenPipeline,
4
4
  destroyWgpuFullscreenPipeline,
5
5
  drawWgpuFullscreenPass,
6
6
  initializeWgpuFullscreenPipeline,
7
7
  } from './wgpuFullscreenPass';
8
- import { createWgpuRenderTarget } from './wgpuRenderTarget';
9
- import { createWgpuRenderStateForTest, installWgpuMock } from './wgpuTestHelper';
8
+ import { beginWgpuScreenRenderPassForTest, createWgpuRenderStateForTest, installWgpuMock } from './wgpuTestHelper';
9
+ import { createWgpuTextureRenderTarget } from './wgpuTextureRenderTarget';
10
10
 
11
11
  beforeAll(() => {
12
12
  installWgpuMock();
@@ -49,17 +49,17 @@ describe('destroyWgpuFullscreenPipeline', () => {
49
49
  describe('drawWgpuFullscreenPass', () => {
50
50
  it('does not throw when render pass is open and no uniforms', async () => {
51
51
  const state = await createWgpuRenderStateForTest();
52
- renderWgpuBackground(state);
52
+ beginWgpuScreenRenderPassForTest(state);
53
53
  const pipeline = createWgpuFullscreenPipeline(state, SIMPLE_FRAGMENT_WGSL, 1);
54
- const target = createWgpuRenderTarget(state, 64, 64);
54
+ const target = createWgpuTextureRenderTarget(state, 64, 64);
55
55
  expect(() => drawWgpuFullscreenPass(state, pipeline, [target], null)).not.toThrow();
56
- submitWgpuRenderPass(state);
56
+ submitWgpuFrame(state);
57
57
  });
58
58
 
59
59
  it('is a no-op when render pass is not open', async () => {
60
60
  const state = await createWgpuRenderStateForTest();
61
61
  const pipeline = createWgpuFullscreenPipeline(state, SIMPLE_FRAGMENT_WGSL, 1);
62
- const target = createWgpuRenderTarget(state, 64, 64);
62
+ const target = createWgpuTextureRenderTarget(state, 64, 64);
63
63
  expect(() => drawWgpuFullscreenPass(state, pipeline, [target], null)).not.toThrow();
64
64
  });
65
65
  });
@@ -1,5 +1,5 @@
1
1
  import { allocateEntity, finishEntity } from '@flighthq/entity/contract';
2
- import type { Entity, WgpuHostAcquisition, WgpuHostBackend } from '@flighthq/types/contract';
2
+ import type { Entity, WgpuHostAcquisition, HostWgpuProvider } from '@flighthq/types/contract';
3
3
  import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
4
4
 
5
5
  import {
@@ -12,23 +12,25 @@ import {
12
12
  } from './wgpuHost';
13
13
  import { createEmptyWgpuRegistries, createWgpuPipeline } from './wgpuPipeline';
14
14
  import {
15
+ createWgpuAcquisition,
15
16
  createWgpuRenderState,
16
- createWgpuRenderStateFromCanvasElement,
17
17
  destroyWgpuRenderState,
18
+ releaseWgpuAcquisition,
18
19
  } from './wgpuRenderState';
19
20
  import { installWgpuMock } from './wgpuTestHelper';
20
21
 
21
- function fakeBackend(): WgpuHostBackend {
22
+ function fakeBackend(): HostWgpuProvider {
22
23
  return entityBackend({
23
24
  acquire: vi.fn(),
25
+ attachSurface: vi.fn(() => null),
24
26
  isSupported: vi.fn(() => true),
25
27
  release: vi.fn(),
26
28
  });
27
29
  }
28
30
 
29
- function entityBackend(fields: Omit<WgpuHostBackend, keyof Entity>): WgpuHostBackend {
31
+ function entityBackend(fields: Omit<HostWgpuProvider, keyof Entity>): HostWgpuProvider {
30
32
  return (() => {
31
- const out = allocateEntity<WgpuHostBackend>();
33
+ const out = allocateEntity<HostWgpuProvider>();
32
34
  Object.assign(out, fields);
33
35
  return finishEntity(out);
34
36
  })();
@@ -126,72 +128,43 @@ describe('setWgpuHostBackend', () => {
126
128
  it('routes acquisition and release through the same selected backend', async () => {
127
129
  const web = createWebWgpuHostBackend();
128
130
  const acquired = await web.acquire(document.createElement('canvas'), {});
129
- // Flight-owned: the state acquired these, so destroying it releases them. The caller-owned case is the
130
- // opposite assertion and lives in its own test below.
131
131
  const acquisition = allocateEntity<WgpuHostAcquisition>();
132
132
  Object.assign(acquisition, acquired);
133
- acquisition.ownership = 'flight' as const;
133
+ acquisition.ownership = 'caller' as const;
134
134
  const backend = entityBackend({
135
135
  acquire: vi.fn(async () => acquisition),
136
+ attachSurface: vi.fn(() => null),
136
137
  isSupported: vi.fn(() => true),
137
138
  release: vi.fn(),
138
139
  });
139
140
  const canvas = document.createElement('canvas');
140
141
  setWgpuHostBackend(backend);
141
142
 
142
- const state = await createWgpuRenderStateFromCanvasElement(canvas, _pipeline);
143
- expect(backend.acquire).toHaveBeenCalledWith(canvas, { format: undefined, powerPreference: undefined });
144
- expect(state.context).toBe(acquisition.context);
145
- expect(state.device).toBe(acquisition.device);
146
- expect(state.format).toBe(acquisition.format);
147
- destroyWgpuRenderState(state);
148
- expect(backend.release).toHaveBeenCalledOnce();
149
- expect(backend.release).toHaveBeenCalledWith(acquisition);
150
-
151
- web.release(acquired);
152
- });
153
-
154
- it('releases flight-owned handles through the selected backend when initialization fails', async () => {
155
- const web = createWebWgpuHostBackend();
156
- const acquired = await web.acquire(document.createElement('canvas'), {});
157
- const acquisition = allocateEntity<WgpuHostAcquisition>();
158
- Object.assign(acquisition, acquired);
159
- acquisition.ownership = 'flight' as const;
160
- const configure = vi.spyOn(acquisition.context, 'configure').mockImplementationOnce(() => {
161
- throw new Error('configure failed');
162
- });
163
- const backend = entityBackend({
164
- acquire: vi.fn(async () => acquisition),
165
- isSupported: vi.fn(() => true),
166
- release: vi.fn(),
167
- });
168
- setWgpuHostBackend(backend);
143
+ const routed = await createWgpuAcquisition(canvas);
144
+ expect(backend.acquire).toHaveBeenCalledWith(canvas, {});
145
+ expect(routed?.device).toBe(acquisition.device);
146
+ expect(routed?.format).toBe(acquisition.format);
169
147
 
170
- expect(() => createWgpuRenderState(acquisition, _pipeline)).toThrow('configure failed');
148
+ releaseWgpuAcquisition(acquisition);
171
149
  expect(backend.release).toHaveBeenCalledOnce();
172
150
  expect(backend.release).toHaveBeenCalledWith(acquisition);
173
151
 
174
- configure.mockRestore();
175
152
  web.release(acquired);
176
153
  });
177
154
 
178
- // ★ O2. The previous shape of this test used a CALLER-owned acquisition and asserted release WAS called,
179
- // with a `vi.fn()` release that could not destroy anything — so it passed whether or not ownership was
180
- // honoured. Flight now refuses to release borrowed handles even while unwinding a failure, and the
181
- // backend here is deliberately one that would destroy them if asked.
182
- it('O2: never releases caller-owned handles when initialization fails, whatever the backend does', async () => {
155
+ // ★ A RENDER STATE OWNS NO HOST HANDLES AT ALL. It used to be built from an acquisition and had to
156
+ // decide, on every path including a failed construction, whether to release handles the caller might
157
+ // still be using — a decision no state is in a position to make correctly. It now takes the device
158
+ // alone, so there is nothing to get wrong: destroying a state cannot reach a context or a device, and
159
+ // the caller's handles outlive it by construction rather than by policy.
160
+ it('never releases host handles when a state built on the device is destroyed', async () => {
183
161
  const web = createWebWgpuHostBackend();
184
162
  const acquired = await web.acquire(document.createElement('canvas'), {});
185
- const acquisition = allocateEntity<WgpuHostAcquisition>();
186
- Object.assign(acquisition, acquired);
187
- acquisition.ownership = 'caller' as const;
188
- const configure = vi.spyOn(acquisition.context, 'configure').mockImplementationOnce(() => {
189
- throw new Error('configure failed');
190
- });
191
- const destroy = vi.spyOn(acquisition.device, 'destroy');
192
- const unconfigure = vi.spyOn(acquisition.context, 'unconfigure');
163
+ const destroy = vi.spyOn(acquired.device, 'destroy');
164
+ const unconfigure = vi.spyOn(acquired.context, 'unconfigure');
193
165
  const backend = entityBackend({
194
- acquire: vi.fn(async () => acquisition),
166
+ acquire: vi.fn(async () => acquired),
167
+ attachSurface: vi.fn(() => null),
195
168
  isSupported: vi.fn(() => true),
196
169
  release: vi.fn((held) => {
197
170
  held.context.unconfigure();
@@ -200,43 +173,16 @@ describe('setWgpuHostBackend', () => {
200
173
  });
201
174
  setWgpuHostBackend(backend);
202
175
 
203
- expect(() => createWgpuRenderState(acquisition, _pipeline)).toThrow('configure failed');
204
- expect(backend.release).not.toHaveBeenCalled();
205
- expect(destroy).not.toHaveBeenCalled();
206
- expect(unconfigure).not.toHaveBeenCalled();
207
-
208
- configure.mockRestore();
209
- destroy.mockRestore();
210
- unconfigure.mockRestore();
211
- web.release(acquired);
212
- });
213
-
214
- // ★ O1. The destroy path, against a backend whose release really destroys.
215
- it('O1: never releases caller-owned handles when the state is destroyed', async () => {
216
- const web = createWebWgpuHostBackend();
217
- const acquired = await web.acquire(document.createElement('canvas'), {});
218
- const acquisition = allocateEntity<WgpuHostAcquisition>();
219
- Object.assign(acquisition, acquired);
220
- acquisition.ownership = 'caller' as const;
221
- const destroy = vi.spyOn(acquisition.device, 'destroy');
222
- const backend = entityBackend({
223
- acquire: vi.fn(async () => acquisition),
224
- isSupported: vi.fn(() => true),
225
- release: vi.fn((held) => {
226
- held.device.destroy();
227
- }),
228
- });
229
- setWgpuHostBackend(backend);
230
-
231
- const state = createWgpuRenderState(acquisition, _pipeline);
232
- destroyWgpuRenderState(state);
176
+ destroyWgpuRenderState(createWgpuRenderState(acquired.device, _pipeline));
233
177
 
234
178
  expect(backend.release).not.toHaveBeenCalled();
235
179
  expect(destroy).not.toHaveBeenCalled();
180
+ expect(unconfigure).not.toHaveBeenCalled();
236
181
  // The handles are still usable, which is the property the caller actually cares about.
237
- expect(acquisition.device.createBuffer({ size: 4, usage: GPUBufferUsage.COPY_DST }).size).toBe(4);
182
+ expect(acquired.device.createBuffer({ size: 4, usage: GPUBufferUsage.COPY_DST }).size).toBe(4);
238
183
 
239
184
  destroy.mockRestore();
185
+ unconfigure.mockRestore();
240
186
  web.release(acquired);
241
187
  });
242
188
 
@@ -0,0 +1,258 @@
1
+ import { createMatrix } from '@flighthq/geometry/contract';
2
+
3
+ import { beginWgpuFrame, submitWgpuFrame } from './wgpuFrame';
4
+ import {
5
+ beginWgpuRenderPass,
6
+ endWgpuRenderPass,
7
+ getWgpuActiveRenderPass,
8
+ getWgpuRenderPassViewport,
9
+ resumeWgpuRenderPass,
10
+ setWgpuRenderTransform2D,
11
+ suspendWgpuRenderPass,
12
+ } from './wgpuRenderPass';
13
+ import { getWgpuRenderStateRuntime } from './wgpuRenderState';
14
+ import {
15
+ beginWgpuScreenRenderPassForTest,
16
+ createWgpuRenderStateForTest,
17
+ createWgpuScreenRenderTargetForTest,
18
+ installWgpuMock,
19
+ } from './wgpuTestHelper';
20
+ import { createWgpuTextureRenderTarget } from './wgpuTextureRenderTarget';
21
+
22
+ beforeAll(() => {
23
+ installWgpuMock();
24
+ });
25
+
26
+ describe('beginWgpuRenderPass', () => {
27
+ it('reports the target extent as the pass viewport', async () => {
28
+ const state = await createWgpuRenderStateForTest();
29
+ const outer = beginWgpuScreenRenderPassForTest(state);
30
+ const pass = beginWgpuRenderPass(state, createWgpuTextureRenderTarget(state, 64, 64));
31
+
32
+ expect(pass.viewport.width).toBe(64);
33
+ expect(getWgpuRenderStateRuntime(state).renderTargetViewport?.width).toBe(64);
34
+
35
+ endWgpuRenderPass(pass);
36
+ endWgpuRenderPass(outer);
37
+ });
38
+
39
+ // The viewport is LOGICAL, not the storage extent. A supersampled target is allocated at 2x per axis,
40
+ // and a projection that divided by that physical width would map logical x = 800 to NDC 0 — the scene
41
+ // filling a quarter of its own target, which is exactly the defect the derived scale exists to prevent.
42
+ it('reports the logical extent for a supersampled target, not its physical storage', async () => {
43
+ const state = await createWgpuRenderStateForTest();
44
+ const outer = beginWgpuScreenRenderPassForTest(state);
45
+ const target = createWgpuTextureRenderTarget(state, 400, 300, state.format, 'srgb', 4);
46
+ const pass = beginWgpuRenderPass(state, target);
47
+
48
+ expect(target.width).toBe(800);
49
+ expect(pass.viewport).toEqual({ height: 300, width: 400 });
50
+
51
+ endWgpuRenderPass(pass);
52
+ endWgpuRenderPass(outer);
53
+ });
54
+
55
+ it('passes float RGBA clear values through to the GPU color attachment', async () => {
56
+ const state = await createWgpuRenderStateForTest();
57
+ const outer = beginWgpuScreenRenderPassForTest(state);
58
+ const beginRenderPass = vi.spyOn(getWgpuRenderStateRuntime(state).commandEncoder!, 'beginRenderPass');
59
+ const target = createWgpuTextureRenderTarget(state, 32, 32, state.format, 'linear');
60
+
61
+ const pass = beginWgpuRenderPass(state, target, { color: [0.25, 0.5, 0.75, 1.0] });
62
+
63
+ const attachment = Array.from(beginRenderPass.mock.calls.at(-1)![0].colorAttachments)[0]!;
64
+ const clearValue = attachment.clearValue as GPUColorDict;
65
+ expect([clearValue.r, clearValue.g, clearValue.b, clearValue.a]).toEqual([0.25, 0.5, 0.75, 1.0]);
66
+ expect(attachment.loadOp).toBe('clear');
67
+
68
+ endWgpuRenderPass(pass);
69
+ endWgpuRenderPass(outer);
70
+ });
71
+
72
+ it('preserves the target when no clear is given', async () => {
73
+ const state = await createWgpuRenderStateForTest();
74
+ const outer = beginWgpuScreenRenderPassForTest(state);
75
+ const beginRenderPass = vi.spyOn(getWgpuRenderStateRuntime(state).commandEncoder!, 'beginRenderPass');
76
+
77
+ const pass = beginWgpuRenderPass(state, createWgpuTextureRenderTarget(state, 32, 32));
78
+
79
+ const attachment = Array.from(beginRenderPass.mock.calls.at(-1)![0].colorAttachments)[0]!;
80
+ expect(attachment.loadOp).toBe('load');
81
+
82
+ endWgpuRenderPass(pass);
83
+ endWgpuRenderPass(outer);
84
+ });
85
+
86
+ it('opens the frame when none is open, and leaves an explicitly opened frame to its owner', async () => {
87
+ const state = await createWgpuRenderStateForTest();
88
+ const owning = beginWgpuScreenRenderPassForTest(state);
89
+ expect(owning.ownsFrame).toBe(true);
90
+ endWgpuRenderPass(owning);
91
+ expect(getWgpuRenderStateRuntime(state).commandEncoder).toBeNull();
92
+
93
+ beginWgpuFrame(state);
94
+ const borrowed = beginWgpuScreenRenderPassForTest(state);
95
+ expect(borrowed.ownsFrame).toBe(false);
96
+ endWgpuRenderPass(borrowed);
97
+ // The frame outlives the pass, which is what lets a shadow pass and a screen pass share one submit.
98
+ expect(getWgpuRenderStateRuntime(state).commandEncoder).not.toBeNull();
99
+ submitWgpuFrame(state);
100
+ });
101
+ });
102
+
103
+ describe('endWgpuRenderPass', () => {
104
+ it('restores the enclosing target, viewport, and clip state', async () => {
105
+ const state = await createWgpuRenderStateForTest();
106
+ const screen = createWgpuScreenRenderTargetForTest(state);
107
+ const outer = beginWgpuRenderPass(state, screen, { color: [0, 0, 0, 0] });
108
+ const runtime = getWgpuRenderStateRuntime(state);
109
+ runtime.currentScissorRect = { height: 10, width: 10, x: 0, y: 0 };
110
+
111
+ const inner = beginWgpuRenderPass(state, createWgpuTextureRenderTarget(state, 32, 32));
112
+ expect(runtime.currentScissorRect).toBeNull();
113
+ endWgpuRenderPass(inner);
114
+
115
+ expect(runtime.currentRenderTarget).toBe(screen);
116
+ expect(runtime.renderTargetViewport).toEqual({ height: 600, width: 800 });
117
+ expect(runtime.currentScissorRect).toEqual({ height: 10, width: 10, x: 0, y: 0 });
118
+
119
+ endWgpuRenderPass(outer);
120
+ expect(runtime.currentRenderTarget).toBeNull();
121
+ expect(runtime.renderTargetViewport).toBeNull();
122
+ });
123
+
124
+ it('resumes the enclosing pass with a fresh encoder that preserves its pixels', async () => {
125
+ // A WebGPU pass encoder cannot be reopened, so the enclosing pass is re-recorded — and it must load
126
+ // rather than clear, or every nested offscreen pass would wipe the frame that surrounds it.
127
+ const state = await createWgpuRenderStateForTest();
128
+ const outer = beginWgpuScreenRenderPassForTest(state);
129
+ const runtime = getWgpuRenderStateRuntime(state);
130
+ const suspendedEncoder = outer.encoder;
131
+
132
+ const inner = beginWgpuRenderPass(state, createWgpuTextureRenderTarget(state, 32, 32));
133
+ expect(outer.encoder).toBeNull();
134
+ const beginRenderPass = vi.spyOn(runtime.commandEncoder!, 'beginRenderPass');
135
+ endWgpuRenderPass(inner);
136
+
137
+ expect(outer.encoder).not.toBeNull();
138
+ expect(outer.encoder).not.toBe(suspendedEncoder);
139
+ expect(runtime.renderPass).toBe(outer.encoder);
140
+ expect(Array.from(beginRenderPass.mock.calls.at(-1)![0].colorAttachments)[0]!.loadOp).toBe('load');
141
+
142
+ endWgpuRenderPass(outer);
143
+ });
144
+
145
+ it('submits the frame it opened, and only that frame', async () => {
146
+ const state = await createWgpuRenderStateForTest();
147
+ const outer = beginWgpuScreenRenderPassForTest(state);
148
+ const inner = beginWgpuRenderPass(state, createWgpuTextureRenderTarget(state, 16, 16));
149
+
150
+ endWgpuRenderPass(inner);
151
+ expect(getWgpuRenderStateRuntime(state).commandEncoder).not.toBeNull();
152
+
153
+ endWgpuRenderPass(outer);
154
+ expect(getWgpuRenderStateRuntime(state).commandEncoder).toBeNull();
155
+ });
156
+
157
+ it('refuses a pass that is not the innermost open one', async () => {
158
+ const state = await createWgpuRenderStateForTest();
159
+ const outer = beginWgpuScreenRenderPassForTest(state);
160
+ const inner = beginWgpuRenderPass(state, createWgpuTextureRenderTarget(state, 16, 16));
161
+
162
+ expect(() => endWgpuRenderPass(outer)).toThrow(/innermost open pass/);
163
+
164
+ endWgpuRenderPass(inner);
165
+ endWgpuRenderPass(outer);
166
+ });
167
+ });
168
+
169
+ describe('getWgpuActiveRenderPass', () => {
170
+ it('names the innermost open pass and nothing outside a bracket', async () => {
171
+ const state = await createWgpuRenderStateForTest();
172
+ expect(getWgpuActiveRenderPass(state)).toBeNull();
173
+
174
+ const outer = beginWgpuScreenRenderPassForTest(state);
175
+ expect(getWgpuActiveRenderPass(state)).toBe(outer);
176
+ const inner = beginWgpuRenderPass(state, createWgpuTextureRenderTarget(state, 16, 16));
177
+ expect(getWgpuActiveRenderPass(state)).toBe(inner);
178
+
179
+ endWgpuRenderPass(inner);
180
+ expect(getWgpuActiveRenderPass(state)).toBe(outer);
181
+ endWgpuRenderPass(outer);
182
+ expect(getWgpuActiveRenderPass(state)).toBeNull();
183
+ });
184
+ });
185
+
186
+ describe('getWgpuRenderPassViewport', () => {
187
+ it('throws outside a pass rather than inventing a canvas-sized default', async () => {
188
+ // A draw with no target bound is API misuse; a default extent would hide it until the picture came
189
+ // out the wrong size, with nothing in the failure naming the missing begin.
190
+ const state = await createWgpuRenderStateForTest();
191
+ expect(() => getWgpuRenderPassViewport(state)).toThrow(/beginWgpuRenderPass/);
192
+ });
193
+
194
+ it('reports the innermost pass extent', async () => {
195
+ const state = await createWgpuRenderStateForTest();
196
+ const outer = beginWgpuScreenRenderPassForTest(state);
197
+ expect(getWgpuRenderPassViewport(state)).toEqual({ height: 600, width: 800 });
198
+
199
+ const inner = beginWgpuRenderPass(state, createWgpuTextureRenderTarget(state, 40, 20));
200
+ expect(getWgpuRenderPassViewport(state)).toEqual({ height: 20, width: 40 });
201
+
202
+ endWgpuRenderPass(inner);
203
+ endWgpuRenderPass(outer);
204
+ });
205
+ });
206
+
207
+ describe('resumeWgpuRenderPass', () => {
208
+ it('reopens a suspended pass over the same target', async () => {
209
+ const state = await createWgpuRenderStateForTest();
210
+ const pass = beginWgpuScreenRenderPassForTest(state);
211
+ const target = pass.target;
212
+ suspendWgpuRenderPass(pass);
213
+
214
+ resumeWgpuRenderPass(pass);
215
+
216
+ expect(pass.encoder).not.toBeNull();
217
+ expect(pass.target).toBe(target);
218
+ expect(getWgpuRenderStateRuntime(state).renderPass).toBe(pass.encoder);
219
+ endWgpuRenderPass(pass);
220
+ });
221
+ });
222
+
223
+ describe('setWgpuRenderTransform2D', () => {
224
+ it('installs a copy of the transform, restored by the enclosing pass', async () => {
225
+ const state = await createWgpuRenderStateForTest();
226
+ const outer = beginWgpuScreenRenderPassForTest(state);
227
+ const original = state.renderTransform2D;
228
+ const bake = createMatrix();
229
+ bake.tx = 42;
230
+
231
+ const pass = beginWgpuRenderPass(state, createWgpuTextureRenderTarget(state, 32, 32));
232
+ setWgpuRenderTransform2D(pass, bake);
233
+ expect(state.renderTransform2D?.tx).toBe(42);
234
+ expect(state.renderTransform2D).not.toBe(bake);
235
+ endWgpuRenderPass(pass);
236
+
237
+ expect(state.renderTransform2D).toBe(original);
238
+ endWgpuRenderPass(outer);
239
+ });
240
+ });
241
+
242
+ describe('suspendWgpuRenderPass', () => {
243
+ it('closes the encoder while leaving the pass open', async () => {
244
+ // The effect pipeline records its own fullscreen passes into the frame, which it can only do while
245
+ // no other pass encoder is recording.
246
+ const state = await createWgpuRenderStateForTest();
247
+ const pass = beginWgpuScreenRenderPassForTest(state);
248
+
249
+ suspendWgpuRenderPass(pass);
250
+
251
+ expect(pass.encoder).toBeNull();
252
+ expect(getWgpuRenderStateRuntime(state).renderPass).toBeNull();
253
+ expect(getWgpuActiveRenderPass(state)).toBe(pass);
254
+
255
+ resumeWgpuRenderPass(pass);
256
+ endWgpuRenderPass(pass);
257
+ });
258
+ });