@flighthq/render-gl 0.1.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 (67) 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 +7 -2
  6. package/dist/glDraw.d.ts.map +1 -1
  7. package/dist/glDraw.js +260 -35
  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 +5 -2
  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 +11 -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 +10 -5
  55. package/src/glBackground.test.ts +3 -1
  56. package/src/glCompressedTexture.test.ts +276 -0
  57. package/src/glDraw.test.ts +449 -4
  58. package/src/glFullscreenPass.test.ts +22 -1
  59. package/src/glLinearToSrgbPass.test.ts +75 -0
  60. package/src/glPresentRenderTarget.test.ts +62 -0
  61. package/src/glReadback.test.ts +1 -0
  62. package/src/glRenderPass.test.ts +170 -0
  63. package/src/glRenderTarget.test.ts +63 -98
  64. package/src/glRenderTargetPool.test.ts +27 -0
  65. package/src/glSkinPaletteTexture.test.ts +129 -0
  66. package/src/glTextureUpload.test.ts +49 -0
  67. package/src/glTextureVideoUpload.test.ts +53 -0
@@ -1,11 +1,18 @@
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,
13
+ isBlendModeSupported,
14
+ registerDefaultGlBlendModes,
15
+ registerGlBlendMode,
9
16
  setGlQuadMatrixFromOffset,
10
17
  updateGlTexture,
11
18
  useGlProgram,
@@ -14,9 +21,51 @@ import { getGlRenderStateRuntime } from './glRenderState';
14
21
  import { registerGlBitmapShader } from './glShaderRegistry';
15
22
  import { createGlState } from './glTestHelper';
