@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
|
@@ -4,115 +4,85 @@ import {
|
|
|
4
4
|
retireWgpuBuffer,
|
|
5
5
|
retireWgpuTexture,
|
|
6
6
|
submitWgpuRenderPass,
|
|
7
|
+
withWgpuFrameBorrow,
|
|
7
8
|
} from './wgpuBackground';
|
|
8
|
-
import {
|
|
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
|
|
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
|
|
28
|
-
const encoder = runtime.commandEncoder;
|
|
29
|
-
runtime.uniformOffset = 512;
|
|
19
|
+
const encoder = getWgpuRenderStateRuntime(state).commandEncoder;
|
|
30
20
|
beginWgpuFrame(state);
|
|
31
|
-
expect(
|
|
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
|
|
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(
|
|
67
|
-
|
|
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('
|
|
35
|
+
it('adds the buffer to the post-submit destruction ledger', async () => {
|
|
74
36
|
const state = await createWgpuRenderStateForTest();
|
|
75
|
-
const
|
|
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('
|
|
44
|
+
it('adds the texture to the post-submit destruction ledger', async () => {
|
|
87
45
|
const state = await createWgpuRenderStateForTest();
|
|
88
|
-
const
|
|
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('
|
|
53
|
+
it('submits and closes the active frame', async () => {
|
|
101
54
|
const state = await createWgpuRenderStateForTest();
|
|
102
|
-
|
|
55
|
+
beginWgpuFrame(state);
|
|
103
56
|
submitWgpuRenderPass(state);
|
|
104
57
|
expect(getWgpuRenderStateRuntime(state).commandEncoder).toBeNull();
|
|
105
58
|
});
|
|
59
|
+
});
|
|
106
60
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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('
|
|
115
|
-
const
|
|
116
|
-
|
|
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
|
+
});
|
package/src/wgpuDraw.test.ts
CHANGED
|
@@ -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.
|
|
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('
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
expect(entry
|
|
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.
|
|
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.
|
|
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.
|
|
546
|
+
expect(entry.bindings.size).toBe(2);
|
|
471
547
|
image.version = 2;
|
|
472
548
|
bindWgpuBitmapTexture(state, image);
|
|
473
|
-
expect(entry.
|
|
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
|
|
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
|
|
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
|
});
|