@flighthq/render-gl 0.2.0 → 0.2.1-next.410.a23f189

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 (66) hide show
  1. package/dist/glCompressedTexture.d.ts +8 -0
  2. package/dist/glCompressedTexture.d.ts.map +1 -0
  3. package/dist/glCompressedTexture.js +270 -0
  4. package/dist/glCompressedTexture.js.map +1 -0
  5. package/dist/glDraw.d.ts +4 -2
  6. package/dist/glDraw.d.ts.map +1 -1
  7. package/dist/glDraw.js +215 -20
  8. package/dist/glDraw.js.map +1 -1
  9. package/dist/glFullscreenPass.d.ts.map +1 -1
  10. package/dist/glFullscreenPass.js +32 -0
  11. package/dist/glFullscreenPass.js.map +1 -1
  12. package/dist/glLinearToSrgbPass.d.ts +4 -0
  13. package/dist/glLinearToSrgbPass.d.ts.map +1 -0
  14. package/dist/glLinearToSrgbPass.js +42 -0
  15. package/dist/glLinearToSrgbPass.js.map +1 -0
  16. package/dist/glPresentRenderTarget.d.ts +3 -0
  17. package/dist/glPresentRenderTarget.d.ts.map +1 -0
  18. package/dist/glPresentRenderTarget.js +35 -0
  19. package/dist/glPresentRenderTarget.js.map +1 -0
  20. package/dist/glRenderPass.d.ts +5 -0
  21. package/dist/glRenderPass.d.ts.map +1 -0
  22. package/dist/glRenderPass.js +130 -0
  23. package/dist/glRenderPass.js.map +1 -0
  24. package/dist/glRenderState.d.ts +1 -1
  25. package/dist/glRenderState.d.ts.map +1 -1
  26. package/dist/glRenderState.js +1 -0
  27. package/dist/glRenderState.js.map +1 -1
  28. package/dist/glRenderTarget.d.ts +2 -15
  29. package/dist/glRenderTarget.d.ts.map +1 -1
  30. package/dist/glRenderTarget.js +48 -57
  31. package/dist/glRenderTarget.js.map +1 -1
  32. package/dist/glRenderTargetPool.d.ts.map +1 -1
  33. package/dist/glRenderTargetPool.js +4 -0
  34. package/dist/glRenderTargetPool.js.map +1 -1
  35. package/dist/glSkinPaletteTexture.d.ts +5 -0
  36. package/dist/glSkinPaletteTexture.d.ts.map +1 -0
  37. package/dist/glSkinPaletteTexture.js +42 -0
  38. package/dist/glSkinPaletteTexture.js.map +1 -0
  39. package/dist/glTestHelper.d.ts.map +1 -1
  40. package/dist/glTestHelper.js +1 -0
  41. package/dist/glTestHelper.js.map +1 -1
  42. package/dist/glTextureUpload.d.ts +5 -0
  43. package/dist/glTextureUpload.d.ts.map +1 -0
  44. package/dist/glTextureUpload.js +27 -0
  45. package/dist/glTextureUpload.js.map +1 -0
  46. package/dist/glTextureVideoUpload.d.ts +3 -0
  47. package/dist/glTextureVideoUpload.d.ts.map +1 -0
  48. package/dist/glTextureVideoUpload.js +24 -0
  49. package/dist/glTextureVideoUpload.js.map +1 -0
  50. package/dist/index.d.ts +7 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +7 -0
  53. package/dist/index.js.map +1 -1
  54. package/package.json +5 -5
  55. package/src/glCompressedTexture.test.ts +276 -0
  56. package/src/glDraw.test.ts +319 -36
  57. package/src/glFullscreenPass.test.ts +22 -1
  58. package/src/glLinearToSrgbPass.test.ts +75 -0
  59. package/src/glPresentRenderTarget.test.ts +62 -0
  60. package/src/glReadback.test.ts +1 -0
  61. package/src/glRenderPass.test.ts +170 -0
  62. package/src/glRenderTarget.test.ts +63 -98
  63. package/src/glRenderTargetPool.test.ts +14 -0
  64. package/src/glSkinPaletteTexture.test.ts +129 -0
  65. package/src/glTextureUpload.test.ts +49 -0
  66. package/src/glTextureVideoUpload.test.ts +53 -0
@@ -1,8 +1,12 @@
1
- import { BlendMode } from '@flighthq/types';
1
+ import type { ImageResource, SamplerLike, VideoTexture } from '@flighthq/types';
2
+ import { AdvancedBlendMode, BlendMode } from '@flighthq/types';
2
3
 
