@flighthq/render-gl 0.1.0

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 (82) hide show
  1. package/dist/glBackground.d.ts +3 -0
  2. package/dist/glBackground.d.ts.map +1 -0
  3. package/dist/glBackground.js +17 -0
  4. package/dist/glBackground.js.map +1 -0
  5. package/dist/glDraw.d.ts +11 -0
  6. package/dist/glDraw.d.ts.map +1 -0
  7. package/dist/glDraw.js +132 -0
  8. package/dist/glDraw.js.map +1 -0
  9. package/dist/glElement.d.ts +2 -0
  10. package/dist/glElement.d.ts.map +1 -0
  11. package/dist/glElement.js +9 -0
  12. package/dist/glElement.js.map +1 -0
  13. package/dist/glFullscreenPass.d.ts +11 -0
  14. package/dist/glFullscreenPass.d.ts.map +1 -0
  15. package/dist/glFullscreenPass.js +114 -0
  16. package/dist/glFullscreenPass.js.map +1 -0
  17. package/dist/glMaterialRegistry.d.ts +5 -0
  18. package/dist/glMaterialRegistry.d.ts.map +1 -0
  19. package/dist/glMaterialRegistry.js +25 -0
  20. package/dist/glMaterialRegistry.js.map +1 -0
  21. package/dist/glProgram.d.ts +4 -0
  22. package/dist/glProgram.d.ts.map +1 -0
  23. package/dist/glProgram.js +37 -0
  24. package/dist/glProgram.js.map +1 -0
  25. package/dist/glReadback.d.ts +3 -0
  26. package/dist/glReadback.d.ts.map +1 -0
  27. package/dist/glReadback.js +33 -0
  28. package/dist/glReadback.js.map +1 -0
  29. package/dist/glRenderState.d.ts +7 -0
  30. package/dist/glRenderState.d.ts.map +1 -0
  31. package/dist/glRenderState.js +153 -0
  32. package/dist/glRenderState.js.map +1 -0
  33. package/dist/glRenderTarget.d.ts +41 -0
  34. package/dist/glRenderTarget.d.ts.map +1 -0
  35. package/dist/glRenderTarget.js +284 -0
  36. package/dist/glRenderTarget.js.map +1 -0
  37. package/dist/glRenderTargetPool.d.ts +6 -0
  38. package/dist/glRenderTargetPool.d.ts.map +1 -0
  39. package/dist/glRenderTargetPool.js +33 -0
  40. package/dist/glRenderTargetPool.js.map +1 -0
  41. package/dist/glShader.d.ts +39 -0
  42. package/dist/glShader.d.ts.map +1 -0
  43. package/dist/glShader.js +116 -0
  44. package/dist/glShader.js.map +1 -0
  45. package/dist/glShaderBinding.d.ts +21 -0
  46. package/dist/glShaderBinding.d.ts.map +1 -0
  47. package/dist/glShaderBinding.js +60 -0
  48. package/dist/glShaderBinding.js.map +1 -0
  49. package/dist/glShaderRegistry.d.ts +5 -0
  50. package/dist/glShaderRegistry.d.ts.map +1 -0
  51. package/dist/glShaderRegistry.js +6 -0
  52. package/dist/glShaderRegistry.js.map +1 -0
  53. package/dist/glShaderTypes.d.ts +2 -0
  54. package/dist/glShaderTypes.d.ts.map +1 -0
  55. package/dist/glShaderTypes.js +2 -0
  56. package/dist/glShaderTypes.js.map +1 -0
  57. package/dist/glTestHelper.d.ts +14 -0
  58. package/dist/glTestHelper.d.ts.map +1 -0
  59. package/dist/glTestHelper.js +69 -0
  60. package/dist/glTestHelper.js.map +1 -0
  61. package/dist/index.d.ts +14 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +13 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/internal.d.ts +2 -0
  66. package/dist/internal.d.ts.map +1 -0
  67. package/dist/internal.js +2 -0
  68. package/dist/internal.js.map +1 -0
  69. package/package.json +40 -0
  70. package/src/glBackground.test.ts +43 -0
  71. package/src/glDraw.test.ts +360 -0
  72. package/src/glElement.test.ts +38 -0
  73. package/src/glFullscreenPass.test.ts +185 -0
  74. package/src/glMaterialRegistry.test.ts +48 -0
  75. package/src/glProgram.test.ts +59 -0
  76. package/src/glReadback.test.ts +82 -0
  77. package/src/glRenderState.test.ts +186 -0
  78. package/src/glRenderTarget.test.ts +317 -0
  79. package/src/glRenderTargetPool.test.ts +97 -0
  80. package/src/glShader.test.ts +331 -0
  81. package/src/glShaderBinding.test.ts +149 -0
  82. package/src/glShaderRegistry.test.ts +19 -0
