@flighthq/render-wgpu 0.3.0-next.906.07cea63 → 0.3.1-next.209.43ef1bc

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 (91) hide show
  1. package/dist/contract.d.ts +7 -1
  2. package/dist/contract.d.ts.map +1 -1
  3. package/dist/contract.js +7 -1
  4. package/dist/contract.js.map +1 -1
  5. package/dist/enableWgpuTextureResolverGuards.d.ts +4 -0
  6. package/dist/enableWgpuTextureResolverGuards.d.ts.map +1 -0
  7. package/dist/enableWgpuTextureResolverGuards.js +8 -0
  8. package/dist/enableWgpuTextureResolverGuards.js.map +1 -0
  9. package/dist/explainWgpuTextureResolution.d.ts +3 -0
  10. package/dist/explainWgpuTextureResolution.d.ts.map +1 -0
  11. package/dist/explainWgpuTextureResolution.js +14 -0
  12. package/dist/explainWgpuTextureResolution.js.map +1 -0
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/wgpuBackground.js +2 -2
  18. package/dist/wgpuBackground.js.map +1 -1
  19. package/dist/wgpuCompressedTexture.d.ts +2 -2
  20. package/dist/wgpuCompressedTexture.d.ts.map +1 -1
  21. package/dist/wgpuCompressedTexture.js +40 -12
  22. package/dist/wgpuCompressedTexture.js.map +1 -1
  23. package/dist/wgpuDraw.d.ts +6 -4
  24. package/dist/wgpuDraw.d.ts.map +1 -1
  25. package/dist/wgpuDraw.js +139 -67
  26. package/dist/wgpuDraw.js.map +1 -1
  27. package/dist/wgpuExternalImageSource.d.ts +3 -0
  28. package/dist/wgpuExternalImageSource.d.ts.map +1 -0
  29. package/dist/wgpuExternalImageSource.js +51 -0
  30. package/dist/wgpuExternalImageSource.js.map +1 -0
  31. package/dist/wgpuExternalTexture.d.ts +4 -0
  32. package/dist/wgpuExternalTexture.d.ts.map +1 -0
  33. package/dist/wgpuExternalTexture.js +62 -0
  34. package/dist/wgpuExternalTexture.js.map +1 -0
  35. package/dist/wgpuMaterialRegistry.d.ts.map +1 -1
  36. package/dist/wgpuMaterialRegistry.js +10 -9
  37. package/dist/wgpuMaterialRegistry.js.map +1 -1
  38. package/dist/wgpuMipmap.d.ts.map +1 -1
  39. package/dist/wgpuMipmap.js +11 -9
  40. package/dist/wgpuMipmap.js.map +1 -1
  41. package/dist/wgpuRenderState.d.ts +11 -2
  42. package/dist/wgpuRenderState.d.ts.map +1 -1
  43. package/dist/wgpuRenderState.js +217 -30
  44. package/dist/wgpuRenderState.js.map +1 -1
  45. package/dist/wgpuRenderTarget.d.ts.map +1 -1
  46. package/dist/wgpuRenderTarget.js +2 -0
  47. package/dist/wgpuRenderTarget.js.map +1 -1
  48. package/dist/wgpuRenderTexture.d.ts +15 -0
  49. package/dist/wgpuRenderTexture.d.ts.map +1 -0
  50. package/dist/wgpuRenderTexture.js +130 -0
  51. package/dist/wgpuRenderTexture.js.map +1 -0
  52. package/dist/wgpuRenderTexturePool.d.ts +7 -0
  53. package/dist/wgpuRenderTexturePool.d.ts.map +1 -0
  54. package/dist/wgpuRenderTexturePool.js +86 -0
  55. package/dist/wgpuRenderTexturePool.js.map +1 -0
  56. package/dist/wgpuShader.d.ts.map +1 -1
  57. package/dist/wgpuShader.js +5 -1
  58. package/dist/wgpuShader.js.map +1 -1
  59. package/dist/wgpuSurface.d.ts +2 -2
  60. package/dist/wgpuSurface.d.ts.map +1 -1
  61. package/dist/wgpuSurface.js +61 -13
  62. package/dist/wgpuSurface.js.map +1 -1
  63. package/dist/wgpuTestHelper.d.ts +1 -0
  64. package/dist/wgpuTestHelper.d.ts.map +1 -1
  65. package/dist/wgpuTestHelper.js +15 -1
  66. package/dist/wgpuTestHelper.js.map +1 -1
  67. package/dist/wgpuTextureResolver.d.ts +9 -0
  68. package/dist/wgpuTextureResolver.d.ts.map +1 -0
  69. package/dist/wgpuTextureResolver.js +67 -0
  70. package/dist/wgpuTextureResolver.js.map +1 -0
  71. package/dist/wgpuTextureUpload.d.ts +2 -2
  72. package/dist/wgpuTextureUpload.d.ts.map +1 -1
  73. package/dist/wgpuTextureUpload.js +5 -10
  74. package/dist/wgpuTextureUpload.js.map +1 -1
  75. package/package.json +13 -7
  76. package/src/enableWgpuTextureResolverGuards.test.ts +65 -0
  77. package/src/explainWgpuTextureResolution.test.ts +36 -0
  78. package/src/wgpuCompressedTexture.test.ts +18 -35
  79. package/src/wgpuDraw.test.ts +164 -62
  80. package/src/wgpuExternalImageSource.test.ts +89 -0
  81. package/src/wgpuExternalTexture.test.ts +35 -0
  82. package/src/wgpuMaterialRegistry.test.ts +28 -2
  83. package/src/wgpuMipmap.test.ts +10 -2
  84. package/src/wgpuRenderState.test.ts +143 -11
  85. package/src/wgpuRenderTarget.test.ts +14 -0
  86. package/src/wgpuRenderTexture.test.ts +165 -0
  87. package/src/wgpuRenderTexturePool.test.ts +146 -0
  88. package/src/wgpuShaderBinding.test.ts +6 -6
  89. package/src/wgpuSurface.test.ts +95 -3
  90. package/src/wgpuTextureResolver.test.ts +209 -0
  91. package/src/wgpuTextureUpload.test.ts +28 -13