4
+ import { registerGlCompressedTextureDecoder, registerGlCompressedTextureUpload } from './glCompressedTexture';
3
5
  import {
4
6
  applyGlBlendMode,
7
+ bindGlImageResourceTexture,
5
8
  bindGlTexture,
9
+ bindGlVideoTexture,
6
10
  createGlTexture,
7
11
  drawGlQuad,
8
12
  enableGlBlendModeSupport,
@@ -17,6 +21,47 @@ import { getGlRenderStateRuntime } from './glRenderState';
17
21
  import { registerGlBitmapShader } from './glShaderRegistry';
18
22
  import { createGlState } from './glTestHelper';
19
23
 
24
+ // A compressed-only ImageResource (single 4×4 bc3 level, no element/data) for exercising the opt-in
25
+ // compressed upload seam.
26
+ function compressedBc3ImageResource(): ImageResource {
27
+ return {
28
+ source: null,
29
+ data: null,
30
+ compressed: {
31
+ container: {
32
+ format: 'bc3',
33
+ width: 4,
34
+ height: 4,
35
+ depth: 1,
36
+ mipLevels: 1,
37
+ layers: 1,
38
+ faces: 1,
39
+ supercompression: 'None',
40
+ levels: [{ byteOffset: 0, byteLength: 16, width: 4, height: 4 }],
41
+ },
42
+ payload: new Uint8Array(16),
43
+ },
44
+ width: 4,
45
+ height: 4,
46
+ version: 1,
47
+ alphaType: 'straight',
48
+ } as unknown as ImageResource;
49
+ }
50
+
51
+ // A plain SamplerLike with the AAA sampling defaults (clamp/linear/trilinear/mips, anisotropy off),
52
+ // mirroring createSampler in @flighthq/texture without pulling that package into render-gl's tests.
53
+ function makeSampler(overrides?: Partial<SamplerLike>): SamplerLike {
54
+ return {
55
+ anisotropy: 1,
56
+ magFilter: 'linear',
57
+ minFilter: 'linear-mipmap-linear',
58
+ mipmaps: true,
59
+ wrapU: 'clamp-to-edge',
60
+ wrapV: 'clamp-to-edge',
61
+ ...overrides,
62
+ };
63
+ }
64
+
20
65
  describe('applyGlBlendMode', () => {
21
66
  it('does not call blendFunc when blend mode has not changed', () => {
22
67
  const { state, gl } = createGlState();
@@ -50,14 +95,6 @@ describe('applyGlBlendMode', () => {
50
95
  expect(gl.blendFunc).toHaveBeenCalledWith(g.DST_COLOR, g.ONE_MINUS_SRC_ALPHA);
51
96
  });
52
97
 
53
- it('sets (ONE, ZERO) for BlendMode.None', () => {
54
- const { state, gl } = createGlState();
55
- registerDefaultGlBlendModes(state);
56
- applyGlBlendMode(state, BlendMode.None);
57
- const g = gl as unknown as { ONE: number; ZERO: number };
58
- expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ZERO);
59
- });
60
-
61
98
  it('sets (ONE, ONE_MINUS_SRC_COLOR) for BlendMode.Screen', () => {
62
99
  const { state, gl } = createGlState();
63
100
  registerDefaultGlBlendModes(state);
@@ -84,23 +121,6 @@ describe('applyGlBlendMode', () => {
84
121
  expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE);
85
122
  });
86
123
 
87
- it('sets the reverse-subtract equation with (ONE, ONE) for BlendMode.Subtract', () => {
88
- const { state, gl } = createGlState();
89
- registerDefaultGlBlendModes(state);
90
- applyGlBlendMode(state, BlendMode.Subtract);
91
- const g = gl as unknown as { ONE: number; FUNC_REVERSE_SUBTRACT: number };
92
- expect(gl.blendEquation).toHaveBeenCalledWith(g.FUNC_REVERSE_SUBTRACT);
93
- expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE);
94
- });
95
-
96
- it('sets (ZERO, ONE_MINUS_SRC_ALPHA) for BlendMode.Erase', () => {
97
- const { state, gl } = createGlState();
98
- registerDefaultGlBlendModes(state);
99
- applyGlBlendMode(state, BlendMode.Erase);
100
- const g = gl as unknown as { ZERO: number; ONE_MINUS_SRC_ALPHA: number };
101
- expect(gl.blendFunc).toHaveBeenCalledWith(g.ZERO, g.ONE_MINUS_SRC_ALPHA);
102
- });
103
-
104
124
  it('resets the blend equation to FUNC_ADD for a mode that does not carry one', () => {
105
125
  const { state, gl } = createGlState();
106
126
  registerDefaultGlBlendModes(state);
@@ -113,7 +133,7 @@ describe('applyGlBlendMode', () => {
113
133
  it('falls back to normal blend for a mode with no registered realization', () => {
114
134
  const { state, gl } = createGlState();
115
135
  registerDefaultGlBlendModes(state);
116
- applyGlBlendMode(state, BlendMode.Overlay);
136
+ applyGlBlendMode(state, AdvancedBlendMode.Overlay);
117
137
  const g = gl as unknown as { ONE: number; ONE_MINUS_SRC_ALPHA: number };
118
138
  expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE_MINUS_SRC_ALPHA);
119
139
  });
@@ -141,6 +161,78 @@ describe('applyGlBlendMode', () => {
141
161
  });
142
162
  });
143
163
 
164
+ describe('bindGlImageResourceTexture', () => {
165
+ function dataResource(size: number, version: number): ImageResource {
166
+ return {
167
+ source: null,
168
+ data: new Uint8ClampedArray(size * size * 4),
169
+ width: size,
170
+ height: size,
171
+ version,
172
+ alphaType: 'straight',
173
+ } as unknown as ImageResource;
174
+ }
175
+
176
+ it('uploads a data-only ImageResource (a generated Surface) via the raw-pixel path', () => {
177
+ const { state, gl } = createGlState();
178
+ bindGlImageResourceTexture(state, dataResource(4, 1));
179
+ expect(gl.createTexture).toHaveBeenCalled();
180
+ expect(gl.texImage2D).toHaveBeenCalled();
181
+ });
182
+
183
+ it('uploads an element-backed ImageResource via the element path', () => {
184
+ const { state, gl } = createGlState();
185
+ const image = {
186
+ source: document.createElement('img'),
187
+ data: null,
188
+ width: 1,
189
+ height: 1,
190
+ version: 1,
191
+ alphaType: 'straight',
192
+ } as unknown as ImageResource;
193
+ bindGlImageResourceTexture(state, image);
194
+ expect(gl.texImage2D).toHaveBeenCalled();
195
+ });
196
+
197
+ it('caches by resource identity and re-uploads only when the version changes', () => {
198
+ const { state, gl } = createGlState();
199
+ const image = dataResource(1, 1);
200
+ const t1 = bindGlImageResourceTexture(state, image);
201
+ const uploads = (gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length;
202
+ const t2 = bindGlImageResourceTexture(state, image);
203
+ expect(t2).toBe(t1);
204
+ expect((gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length).toBe(uploads);
205
+ image.version = 2;
206
+ bindGlImageResourceTexture(state, image);
207
+ expect((gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length).toBe(uploads + 1);
208
+ });
209
+
210
+ it('routes a compressed-only ImageResource through the registered compressed upload seam (decode fallback)', () => {
211
+ // The device mock exposes no block extension, so the compressed container falls back to the
212
+ // registered RGBA decoder and uploads via texImage2D — proving the real bind/draw path reaches the
213
+ // installed compressed uploader, not just the raw data/element branches.
214
+ const { state, gl } = createGlState();
215
+ const rgba = new Uint8ClampedArray(4 * 4 * 4);
216
+ const decode = vi.fn(() => rgba);
217
+ registerGlCompressedTextureUpload(state);
218
+ registerGlCompressedTextureDecoder(state, decode);
219
+ const image = compressedBc3ImageResource();
220
+ bindGlImageResourceTexture(state, image);
221
+ expect(decode).toHaveBeenCalledWith('bc3', 4, 4, expect.any(Uint8Array));
222
+ expect(gl.texImage2D).toHaveBeenCalled();
223
+ });
224
+
225
+ it('skips a compressed-only ImageResource when no compressed uploader is registered', () => {
226
+ // The compressed path is an opt-in seam: without registerGlCompressedTextureUpload, a compressed
227
+ // resource uploads nothing (the enum table tree-shakes out of a bitmap-only bundle) rather than
228
+ // reaching texImage2D.
229
+ const { state, gl } = createGlState();
230
+ bindGlImageResourceTexture(state, compressedBc3ImageResource());
231
+ expect(gl.texImage2D).not.toHaveBeenCalled();
232
+ expect(gl.compressedTexImage2D).not.toHaveBeenCalled();
233
+ });
234
+ });
235
+
144
236
  describe('bindGlTexture', () => {
145
237
  it('creates a new texture for an uncached image source', () => {
146
238
  const { state, gl } = createGlState();
@@ -206,6 +298,198 @@ describe('bindGlTexture', () => {
206
298
  expect(gl.bindTexture).toHaveBeenCalledWith((gl as unknown as { TEXTURE_2D: number }).TEXTURE_2D, texture);
207
299
  expect(runtime.currentTexture).toBe(texture);
208
300
  });
301
+
302
+ it('rebinds a cached texture even when it is already current, for multi-unit correctness', () => {
303
+ // The same image reused as two maps (e.g. normal + metallic-roughness) is bound to two active
304
+ // units in a row; currentTexture is unit-blind, so a skip would leave the second unit unbound.
305
+ const { state, gl } = createGlState();
306
+ const img = document.createElement('img');
307
+ const texture = bindGlTexture(state, img);
308
+ const g = gl as unknown as { TEXTURE_2D: number };
309
+ const bindsBefore = (gl.bindTexture as ReturnType<typeof vi.fn>).mock.calls.filter((c) => c[1] === texture).length;
310
+ // currentTexture === texture here; the second bind (a different active unit in practice) must not skip.
311
+ bindGlTexture(state, img);
312
+ const bindsAfter = (gl.bindTexture as ReturnType<typeof vi.fn>).mock.calls.filter((c) => c[1] === texture).length;
313
+ expect(bindsAfter).toBe(bindsBefore + 1);
314
+ expect(gl.bindTexture).toHaveBeenLastCalledWith(g.TEXTURE_2D, texture);
315
+ });
316
+
317
+ it('defaults both wrap modes to clamp-to-edge', () => {
318
+ const { state, gl } = createGlState();
319
+ bindGlTexture(state, document.createElement('img'));
320
+ const g = gl as unknown as {
321
+ TEXTURE_2D: number;
322
+ TEXTURE_WRAP_S: number;
323
+ TEXTURE_WRAP_T: number;
324
+ CLAMP_TO_EDGE: number;
325
+ };
326
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.CLAMP_TO_EDGE);
327
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_T, g.CLAMP_TO_EDGE);
328
+ });
329
+
330
+ it('applies the sampler wrap mode — repeat → REPEAT — so tiled uvs actually repeat', () => {
331
+ const { state, gl } = createGlState();
332
+ bindGlTexture(state, document.createElement('img'), makeSampler({ wrapU: 'repeat', wrapV: 'repeat' }));
333
+ const g = gl as unknown as { TEXTURE_2D: number; TEXTURE_WRAP_S: number; TEXTURE_WRAP_T: number; REPEAT: number };
334
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.REPEAT);
335
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_T, g.REPEAT);
336
+ });
337
+
338
+ it('maps mirror-repeat to MIRRORED_REPEAT', () => {
339
+ const { state, gl } = createGlState();
340
+ bindGlTexture(
341
+ state,
342
+ document.createElement('img'),
343
+ makeSampler({ wrapU: 'mirror-repeat', wrapV: 'mirror-repeat' }),
344
+ );
345
+ const g = gl as unknown as {
346
+ TEXTURE_2D: number;
347
+ TEXTURE_WRAP_S: number;
348
+ TEXTURE_WRAP_T: number;
349
+ MIRRORED_REPEAT: number;
350
+ };
351
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.MIRRORED_REPEAT);
352
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_T, g.MIRRORED_REPEAT);
353
+ });
354
+
355
+ it('re-applies wrap on a cache hit so a shared image follows the current draw sampler', () => {
356
+ // The torus in basic-shading reuses one jpg as both normal and metallic-roughness; if wrap were
357
+ // baked only at upload, the second material would inherit the first's wrap. Re-applying per bind
358
+ // means a repeat draw then a clamp draw each set their own wrap on the shared cached texture.
359
+ const { state, gl } = createGlState();
360
+ const img = document.createElement('img');
361
+ bindGlTexture(state, img, makeSampler({ wrapU: 'repeat', wrapV: 'repeat' }));
362
+ bindGlTexture(state, img, makeSampler({ wrapU: 'clamp-to-edge', wrapV: 'clamp-to-edge' }));
363
+ const g = gl as unknown as {
364
+ TEXTURE_2D: number;
365
+ TEXTURE_WRAP_S: number;
366
+ REPEAT: number;
367
+ CLAMP_TO_EDGE: number;
368
+ };
369
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.REPEAT);
370
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.CLAMP_TO_EDGE);
371
+ });
372
+
373
+ it('falls back to the allowSmoothing LINEAR filter and clamp-to-edge when no sampler is given', () => {
374
+ // The 2D bitmap/sprite path passes no sampler and must keep its historical bilinear, no-mip,
375
+ // clamp default — a material sampler never leaks into it.
376
+ const { state, gl } = createGlState({ allowSmoothing: true });
377
+ bindGlTexture(state, document.createElement('img'));
378
+ const g = gl as unknown as {
379
+ TEXTURE_2D: number;
380
+ TEXTURE_MIN_FILTER: number;
381
+ TEXTURE_MAG_FILTER: number;
382
+ TEXTURE_WRAP_S: number;
383
+ LINEAR: number;
384
+ CLAMP_TO_EDGE: number;
385
+ };
386
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, g.LINEAR);
387
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, g.LINEAR);
388
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.CLAMP_TO_EDGE);
389
+ expect(gl.generateMipmap).not.toHaveBeenCalled();
390
+ });
391
+
392
+ it('applies the sampler min/mag filter and generates a mip chain for a trilinear sampler', () => {
393
+ const { state, gl } = createGlState({ allowSmoothing: false });
394
+ bindGlTexture(state, document.createElement('img'), makeSampler());
395
+ const g = gl as unknown as {
396
+ TEXTURE_2D: number;
397
+ TEXTURE_MIN_FILTER: number;
398
+ TEXTURE_MAG_FILTER: number;
399
+ LINEAR: number;
400
+ LINEAR_MIPMAP_LINEAR: number;
401
+ };
402
+ // Sampler filters win over the state's allowSmoothing flag for material textures.
403
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, g.LINEAR_MIPMAP_LINEAR);
404
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, g.LINEAR);
405
+ expect(gl.generateMipmap).toHaveBeenCalledWith(g.TEXTURE_2D);
406
+ });
407
+
408
+ it('collapses a mip-named min filter to LINEAR and skips mip generation when mipmaps is false', () => {
409
+ // minFilter names a mip level but mipmaps is off: selecting an absent chain would render black, so
410
+ // the filter must fall back to the base level and generateMipmap must not run.
411
+ const { state, gl } = createGlState();
412
+ bindGlTexture(state, document.createElement('img'), makeSampler({ mipmaps: false }));
413
+ const g = gl as unknown as { TEXTURE_2D: number; TEXTURE_MIN_FILTER: number; LINEAR: number };
414
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, g.LINEAR);
415
+ expect(gl.generateMipmap).not.toHaveBeenCalled();
416
+ });
417
+
418
+ it('generates the mip chain only once for a texture bound repeatedly', () => {
419
+ const { state, gl } = createGlState();
420
+ const img = document.createElement('img');
421
+ bindGlTexture(state, img, makeSampler());
422
+ bindGlTexture(state, img, makeSampler());
423
+ expect((gl.generateMipmap as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
424
+ });
425
+
426
+ it('applies clamped anisotropy through EXT_texture_filter_anisotropic when supported', () => {
427
+ const { state, gl } = createGlState();
428
+ const ext = { TEXTURE_MAX_ANISOTROPY_EXT: 0x84fe, MAX_TEXTURE_MAX_ANISOTROPY_EXT: 0x84ff };
429
+ (gl.getExtension as ReturnType<typeof vi.fn>).mockImplementation((name: string) =>
430
+ name === 'EXT_texture_filter_anisotropic' ? ext : null,
431
+ );
432
+ (gl.getParameter as ReturnType<typeof vi.fn>).mockImplementation((p: number) =>
433
+ p === ext.MAX_TEXTURE_MAX_ANISOTROPY_EXT ? 8 : undefined,
434
+ );
435
+ bindGlTexture(state, document.createElement('img'), makeSampler({ anisotropy: 16 }));
436
+ const g = gl as unknown as { TEXTURE_2D: number };
437
+ // Requested 16 clamps to the hardware max of 8.
438
+ expect(gl.texParameterf).toHaveBeenCalledWith(g.TEXTURE_2D, ext.TEXTURE_MAX_ANISOTROPY_EXT, 8);
439
+ });
440
+
441
+ it('skips anisotropy setup when the extension is unavailable', () => {
442
+ const { state, gl } = createGlState();
443
+ (gl.getExtension as ReturnType<typeof vi.fn>).mockImplementation(() => null);
444
+ bindGlTexture(state, document.createElement('img'), makeSampler({ anisotropy: 16 }));
445
+ expect(gl.texParameterf).not.toHaveBeenCalled();
446
+ });
447
+ });
448
+
449
+ describe('bindGlVideoTexture', () => {
450
+ function videoTexture(frameId: number, readyState = 4, videoWidth = 320, videoHeight = 240): VideoTexture {
451
+ return {
452
+ frameId,
453
+ sampler: null,
454
+ source: { element: { readyState, videoWidth, videoHeight } as unknown as HTMLVideoElement },
455
+ } as unknown as VideoTexture;
456
+ }
457
+
458
+ it('creates a texture, uploads the current frame, and caches by VideoTexture identity', () => {
459
+ const { state, gl } = createGlState();
460
+ const vt = videoTexture(3);
461
+ const t1 = bindGlVideoTexture(state, vt);
462
+ expect(gl.createTexture).toHaveBeenCalled();
463
+ expect(gl.texImage2D).toHaveBeenCalledWith(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, vt.source.element);
464
+ const t2 = bindGlVideoTexture(state, vt);
465
+ expect(t2).toBe(t1);
466
+ });
467
+
468
+ it('re-uploads only when the frameId advances (the dirty-gate)', () => {
469
+ const { state, gl } = createGlState();
470
+ const vt = videoTexture(1);
471
+ bindGlVideoTexture(state, vt);
472
+ const uploads = (gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length;
473
+ bindGlVideoTexture(state, vt);
474
+ expect((gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length).toBe(uploads);
475
+ vt.frameId = 2;
476
+ bindGlVideoTexture(state, vt);
477
+ expect((gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length).toBe(uploads + 1);
478
+ });
479
+
480
+ it('does not upload when the element has no decoded frame yet', () => {
481
+ const { state, gl } = createGlState();
482
+ bindGlVideoTexture(state, videoTexture(1, 1, 0, 0));
483
+ expect(gl.texImage2D).not.toHaveBeenCalled();
484
+ });
485
+
486
+ it('applies the VideoTexture sampler when no explicit sampler is passed', () => {
487
+ const { state, gl } = createGlState();
488
+ const vt = videoTexture(1);
489
+ (vt as { sampler: SamplerLike }).sampler = makeSampler({ wrapU: 'repeat' });
490
+ bindGlVideoTexture(state, vt);
491
+ expect(gl.texParameteri).toHaveBeenCalledWith(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
492
+ });
209
493
  });
210
494
 
211
495
  describe('createGlTexture', () => {
@@ -346,10 +630,10 @@ describe('isBlendModeSupported', () => {
346
630
  expect(isBlendModeSupported(state, BlendMode.Screen)).toBe(true);
347
631
  });
348
632
 
349
- it('returns false for a built-in with no fixed-function realization', () => {
633
+ it('returns false for an advanced mode with no fixed-function realization', () => {
350
634
  const { state } = createGlState();
351
635
  registerDefaultGlBlendModes(state);
352
- expect(isBlendModeSupported(state, BlendMode.Overlay)).toBe(false);
636
+ expect(isBlendModeSupported(state, AdvancedBlendMode.Overlay)).toBe(false);
353
637
  });
354
638
 
355
639
  it('returns true for a custom registered mode', () => {
@@ -365,23 +649,20 @@ describe('registerDefaultGlBlendModes', () => {
365
649
  registerDefaultGlBlendModes(state);
366
650
  for (const mode of [
367
651
  BlendMode.Normal,
368
- BlendMode.Layer,
369
652
  BlendMode.Add,
370
653
  BlendMode.Multiply,
371
654
  BlendMode.Screen,
372
655
  BlendMode.Darken,
373
656
  BlendMode.Lighten,
374
- BlendMode.Subtract,
375
- BlendMode.Erase,
376
657
  ]) {
377
658
  expect(isBlendModeSupported(state, mode)).toBe(true);
378
659
  }
379
660
  });
380
661
 
381
- it('does not register the shader-only modes', () => {
662
+ it('does not register the shader-composited or unary modes', () => {
382
663
  const { state } = createGlState();
383
664
  registerDefaultGlBlendModes(state);
384
- for (const mode of [BlendMode.Overlay, BlendMode.HardLight, BlendMode.Difference, BlendMode.Invert]) {
665
+ for (const mode of [AdvancedBlendMode.Overlay, AdvancedBlendMode.HardLight, AdvancedBlendMode.Difference]) {
385
666
  expect(isBlendModeSupported(state, mode)).toBe(false);
386
667
  }
387
668
  });
@@ -449,12 +730,14 @@ describe('updateGlTexture', () => {
449
730
  expect(runtime.currentTexture).toBe(texture);
450
731
  });
451
732
 
452
- it('skips bindTexture when texture is already current', () => {
733
+ it('rebinds even when the texture is already current (currentTexture is unit-blind)', () => {
453
734
  const { state, gl } = createGlState();
454
735
  const texture = {} as WebGLTexture;
455
736
  getGlRenderStateRuntime(state).currentTexture = texture;
456
737
  updateGlTexture(state, texture, document.createElement('canvas'));
457
- expect(gl.bindTexture).not.toHaveBeenCalled();
738
+ // Never skip on currentTexture: the active unit may have changed since it was set, so uploading
739
+ // without rebinding could target the wrong texture.
740
+ expect(gl.bindTexture).toHaveBeenCalledWith((gl as unknown as { TEXTURE_2D: number }).TEXTURE_2D, texture);
458
741
  });
459
742
 
460
743
  it('always calls texImage2D to upload canvas data', () => {
@@ -19,6 +19,9 @@ function makeTarget(framebuffer: WebGLFramebuffer, width = 32, height = 16): GlR
19
19
  width,
20
20
  height,
21
21
  format: 'rgba8',
22
+ colorSpace: 'srgb',
23
+ clearColors: [],
24
+ clearDepth: 1,
22
25
  sampleCount: 1,
23
26
  framebuffer,
24
27
  resolveFramebuffer: null,
@@ -172,14 +175,32 @@ describe('drawGlFullscreenPass', () => {
172
175
  expect(drawSpy).toHaveBeenCalled();
173
176
  });
174
177
 
175
- it('sets premultiplied-alpha blending and invalidates the cached blend mode', () => {
178
+ it('sets premultiplied-alpha blending and caches the normal blend mode', () => {
176
179
  const { state, gl } = createGlState();
177
180
  const program = compileGlFullscreenProgram(gl, FRAG_SRC);
178
181
  const blendSpy = vi.spyOn(gl, 'blendFunc');
182
+ const blendEquationSpy = vi.spyOn(gl, 'blendEquation');
179
183
 
180
184
  drawGlFullscreenPass(state, program, [], null, () => {});
181
185
 
182
186
  expect(blendSpy).toHaveBeenCalledWith(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
187
+ expect(blendEquationSpy).toHaveBeenCalledWith(gl.FUNC_ADD);
188
+ expect(getGlRenderStateRuntime(state).currentBlendMode).toBeNull();
189
+ });
190
+
191
+ it('restores normal blending when setUniforms overrides the blend function', () => {
192
+ const { state, gl } = createGlState();
193
+ const program = compileGlFullscreenProgram(gl, FRAG_SRC);
194
+ const blendSpy = vi.spyOn(gl, 'blendFunc');
195
+ const blendEquationSpy = vi.spyOn(gl, 'blendEquation');
196
+
197
+ drawGlFullscreenPass(state, program, [], null, (gl) => {
198
+ gl.blendFunc(gl.ZERO, gl.ONE_MINUS_SRC_ALPHA);
199
+ });
200
+
201
+ expect(blendSpy).toHaveBeenCalledWith(gl.ZERO, gl.ONE_MINUS_SRC_ALPHA);
202
+ expect(blendSpy).toHaveBeenLastCalledWith(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
203
+ expect(blendEquationSpy).toHaveBeenLastCalledWith(gl.FUNC_ADD);
183
204
  expect(getGlRenderStateRuntime(state).currentBlendMode).toBeNull();
184
205
  });
185
206
  });
@@ -0,0 +1,75 @@
1
+ import type { GlRenderTarget } from '@flighthq/types';
2
+
3
+ import { drawGlLinearToSrgbPass, LINEAR_TO_SRGB_FRAGMENT_SRC } from './glLinearToSrgbPass';
4
+ import { createGlState } from './glTestHelper';
5
+
6
+ function makeTarget(framebuffer: WebGLFramebuffer, texture: WebGLTexture, width = 32, height = 16): GlRenderTarget {
7
+ return {
8
+ width,
9
+ height,
10
+ format: 'rgba16f',
11
+ colorSpace: 'linear',
12
+ clearColors: [],
13
+ clearDepth: 1,
14
+ sampleCount: 1,
15
+ framebuffer,
16
+ resolveFramebuffer: null,
17
+ textures: [],
18
+ texture,
19
+ depthTexture: null,
20
+ colorRenderbuffers: [],
21
+ depthStencilRenderbuffer: null,
22
+ };
23
+ }
24
+
25
+ describe('drawGlLinearToSrgbPass', () => {
26
+ it('binds the source texture and draws to the canvas when dest is null', () => {
27
+ const { state, gl, canvas } = createGlState();
28
+ const source = makeTarget({} as WebGLFramebuffer, { id: 'src' } as unknown as WebGLTexture);
29
+ const bindTexture = vi.spyOn(gl, 'bindTexture');
30
+ const viewport = vi.spyOn(gl, 'viewport');
31
+ const drawElements = vi.spyOn(gl, 'drawElements');
32
+
33
+ drawGlLinearToSrgbPass(state, source, null);
34
+
35
+ expect(bindTexture).toHaveBeenCalledWith(gl.TEXTURE_2D, source.texture);
36
+ // dest === null presents to the canvas, so the viewport covers the full canvas.
37
+ expect(viewport).toHaveBeenCalledWith(0, 0, canvas.width, canvas.height);
38
+ expect(drawElements).toHaveBeenCalled();
39
+ });
40
+
41
+ it('presents into dest when a target is given', () => {
42
+ const { state, gl } = createGlState();
43
+ const source = makeTarget({} as WebGLFramebuffer, { id: 'src' } as unknown as WebGLTexture);
44
+ const dest = makeTarget({ id: 'destFb' } as unknown as WebGLFramebuffer, { id: 'dst' } as unknown as WebGLTexture);
45
+ const bindFramebuffer = vi.spyOn(gl, 'bindFramebuffer');
46
+
47
+ drawGlLinearToSrgbPass(state, source, dest);
48
+
49
+ expect(bindFramebuffer).toHaveBeenCalledWith(gl.FRAMEBUFFER, dest.framebuffer);
50
+ });
51
+
52
+ it('compiles its program once per state and reuses it across frames', () => {
53
+ const { state, gl } = createGlState();
54
+ const source = makeTarget({} as WebGLFramebuffer, {} as WebGLTexture);
55
+ const createProgram = vi.spyOn(gl, 'createProgram');
56
+
57
+ drawGlLinearToSrgbPass(state, source, null);
58
+ drawGlLinearToSrgbPass(state, source, null);
59
+ drawGlLinearToSrgbPass(state, source, null);
60
+
61
+ expect(createProgram).toHaveBeenCalledTimes(1);
62
+ });
63
+
64
+ it('encodes the exact IEC 61966-2-1 sRGB OETF constants (parity with color.linearChannelToSrgb)', () => {
65
+ // Guards against the shader drifting from @flighthq/color's canonical transfer, which cannot be
66
+ // executed here (jsdom does not compile GLSL). The constants are the piecewise sRGB OETF.
67
+ expect(LINEAR_TO_SRGB_FRAGMENT_SRC).toContain('0.0031308');
68
+ expect(LINEAR_TO_SRGB_FRAGMENT_SRC).toContain('12.92');
69
+ expect(LINEAR_TO_SRGB_FRAGMENT_SRC).toContain('1.055');
70
+ expect(LINEAR_TO_SRGB_FRAGMENT_SRC).toContain('1.0 / 2.4');
71
+ expect(LINEAR_TO_SRGB_FRAGMENT_SRC).toContain('0.055');
72
+ // Alpha must pass through unencoded — it is linear coverage, not a color channel.
73
+ expect(LINEAR_TO_SRGB_FRAGMENT_SRC).toContain('linear.a');
74
+ });
75
+ });
@@ -0,0 +1,62 @@
1
+ import type { GlRenderTarget } from '@flighthq/types';
2
+
3
+ import { presentGlRenderTarget } from './glPresentRenderTarget';
4
+ import { createGlState } from './glTestHelper';
5
+
6
+ function makeTarget(colorSpace: 'linear' | 'srgb', texture: WebGLTexture): GlRenderTarget {
7
+ return {
8
+ width: 32,
9
+ height: 16,
10
+ format: colorSpace === 'linear' ? 'rgba16f' : 'rgba8',
11
+ colorSpace,
12
+ clearColors: [],
13
+ clearDepth: 1,
14
+ sampleCount: 1,
15
+ framebuffer: {} as WebGLFramebuffer,
16
+ resolveFramebuffer: null,
17
+ textures: [texture],
18
+ texture,
19
+ depthTexture: null,
20
+ colorRenderbuffers: [],
21
+ depthStencilRenderbuffer: null,
22
+ };
23
+ }
24
+
25
+ describe('presentGlRenderTarget', () => {
26
+ it('encodes a linear target to sRGB and draws to the canvas when dest is null', () => {
27
+ const { state, gl, canvas } = createGlState();
28
+ const source = makeTarget('linear', { id: 'lin' } as unknown as WebGLTexture);
29
+ const bindTexture = vi.spyOn(gl, 'bindTexture');
30
+ const viewport = vi.spyOn(gl, 'viewport');
31
+ const drawElements = vi.spyOn(gl, 'drawElements');
32
+
33
+ presentGlRenderTarget(state, source);
34
+
35
+ expect(bindTexture).toHaveBeenCalledWith(gl.TEXTURE_2D, source.texture);
36
+ expect(viewport).toHaveBeenCalledWith(0, 0, canvas.width, canvas.height);
37
+ expect(drawElements).toHaveBeenCalled();
38
+ });
39
+
40
+ it('copies an srgb target straight through (already encoded, no OETF pass)', () => {
41
+ const { state, gl } = createGlState();
42
+ const source = makeTarget('srgb', { id: 'srgb' } as unknown as WebGLTexture);
43
+ const bindTexture = vi.spyOn(gl, 'bindTexture');
44
+ const drawElements = vi.spyOn(gl, 'drawElements');
45
+
46
+ presentGlRenderTarget(state, source);
47
+
48
+ expect(bindTexture).toHaveBeenCalledWith(gl.TEXTURE_2D, source.texture);
49
+ expect(drawElements).toHaveBeenCalled();
50
+ });
51
+
52
+ it('presents into dest when a target is given', () => {
53
+ const { state, gl } = createGlState();
54
+ const source = makeTarget('srgb', { id: 'srgb' } as unknown as WebGLTexture);
55
+ const dest = makeTarget('srgb', { id: 'dst' } as unknown as WebGLTexture);
56
+ const bindFramebuffer = vi.spyOn(gl, 'bindFramebuffer');
57
+
58
+ presentGlRenderTarget(state, source, dest);
59
+
60
+ expect(bindFramebuffer).toHaveBeenCalledWith(gl.FRAMEBUFFER, dest.framebuffer);
61
+ });
62
+ });
@@ -8,6 +8,7 @@ function makeTarget(overrides?: Partial<GlRenderTarget>): GlRenderTarget {
8
8
  width: 4,
9
9
  height: 4,
10
10
  format: 'rgba8',
11
+ colorSpace: 'srgb',
11
12
  sampleCount: 1,
12
13
  framebuffer: {} as WebGLFramebuffer,
13
14
  resolveFramebuffer: null,