@@ -0,0 +1,360 @@
1
+ import { BlendMode } from '@flighthq/types';
2
+
3
+ import {
4
+ applyGlBlendMode,
5
+ bindGlTexture,
6
+ createGlTexture,
7
+ drawGlQuad,
8
+ enableGlBlendModeSupport,
9
+ setGlQuadMatrixFromOffset,
10
+ updateGlTexture,
11
+ useGlProgram,
12
+ } from './glDraw';
13
+ import { getGlRenderStateRuntime } from './glRenderState';
14
+ import { registerGlBitmapShader } from './glShaderRegistry';
15
+ import { createGlState } from './glTestHelper';
16
+
17
+ describe('applyGlBlendMode', () => {
18
+ it('does not call blendFunc when blend mode has not changed', () => {
19
+ const { state, gl } = createGlState();
20
+ getGlRenderStateRuntime(state).currentBlendMode = BlendMode.Normal;
21
+ applyGlBlendMode(state, BlendMode.Normal);
22
+ expect(gl.blendFunc).not.toHaveBeenCalled();
23
+ });
24
+
25
+ it('sets normal blend (ONE, ONE_MINUS_SRC_ALPHA) for BlendMode.Normal', () => {
26
+ const { state, gl } = createGlState();
27
+ applyGlBlendMode(state, BlendMode.Normal);
28
+ const g = gl as unknown as { ONE: number; ONE_MINUS_SRC_ALPHA: number };
29
+ expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE_MINUS_SRC_ALPHA);
30
+ });
31
+
32
+ it('sets additive blend (ONE, ONE) for BlendMode.Add', () => {
33
+ const { state, gl } = createGlState();
34
+ applyGlBlendMode(state, BlendMode.Add);
35
+ const g = gl as unknown as { ONE: number };
36
+ expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE);
37
+ });
38
+
39
+ it('falls back to normal blend for a mode with no fixed-function equivalent', () => {
40
+ const { state, gl } = createGlState();
41
+ applyGlBlendMode(state, BlendMode.Multiply);
42
+ const g = gl as unknown as { ONE: number; ONE_MINUS_SRC_ALPHA: number };
43
+ expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE_MINUS_SRC_ALPHA);
44
+ });
45
+
46
+ it('updates currentBlendMode after the change', () => {
47
+ const { state } = createGlState();
48
+ applyGlBlendMode(state, BlendMode.Add);
49
+ expect(getGlRenderStateRuntime(state).currentBlendMode).toBe(BlendMode.Add);
50
+ });
51
+
52
+ it('calls blendFunc again when mode switches', () => {
53
+ const { state, gl } = createGlState();
54
+ applyGlBlendMode(state, BlendMode.Normal);
55
+ applyGlBlendMode(state, BlendMode.Add);
56
+ expect(gl.blendFunc).toHaveBeenCalledTimes(2);
57
+ });
58
+ });
59
+
60
+ describe('bindGlTexture', () => {
61
+ it('creates a new texture for an uncached image source', () => {
62
+ const { state, gl } = createGlState();
63
+ const img = document.createElement('img');
64
+ bindGlTexture(state, img);
65
+ expect(gl.createTexture).toHaveBeenCalled();
66
+ });
67
+
68
+ it('uploads texture data on first bind', () => {
69
+ const { state, gl } = createGlState();
70
+ const img = document.createElement('img');
71
+ bindGlTexture(state, img);
72
+ expect(gl.texImage2D).toHaveBeenCalled();
73
+ });
74
+
75
+ it('returns the same texture object on subsequent calls with the same source', () => {
76
+ const { state } = createGlState();
77
+ const img = document.createElement('img');
78
+ const t1 = bindGlTexture(state, img);
79
+ const t2 = bindGlTexture(state, img);
80
+ expect(t1).toBe(t2);
81
+ });
82
+
83
+ it('does not call texImage2D again for a cached texture', () => {
84
+ const { state, gl } = createGlState();
85
+ const img = document.createElement('img');
86
+ bindGlTexture(state, img);
87
+ const uploadCount = (gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length;
88
+ bindGlTexture(state, img);
89
+ expect((gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length).toBe(uploadCount);
90
+ });
91
+
92
+ it('premultiplies alpha on upload for HTMLCanvasElement sources', () => {
93
+ // Canvas pixels reach texImage2D as straight alpha; premultiplying on upload keeps canvas-backed
94
+ // shapes/text consistent with the premultiplied (ONE, ONE_MINUS_SRC_ALPHA) blend. Without this a
95
+ // semi-transparent shape blows out to full opacity.
96
+ const { state, gl } = createGlState();
97
+ const canvas = document.createElement('canvas');
98
+ bindGlTexture(state, canvas);
99
+ expect(gl.pixelStorei).toHaveBeenCalledWith(
100
+ (gl as unknown as { UNPACK_PREMULTIPLY_ALPHA_WEBGL: number }).UNPACK_PREMULTIPLY_ALPHA_WEBGL,
101
+ true,
102
+ );
103
+ });
104
+
105
+ it('sets premultiply to true for non-canvas image sources', () => {
106
+ const { state, gl } = createGlState();
107
+ const img = document.createElement('img');
108
+ bindGlTexture(state, img);
109
+ expect(gl.pixelStorei).toHaveBeenCalledWith(
110
+ (gl as unknown as { UNPACK_PREMULTIPLY_ALPHA_WEBGL: number }).UNPACK_PREMULTIPLY_ALPHA_WEBGL,
111
+ true,
112
+ );
113
+ });
114
+
115
+ it('rebinds and updates currentTexture when switching to a cached texture', () => {
116
+ const { state, gl } = createGlState();
117
+ const img = document.createElement('img');
118
+ const texture = bindGlTexture(state, img);
119
+ const runtime = getGlRenderStateRuntime(state);
120
+ runtime.currentTexture = null;
121
+ bindGlTexture(state, img);
122
+ expect(gl.bindTexture).toHaveBeenCalledWith((gl as unknown as { TEXTURE_2D: number }).TEXTURE_2D, texture);
123
+ expect(runtime.currentTexture).toBe(texture);
124
+ });
125
+ });
126
+
127
+ describe('createGlTexture', () => {
128
+ it('creates and returns a WebGLTexture', () => {
129
+ const { state } = createGlState();
130
+ const texture = createGlTexture(state);
131
+ expect(texture).toBeDefined();
132
+ });
133
+
134
+ it('binds the new texture', () => {
135
+ const { state, gl } = createGlState();
136
+ const texture = createGlTexture(state);
137
+ expect(gl.bindTexture).toHaveBeenCalledWith((gl as unknown as { TEXTURE_2D: number }).TEXTURE_2D, texture);
138
+ });
139
+
140
+ it('sets CLAMP_TO_EDGE for both wrap modes', () => {
141
+ const { state, gl } = createGlState();
142
+ createGlTexture(state);
143
+ const g = gl as unknown as {
144
+ TEXTURE_2D: number;
145
+ TEXTURE_WRAP_S: number;
146
+ TEXTURE_WRAP_T: number;
147
+ CLAMP_TO_EDGE: number;
148
+ };
149
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.CLAMP_TO_EDGE);
150
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_T, g.CLAMP_TO_EDGE);
151
+ });
152
+
153
+ it('uses LINEAR filter when allowSmoothing is true', () => {
154
+ const { state, gl } = createGlState({ allowSmoothing: true });
155
+ createGlTexture(state);
156
+ const g = gl as unknown as {
157
+ TEXTURE_2D: number;
158
+ TEXTURE_MIN_FILTER: number;
159
+ TEXTURE_MAG_FILTER: number;
160
+ LINEAR: number;
161
+ };
162
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, g.LINEAR);
163
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, g.LINEAR);
164
+ });
165
+
166
+ it('uses NEAREST filter when allowSmoothing is false', () => {
167
+ const { state, gl } = createGlState({ allowSmoothing: false });
168
+ createGlTexture(state);
169
+ const g = gl as unknown as {
170
+ TEXTURE_2D: number;
171
+ TEXTURE_MIN_FILTER: number;
172
+ TEXTURE_MAG_FILTER: number;
173
+ NEAREST: number;
174
+ };
175
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, g.NEAREST);
176
+ expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, g.NEAREST);
177
+ });
178
+
179
+ it('stores the new texture as currentTexture on state', () => {
180
+ const { state } = createGlState();
181
+ const texture = createGlTexture(state);
182
+ expect(getGlRenderStateRuntime(state).currentTexture).toBe(texture);
183
+ });
184
+ });
185
+
186
+ describe('drawGlQuad', () => {
187
+ it('writes vertex positions and UVs into quadVertexData', () => {
188
+ const { state } = createGlState();
189
+ drawGlQuad(state, 0, 0, 100, 50, 0, 0, 1, 1);
190
+ const v = getGlRenderStateRuntime(state).quadVertexData;
191
+ // Bottom-left
192
+ expect(v[0]).toBe(0);
193
+ expect(v[1]).toBe(0);
194
+ expect(v[2]).toBe(0);
195
+ expect(v[3]).toBe(0);
196
+ // Bottom-right
197
+ expect(v[4]).toBe(100);
198
+ expect(v[5]).toBe(0);
199
+ expect(v[6]).toBe(1);
200
+ expect(v[7]).toBe(0);
201
+ // Top-right
202
+ expect(v[8]).toBe(100);
203
+ expect(v[9]).toBe(50);
204
+ expect(v[10]).toBe(1);
205
+ expect(v[11]).toBe(1);
206
+ // Top-left
207
+ expect(v[12]).toBe(0);
208
+ expect(v[13]).toBe(50);
209
+ expect(v[14]).toBe(0);
210
+ expect(v[15]).toBe(1);
211
+ });
212
+
213
+ it('calls bufferSubData to upload vertex data', () => {
214
+ const { state, gl } = createGlState();
215
+ drawGlQuad(state, 10, 20, 110, 70, 0.1, 0.2, 0.9, 0.8);
216
+ expect(gl.bufferSubData).toHaveBeenCalledWith(
217
+ (gl as unknown as { ARRAY_BUFFER: number }).ARRAY_BUFFER,
218
+ 0,
219
+ getGlRenderStateRuntime(state).quadVertexData,
220
+ );
221
+ });
222
+
223
+ it('calls drawElements for 6 indices forming 2 triangles', () => {
224
+ const { state, gl } = createGlState();
225
+ drawGlQuad(state, 0, 0, 100, 50, 0, 0, 1, 1);
226
+ const g = gl as unknown as { TRIANGLES: number; UNSIGNED_SHORT: number };
227
+ expect(gl.drawElements).toHaveBeenCalledWith(g.TRIANGLES, 6, g.UNSIGNED_SHORT, 0);
228
+ });
229
+ });
230
+
231
+ describe('enableGlBlendModeSupport', () => {
232
+ it('wires applyBlendMode onto the state', () => {
233
+ const { state } = createGlState();
234
+ expect(state.applyBlendMode).toBeNull();
235
+ enableGlBlendModeSupport(state);
236
+ expect(state.applyBlendMode).not.toBeNull();
237
+ });
238
+
239
+ it('causes blend modes to be applied via gl.blendFunc', () => {
240
+ const { state, gl } = createGlState();
241
+ enableGlBlendModeSupport(state);
242
+ state.applyBlendMode!(state, BlendMode.Add);
243
+ expect(gl.blendFunc).toHaveBeenCalled();
244
+ });
245
+ });
246
+
247
+ describe('setGlQuadMatrixFromOffset', () => {
248
+ it('bakes the offset into the translation before setting the matrix', () => {
249
+ const { state, gl } = createGlState();
250
+ // Identity transform + offset (dx=10, dy=20): effective tx = 0 + 1*10 + 0*20 = 10
251
+ const runtime = getGlRenderStateRuntime(state);
252
+ setGlQuadMatrixFromOffset(state, 1, 0, 0, 1, 0, 0, 10, 20);
253
+ expect(gl.uniformMatrix3fv).toHaveBeenCalledWith(runtime.shaderLoc.locMatrix, false, runtime.matrixArray);
254
+ // tx * 2/200 - 1 = 10 * 0.01 - 1 = -0.9
255
+ expect(runtime.matrixArray[6]).toBeCloseTo(-0.9);
256
+ // -ty * 2/100 + 1 = -20 * 0.02 + 1 = 0.6
257
+ expect(runtime.matrixArray[7]).toBeCloseTo(0.6);
258
+ });
259
+
260
+ it('applies the offset through the transform matrix components', () => {
261
+ const { state } = createGlState();
262
+ // Scale-2 transform with offset (dx=5, dy=0): effective tx = 0 + 2*5 + 0*0 = 10
263
+ setGlQuadMatrixFromOffset(state, 2, 0, 0, 2, 0, 0, 5, 0);
264
+ // tx * 2/200 - 1 = 10 * 0.01 - 1 = -0.9
265
+ expect(getGlRenderStateRuntime(state).matrixArray[6]).toBeCloseTo(-0.9);
266
+ });
267
+ });
268
+
269
+ describe('updateGlTexture', () => {
270
+ it('binds the texture when it is not the current one', () => {
271
+ const { state, gl } = createGlState();
272
+ const texture = {} as WebGLTexture;
273
+ const canvas = document.createElement('canvas');
274
+ getGlRenderStateRuntime(state).currentTexture = null;
275
+ updateGlTexture(state, texture, canvas);
276
+ expect(gl.bindTexture).toHaveBeenCalledWith((gl as unknown as { TEXTURE_2D: number }).TEXTURE_2D, texture);
277
+ });
278
+
279
+ it('updates currentTexture after binding', () => {
280
+ const { state } = createGlState();
281
+ const texture = {} as WebGLTexture;
282
+ const runtime = getGlRenderStateRuntime(state);
283
+ runtime.currentTexture = null;
284
+ updateGlTexture(state, {} as WebGLTexture, document.createElement('canvas'));
285
+ updateGlTexture(state, texture, document.createElement('canvas'));
286
+ // The last call should have updated currentTexture
287
+ expect(runtime.currentTexture).toBe(texture);
288
+ });
289
+
290
+ it('skips bindTexture when texture is already current', () => {
291
+ const { state, gl } = createGlState();
292
+ const texture = {} as WebGLTexture;
293
+ getGlRenderStateRuntime(state).currentTexture = texture;
294
+ updateGlTexture(state, texture, document.createElement('canvas'));
295
+ expect(gl.bindTexture).not.toHaveBeenCalled();
296
+ });
297
+
298
+ it('always calls texImage2D to upload canvas data', () => {
299
+ const { state, gl } = createGlState();
300
+ const texture = {} as WebGLTexture;
301
+ getGlRenderStateRuntime(state).currentTexture = texture;
302
+ updateGlTexture(state, texture, document.createElement('canvas'));
303
+ expect(gl.texImage2D).toHaveBeenCalled();
304
+ });
305
+
306
+ it('sets premultiply alpha before uploading', () => {
307
+ const { state, gl } = createGlState();
308
+ const texture = {} as WebGLTexture;
309
+ getGlRenderStateRuntime(state).currentTexture = texture;
310
+ updateGlTexture(state, texture, document.createElement('canvas'));
311
+ expect(gl.pixelStorei).toHaveBeenCalledWith(
312
+ (gl as unknown as { UNPACK_PREMULTIPLY_ALPHA_WEBGL: number }).UNPACK_PREMULTIPLY_ALPHA_WEBGL,
313
+ true,
314
+ );
315
+ });
316
+ });
317
+
318
+ describe('useGlProgram', () => {
319
+ it('calls useProgram when no program is active', () => {
320
+ const { state, gl } = createGlState();
321
+ const runtime = getGlRenderStateRuntime(state);
322
+ runtime.currentProgram = null;
323
+ useGlProgram(state);
324
+ expect(gl.useProgram).toHaveBeenCalledWith(runtime.shaderLoc.program);
325
+ });
326
+
327
+ it('does not call useProgram when program is already active', () => {
328
+ const { state, gl } = createGlState();
329
+ const runtime = getGlRenderStateRuntime(state);
330
+ runtime.currentProgram = runtime.shaderLoc.program;
331
+ useGlProgram(state);
332
+ expect(gl.useProgram).not.toHaveBeenCalled();
333
+ });
334
+
335
+ it('stores the program as currentProgram after activation', () => {
336
+ const { state } = createGlState();
337
+ const runtime = getGlRenderStateRuntime(state);
338
+ runtime.currentProgram = null;
339
+ useGlProgram(state);
340
+ expect(runtime.currentProgram).toBe(runtime.shaderLoc.program);
341
+ });
342
+
343
+ it('uses the registered bitmap shader program and locations', () => {
344
+ const { state, gl } = createGlState();
345
+ const runtime = getGlRenderStateRuntime(state);
346
+ const shader = {
347
+ bind: vi.fn(),
348
+ locations: { ...runtime.shaderLoc, program: {} as WebGLProgram },
349
+ program: {} as WebGLProgram,
350
+ };
351
+ shader.locations.program = shader.program;
352
+
353
+ registerGlBitmapShader(state, shader);
354
+ useGlProgram(state);
355
+
356
+ expect(gl.useProgram).toHaveBeenCalledWith(shader.program);
357
+ expect(runtime.shaderLoc).toBe(shader.locations);
358
+ expect(runtime.currentProgram).toBe(shader.program);
359
+ });
360
+ });
@@ -0,0 +1,38 @@
1
+ import { createGlCanvasElement } from './glElement';
2
+
3
+ describe('createGlCanvasElement', () => {
4
+ it('returns an HTMLCanvasElement', () => {
5
+ const canvas = createGlCanvasElement(100, 200);
6
+ expect(canvas).toBeInstanceOf(HTMLCanvasElement);
7
+ });
8
+
9
+ it('sets pixel dimensions equal to logical size with default pixelRatio', () => {
10
+ const canvas = createGlCanvasElement(100, 200);
11
+ expect(canvas.width).toBe(100);
12
+ expect(canvas.height).toBe(200);
13
+ });
14
+
15
+ it('scales pixel dimensions by pixelRatio', () => {
16
+ const canvas = createGlCanvasElement(100, 200, 2);
17
+ expect(canvas.width).toBe(200);
18
+ expect(canvas.height).toBe(400);
19
+ });
20
+
21
+ it('sets CSS style width to the logical width', () => {
22
+ const canvas = createGlCanvasElement(100, 200, 2);
23
+ expect(canvas.style.width).toBe('100px');
24
+ });
25
+
26
+ it('sets CSS style height to the logical height', () => {
27
+ const canvas = createGlCanvasElement(100, 200, 2);
28
+ expect(canvas.style.height).toBe('200px');
29
+ });
30
+
31
+ it('keeps CSS dimensions unchanged regardless of pixelRatio', () => {
32
+ const canvas = createGlCanvasElement(300, 150, 3);
33
+ expect(canvas.style.width).toBe('300px');
34
+ expect(canvas.style.height).toBe('150px');
35
+ expect(canvas.width).toBe(900);
36
+ expect(canvas.height).toBe(450);
37
+ });
38
+ });
@@ -0,0 +1,185 @@
1
+ import type { GlRenderTarget } from '@flighthq/types';
2
+ import { BlendMode } from '@flighthq/types';
3
+
4
+ import { clearGlRenderTarget, compileGlFullscreenProgram, drawGlFullscreenPass } from './glFullscreenPass';
5
+ import { getGlRenderStateRuntime } from './glRenderState';
6
+ import { createGlState, makeGL } from './glTestHelper';
7
+
8
+ const FRAG_SRC = `#version 300 es
9
+ precision mediump float;
10
+ in vec2 v_texCoord;
11
+ uniform sampler2D u_texture;
12
+ out vec4 fragColor;
13
+ void main() {
14
+ fragColor = texture(u_texture, v_texCoord);
15
+ }`;
16
+
17
+ function makeTarget(framebuffer: WebGLFramebuffer, width = 32, height = 16): GlRenderTarget {
18
+ return {
19
+ width,
20
+ height,
21
+ format: 'rgba8',
22
+ sampleCount: 1,
23
+ framebuffer,
24
+ resolveFramebuffer: null,
25
+ textures: [],
26
+ texture: {} as WebGLTexture,
27
+ depthTexture: null,
28
+ colorRenderbuffers: [],
29
+ depthStencilRenderbuffer: null,
30
+ };
31
+ }
32
+
33
+ describe('clearGlRenderTarget', () => {
34
+ it('binds the target framebuffer and clears it', () => {
35
+ const { state, gl } = createGlState();
36
+ const fb = {} as WebGLFramebuffer;
37
+ const target = makeTarget(fb);
38
+ const bindSpy = vi.spyOn(gl, 'bindFramebuffer');
39
+ const clearSpy = vi.spyOn(gl, 'clear');
40
+
41
+ clearGlRenderTarget(state, target);
42
+
43
+ expect(bindSpy).toHaveBeenCalledWith(gl.FRAMEBUFFER, fb);
44
+ expect(clearSpy).toHaveBeenCalledWith(gl.COLOR_BUFFER_BIT);
45
+ });
46
+
47
+ it('sets the viewport and renderTargetViewport to the target size', () => {
48
+ const { state, gl } = createGlState();
49
+ const target = makeTarget({} as WebGLFramebuffer, 48, 24);
50
+ const viewportSpy = vi.spyOn(gl, 'viewport');
51
+
52
+ clearGlRenderTarget(state, target);
53
+
54
+ expect(viewportSpy).toHaveBeenCalledWith(0, 0, 48, 24);
55
+ expect(getGlRenderStateRuntime(state).renderTargetViewport).toEqual({ width: 48, height: 24 });
56
+ });
57
+
58
+ it('clears to fully transparent', () => {
59
+ const { state, gl } = createGlState();
60
+ const clearColorSpy = vi.spyOn(gl, 'clearColor');
61
+
62
+ clearGlRenderTarget(state, makeTarget({} as WebGLFramebuffer));
63
+
64
+ expect(clearColorSpy).toHaveBeenCalledWith(0, 0, 0, 0);
65
+ });
66
+
67
+ it('invalidates cached texture and blend-mode bindings', () => {
68
+ const { state } = createGlState();
69
+ const runtime = getGlRenderStateRuntime(state);
70
+ runtime.currentTexture = {} as WebGLTexture;
71
+ runtime.currentBlendMode = BlendMode.Add;
72
+
73
+ clearGlRenderTarget(state, makeTarget({} as WebGLFramebuffer));
74
+
75
+ expect(runtime.currentTexture).toBeNull();
76
+ expect(runtime.currentBlendMode).toBeNull();
77
+ });
78
+
79
+ it('skips rebinding when the target framebuffer is already current', () => {
80
+ const { state, gl } = createGlState();
81
+ const fb = {} as WebGLFramebuffer;
82
+ const runtime = getGlRenderStateRuntime(state);
83
+ runtime.currentFramebuffer = fb;
84
+ const bindSpy = vi.spyOn(gl, 'bindFramebuffer');
85
+
86
+ clearGlRenderTarget(state, makeTarget(fb));
87
+
88
+ expect(bindSpy).not.toHaveBeenCalled();
89
+ });
90
+ });
91
+
92
+ describe('compileGlFullscreenProgram', () => {
93
+ it('returns a program with position and texCoord attribute locations', () => {
94
+ const gl = makeGL();
95
+ const prog = compileGlFullscreenProgram(gl, FRAG_SRC);
96
+ expect(prog.program).toBeDefined();
97
+ expect(typeof prog.locPosition).toBe('number');
98
+ expect(typeof prog.locTexCoord).toBe('number');
99
+ });
100
+
101
+ it('collects u_texture0 sampler uniform locations', () => {
102
+ const gl = makeGL();
103
+ const prog = compileGlFullscreenProgram(gl, FRAG_SRC);
104
+ expect(prog.textures).toBeDefined();
105
+ });
106
+
107
+ it('throws when vertex shader compilation fails', () => {
108
+ const gl = makeGL();
109
+ (gl.getShaderParameter as ReturnType<typeof vi.fn>).mockReturnValueOnce(false);
110
+ expect(() => compileGlFullscreenProgram(gl, FRAG_SRC)).toThrow('shader compile error');
111
+ });
112
+
113
+ it('throws when program linking fails', () => {
114
+ const gl = makeGL();
115
+ (gl.getProgramParameter as ReturnType<typeof vi.fn>).mockReturnValue(false);
116
+ expect(() => compileGlFullscreenProgram(gl, FRAG_SRC)).toThrow('program link error');
117
+ });
118
+ });
119
+
120
+ describe('drawGlFullscreenPass', () => {
121
+ it('binds the destination framebuffer and sets its viewport', () => {
122
+ const { state, gl } = createGlState();
123
+ const program = compileGlFullscreenProgram(gl, FRAG_SRC);
124
+ const dest = makeTarget({} as WebGLFramebuffer, 40, 20);
125
+ const bindSpy = vi.spyOn(gl, 'bindFramebuffer');
126
+ const viewportSpy = vi.spyOn(gl, 'viewport');
127
+
128
+ drawGlFullscreenPass(state, program, [], dest, () => {});
129
+
130
+ expect(bindSpy).toHaveBeenCalledWith(gl.FRAMEBUFFER, dest.framebuffer);
131
+ expect(viewportSpy).toHaveBeenCalledWith(0, 0, 40, 20);
132
+ expect(getGlRenderStateRuntime(state).renderTargetViewport).toEqual({ width: 40, height: 20 });
133
+ });
134
+
135
+ it('targets the canvas and clears renderTargetViewport when dest is null', () => {
136
+ const { state, gl, canvas } = createGlState();
137
+ const program = compileGlFullscreenProgram(gl, FRAG_SRC);
138
+ const viewportSpy = vi.spyOn(gl, 'viewport');
139
+
140
+ drawGlFullscreenPass(state, program, [], null, () => {});
141
+
142
+ expect(viewportSpy).toHaveBeenCalledWith(0, 0, canvas.width, canvas.height);
143
+ expect(getGlRenderStateRuntime(state).renderTargetViewport).toBeNull();
144
+ });
145
+
146
+ it('binds each input texture to its sampler unit', () => {
147
+ const { state, gl } = createGlState();
148
+ const program = compileGlFullscreenProgram(gl, FRAG_SRC);
149
+ const inputs = [{} as WebGLTexture, {} as WebGLTexture];
150
+ const bindTextureSpy = vi.spyOn(gl, 'bindTexture');
151
+ const activeTextureSpy = vi.spyOn(gl, 'activeTexture');
152
+
153
+ drawGlFullscreenPass(state, program, inputs, null, () => {});
154
+
155
+ expect(activeTextureSpy).toHaveBeenCalledWith(gl.TEXTURE0);
156
+ expect(activeTextureSpy).toHaveBeenCalledWith(gl.TEXTURE0 + 1);
157
+ expect(bindTextureSpy).toHaveBeenCalledWith(gl.TEXTURE_2D, inputs[0]);
158
+ expect(bindTextureSpy).toHaveBeenCalledWith(gl.TEXTURE_2D, inputs[1]);
159
+ });
160
+
161
+ it('invokes the setUniforms callback before drawing', () => {
162
+ const { state, gl } = createGlState();
163
+ const program = compileGlFullscreenProgram(gl, FRAG_SRC);
164
+ const drawSpy = vi.spyOn(gl, 'drawElements');
165
+ const setUniforms = vi.fn(() => {
166
+ expect(drawSpy).not.toHaveBeenCalled();
167
+ });
168
+
169
+ drawGlFullscreenPass(state, program, [], null, setUniforms);
170
+
171
+ expect(setUniforms).toHaveBeenCalledWith(gl, program);
172
+ expect(drawSpy).toHaveBeenCalled();
173
+ });
174
+
175
+ it('sets premultiplied-alpha blending and invalidates the cached blend mode', () => {
176
+ const { state, gl } = createGlState();
177
+ const program = compileGlFullscreenProgram(gl, FRAG_SRC);
178
+ const blendSpy = vi.spyOn(gl, 'blendFunc');
179
+
180
+ drawGlFullscreenPass(state, program, [], null, () => {});
181
+
182
+ expect(blendSpy).toHaveBeenCalledWith(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
183
+ expect(getGlRenderStateRuntime(state).currentBlendMode).toBeNull();
184
+ });
185
+ });
@@ -0,0 +1,48 @@
1
+ import type { GlMaterialRenderer, Material } from '@flighthq/types';
2
+ import { DefaultMaterialKind } from '@flighthq/types';
3
+
4
+ import { getGlMaterialRenderer, registerGlMaterialRenderer, resolveGlMaterialRenderer } from './glMaterialRegistry';
5
+ import { createGlState } from './glTestHelper';
6
+
7
+ const TestKind = 'TestMaterial';
8
+ const testRenderer: GlMaterialRenderer = { instanceFloatCount: 0, bind() {} };
9
+
10
+ function makeMaterial(kind: string): Material {
11
+ return { kind } as Material;
12
+ }
13
+
14
+ describe('getGlMaterialRenderer', () => {
15
+ it('returns null when nothing is registered for the kind', () => {
16
+ const { state } = createGlState();
17
+ expect(getGlMaterialRenderer(state, TestKind)).toBeNull();
18
+ });
19
+ });
20
+
21
+ describe('registerGlMaterialRenderer', () => {
22
+ it('registers a renderer retrievable by kind', () => {
23
+ const { state } = createGlState();
24
+ registerGlMaterialRenderer(state, TestKind, testRenderer);
25
+ expect(getGlMaterialRenderer(state, TestKind)).toBe(testRenderer);
26
+ });
27
+ });
28
+
29
+ describe('resolveGlMaterialRenderer', () => {
30
+ it('returns null when nothing is registered — no built-in fallback', () => {
31
+ const { state } = createGlState();
32
+ expect(resolveGlMaterialRenderer(state, null)).toBeNull();
33
+ expect(resolveGlMaterialRenderer(state, makeMaterial(TestKind))).toBeNull();
34
+ });
35
+
36
+ it('returns the registered renderer for a material kind', () => {
37
+ const { state } = createGlState();
38
+ registerGlMaterialRenderer(state, TestKind, testRenderer);
39
+ expect(resolveGlMaterialRenderer(state, makeMaterial(TestKind))).toBe(testRenderer);
40
+ });
41
+
42
+ it('falls back to the renderer registered for DefaultMaterialKind', () => {
43
+ const { state } = createGlState();
44
+ registerGlMaterialRenderer(state, DefaultMaterialKind, testRenderer);
45
+ expect(resolveGlMaterialRenderer(state, makeMaterial('Other'))).toBe(testRenderer);
46
+ expect(resolveGlMaterialRenderer(state, null)).toBe(testRenderer);
47
+ });
48
+ });
@@ -0,0 +1,59 @@
1
+ import { compileGlShader, createGlProgram, linkGlProgram } from './glProgram';
2
+ import { makeGL } from './glTestHelper';
3
+
4
+ describe('compileGlShader', () => {
5
+ it('compiles a shader of the requested type and returns it', () => {
6
+ const gl = makeGL();
7
+ const shader = compileGlShader(gl, gl.VERTEX_SHADER, 'void main(){}');
8
+ expect(shader).toBeDefined();
9
+ expect(gl.createShader).toHaveBeenCalledWith(gl.VERTEX_SHADER);
10
+ expect(gl.shaderSource).toHaveBeenCalledWith(shader, 'void main(){}');
11
+ expect(gl.compileShader).toHaveBeenCalledWith(shader);
12
+ });
13
+
14
+ it('throws with the label and info log when compilation fails', () => {
15
+ const gl = makeGL();
16
+ (gl.getShaderParameter as ReturnType<typeof vi.fn>).mockReturnValueOnce(false);
17
+ (gl.getShaderInfoLog as ReturnType<typeof vi.fn>).mockReturnValueOnce('bad source');
18
+ expect(() => compileGlShader(gl, gl.FRAGMENT_SHADER, 'x', 'Widget')).toThrow(
19
+ 'Widget shader compile error: bad source',
20
+ );
21
+ });
22
+ });
23
+
24
+ describe('createGlProgram', () => {
25
+ it('compiles both stages, links, deletes the shaders, and returns the program', () => {
26
+ const gl = makeGL();
27
+ const program = createGlProgram(gl, 'void main(){}', 'void main(){}');
28
+ expect(program).toBeDefined();
29
+ expect(gl.createShader).toHaveBeenCalledWith(gl.VERTEX_SHADER);
30
+ expect(gl.createShader).toHaveBeenCalledWith(gl.FRAGMENT_SHADER);
31
+ expect(gl.attachShader).toHaveBeenCalledTimes(2);
32
+ expect(gl.linkProgram).toHaveBeenCalledWith(program);
33
+ expect(gl.deleteShader).toHaveBeenCalledTimes(2);
34
+ });
35
+
36
+ it('propagates a link failure as a labelled error', () => {
37
+ const gl = makeGL();
38
+ (gl.getProgramParameter as ReturnType<typeof vi.fn>).mockReturnValue(false);
39
+ (gl.getProgramInfoLog as ReturnType<typeof vi.fn>).mockReturnValueOnce('link failed');
40
+ expect(() => createGlProgram(gl, 'a', 'b', 'Widget')).toThrow('Widget program link error: link failed');
41
+ });
42
+ });
43
+
44
+ describe('linkGlProgram', () => {
45
+ it('links the program and does not throw on success', () => {
46
+ const gl = makeGL();
47
+ const program = gl.createProgram()!;
48
+ expect(() => linkGlProgram(gl, program)).not.toThrow();
49
+ expect(gl.linkProgram).toHaveBeenCalledWith(program);
50
+ });
51
+
52
+ it('throws with the label and info log when linking fails', () => {
53
+ const gl = makeGL();
54
+ const program = gl.createProgram()!;
55
+ (gl.getProgramParameter as ReturnType<typeof vi.fn>).mockReturnValue(false);
56
+ (gl.getProgramInfoLog as ReturnType<typeof vi.fn>).mockReturnValueOnce('no link');
57
+ expect(() => linkGlProgram(gl, program, 'Widget')).toThrow('Widget program link error: no link');
58
+ });
59
+ });