@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
@@ -0,0 +1,170 @@
1
+ import { createMatrix } from '@flighthq/geometry';
2
+ import type { GlRenderTarget } from '@flighthq/types';
3
+
4
+ import { beginGlRenderPass, endGlRenderPass, setGlRenderTransform2D } from './glRenderPass';
5
+ import { getGlRenderStateRuntime } from './glRenderState';
6
+ import { createGlState } from './glTestHelper';
7
+
8
+ function makeTarget(overrides?: Partial<GlRenderTarget>): GlRenderTarget {
9
+ const texture = { id: 'c0' } as unknown as WebGLTexture;
10
+ return {
11
+ width: 32,
12
+ height: 16,
13
+ format: 'rgba8',
14
+ colorSpace: 'srgb',
15
+ clearColors: [],
16
+ clearDepth: 1,
17
+ sampleCount: 1,
18
+ framebuffer: {} as WebGLFramebuffer,
19
+ resolveFramebuffer: null,
20
+ textures: [texture],
21
+ texture,
22
+ depthTexture: null,
23
+ colorRenderbuffers: [],
24
+ depthStencilRenderbuffer: { id: 'depth' } as unknown as WebGLRenderbuffer,
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ describe('beginGlRenderPass', () => {
30
+ it('clears every color attachment and depth by default', () => {
31
+ const { state, gl } = createGlState();
32
+ const clearColor = vi.spyOn(gl, 'clearBufferfv');
33
+ const clearDepth = vi.spyOn(gl, 'clearBufferfi');
34
+
35
+ beginGlRenderPass(state, makeTarget());
36
+
37
+ expect(clearColor).toHaveBeenCalledWith(gl.COLOR, 0, expect.anything());
38
+ expect(clearDepth).toHaveBeenCalledWith(gl.DEPTH_STENCIL, 0, 1, 0);
39
+ });
40
+
41
+ it('spares color when preserveColor is true, still clears depth', () => {
42
+ const { state, gl } = createGlState();
43
+ const clearColor = vi.spyOn(gl, 'clearBufferfv');
44
+ const clearDepth = vi.spyOn(gl, 'clearBufferfi');
45
+
46
+ beginGlRenderPass(state, makeTarget(), { preserveColor: true });
47
+
48
+ expect(clearColor).not.toHaveBeenCalled();
49
+ expect(clearDepth).toHaveBeenCalled();
50
+ });
51
+
52
+ it('spares depth when preserveDepth is true, still clears color', () => {
53
+ const { state, gl } = createGlState();
54
+ const clearColor = vi.spyOn(gl, 'clearBufferfv');
55
+ const clearDepth = vi.spyOn(gl, 'clearBufferfi');
56
+
57
+ beginGlRenderPass(state, makeTarget(), { preserveDepth: true });
58
+
59
+ expect(clearColor).toHaveBeenCalled();
60
+ expect(clearDepth).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('preserves per attachment location when preserveColor is an array', () => {
64
+ const { state, gl } = createGlState();
65
+ const c0 = { id: 'c0' } as unknown as WebGLTexture;
66
+ const c1 = { id: 'c1' } as unknown as WebGLTexture;
67
+ const clearColor = vi.spyOn(gl, 'clearBufferfv');
68
+
69
+ // Keep location 0, clear location 1 — the MRT / G-buffer path.
70
+ beginGlRenderPass(state, makeTarget({ textures: [c0, c1], texture: c0 }), { preserveColor: [true, false] });
71
+
72
+ expect(clearColor.mock.calls.map((c) => c[1])).toEqual([1]);
73
+ });
74
+
75
+ it("uses the target's packed clearColor over the background color when present", () => {
76
+ const { state, gl } = createGlState({ backgroundColorRgba: [0, 0, 0, 1] });
77
+ const clearColor = vi.spyOn(gl, 'clearBufferfv');
78
+
79
+ beginGlRenderPass(state, makeTarget({ clearColors: [0xff0000ff] })); // opaque red
80
+
81
+ const rgba = clearColor.mock.calls[0][2] as Float32Array;
82
+ expect(rgba[0]).toBeCloseTo(1);
83
+ expect(rgba[1]).toBeCloseTo(0);
84
+ expect(rgba[3]).toBeCloseTo(1);
85
+ });
86
+
87
+ it('binds the target and sets the viewport to its dimensions', () => {
88
+ const { state } = createGlState();
89
+ const target = makeTarget({ width: 64, height: 48 });
90
+
91
+ beginGlRenderPass(state, target);
92
+
93
+ const runtime = getGlRenderStateRuntime(state);
94
+ expect(runtime.currentRenderTarget).toBe(target);
95
+ expect(runtime.renderTargetViewport).toEqual({ width: 64, height: 48 });
96
+ });
97
+
98
+ it('nests: the inner pass is current until it ends, then the outer is restored', () => {
99
+ const { state } = createGlState();
100
+ const outer = makeTarget({ width: 64, height: 48 });
101
+ const inner = makeTarget({ width: 32, height: 32 });
102
+ const runtime = getGlRenderStateRuntime(state);
103
+
104
+ beginGlRenderPass(state, outer);
105
+ beginGlRenderPass(state, inner);
106
+ expect(runtime.currentRenderTarget).toBe(inner);
107
+
108
+ endGlRenderPass(state);
109
+ expect(runtime.currentRenderTarget).toBe(outer);
110
+ expect(runtime.renderTargetViewport).toEqual({ width: 64, height: 48 });
111
+ });
112
+ });
113
+
114
+ describe('endGlRenderPass', () => {
115
+ it('restores the framebuffer binding that preceded the pass', () => {
116
+ const { state, gl } = createGlState();
117
+ const target = makeTarget();
118
+ const bindFramebuffer = vi.spyOn(gl, 'bindFramebuffer');
119
+
120
+ beginGlRenderPass(state, target);
121
+ endGlRenderPass(state);
122
+
123
+ // The pass began with the canvas default bound (null); ending restores it.
124
+ expect(bindFramebuffer.mock.calls.at(-1)?.[1]).toBe(null);
125
+ });
126
+
127
+ it('restores the render-target viewport to its pre-pass value (null = canvas)', () => {
128
+ const { state } = createGlState();
129
+ const target = makeTarget();
130
+
131
+ beginGlRenderPass(state, target);
132
+ endGlRenderPass(state);
133
+
134
+ expect(getGlRenderStateRuntime(state).renderTargetViewport).toBeNull();
135
+ });
136
+
137
+ it('is a no-op when there is no matching begin', () => {
138
+ const { state } = createGlState();
139
+ expect(() => endGlRenderPass(state)).not.toThrow();
140
+ });
141
+ });
142
+
143
+ describe('setGlRenderTransform2D', () => {
144
+ it('installs a copy of the transform as the 2D root device transform', () => {
145
+ const { state } = createGlState();
146
+ const transform = createMatrix();
147
+ transform.tx = 7;
148
+
149
+ setGlRenderTransform2D(state, transform);
150
+
151
+ expect(state.renderTransform2D?.tx).toBe(7);
152
+ // A copy, not the caller's object — so later caller mutations don't leak into render state.
153
+ expect(state.renderTransform2D).not.toBe(transform);
154
+ });
155
+
156
+ it('is undone by the enclosing pass: endGlRenderPass restores the pre-pass transform', () => {
157
+ const { state } = createGlState();
158
+ const original = state.renderTransform2D;
159
+ const target = makeTarget();
160
+ const cacheTransform = createMatrix();
161
+ cacheTransform.tx = 99;
162
+
163
+ beginGlRenderPass(state, target, { preserveColor: true });
164
+ setGlRenderTransform2D(state, cacheTransform);
165
+ expect(state.renderTransform2D?.tx).toBe(99);
166
+ endGlRenderPass(state);
167
+
168
+ expect(state.renderTransform2D).toBe(original);
169
+ });
170
+ });
@@ -2,13 +2,13 @@ import { createDisplayObject } from '@flighthq/displayobject';
2
2
  import { createMatrix } from '@flighthq/geometry';
3
3
  import { getOrCreateRenderProxy2D } from '@flighthq/render';
4
4
 
5
+ import { beginGlRenderPass, endGlRenderPass } from './glRenderPass';
5
6
  import { getGlRenderStateRuntime } from './glRenderState';
6
7
  import {
7
- beginGlRenderTarget,
8
8
  createGlRenderTarget,
9
+ declareGlRenderTargetColorSpace,
9
10
  destroyGlRenderTarget,
10
11
  drawGlRenderTargetResult,
11
- endGlRenderTarget,
12
12
  resizeGlRenderTarget,
13
13
  resolveGlRenderTarget,
14
14
  } from './glRenderTarget';
@@ -39,57 +39,6 @@ function makeState() {
39
39
  return { state, gl };
40
40
  }
41
41
 
42
- describe('beginGlRenderTarget', () => {
43
- it('binds the target framebuffer', () => {
44
- const { state, gl } = makeState();
45
- const target = createGlRenderTarget(state, { width: 64, height: 48 });
46
- vi.clearAllMocks();
47
-
48
- beginGlRenderTarget(state, target, createMatrix());
49
-
50
- expect(vi.mocked(gl.bindFramebuffer)).toHaveBeenCalledWith(
51
- (gl as unknown as { FRAMEBUFFER: number }).FRAMEBUFFER,
52
- target.framebuffer,
53
- );
54
- });
55
-
56
- it('sets renderTargetViewport to the target dimensions', () => {
57
- const { state } = makeState();
58
- const target = createGlRenderTarget(state, { width: 64, height: 48 });
59
-
60
- beginGlRenderTarget(state, target, createMatrix());
61
-
62
- expect(getGlRenderStateRuntime(state).renderTargetViewport).toEqual({ width: 64, height: 48 });
63
- });
64
-
65
- it('sets renderTransform2D to the provided transform', () => {
66
- const { state } = makeState();
67
- const target = createGlRenderTarget(state, { width: 64, height: 48 });
68
- const renderTransform = createMatrix();
69
- renderTransform.tx = 10;
70
- renderTransform.ty = 20;
71
-
72
- beginGlRenderTarget(state, target, renderTransform);
73
-
74
- expect(state.renderTransform2D!.tx).toBe(10);
75
- expect(state.renderTransform2D!.ty).toBe(20);
76
- });
77
-
78
- it('supports nested begin calls', () => {
79
- const { state } = makeState();
80
- const targetA = createGlRenderTarget(state, { width: 64, height: 48 });
81
- const targetB = createGlRenderTarget(state, { width: 32, height: 32 });
82
-
83
- beginGlRenderTarget(state, targetA, createMatrix());
84
- beginGlRenderTarget(state, targetB, createMatrix());
85
-
86
- expect(getGlRenderStateRuntime(state).renderTargetViewport).toEqual({ width: 32, height: 32 });
87
-
88
- endGlRenderTarget(state);
89
- expect(getGlRenderStateRuntime(state).renderTargetViewport).toEqual({ width: 64, height: 48 });
90
- });
91
- });
92
-
93
42
  describe('createGlRenderTarget', () => {
94
43
  it('returns a render target with the requested dimensions', () => {
95
44
  const { state } = makeState();
@@ -126,6 +75,67 @@ describe('createGlRenderTarget', () => {
126
75
  createGlRenderTarget(state, { width: 32, height: 32 });
127
76
  expect(runtime.currentTexture).toBeNull();
128
77
  });
78
+
79
+ it("defaults colorSpace to 'srgb' when the descriptor omits it", () => {
80
+ const { state } = makeState();
81
+ const target = createGlRenderTarget(state, { width: 32, height: 32 });
82
+ expect(target.colorSpace).toBe('srgb');
83
+ });
84
+
85
+ it('honors an explicit colorSpace', () => {
86
+ const { state } = makeState();
87
+ const target = createGlRenderTarget(state, { width: 32, height: 32, colorSpace: 'linear' });
88
+ expect(target.colorSpace).toBe('linear');
89
+ });
90
+
91
+ it('falls back a float format to rgba8 when EXT_color_buffer_float is unavailable', () => {
92
+ const { state, gl } = makeState();
93
+ // Simulate GL without float-render support (e.g. headless SwiftShader): a float target would be
94
+ // framebuffer-incomplete and render black, so the effective format degrades to the renderable rgba8.
95
+ (gl as unknown as { getExtension: (n: string) => unknown }).getExtension = (name: string) =>
96
+ name === 'EXT_color_buffer_float' ? null : {};
97
+ const target = createGlRenderTarget(state, { width: 32, height: 32, format: 'rgba16f' });
98
+ expect(target.format).toBe('rgba8');
99
+ });
100
+
101
+ it('keeps a float format when EXT_color_buffer_float is available', () => {
102
+ const { state, gl } = makeState();
103
+ (gl as unknown as { getExtension: (n: string) => unknown }).getExtension = () => ({});
104
+ const target = createGlRenderTarget(state, { width: 32, height: 32, format: 'rgba16f' });
105
+ expect(target.format).toBe('rgba16f');
106
+ });
107
+ });
108
+
109
+ describe('declareGlRenderTargetColorSpace', () => {
110
+ it('stamps the currently bound target and returns true', () => {
111
+ const { state } = makeState();
112
+ const target = createGlRenderTarget(state, { width: 64, height: 48 });
113
+ expect(target.colorSpace).toBe('srgb');
114
+ beginGlRenderPass(state, target);
115
+ expect(declareGlRenderTargetColorSpace(state, 'linear')).toBe(true);
116
+ expect(target.colorSpace).toBe('linear');
117
+ endGlRenderPass(state);
118
+ });
119
+
120
+ it('returns false when no target is bound (rendering to the canvas)', () => {
121
+ const { state } = makeState();
122
+ expect(declareGlRenderTargetColorSpace(state, 'linear')).toBe(false);
123
+ });
124
+
125
+ it('is restored to the outer target when a nested target ends', () => {
126
+ const { state } = makeState();
127
+ const outer = createGlRenderTarget(state, { width: 64, height: 48 });
128
+ const inner = createGlRenderTarget(state, { width: 32, height: 32 });
129
+ beginGlRenderPass(state, outer);
130
+ beginGlRenderPass(state, inner);
131
+ declareGlRenderTargetColorSpace(state, 'linear');
132
+ expect(inner.colorSpace).toBe('linear');
133
+ endGlRenderPass(state);
134
+ // Back on `outer`: a declare now stamps it, not the popped inner target.
135
+ declareGlRenderTargetColorSpace(state, 'linear');
136
+ expect(outer.colorSpace).toBe('linear');
137
+ endGlRenderPass(state);
138
+ });
129
139
  });
130
140
 
131
141
  describe('destroyGlRenderTarget', () => {
@@ -178,51 +188,6 @@ describe('drawGlRenderTargetResult', () => {
178
188
  });
179
189
  });
180
190
 
181
- describe('endGlRenderTarget', () => {
182
- it('restores the previous framebuffer', () => {
183
- const { state, gl } = makeState();
184
- const target = createGlRenderTarget(state, { width: 64, height: 48 });
185
-
186
- beginGlRenderTarget(state, target, createMatrix());
187
- vi.clearAllMocks();
188
- endGlRenderTarget(state);
189
-
190
- expect(vi.mocked(gl.bindFramebuffer)).toHaveBeenCalledWith(
191
- (gl as unknown as { FRAMEBUFFER: number }).FRAMEBUFFER,
192
- null,
193
- );
194
- });
195
-
196
- it('restores renderTargetViewport to null', () => {
197
- const { state } = makeState();
198
- const target = createGlRenderTarget(state, { width: 64, height: 48 });
199
-
200
- beginGlRenderTarget(state, target, createMatrix());
201
- endGlRenderTarget(state);
202
-
203
- expect(getGlRenderStateRuntime(state).renderTargetViewport).toBeNull();
204
- });
205
-
206
- it('restores the original renderTransform2D', () => {
207
- const { state } = makeState();
208
- const originalTransform = state.renderTransform2D;
209
- const target = createGlRenderTarget(state, { width: 64, height: 48 });
210
- const renderTransform = createMatrix();
211
- renderTransform.tx = 99;
212
-
213
- beginGlRenderTarget(state, target, renderTransform);
214
- endGlRenderTarget(state);
215
-
216
- expect(state.renderTransform2D).toBe(originalTransform);
217
- });
218
-
219
- it('without a matching begin is a no-op', () => {
220
- const { state, gl } = makeState();
221
- expect(() => endGlRenderTarget(state)).not.toThrow();
222
- expect(vi.mocked(gl.bindFramebuffer)).not.toHaveBeenCalled();
223
- });
224
- });
225
-
226
191
  describe('resizeGlRenderTarget', () => {
227
192
  it('updates the target dimensions', () => {
228
193
  const { state } = makeState();
@@ -27,6 +27,20 @@ describe('acquireGlRenderTarget', () => {
27
27
  expect(pool.free.length).toBe(0);
28
28
  });
29
29
 
30
+ it('re-stamps colorSpace on a reused target (not part of the physical reuse match)', () => {
31
+ const { state } = createGlState();
32
+ const pool = createGlRenderTargetPool();
33
+ const first = acquireGlRenderTarget(state, pool, { width: 64, height: 48, colorSpace: 'linear' });
34
+ expect(first.colorSpace).toBe('linear');
35
+ releaseGlRenderTarget(pool, first);
36
+
37
+ // Same dimensions/format/samples but a different color space: the pooled target is reused, so its
38
+ // stale 'linear' must be overwritten to avoid double-encoding sRGB content at present.
39
+ const reused = acquireGlRenderTarget(state, pool, { width: 64, height: 48, colorSpace: 'srgb' });
40
+ expect(reused).toBe(first);
41
+ expect(reused.colorSpace).toBe('srgb');
42
+ });
43
+
30
44
  it('allocates a new target when the free target has different dimensions', () => {
31
45
  const { state } = createGlState();
32
46
  const pool = createGlRenderTargetPool();
@@ -0,0 +1,129 @@
1
+ import type { GlSkinPaletteTexture } from '@flighthq/types';
2
+
3
+ import {
4
+ createGlSkinPaletteTexture,
5
+ destroyGlSkinPaletteTexture,
6
+ uploadGlSkinPaletteTexture,
7
+ } from './glSkinPaletteTexture';
8
+
9
+ interface Call {
10
+ name: string;
11
+ args: unknown[];
12
+ }
13
+
14
+ function makeGl(): { gl: WebGL2RenderingContext; calls: Call[] } {
15
+ const calls: Call[] = [];
16
+ const record =
17
+ (name: string, result?: unknown) =>
18
+ (...args: unknown[]): unknown => {
19
+ calls.push({ name, args });
20
+ return result;
21
+ };
22
+ const gl = {
23
+ TEXTURE_2D: 0x0de1,
24
+ RGBA: 0x1908,
25
+ RGBA32F: 0x8814,
26
+ FLOAT: 0x1406,
27
+ NEAREST: 0x2600,
28
+ CLAMP_TO_EDGE: 0x812f,
29
+ TEXTURE_MIN_FILTER: 0x2801,
30
+ TEXTURE_MAG_FILTER: 0x2800,
31
+ TEXTURE_WRAP_S: 0x2802,
32
+ TEXTURE_WRAP_T: 0x2803,
33
+ createTexture: record('createTexture', { id: 'tex' }),
34
+ deleteTexture: record('deleteTexture'),
35
+ bindTexture: record('bindTexture'),
36
+ texImage2D: record('texImage2D'),
37
+ texSubImage2D: record('texSubImage2D'),
38
+ texParameteri: record('texParameteri'),
39
+ } as unknown as WebGL2RenderingContext;
40
+ return { gl, calls };
41
+ }
42
+
43
+ // Two joints' worth of identity-ish palette (32 floats). Values are irrelevant to the upload path.
44
+ function makePalette(jointCount: number): Float32Array {
45
+ return new Float32Array(jointCount * 16);
46
+ }
47
+
48
+ describe('createGlSkinPaletteTexture', () => {
49
+ it('allocates a texture with zero joint capacity', () => {
50
+ const { gl, calls } = makeGl();
51
+ const palette = createGlSkinPaletteTexture(gl);
52
+ expect(palette.jointCapacity).toBe(0);
53
+ expect(calls.some((c) => c.name === 'createTexture')).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe('destroyGlSkinPaletteTexture', () => {
58
+ it('deletes the palette texture', () => {
59
+ const { gl, calls } = makeGl();
60
+ const palette = createGlSkinPaletteTexture(gl);
61
+ destroyGlSkinPaletteTexture(gl, palette);
62
+ const del = calls.find((c) => c.name === 'deleteTexture');
63
+ expect(del?.args[0]).toBe(palette.texture);
64
+ });
65
+ });
66
+
67
+ describe('uploadGlSkinPaletteTexture', () => {
68
+ it('allocates RGBA32F storage width = jointCount*4 and sets NEAREST/CLAMP on first upload', () => {
69
+ const { gl, calls } = makeGl();
70
+ const palette = createGlSkinPaletteTexture(gl);
71
+ uploadGlSkinPaletteTexture(gl, palette, makePalette(3), 3);
72
+
73
+ const alloc = calls.find((c) => c.name === 'texImage2D');
74
+ expect(alloc).toBeDefined();
75
+ expect(alloc?.args[3]).toBe(12); // width = 3 joints * 4 texels
76
+ expect(alloc?.args[4]).toBe(1); // height
77
+ expect(alloc?.args[2]).toBe(gl.RGBA32F);
78
+ expect(palette.jointCapacity).toBe(3);
79
+ // NEAREST min+mag, CLAMP_TO_EDGE s+t — four texParameteri calls.
80
+ expect(calls.filter((c) => c.name === 'texParameteri').length).toBe(4);
81
+ expect(calls.some((c) => c.name === 'texParameteri' && c.args[2] === gl.NEAREST)).toBe(true);
82
+ expect(calls.some((c) => c.name === 'texParameteri' && c.args[2] === gl.CLAMP_TO_EDGE)).toBe(true);
83
+ });
84
+
85
+ it('binds the palette texture so the caller can set the sampler unit', () => {
86
+ const { gl, calls } = makeGl();
87
+ const palette = createGlSkinPaletteTexture(gl);
88
+ uploadGlSkinPaletteTexture(gl, palette, makePalette(1), 1);
89
+ const bind = calls.find((c) => c.name === 'bindTexture');
90
+ expect(bind?.args[0]).toBe(gl.TEXTURE_2D);
91
+ expect(bind?.args[1]).toBe(palette.texture);
92
+ });
93
+
94
+ it('writes in place with texSubImage2D when the palette fits the current capacity', () => {
95
+ const { gl, calls } = makeGl();
96
+ const palette = createGlSkinPaletteTexture(gl);
97
+ uploadGlSkinPaletteTexture(gl, palette, makePalette(4), 4);
98
+ calls.length = 0;
99
+
100
+ uploadGlSkinPaletteTexture(gl, palette, makePalette(2), 2);
101
+
102
+ expect(calls.some((c) => c.name === 'texImage2D')).toBe(false);
103
+ const sub = calls.find((c) => c.name === 'texSubImage2D');
104
+ expect(sub).toBeDefined();
105
+ expect(sub?.args[4]).toBe(8); // width = 2 joints * 4 (texSubImage2D arg order: target,level,x,y,w,h,…)
106
+ expect(palette.jointCapacity).toBe(4); // capacity unchanged; no reallocation
107
+ expect(calls.some((c) => c.name === 'texParameteri')).toBe(false);
108
+ });
109
+
110
+ it('grows storage (reallocates) when the palette exceeds the current capacity', () => {
111
+ const { gl, calls } = makeGl();
112
+ const palette = createGlSkinPaletteTexture(gl);
113
+ uploadGlSkinPaletteTexture(gl, palette, makePalette(2), 2);
114
+ calls.length = 0;
115
+
116
+ uploadGlSkinPaletteTexture(gl, palette, makePalette(5), 5);
117
+
118
+ const alloc = calls.find((c) => c.name === 'texImage2D');
119
+ expect(alloc?.args[3]).toBe(20); // width = 5 joints * 4
120
+ expect(palette.jointCapacity).toBe(5);
121
+ });
122
+
123
+ it('is alias-safe: reads jointCount before growing capacity', () => {
124
+ const { gl } = makeGl();
125
+ const palette: GlSkinPaletteTexture = createGlSkinPaletteTexture(gl);
126
+ uploadGlSkinPaletteTexture(gl, palette, makePalette(6), 6);
127
+ expect(palette.jointCapacity).toBe(6);
128
+ });
129
+ });
@@ -0,0 +1,49 @@
1
+ import type { ImageResource } from '@flighthq/types';
2
+
3
+ import { uploadGlTextureData, uploadGlTextureElement, uploadGlTextureImageResource } from './glTextureUpload';
4
+
5
+ // The GL enums the primitives read; texImage2D is a spy so a test can assert which overload was driven.
6
+ function makeGl(): WebGL2RenderingContext {
7
+ return {
8
+ RGBA: 0x1908,
9
+ UNSIGNED_BYTE: 0x1401,
10
+ TEXTURE_2D: 0x0de1,
11
+ texImage2D: vi.fn(),
12
+ } as unknown as WebGL2RenderingContext;
13
+ }
14
+
15
+ describe('uploadGlTextureData', () => {
16
+ it('drives the raw-pixel overload with width/height/border and the data buffer', () => {
17
+ const gl = makeGl();
18
+ const data = new Uint8ClampedArray(2 * 2 * 4);
19
+ uploadGlTextureData(gl, gl.TEXTURE_2D, 2, 2, data);
20
+ expect(gl.texImage2D).toHaveBeenCalledWith(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
21
+ });
22
+ });
23
+
24
+ describe('uploadGlTextureElement', () => {
25
+ it('drives the element overload with the TexImageSource', () => {
26
+ const gl = makeGl();
27
+ const source = {} as TexImageSource;
28
+ uploadGlTextureElement(gl, gl.TEXTURE_2D, source);
29
+ expect(gl.texImage2D).toHaveBeenCalledWith(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
30
+ });
31
+ });
32
+
33
+ describe('uploadGlTextureImageResource', () => {
34
+ it('takes the element path when the resource carries a source', () => {
35
+ const gl = makeGl();
36
+ const source = {} as TexImageSource;
37
+ const image = { source, data: null, width: 4, height: 4 } as ImageResource;
38
+ uploadGlTextureImageResource(gl, gl.TEXTURE_2D, image);
39
+ expect(gl.texImage2D).toHaveBeenCalledWith(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
40
+ });
41
+
42
+ it('takes the raw-pixel path when the resource is data-only', () => {
43
+ const gl = makeGl();
44
+ const data = new Uint8ClampedArray(4 * 4 * 4);
45
+ const image = { source: null, data, width: 4, height: 4 } as ImageResource;
46
+ uploadGlTextureImageResource(gl, gl.TEXTURE_2D, image);
47
+ expect(gl.texImage2D).toHaveBeenCalledWith(gl.TEXTURE_2D, 0, gl.RGBA, 4, 4, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
48
+ });
49
+ });
@@ -0,0 +1,53 @@
1
+ import type { VideoTexture } from '@flighthq/types';
2
+
3
+ import { uploadGlTextureVideoFrame } from './glTextureVideoUpload';
4
+
5
+ function makeGl(): WebGL2RenderingContext {
6
+ return {
7
+ RGBA: 0x1908,
8
+ UNSIGNED_BYTE: 0x1401,
9
+ TEXTURE_2D: 0x0de1,
10
+ texImage2D: vi.fn(),
11
+ } as unknown as WebGL2RenderingContext;
12
+ }
13
+
14
+ // A VideoTexture whose only relevant fields are frameId and the element carrier.
15
+ function makeVideoTexture(frameId: number, readyState = 4, videoWidth = 320, videoHeight = 240): VideoTexture {
16
+ return {
17
+ frameId,
18
+ source: { element: { readyState, videoWidth, videoHeight } as unknown as HTMLVideoElement },
19
+ } as unknown as VideoTexture;
20
+ }
21
+
22
+ describe('uploadGlTextureVideoFrame', () => {
23
+ it('uploads the element and returns the new frame id when the frame advanced', () => {
24
+ const gl = makeGl();
25
+ const vt = makeVideoTexture(3);
26
+ const uploaded = uploadGlTextureVideoFrame(gl, vt, -1);
27
+ expect(uploaded).toBe(3);
28
+ expect(gl.texImage2D).toHaveBeenCalledWith(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, vt.source.element);
29
+ });
30
+
31
+ it('skips the upload and returns the same id when the frame has not advanced', () => {
32
+ const gl = makeGl();
33
+ const vt = makeVideoTexture(5);
34
+ const uploaded = uploadGlTextureVideoFrame(gl, vt, 5);
35
+ expect(uploaded).toBe(5);
36
+ expect(gl.texImage2D).not.toHaveBeenCalled();
37
+ });
38
+
39
+ it('skips and reports no upload when the element has no decoded frame yet', () => {
40
+ const gl = makeGl();
41
+ const vt = makeVideoTexture(1, 1, 0, 0);
42
+ const uploaded = uploadGlTextureVideoFrame(gl, vt, -1);
43
+ expect(uploaded).toBe(-1);
44
+ expect(gl.texImage2D).not.toHaveBeenCalled();
45
+ });
46
+
47
+ it('skips when the element is null', () => {
48
+ const gl = makeGl();
49
+ const vt = { frameId: 2, source: { element: null } } as unknown as VideoTexture;
50
+ expect(uploadGlTextureVideoFrame(gl, vt, -1)).toBe(-1);
51
+ expect(gl.texImage2D).not.toHaveBeenCalled();
52
+ });
53
+ });