16
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
+
17
65
  describe('applyGlBlendMode', () => {
18
66
  it('does not call blendFunc when blend mode has not changed', () => {
19
67
  const { state, gl } = createGlState();
68
+ registerDefaultGlBlendModes(state);
20
69
  getGlRenderStateRuntime(state).currentBlendMode = BlendMode.Normal;
21
70
  applyGlBlendMode(state, BlendMode.Normal);
22
71
  expect(gl.blendFunc).not.toHaveBeenCalled();
@@ -24,6 +73,7 @@ describe('applyGlBlendMode', () => {
24
73
 
25
74
  it('sets normal blend (ONE, ONE_MINUS_SRC_ALPHA) for BlendMode.Normal', () => {
26
75
  const { state, gl } = createGlState();
76
+ registerDefaultGlBlendModes(state);
27
77
  applyGlBlendMode(state, BlendMode.Normal);
28
78
  const g = gl as unknown as { ONE: number; ONE_MINUS_SRC_ALPHA: number };
29
79
  expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE_MINUS_SRC_ALPHA);
@@ -31,12 +81,64 @@ describe('applyGlBlendMode', () => {
31
81
 
32
82
  it('sets additive blend (ONE, ONE) for BlendMode.Add', () => {
33
83
  const { state, gl } = createGlState();
84
+ registerDefaultGlBlendModes(state);
34
85
  applyGlBlendMode(state, BlendMode.Add);
35
86
  const g = gl as unknown as { ONE: number };
36
87
  expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE);
37
88
  });
38
89
 
39
- it('falls back to normal blend for a mode with no fixed-function equivalent', () => {
90
+ it('sets (DST_COLOR, ONE_MINUS_SRC_ALPHA) for BlendMode.Multiply', () => {
91
+ const { state, gl } = createGlState();
92
+ registerDefaultGlBlendModes(state);
93
+ applyGlBlendMode(state, BlendMode.Multiply);
94
+ const g = gl as unknown as { DST_COLOR: number; ONE_MINUS_SRC_ALPHA: number };
95
+ expect(gl.blendFunc).toHaveBeenCalledWith(g.DST_COLOR, g.ONE_MINUS_SRC_ALPHA);
96
+ });
97
+
98
+ it('sets (ONE, ONE_MINUS_SRC_COLOR) for BlendMode.Screen', () => {
99
+ const { state, gl } = createGlState();
100
+ registerDefaultGlBlendModes(state);
101
+ applyGlBlendMode(state, BlendMode.Screen);
102
+ const g = gl as unknown as { ONE: number; ONE_MINUS_SRC_COLOR: number };
103
+ expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE_MINUS_SRC_COLOR);
104
+ });
105
+
106
+ it('sets the MIN blend equation with (ONE, ONE) for BlendMode.Darken', () => {
107
+ const { state, gl } = createGlState();
108
+ registerDefaultGlBlendModes(state);
109
+ applyGlBlendMode(state, BlendMode.Darken);
110
+ const g = gl as unknown as { ONE: number; MIN: number };
111
+ expect(gl.blendEquation).toHaveBeenCalledWith(g.MIN);
112
+ expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE);
113
+ });
114
+
115
+ it('sets the MAX blend equation with (ONE, ONE) for BlendMode.Lighten', () => {
116
+ const { state, gl } = createGlState();
117
+ registerDefaultGlBlendModes(state);
118
+ applyGlBlendMode(state, BlendMode.Lighten);
119
+ const g = gl as unknown as { ONE: number; MAX: number };
120
+ expect(gl.blendEquation).toHaveBeenCalledWith(g.MAX);
121
+ expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE);
122
+ });
123
+
124
+ it('resets the blend equation to FUNC_ADD for a mode that does not carry one', () => {
125
+ const { state, gl } = createGlState();
126
+ registerDefaultGlBlendModes(state);
127
+ applyGlBlendMode(state, BlendMode.Darken);
128
+ applyGlBlendMode(state, BlendMode.Normal);
129
+ const g = gl as unknown as { FUNC_ADD: number };
130
+ expect(gl.blendEquation).toHaveBeenLastCalledWith(g.FUNC_ADD);
131
+ });
132
+
133
+ it('falls back to normal blend for a mode with no registered realization', () => {
134
+ const { state, gl } = createGlState();
135
+ registerDefaultGlBlendModes(state);
136
+ applyGlBlendMode(state, AdvancedBlendMode.Overlay);
137
+ const g = gl as unknown as { ONE: number; ONE_MINUS_SRC_ALPHA: number };
138
+ expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE_MINUS_SRC_ALPHA);
139
+ });
140
+
141
+ it('falls back to normal blend when no modes are registered at all', () => {
40
142
  const { state, gl } = createGlState();
41
143
  applyGlBlendMode(state, BlendMode.Multiply);
42
144
  const g = gl as unknown as { ONE: number; ONE_MINUS_SRC_ALPHA: number };
@@ -45,18 +147,92 @@ describe('applyGlBlendMode', () => {
45
147
 
46
148
  it('updates currentBlendMode after the change', () => {
47
149
  const { state } = createGlState();
150
+ registerDefaultGlBlendModes(state);
48
151
  applyGlBlendMode(state, BlendMode.Add);
49
152
  expect(getGlRenderStateRuntime(state).currentBlendMode).toBe(BlendMode.Add);
50
153
  });
51
154
 
52
155
  it('calls blendFunc again when mode switches', () => {
53
156
  const { state, gl } = createGlState();
157
+ registerDefaultGlBlendModes(state);
54
158
  applyGlBlendMode(state, BlendMode.Normal);
55
159
  applyGlBlendMode(state, BlendMode.Add);
56
160
  expect(gl.blendFunc).toHaveBeenCalledTimes(2);
57
161
  });
58
162
  });
59
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
+
60
236
  describe('bindGlTexture', () => {
61
237
  it('creates a new texture for an uncached image source', () => {
62
238
  const { state, gl } = createGlState();
@@ -122,6 +298,198 @@ describe('bindGlTexture', () => {
122
298
  expect(gl.bindTexture).toHaveBeenCalledWith((gl as unknown as { TEXTURE_2D: number }).TEXTURE_2D, texture);
123
299
  expect(runtime.currentTexture).toBe(texture);
124
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
+ });
125
493
  });
126
494
 
127
495
  describe('createGlTexture', () => {
@@ -242,6 +610,81 @@ describe('enableGlBlendModeSupport', () => {
242
610
  state.applyBlendMode!(state, BlendMode.Add);
243
611
  expect(gl.blendFunc).toHaveBeenCalled();
244
612
  });
613
+
614
+ it('registers the default blend modes', () => {
615
+ const { state } = createGlState();
616
+ enableGlBlendModeSupport(state);
617
+ expect(isBlendModeSupported(state, BlendMode.Multiply)).toBe(true);
618
+ });
619
+ });
620
+
621
+ describe('isBlendModeSupported', () => {
622
+ it('returns false when no modes are registered', () => {
623
+ const { state } = createGlState();
624
+ expect(isBlendModeSupported(state, BlendMode.Normal)).toBe(false);
625
+ });
626
+
627
+ it('returns true for a registered built-in mode', () => {
628
+ const { state } = createGlState();
629
+ registerDefaultGlBlendModes(state);
630
+ expect(isBlendModeSupported(state, BlendMode.Screen)).toBe(true);
631
+ });
632
+
633
+ it('returns false for an advanced mode with no fixed-function realization', () => {
634
+ const { state } = createGlState();
635
+ registerDefaultGlBlendModes(state);
636
+ expect(isBlendModeSupported(state, AdvancedBlendMode.Overlay)).toBe(false);
637
+ });
638
+
639
+ it('returns true for a custom registered mode', () => {
640
+ const { state } = createGlState();
641
+ registerGlBlendMode(state, 'acme.Foo', { src: 'ONE', dst: 'ZERO' });
642
+ expect(isBlendModeSupported(state, 'acme.Foo')).toBe(true);
643
+ });
644
+ });
645
+
646
+ describe('registerDefaultGlBlendModes', () => {
647
+ it('registers the tier-1 fixed-function modes', () => {
648
+ const { state } = createGlState();
649
+ registerDefaultGlBlendModes(state);
650
+ for (const mode of [
651
+ BlendMode.Normal,
652
+ BlendMode.Add,
653
+ BlendMode.Multiply,
654
+ BlendMode.Screen,
655
+ BlendMode.Darken,
656
+ BlendMode.Lighten,
657
+ ]) {
658
+ expect(isBlendModeSupported(state, mode)).toBe(true);
659
+ }
660
+ });
661
+
662
+ it('does not register the shader-composited or unary modes', () => {
663
+ const { state } = createGlState();
664
+ registerDefaultGlBlendModes(state);
665
+ for (const mode of [AdvancedBlendMode.Overlay, AdvancedBlendMode.HardLight, AdvancedBlendMode.Difference]) {
666
+ expect(isBlendModeSupported(state, mode)).toBe(false);
667
+ }
668
+ });
669
+ });
670
+
671
+ describe('registerGlBlendMode', () => {
672
+ it('makes a custom mode applied through gl.blendFunc', () => {
673
+ const { state, gl } = createGlState();
674
+ registerGlBlendMode(state, 'acme.Foo', { src: 'DST_COLOR', dst: 'ZERO' });
675
+ applyGlBlendMode(state, 'acme.Foo');
676
+ const g = gl as unknown as { DST_COLOR: number; ZERO: number };
677
+ expect(gl.blendFunc).toHaveBeenCalledWith(g.DST_COLOR, g.ZERO);
678
+ });
679
+
680
+ it('overrides a built-in mode last-write-wins', () => {
681
+ const { state, gl } = createGlState();
682
+ registerDefaultGlBlendModes(state);
683
+ registerGlBlendMode(state, BlendMode.Add, { src: 'ZERO', dst: 'ONE' });
684
+ applyGlBlendMode(state, BlendMode.Add);
685
+ const g = gl as unknown as { ONE: number; ZERO: number };
686
+ expect(gl.blendFunc).toHaveBeenCalledWith(g.ZERO, g.ONE);
687
+ });
245
688
  });
246
689
 
247
690
  describe('setGlQuadMatrixFromOffset', () => {
@@ -287,12 +730,14 @@ describe('updateGlTexture', () => {
287
730
  expect(runtime.currentTexture).toBe(texture);
288
731
  });
289
732
 
290
- it('skips bindTexture when texture is already current', () => {
733
+ it('rebinds even when the texture is already current (currentTexture is unit-blind)', () => {
291
734
  const { state, gl } = createGlState();
292
735
  const texture = {} as WebGLTexture;
293
736
  getGlRenderStateRuntime(state).currentTexture = texture;
294
737
  updateGlTexture(state, texture, document.createElement('canvas'));
295
- 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);
296
741
  });
297
742
 
298
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
+ });