@@ -1,11 +1,20 @@
1
1
  import { getOrCreateRenderProxy2D, prepareScene2DRender } from '@flighthq/render/contract';
2
- import { createBitmap } from '@flighthq/scene2d/contract';
3
- import type { ImageResource, Texture } from '@flighthq/types/contract';
4
- import { BlendMode } from '@flighthq/types/contract';
2
+ import { createSprite } from '@flighthq/scene2d/contract';
3
+ import { getTextureSource } from '@flighthq/texture/contract';
4
+ import type { Bitmap, CompressedImage, Image, Texture, WgpuTextureEntry } from '@flighthq/types/contract';
5
+ import {
6
+ BitmapTextureSourceKind,
7
+ BlendMode,
8
+ CompressedImageTextureSourceKind,
9
+ ImageTextureSourceKind,
10
+ } from '@flighthq/types/contract';
5
11
 
6
12
  import { renderWgpuBackground, submitWgpuRenderPass } from './wgpuBackground';
13
+ import { registerWgpuCompressedTextureDecoder, registerWgpuCompressedTextureUpload } from './wgpuCompressedTexture';
7
14
  import {
8
15
  applyWgpuBlendMode,
16
+ bindWgpuBitmapTexture,
17
+ bindWgpuCompressedImageTexture,
9
18
  bindWgpuImageResourceTexture,
10
19
  bindWgpuTexture,
11
20
  bindWgpuVideoTexture,
@@ -28,6 +37,47 @@ beforeAll(() => {
28
37
  installWgpuMock();
29
38
  });
30
39
 
40
+ function requireTextureEntry(entry: WgpuTextureEntry | null): WgpuTextureEntry {
41
+ if (entry === null) throw new Error('Expected a ready WebGPU texture entry');
42
+ return entry;
43
+ }
44
+
45
+ function bitmap(size: number, version: number): Bitmap {
46
+ return {
47
+ alphaType: 'straight',
48
+ colorSpace: 'srgb',
49
+ data: new Uint8ClampedArray(size * size * 4),
50
+ format: 'rgba8unorm',
51
+ height: size,
52
+ kind: BitmapTextureSourceKind,
53
+ version,
54
+ width: size,
55
+ } as unknown as Bitmap;
56
+ }
57
+
58
+ function compressedBc3Image(): CompressedImage {
59
+ return {
60
+ compressed: {
61
+ container: {
62
+ depth: 1,
63
+ faces: 1,
64
+ format: 'bc3',
65
+ height: 4,
66
+ layers: 1,
67
+ levels: [{ byteLength: 16, byteOffset: 0, height: 4, width: 4 }],
68
+ mipLevels: 1,
69
+ supercompression: 'None',
70
+ width: 4,
71
+ },
72
+ payload: new Uint8Array(16),
73
+ },
74
+ height: 4,
75
+ kind: CompressedImageTextureSourceKind,
76
+ version: 1,
77
+ width: 4,
78
+ } as unknown as CompressedImage;
79
+ }
80
+
31
81
  describe('applyWgpuBlendMode', () => {
32
82
  it('updates currentBlendMode on the state', async () => {
33
83
  const state = await createWgpuRenderStateForTest();
@@ -42,27 +92,46 @@ describe('applyWgpuBlendMode', () => {
42
92
  });
43
93
  });
44
94
 
45
- describe('bindWgpuImageResourceTexture', () => {
46
- function dataResource(size: number, version: number): ImageResource {
47
- return {
48
- source: null,
49
- data: new Uint8ClampedArray(size * size * 4),
50
- width: size,
51
- height: size,
52
- version,
53
- alphaType: 'straight',
54
- } as unknown as ImageResource;
55
- }
56
-
57
- it('uploads a data-only ImageResource (a generated Surface) via writeTexture', async () => {
95
+ describe('bindWgpuBitmapTexture', () => {
96
+ it('uploads CPU-readable pixels via writeTexture', async () => {
58
97
  const state = await createWgpuRenderStateForTest();
59
98
  const writeTexture = vi.spyOn(state.device.queue, 'writeTexture');
60
- const entry = bindWgpuImageResourceTexture(state, dataResource(4, 1));
99
+ const entry = bindWgpuBitmapTexture(state, bitmap(4, 1));
61
100
  expect(entry.texture).toBeDefined();
62
101
  expect(writeTexture).toHaveBeenCalled();
63
102
  });
64
103
 
65
- it('uploads an element-backed ImageResource via copyExternalImageToTexture', async () => {
104
+ it('caches by resource identity and re-uploads only when the version changes', async () => {
105
+ const state = await createWgpuRenderStateForTest();
106
+ const image = bitmap(2, 1);
107
+ const first = bindWgpuBitmapTexture(state, image);
108
+ expect(bindWgpuBitmapTexture(state, image)).toBe(first);
109
+ const createTexture = vi.spyOn(state.device, 'createTexture');
110
+ image.version = 2;
111
+ bindWgpuBitmapTexture(state, image);
112
+ expect(createTexture).toHaveBeenCalled();
113
+ });
114
+ });
115
+
116
+ describe('bindWgpuCompressedImageTexture', () => {
117
+ it('routes through the registered compressed upload seam', async () => {
118
+ const state = await createWgpuRenderStateForTest();
119
+ const decode = vi.fn(() => new Uint8ClampedArray(4 * 4 * 4));
120
+ registerWgpuCompressedTextureUpload(state);
121
+ registerWgpuCompressedTextureDecoder(state, decode);
122
+ const entry = bindWgpuCompressedImageTexture(state, compressedBc3Image());
123
+ expect(entry?.texture).toBeDefined();
124
+ expect(decode).toHaveBeenCalledWith('bc3', 4, 4, expect.any(Uint8Array));
125
+ });
126
+
127
+ it('returns null when no compressed uploader is registered', async () => {
128
+ const state = await createWgpuRenderStateForTest();
129
+ expect(bindWgpuCompressedImageTexture(state, compressedBc3Image())).toBeNull();
130
+ });
131
+ });
132
+
133
+ describe('bindWgpuImageResourceTexture', () => {
134
+ it('uploads a host-backed image via copyExternalImageToTexture', async () => {
66
135
  const state = await createWgpuRenderStateForTest();
67
136
  const copy = vi.spyOn(state.device.queue, 'copyExternalImageToTexture');
68
137
  const canvas = document.createElement('canvas');
@@ -70,25 +139,52 @@ describe('bindWgpuImageResourceTexture', () => {
70
139
  canvas.height = 4;
71
140
  const image = {
72
141
  source: canvas,
73
- data: null,
74
142
  width: 4,
75
143
  height: 4,
144
+ kind: ImageTextureSourceKind,
76
145
  version: 1,
77
- alphaType: 'straight',
78
- } as unknown as ImageResource;
146
+ } as unknown as Image;
79
147
  bindWgpuImageResourceTexture(state, image);
80
148
  expect(copy).toHaveBeenCalled();
81
149
  });
82
150
 
83
- it('caches by resource identity and re-uploads only when the version changes', async () => {
151
+ it('skips an initial upload when the canvas has no pixels', async () => {
84
152
  const state = await createWgpuRenderStateForTest();
85
- const image = dataResource(2, 1);
86
- const first = bindWgpuImageResourceTexture(state, image);
87
- expect(bindWgpuImageResourceTexture(state, image)).toBe(first);
88
- const createTexture = vi.spyOn(state.device, 'createTexture');
153
+ const copy = vi.spyOn(state.device.queue, 'copyExternalImageToTexture');
154
+ const canvas = document.createElement('canvas');
155
+ canvas.width = 0;
156
+ canvas.height = 4;
157
+ const image = {
158
+ source: canvas,
159
+ width: 0,
160
+ height: 4,
161
+ kind: ImageTextureSourceKind,
162
+ version: 1,
163
+ } as unknown as Image;
164
+ expect(bindWgpuImageResourceTexture(state, image)).toBeNull();
165
+ expect(copy).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it('keeps the previous upload when the browser cannot snapshot the current source', async () => {
169
+ const state = await createWgpuRenderStateForTest();
170
+ const copy = vi.spyOn(state.device.queue, 'copyExternalImageToTexture');
171
+ const canvas = document.createElement('canvas');
172
+ canvas.width = 4;
173
+ canvas.height = 4;
174
+ const image = {
175
+ source: canvas,
176
+ width: 4,
177
+ height: 4,
178
+ kind: ImageTextureSourceKind,
179
+ version: 1,
180
+ } as unknown as Image;
181
+ const entry = requireTextureEntry(bindWgpuImageResourceTexture(state, image));
182
+ copy.mockImplementation(() => {
183
+ throw new TypeError('Failed to copy content from external image.');
184
+ });
89
185
  image.version = 2;
90
- bindWgpuImageResourceTexture(state, image);
91
- expect(createTexture).toHaveBeenCalled();
186
+ expect(bindWgpuImageResourceTexture(state, image)).toBe(entry);
187
+ expect(copy).toHaveBeenCalledTimes(2);
92
188
  });
93
189
  });
94
190
 
@@ -98,7 +194,7 @@ describe('bindWgpuTexture', () => {
98
194
  const canvas = document.createElement('canvas');
99
195
  canvas.width = 8;
100
196
  canvas.height = 8;
101
- const entry = bindWgpuTexture(state, canvas);
197
+ const entry = requireTextureEntry(bindWgpuTexture(state, canvas));
102
198
  expect(entry).toBeDefined();
103
199
  expect(entry.texture).toBeDefined();
104
200
  expect(bindWgpuTexture(state, canvas)).toBe(entry);
@@ -130,7 +226,14 @@ describe('bindWgpuTexture', () => {
130
226
 
131
227
  describe('bindWgpuVideoTexture', () => {
132
228
  function videoTexture(version: number, readyState = 4, width = 320, height = 240): Texture {
229
+ const source = document.createElement('video');
230
+ Object.defineProperties(source, {
231
+ readyState: { configurable: true, value: readyState, writable: true },
232
+ videoHeight: { configurable: true, value: height, writable: true },
233
+ videoWidth: { configurable: true, value: width, writable: true },
234
+ });
133
235
  return {
236
+ colorSpace: 'srgb',
134
237
  sampler: {
135
238
  anisotropy: 1,
136
239
  magFilter: 'linear',
@@ -139,18 +242,16 @@ describe('bindWgpuVideoTexture', () => {
139
242
  wrapU: 'clamp-to-edge',
140
243
  wrapV: 'clamp-to-edge',
141
244
  },
142
- storage: {
143
- dimension: '2d',
144
- image: {
145
- source: { readyState, videoHeight: height, videoWidth: width } as HTMLVideoElement,
146
- version,
147
- },
245
+ dimension: '2d',
246
+ source: {
247
+ source,
248
+ version,
148
249
  },
149
250
  version,
150
251
  } as unknown as Texture;
151
252
  }
152
253
 
153
- it('uploads once per backing version and caches the texture by backing identity', async () => {
254
+ it('uploads once per source version and caches the texture by source identity', async () => {
154
255
  const state = await createWgpuRenderStateForTest();
155
256
  const copy = vi.spyOn(state.device.queue, 'copyExternalImageToTexture');
156
257
  const video = videoTexture(1);
@@ -159,7 +260,7 @@ describe('bindWgpuVideoTexture', () => {
159
260
  expect(copy).toHaveBeenCalledTimes(1);
160
261
  expect(bindWgpuVideoTexture(state, video)).toBe(first);
161
262
  expect(copy).toHaveBeenCalledTimes(1);
162
- video.storage.image!.version = 2;
263
+ getTextureSource(video)!.version = 2;
163
264
  expect(bindWgpuVideoTexture(state, video)).toBe(first);
164
265
  expect(copy).toHaveBeenCalledTimes(2);
165
266
  });
@@ -168,7 +269,7 @@ describe('bindWgpuVideoTexture', () => {
168
269
  const state = await createWgpuRenderStateForTest();
169
270
  const video = videoTexture(1, 1, 0, 0);
170
271
  expect(bindWgpuVideoTexture(state, video)).toBeNull();
171
- const element = video.storage.image!.source as HTMLVideoElement;
272
+ const element = (getTextureSource(video) as Image).source as HTMLVideoElement;
172
273
  Object.assign(element, { readyState: 4, videoHeight: 120, videoWidth: 160 });
173
274
  const first = bindWgpuVideoTexture(state, video)!;
174
275
  const destroy = vi.spyOn(first.texture, 'destroy');
@@ -178,6 +279,18 @@ describe('bindWgpuVideoTexture', () => {
178
279
  expect(destroy).toHaveBeenCalled();
179
280
  });
180
281
 
282
+ it('realizes distinct linear and sRGB textures for one video source', async () => {
283
+ const state = await createWgpuRenderStateForTest();
284
+ const createTexture = vi.spyOn(state.device, 'createTexture');
285
+ const video = videoTexture(1);
286
+ const srgb = bindWgpuVideoTexture(state, video);
287
+ expect(createTexture).toHaveBeenLastCalledWith(expect.objectContaining({ format: 'rgba8unorm-srgb' }));
288
+ video.colorSpace = 'linear';
289
+ const linear = bindWgpuVideoTexture(state, video);
290
+ expect(linear).not.toBe(srgb);
291
+ expect(createTexture).toHaveBeenLastCalledWith(expect.objectContaining({ format: 'rgba8unorm' }));
292
+ });
293
+
181
294
  it('destroys and removes an uploaded video texture', async () => {
182
295
  const state = await createWgpuRenderStateForTest();
183
296
  const video = videoTexture(1);
@@ -204,7 +317,7 @@ describe('createWgpuTextureEntry', () => {
204
317
  const canvas = document.createElement('canvas');
205
318
  canvas.width = 4;
206
319
  canvas.height = 4;
207
- const entry = createWgpuTextureEntry(state, 4, 4, canvas);
320
+ const entry = requireTextureEntry(createWgpuTextureEntry(state, 4, 4, canvas));
208
321
  expect(entry.texture).toBeDefined();
209
322
  expect(entry.bindGroup).toBeDefined();
210
323
  });
@@ -220,13 +333,13 @@ describe('drawWgpuQuad', () => {
220
333
  it('does not throw when render pass is open', async () => {
221
334
  const state = await createWgpuRenderStateForTest();
222
335
  renderWgpuBackground(state);
223
- const bitmap = createBitmap();
336
+ const bitmap = createSprite();
224
337
  prepareScene2DRender(state, bitmap);
225
338
  const renderProxy = getOrCreateRenderProxy2D(state, bitmap);
226
339
  const canvas = document.createElement('canvas');
227
340
  canvas.width = 4;
228
341
  canvas.height = 4;
229
- const entry = bindWgpuTexture(state, canvas);
342
+ const entry = requireTextureEntry(bindWgpuTexture(state, canvas));
230
343
  expect(() => drawWgpuQuad(state, renderProxy, entry, 0, 0, 4, 4, 0, 0, 1, 1)).not.toThrow();
231
344
  submitWgpuRenderPass(state);
232
345
  });
@@ -236,13 +349,13 @@ describe('drawWgpuQuadWithTransform', () => {
236
349
  it('does not throw when render pass is open', async () => {
237
350
  const state = await createWgpuRenderStateForTest();
238
351
  renderWgpuBackground(state);
239
- const bitmap = createBitmap();
352
+ const bitmap = createSprite();
240
353
  prepareScene2DRender(state, bitmap);
241
354
  const renderProxy = getOrCreateRenderProxy2D(state, bitmap);
242
355
  const canvas = document.createElement('canvas');
243
356
  canvas.width = 4;
244
357
  canvas.height = 4;
245
- const entry = bindWgpuTexture(state, canvas);
358
+ const entry = requireTextureEntry(bindWgpuTexture(state, canvas));
246
359
  const t = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
247
360
  expect(() => drawWgpuQuadWithTransform(state, renderProxy, t, entry, 0, 0, 4, 4, 0, 0, 1, 1)).not.toThrow();
248
361
  submitWgpuRenderPass(state);
@@ -270,20 +383,9 @@ describe('getWgpuRenderProxyColorScaleBias', () => {
270
383
  });
271
384
 
272
385
  describe('resolveWgpuSmoothingBindGroup', () => {
273
- function dataResource(size: number, version: number): ImageResource {
274
- return {
275
- source: null,
276
- data: new Uint8ClampedArray(size * size * 4),
277
- width: size,
278
- height: size,
279
- version,
280
- alphaType: 'straight',
281
- } as unknown as ImageResource;
282
- }
283
-
284
386
  it('returns the default bind group for a null smoothing (no variant built)', async () => {
285
387
  const state = await createWgpuRenderStateForTest();
286
- const entry = bindWgpuImageResourceTexture(state, dataResource(4, 1));
388
+ const entry = bindWgpuBitmapTexture(state, bitmap(4, 1));
287
389
  expect(resolveWgpuSmoothingBindGroup(state, entry, null)).toBe(entry.bindGroup);
288
390
  expect(entry.bindGroupLinear).toBeUndefined();
289
391
  expect(entry.bindGroupNearest).toBeUndefined();
@@ -291,7 +393,7 @@ describe('resolveWgpuSmoothingBindGroup', () => {
291
393
 
292
394
  it('builds and caches distinct LINEAR and NEAREST variants for true/false', async () => {
293
395
  const state = await createWgpuRenderStateForTest();
294
- const entry = bindWgpuImageResourceTexture(state, dataResource(4, 1));
396
+ const entry = bindWgpuBitmapTexture(state, bitmap(4, 1));
295
397
  const createBindGroup = vi.spyOn(state.device, 'createBindGroup');
296
398
 
297
399
  resolveWgpuSmoothingBindGroup(state, entry, true);
@@ -310,13 +412,13 @@ describe('resolveWgpuSmoothingBindGroup', () => {
310
412
 
311
413
  it('drops the cached variants when the texture re-uploads (version bump)', async () => {
312
414
  const state = await createWgpuRenderStateForTest();
313
- const image = dataResource(4, 1);
314
- const entry = bindWgpuImageResourceTexture(state, image);
415
+ const image = bitmap(4, 1);
416
+ const entry = bindWgpuBitmapTexture(state, image);
315
417
  resolveWgpuSmoothingBindGroup(state, entry, true);
316
418
  resolveWgpuSmoothingBindGroup(state, entry, false);
317
419
  expect(entry.bindGroupLinear).toBeDefined();
318
420
  image.version = 2;
319
- bindWgpuImageResourceTexture(state, image);
421
+ bindWgpuBitmapTexture(state, image);
320
422
  expect(entry.bindGroupLinear).toBeUndefined();
321
423
  expect(entry.bindGroupNearest).toBeUndefined();
322
424
  });
@@ -329,7 +431,7 @@ describe('submitWgpuQuadDraw', () => {
329
431
  const canvas = document.createElement('canvas');
330
432
  canvas.width = 4;
331
433
  canvas.height = 4;
332
- const entry = bindWgpuTexture(state, canvas);
434
+ const entry = requireTextureEntry(bindWgpuTexture(state, canvas));
333
435
  expect(() => submitWgpuQuadDraw(state, 0, entry.bindGroup)).not.toThrow();
334
436
  submitWgpuRenderPass(state);
335
437
  });
@@ -339,7 +441,7 @@ describe('submitWgpuQuadDraw', () => {
339
441
  const canvas = document.createElement('canvas');
340
442
  canvas.width = 4;
341
443
  canvas.height = 4;
342
- const entry = bindWgpuTexture(state, canvas);
444
+ const entry = requireTextureEntry(bindWgpuTexture(state, canvas));
343
445
  expect(() => submitWgpuQuadDraw(state, 0, entry.bindGroup)).not.toThrow();
344
446
  });
345
447
  });
@@ -350,7 +452,7 @@ describe('updateWgpuTextureEntry', () => {
350
452
  const canvas = document.createElement('canvas');
351
453
  canvas.width = 4;
352
454
  canvas.height = 4;
353
- const entry = createWgpuTextureEntry(state, 4, 4, canvas);
455
+ const entry = requireTextureEntry(createWgpuTextureEntry(state, 4, 4, canvas));
354
456
  expect(() => updateWgpuTextureEntry(state, entry, canvas)).not.toThrow();
355
457
  });
356
458
  });
@@ -0,0 +1,89 @@
1
+ import { isWgpuExternalImageSourceReady, tryCopyWgpuExternalImageToTexture } from './wgpuExternalImageSource';
2
+ import { createReadyImageElementForTest } from './wgpuTestHelper';
3
+
4
+ describe('isWgpuExternalImageSourceReady', () => {
5
+ it('accepts a dimension-valid canvas without probing or binding its context', () => {
6
+ const canvas = document.createElement('canvas');
7
+ canvas.width = 4;
8
+ canvas.height = 4;
9
+ const getContext = vi.spyOn(canvas, 'getContext');
10
+ expect(isWgpuExternalImageSourceReady(canvas, 4, 4)).toBe(true);
11
+ expect(getContext).not.toHaveBeenCalled();
12
+ });
13
+
14
+ it('accepts a decoded image with enough pixels', () => {
15
+ const image = createReadyImageElementForTest(4, 4);
16
+ expect(isWgpuExternalImageSourceReady(image, 4, 4)).toBe(true);
17
+ });
18
+
19
+ it('rejects zero extents and undersized sources', () => {
20
+ const canvas = document.createElement('canvas');
21
+ canvas.width = 4;
22
+ canvas.height = 4;
23
+ expect(isWgpuExternalImageSourceReady(canvas, 0, 4)).toBe(false);
24
+ expect(isWgpuExternalImageSourceReady(canvas, 5, 4)).toBe(false);
25
+ });
26
+
27
+ it('rejects an undecoded image element', () => {
28
+ const image = document.createElement('img');
29
+ expect(isWgpuExternalImageSourceReady(image, 1, 1)).toBe(false);
30
+ });
31
+
32
+ it('rejects an unrecognized source instead of failing open', () => {
33
+ expect(isWgpuExternalImageSourceReady({} as GPUCopyExternalImageSource, 1, 1)).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe('tryCopyWgpuExternalImageToTexture', () => {
38
+ it('copies a ready source', () => {
39
+ const copyExternalImageToTexture = vi.fn();
40
+ const queue = { copyExternalImageToTexture } as unknown as GPUQueue;
41
+ const texture = {} as GPUTexture;
42
+ const source = createReadyImageElementForTest(4, 4);
43
+ expect(tryCopyWgpuExternalImageToTexture(queue, { source }, { texture }, 4, 4)).toBe(true);
44
+ expect(copyExternalImageToTexture).toHaveBeenCalledWith({ source }, { texture }, [4, 4, 1]);
45
+ });
46
+
47
+ it('fails closed when a canvas has no usable context', () => {
48
+ const canvas = document.createElement('canvas');
49
+ canvas.width = 4;
50
+ canvas.height = 4;
51
+ const copyExternalImageToTexture = vi.fn(() => {
52
+ throw new DOMException('canvas has no rendering context', 'OperationError');
53
+ });
54
+ const queue = { copyExternalImageToTexture } as unknown as GPUQueue;
55
+ expect(tryCopyWgpuExternalImageToTexture(queue, { source: canvas }, { texture: {} as GPUTexture }, 4, 4)).toBe(
56
+ false,
57
+ );
58
+ });
59
+
60
+ it('fails closed when the browser cannot snapshot current canvas content', () => {
61
+ const canvas = document.createElement('canvas');
62
+ canvas.width = 4;
63
+ canvas.height = 4;
64
+ const copyExternalImageToTexture = vi.fn(() => {
65
+ throw new TypeError(
66
+ "Failed to execute 'copyExternalImageToTexture' on 'GPUQueue': Failed to copy content from external image.",
67
+ );
68
+ });
69
+ const queue = { copyExternalImageToTexture } as unknown as GPUQueue;
70
+ expect(tryCopyWgpuExternalImageToTexture(queue, { source: canvas }, { texture: {} as GPUTexture }, 4, 4)).toBe(
71
+ false,
72
+ );
73
+ });
74
+
75
+ it('rethrows security failures', () => {
76
+ const canvas = document.createElement('canvas');
77
+ canvas.width = 4;
78
+ canvas.height = 4;
79
+ const error = new DOMException('tainted canvas', 'SecurityError');
80
+ const queue = {
81
+ copyExternalImageToTexture: vi.fn(() => {
82
+ throw error;
83
+ }),
84
+ } as unknown as GPUQueue;
85
+ expect(() =>
86
+ tryCopyWgpuExternalImageToTexture(queue, { source: canvas }, { texture: {} as GPUTexture }, 4, 4),
87
+ ).toThrow(error);
88
+ });
89
+ });
@@ -0,0 +1,35 @@
1
+ import { getTextureSourceKind } from '@flighthq/texture/contract';
2
+ import { ExternalTextureSourceKind } from '@flighthq/types/contract';
3
+
4
+ import { createExternalWgpuTexture, disposeExternalWgpuTexture } from './wgpuExternalTexture';
5
+ import { createWgpuRenderStateForTest, installWgpuMock } from './wgpuTestHelper';
6
+ import { resolveWgpuTexture } from './wgpuTextureResolver';
7
+
8
+ beforeAll(() => {
9
+ installWgpuMock();
10
+ });
11
+
12
+ describe('createExternalWgpuTexture', () => {
13
+ it('resolves a borrowed handle and forgets it without destroying the allocation', async () => {
14
+ const state = await createWgpuRenderStateForTest();
15
+ const destroy = vi.fn();
16
+ const handle = { createView: vi.fn(() => ({})), destroy } as unknown as GPUTexture;
17
+ const texture = createExternalWgpuTexture(state, handle, { height: 16, width: 32 });
18
+
19
+ expect(getTextureSourceKind(texture)).toBe(ExternalTextureSourceKind);
20
+ expect(resolveWgpuTexture(state, texture)?.texture).toBe(handle);
21
+ expect(disposeExternalWgpuTexture(state, texture)).toBe(true);
22
+ expect(resolveWgpuTexture(state, texture)).toBeNull();
23
+ expect(destroy).not.toHaveBeenCalled();
24
+ });
25
+ });
26
+
27
+ describe('disposeExternalWgpuTexture', () => {
28
+ it('returns false after the borrowed handle has already been forgotten', async () => {
29
+ const state = await createWgpuRenderStateForTest();
30
+ const handle = { createView: () => ({}) } as unknown as GPUTexture;
31
+ const texture = createExternalWgpuTexture(state, handle, { height: 1, width: 1 });
32
+ expect(disposeExternalWgpuTexture(state, texture)).toBe(true);
33
+ expect(disposeExternalWgpuTexture(state, texture)).toBe(false);
34
+ });
35
+ });
@@ -1,5 +1,7 @@
1
+ import { addLogSink, createMemoryLogSink, getMemoryLogSinkEntries, removeLogSink } from '@flighthq/log/contract';
2
+ import { enableRenderRegistryGuards, explainRenderRegistryMisses } from '@flighthq/render/contract';
1
3
  import type { Material, WgpuMaterialRenderer, WgpuRenderState } from '@flighthq/types/contract';
2
- import { StandardMaterialKind, EntityRuntimeKey } from '@flighthq/types/contract';
4
+ import { StandardMaterialKind, EntityRuntimeKey, RenderRegistry } from '@flighthq/types/contract';
3
5
 
4
6
  import {
5
7
  getWgpuMaterialRenderer,
@@ -12,7 +14,8 @@ const TestKind = 'TestMaterial';
12
14
  const testRenderer: WgpuMaterialRenderer = { instanceFloatCount: 0, getShaderModule: () => ({}) as GPUShaderModule };
13
15
 
14
16
  function makeState(): WgpuRenderState {
15
- const state = {} as WgpuRenderState;
17
+ // `device` is what the guard's message table dispatches on to name the wgpu registrar.
18
+ const state = { device: {} } as WgpuRenderState;
16
19
  state[EntityRuntimeKey] = createWgpuRenderStateRuntime();
17
20
  return state;
18
21
  }
@@ -53,4 +56,27 @@ describe('resolveWgpuMaterialRenderer', () => {
53
56
  expect(resolveWgpuMaterialRenderer(state, makeMaterial('Other'))).toBe(testRenderer);
54
57
  expect(resolveWgpuMaterialRenderer(state, null)).toBe(testRenderer);
55
58
  });
59
+
60
+ it('reports the missing kind against the wgpu registrar, not the gl one', () => {
61
+ const state = makeState();
62
+ enableRenderRegistryGuards(state);
63
+ const sink = createMemoryLogSink(4);
64
+ addLogSink(sink.sink);
65
+
66
+ try {
67
+ resolveWgpuMaterialRenderer(state, makeMaterial(TestKind));
68
+
69
+ expect(explainRenderRegistryMisses(state).misses).toEqual([
70
+ { kind: TestKind, registry: RenderRegistry.MaterialRenderer },
71
+ ]);
72
+ expect(getMemoryLogSinkEntries(sink)[0]?.data).toMatchObject({
73
+ kind: TestKind,
74
+ message:
75
+ 'resolveWgpuMaterialRenderer: material kind has no registered renderer, so nodes using it do not draw — call registerWgpuMaterialRenderer(state, kind, renderer)',
76
+ registry: RenderRegistry.MaterialRenderer,
77
+ });
78
+ } finally {
79
+ removeLogSink(sink.sink);
80
+ }
81
+ });
56
82
  });
@@ -53,10 +53,18 @@ describe('generateWgpuMipmaps', () => {
53
53
  const state = await createWgpuRenderStateForTest();
54
54
  spyEncoderPasses(state);
55
55
  generateWgpuMipmaps(state, makeTextureSpy(), 4, 4, 'rgba8unorm');
56
- const pipeline = getWgpuRenderStateRuntime(state).mipmapPipeline;
56
+ const pipeline = getWgpuRenderStateRuntime(state).mipmapPipelineCache.get('rgba8unorm')?.pipeline;
57
57
  expect(pipeline).toBeDefined();
58
58
  generateWgpuMipmaps(state, makeTextureSpy(), 8, 8, 'rgba8unorm');
59
- expect(getWgpuRenderStateRuntime(state).mipmapPipeline).toBe(pipeline);
59
+ expect(getWgpuRenderStateRuntime(state).mipmapPipelineCache.get('rgba8unorm')?.pipeline).toBe(pipeline);
60
+ });
61
+
62
+ it('keeps linear and sRGB downsample pipelines distinct', async () => {
63
+ const state = await createWgpuRenderStateForTest();
64
+ spyEncoderPasses(state);
65
+ generateWgpuMipmaps(state, makeTextureSpy(), 4, 4, 'rgba8unorm');
66
+ generateWgpuMipmaps(state, makeTextureSpy(), 4, 4, 'rgba8unorm-srgb');
67
+ expect(getWgpuRenderStateRuntime(state).mipmapPipelineCache.size).toBe(2);
60
68
  });
61
69
  });
62
70