@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.
- package/README.md +2 -2
- package/dist/contract.d.ts +3 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +3 -0
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/wgpuAntialias.d.ts +8 -4
- package/dist/wgpuAntialias.d.ts.map +1 -1
- package/dist/wgpuAntialias.js +23 -4
- package/dist/wgpuAntialias.js.map +1 -1
- package/dist/wgpuBackground.d.ts +10 -4
- package/dist/wgpuBackground.d.ts.map +1 -1
- package/dist/wgpuBackground.js +87 -3
- package/dist/wgpuBackground.js.map +1 -1
- package/dist/wgpuCompressedTexture.js +9 -9
- package/dist/wgpuCompressedTexture.js.map +1 -1
- package/dist/wgpuDeviceLoss.d.ts +21 -0
- package/dist/wgpuDeviceLoss.d.ts.map +1 -0
- package/dist/wgpuDeviceLoss.js +62 -0
- package/dist/wgpuDeviceLoss.js.map +1 -0
- package/dist/wgpuDraw.d.ts +0 -1
- package/dist/wgpuDraw.d.ts.map +1 -1
- package/dist/wgpuDraw.js +73 -96
- package/dist/wgpuDraw.js.map +1 -1
- package/dist/wgpuElement.d.ts +5 -0
- package/dist/wgpuElement.d.ts.map +1 -1
- package/dist/wgpuElement.js +17 -6
- package/dist/wgpuElement.js.map +1 -1
- package/dist/wgpuExternalTexture.d.ts.map +1 -1
- package/dist/wgpuExternalTexture.js +12 -11
- package/dist/wgpuExternalTexture.js.map +1 -1
- package/dist/wgpuFullscreenPass.js +1 -1
- package/dist/wgpuFullscreenPass.js.map +1 -1
- package/dist/wgpuHost.d.ts +7 -0
- package/dist/wgpuHost.d.ts.map +1 -0
- package/dist/wgpuHost.js +80 -0
- package/dist/wgpuHost.js.map +1 -0
- package/dist/wgpuMipmap.js +3 -3
- package/dist/wgpuMipmap.js.map +1 -1
- package/dist/wgpuPipeline.d.ts +5 -0
- package/dist/wgpuPipeline.d.ts.map +1 -0
- package/dist/wgpuPipeline.js +29 -0
- package/dist/wgpuPipeline.js.map +1 -0
- package/dist/wgpuRenderState.d.ts +13 -6
- package/dist/wgpuRenderState.d.ts.map +1 -1
- package/dist/wgpuRenderState.js +220 -271
- package/dist/wgpuRenderState.js.map +1 -1
- package/dist/wgpuRenderTarget.d.ts.map +1 -1
- package/dist/wgpuRenderTarget.js +6 -5
- package/dist/wgpuRenderTarget.js.map +1 -1
- package/dist/wgpuRenderTexture.js +4 -4
- package/dist/wgpuRenderTexture.js.map +1 -1
- package/dist/wgpuShader.d.ts.map +1 -1
- package/dist/wgpuShader.js +6 -4
- package/dist/wgpuShader.js.map +1 -1
- package/dist/wgpuSurface.d.ts +5 -5
- package/dist/wgpuSurface.d.ts.map +1 -1
- package/dist/wgpuSurface.js +2 -2
- package/dist/wgpuSurface.js.map +1 -1
- package/dist/wgpuTestHelper.d.ts +2 -2
- package/dist/wgpuTestHelper.d.ts.map +1 -1
- package/dist/wgpuTestHelper.js +6 -2
- package/dist/wgpuTestHelper.js.map +1 -1
- package/package.json +11 -11
- package/src/wgpuAntialias.test.ts +26 -4
- package/src/wgpuBackground.test.ts +43 -73
- package/src/wgpuDeviceLoss.test.ts +240 -0
- package/src/wgpuDraw.test.ts +99 -24
- package/src/wgpuElement.test.ts +127 -13
- package/src/wgpuHost.test.ts +227 -0
- package/src/wgpuMaterialRegistry.test.ts +6 -3
- package/src/wgpuMipmap.test.ts +3 -3
- package/src/wgpuPipeline.test.ts +52 -0
- package/src/wgpuRenderState.test.ts +679 -88
- package/src/wgpuRenderTarget.test.ts +12 -8
- package/src/wgpuRenderTexture.test.ts +1 -1
- 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 {
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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('
|
|
65
|
-
it('
|
|
66
|
-
const
|
|
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
|
-
|
|
79
|
-
).
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
).toBe(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
).toBe(false);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
).toBe(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
208
|
-
expect(offscreenRuntime.
|
|
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
|
-
|
|
291
|
-
getWgpuRenderStateRuntime(
|
|
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
|
|
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
|
-
|
|
328
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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(
|
|
525
|
-
|
|
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
|
+
});
|