@flighthq/render-gl 0.3.0-next.1032.e2b99fc → 0.3.0-next.1220.6982c21

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 (96) hide show
  1. package/dist/contract.d.ts +4 -0
  2. package/dist/contract.d.ts.map +1 -1
  3. package/dist/contract.js +4 -0
  4. package/dist/contract.js.map +1 -1
  5. package/dist/enableGlRenderStateGuards.d.ts +4 -0
  6. package/dist/enableGlRenderStateGuards.d.ts.map +1 -0
  7. package/dist/enableGlRenderStateGuards.js +30 -0
  8. package/dist/enableGlRenderStateGuards.js.map +1 -0
  9. package/dist/enableGlTextureResolverGuards.d.ts +4 -0
  10. package/dist/enableGlTextureResolverGuards.d.ts.map +1 -0
  11. package/dist/enableGlTextureResolverGuards.js +8 -0
  12. package/dist/enableGlTextureResolverGuards.js.map +1 -0
  13. package/dist/explainGlTextureResolution.d.ts +3 -0
  14. package/dist/explainGlTextureResolution.d.ts.map +1 -0
  15. package/dist/explainGlTextureResolution.js +12 -0
  16. package/dist/explainGlTextureResolution.js.map +1 -0
  17. package/dist/glCompressedTexture.js +1 -1
  18. package/dist/glCompressedTexture.js.map +1 -1
  19. package/dist/glDraw.d.ts +2 -2
  20. package/dist/glDraw.d.ts.map +1 -1
  21. package/dist/glDraw.js +17 -14
  22. package/dist/glDraw.js.map +1 -1
  23. package/dist/glExternalTexture.d.ts.map +1 -1
  24. package/dist/glExternalTexture.js +23 -18
  25. package/dist/glExternalTexture.js.map +1 -1
  26. package/dist/glFullscreenPass.js +3 -4
  27. package/dist/glFullscreenPass.js.map +1 -1
  28. package/dist/glLinearToSrgbPass.js +3 -3
  29. package/dist/glLinearToSrgbPass.js.map +1 -1
  30. package/dist/glPresentRenderTarget.js +3 -3
  31. package/dist/glPresentRenderTarget.js.map +1 -1
  32. package/dist/glRenderPass.d.ts.map +1 -1
  33. package/dist/glRenderPass.js +82 -36
  34. package/dist/glRenderPass.js.map +1 -1
  35. package/dist/glRenderState.d.ts +11 -1
  36. package/dist/glRenderState.d.ts.map +1 -1
  37. package/dist/glRenderState.js +180 -5
  38. package/dist/glRenderState.js.map +1 -1
  39. package/dist/glRenderStateBracket.d.ts.map +1 -1
  40. package/dist/glRenderStateBracket.js +16 -4
  41. package/dist/glRenderStateBracket.js.map +1 -1
  42. package/dist/glRenderTarget.d.ts +10 -1
  43. package/dist/glRenderTarget.d.ts.map +1 -1
  44. package/dist/glRenderTarget.js +129 -60
  45. package/dist/glRenderTarget.js.map +1 -1
  46. package/dist/glRenderTargetPool.d.ts +4 -1
  47. package/dist/glRenderTargetPool.d.ts.map +1 -1
  48. package/dist/glRenderTargetPool.js +32 -23
  49. package/dist/glRenderTargetPool.js.map +1 -1
  50. package/dist/glRenderTexture.d.ts +4 -1
  51. package/dist/glRenderTexture.d.ts.map +1 -1
  52. package/dist/glRenderTexture.js +75 -17
  53. package/dist/glRenderTexture.js.map +1 -1
  54. package/dist/glRenderTexturePool.d.ts +7 -0
  55. package/dist/glRenderTexturePool.d.ts.map +1 -0
  56. package/dist/glRenderTexturePool.js +87 -0
  57. package/dist/glRenderTexturePool.js.map +1 -0
  58. package/dist/glTestHelper.js +2 -2
  59. package/dist/glTestHelper.js.map +1 -1
  60. package/dist/glTextureResolver.d.ts +3 -3
  61. package/dist/glTextureResolver.d.ts.map +1 -1
  62. package/dist/glTextureResolver.js +32 -31
  63. package/dist/glTextureResolver.js.map +1 -1
  64. package/dist/glTextureUpload.d.ts +2 -2
  65. package/dist/glTextureUpload.d.ts.map +1 -1
  66. package/dist/glTextureUpload.js +1 -1
  67. package/dist/glTextureUpload.js.map +1 -1
  68. package/dist/glTextureVideoUpload.d.ts +2 -2
  69. package/dist/glTextureVideoUpload.d.ts.map +1 -1
  70. package/dist/glTextureVideoUpload.js +3 -3
  71. package/dist/glTextureVideoUpload.js.map +1 -1
  72. package/dist/index.d.ts +1 -1
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +1 -1
  75. package/dist/index.js.map +1 -1
  76. package/package.json +9 -7
  77. package/src/enableGlRenderStateGuards.test.ts +27 -0
  78. package/src/enableGlTextureResolverGuards.test.ts +58 -0
  79. package/src/explainGlTextureResolution.test.ts +32 -0
  80. package/src/glCompressedTexture.test.ts +2 -2
  81. package/src/glDraw.test.ts +17 -18
  82. package/src/glExternalTexture.test.ts +3 -3
  83. package/src/glFullscreenPass.test.ts +13 -0
  84. package/src/glLinearToSrgbPass.test.ts +13 -0
  85. package/src/glPresentRenderTarget.test.ts +15 -1
  86. package/src/glReadback.test.ts +13 -0
  87. package/src/glRenderPass.test.ts +63 -0
  88. package/src/glRenderState.test.ts +135 -0
  89. package/src/glRenderStateBracket.test.ts +31 -1
  90. package/src/glRenderTarget.test.ts +186 -3
  91. package/src/glRenderTargetPool.test.ts +110 -5
  92. package/src/glRenderTexture.test.ts +71 -11
  93. package/src/glRenderTexturePool.test.ts +112 -0
  94. package/src/glTextureResolver.test.ts +73 -51
  95. package/src/glTextureUpload.test.ts +2 -2
  96. package/src/glTextureVideoUpload.test.ts +5 -5
@@ -1,4 +1,10 @@
1
- import type { BlendMode as BlendModeType, GlRenderStateRuntime, GlScissorRect } from '@flighthq/types/contract';
1
+ import type {
2
+ BlendMode as BlendModeType,
3
+ GlRenderStateRuntime,
4
+ GlRenderTarget,
5
+ GlScissorRect,
6
+ GlViewportRect,
7
+ } from '@flighthq/types/contract';
2
8
  import { BlendMode } from '@flighthq/types/contract';
3
9
 
4
10
  import { getGlRenderStateRuntime } from './glRenderState';
@@ -17,10 +23,13 @@ type TestGlState = {
17
23
  blendSrcRgb: number;
18
24
  cullFace: boolean;
19
25
  cullFaceMode: number;
26
+ currentRenderTarget: GlRenderTarget | null;
20
27
  depthFunc: number;
21
28
  depthMask: boolean;
22
29
  depthTest: boolean;
30
+ framebuffer: WebGLFramebuffer | null;
23
31
  program: WebGLProgram | null;
32
+ renderTargetViewport: GlViewportRect | null;
24
33
  scissorBox: [number, number, number, number];
25
34
  scissorRect: GlScissorRect | null;
26
35
  scissorTest: boolean;
@@ -49,10 +58,13 @@ const OUTER_STATE: TestGlState = {
49
58
  blendSrcRgb: 1,
50
59
  cullFace: false,
51
60
  cullFaceMode: 0x0405,
61
+ currentRenderTarget: { name: 'outer-target' } as unknown as GlRenderTarget,
52
62
  depthFunc: 0x0203,
53
63
  depthMask: false,
54
64
  depthTest: true,
65
+ framebuffer: { name: 'outer-framebuffer' } as unknown as WebGLFramebuffer,
55
66
  program: { name: 'outer-program' } as unknown as WebGLProgram,
67
+ renderTargetViewport: { height: 220, width: 300, x: 4, y: 5 },
56
68
  scissorBox: [2, 3, 40, 50],
57
69
  scissorRect: { x: 2, y: 3, width: 40, height: 50 },
58
70
  scissorTest: true,
@@ -74,10 +86,13 @@ const MIDDLE_STATE: TestGlState = {
74
86
  blendSrcRgb: 1,
75
87
  cullFace: true,
76
88
  cullFaceMode: 0x0404,
89
+ currentRenderTarget: { name: 'middle-target' } as unknown as GlRenderTarget,
77
90
  depthFunc: 0x0201,
78
91
  depthMask: true,
79
92
  depthTest: false,
93
+ framebuffer: { name: 'middle-framebuffer' } as unknown as WebGLFramebuffer,
80
94
  program: { name: 'middle-program' } as unknown as WebGLProgram,
95
+ renderTargetViewport: { height: 460, width: 620, x: 8, y: 9 },
81
96
  scissorBox: [6, 7, 80, 90],
82
97
  scissorRect: { x: 6, y: 7, width: 80, height: 90 },
83
98
  scissorTest: false,
@@ -91,7 +106,10 @@ const INNER_STATE: TestGlState = {
91
106
  ...OUTER_STATE,
92
107
  activeTexture: 0x84c3,
93
108
  blendMode: BlendMode.Screen,
109
+ currentRenderTarget: { name: 'inner-target' } as unknown as GlRenderTarget,
110
+ framebuffer: { name: 'inner-framebuffer' } as unknown as WebGLFramebuffer,
94
111
  program: { name: 'inner-program' } as unknown as WebGLProgram,
112
+ renderTargetViewport: { height: 200, width: 280, x: 10, y: 11 },
95
113
  scissorRect: { x: 10, y: 11, width: 12, height: 13 },
96
114
  texture: { name: 'inner-texture' } as unknown as WebGLTexture,
97
115
  vertexArray: { name: 'inner-vao' } as unknown as WebGLVertexArrayObject,
@@ -194,16 +212,20 @@ function applyTestGlState(fixture: StatefulGl, values: Readonly<TestGlState>): v
194
212
  gl.blendEquationSeparate(values.blendEquationRgb, values.blendEquationAlpha);
195
213
  gl.bindVertexArray(values.vertexArray);
196
214
  gl.useProgram(values.program);
215
+ gl.bindFramebuffer(gl.FRAMEBUFFER, values.framebuffer);
197
216
  gl.activeTexture(values.activeTexture);
198
217
  gl.bindTexture(gl.TEXTURE_2D, values.texture);
199
218
  setCapability(gl, gl.SCISSOR_TEST, values.scissorTest);
200
219
  gl.scissor(values.scissorBox[0], values.scissorBox[1], values.scissorBox[2], values.scissorBox[3]);
201
220
  gl.viewport(values.viewport[0], values.viewport[1], values.viewport[2], values.viewport[3]);
202
221
  runtime.currentBlendMode = values.blendMode;
222
+ runtime.currentFramebuffer = values.framebuffer;
223
+ runtime.currentRenderTarget = values.currentRenderTarget;
203
224
  runtime.currentProgram = values.program;
204
225
  runtime.currentScissorRect = values.scissorRect;
205
226
  runtime.currentTexture = values.texture;
206
227
  runtime.currentTextureStraightAlpha = values.straightAlpha;
228
+ runtime.renderTargetViewport = values.renderTargetViewport;
207
229
  }
208
230
 
209
231
  function createStatefulGl(): StatefulGl & ReturnType<typeof createGlState> {
@@ -221,6 +243,7 @@ function createStatefulGl(): StatefulGl & ReturnType<typeof createGlState> {
221
243
  CURRENT_PROGRAM: 0x8b8d,
222
244
  DEPTH_FUNC: 0x0b74,
223
245
  DEPTH_WRITEMASK: 0x0b72,
246
+ FRAMEBUFFER_BINDING: 0x8ca6,
224
247
  SCISSOR_BOX: 0x0c10,
225
248
  TEXTURE_BINDING_2D: 0x8069,
226
249
  VERTEX_ARRAY_BINDING: 0x85b5,
@@ -266,6 +289,9 @@ function createStatefulGl(): StatefulGl & ReturnType<typeof createGlState> {
266
289
  (gl.useProgram as ReturnType<typeof vi.fn>).mockImplementation((value: WebGLProgram | null) =>
267
290
  parameters.set(gl.CURRENT_PROGRAM, value),
268
291
  );
292
+ (gl.bindFramebuffer as ReturnType<typeof vi.fn>).mockImplementation(
293
+ (_target: number, value: WebGLFramebuffer | null) => parameters.set(gl.FRAMEBUFFER_BINDING, value),
294
+ );
269
295
  (gl.activeTexture as ReturnType<typeof vi.fn>).mockImplementation((value: number) =>
270
296
  parameters.set(gl.ACTIVE_TEXTURE, value),
271
297
  );
@@ -298,16 +324,20 @@ function expectTestGlState(fixture: StatefulGl, expected: Readonly<TestGlState>)
298
324
  expect(parameters.get(gl.BLEND_EQUATION_ALPHA)).toBe(expected.blendEquationAlpha);
299
325
  expect(parameters.get(gl.VERTEX_ARRAY_BINDING)).toBe(expected.vertexArray);
300
326
  expect(parameters.get(gl.CURRENT_PROGRAM)).toBe(expected.program);
327
+ expect(parameters.get(gl.FRAMEBUFFER_BINDING)).toBe(expected.framebuffer);
301
328
  expect(parameters.get(gl.ACTIVE_TEXTURE)).toBe(expected.activeTexture);
302
329
  expect(fixture.textureBindings.get(expected.activeTexture)).toBe(expected.texture);
303
330
  expect(gl.isEnabled(gl.SCISSOR_TEST)).toBe(expected.scissorTest);
304
331
  expect(parameters.get(gl.SCISSOR_BOX)).toEqual(expected.scissorBox);
305
332
  expect(parameters.get(gl.VIEWPORT)).toEqual(expected.viewport);
306
333
  expect(runtime.currentBlendMode).toBe(expected.blendMode);
334
+ expect(runtime.currentFramebuffer).toBe(expected.framebuffer);
335
+ expect(runtime.currentRenderTarget).toBe(expected.currentRenderTarget);
307
336
  expect(runtime.currentProgram).toBe(expected.program);
308
337
  expect(runtime.currentScissorRect).toBe(expected.scissorRect);
309
338
  expect(runtime.currentTexture).toBe(expected.texture);
310
339
  expect(runtime.currentTextureStraightAlpha).toBe(expected.straightAlpha);
340
+ expect(runtime.renderTargetViewport).toBe(expected.renderTargetViewport);
311
341
  }
312
342
 
313
343
  function setCapability(gl: WebGL2RenderingContext, capability: number, enabled: boolean): void {
@@ -9,7 +9,10 @@ import {
9
9
  declareGlRenderTargetColorSpace,
10
10
  destroyGlRenderTarget,
11
11
  drawGlRenderTargetResult,
12
+ explainGlRenderTarget,
13
+ isGlRenderTargetFormatSupported,
12
14
  resizeGlRenderTarget,
15
+ resolveGlRenderTargetAxes,
13
16
  resolveGlRenderTarget,
14
17
  } from './glRenderTarget';
15
18
  import { createGlState } from './glTestHelper';
@@ -88,14 +91,44 @@ describe('createGlRenderTarget', () => {
88
91
  expect(target.colorSpace).toBe('linear');
89
92
  });
90
93
 
91
- it('falls back a float format to rgba8 when EXT_color_buffer_float is unavailable', () => {
94
+ it('retains canonical requested axes separately from effective storage', () => {
95
+ const { state, gl } = makeState();
96
+ vi.mocked(gl.getParameter).mockImplementation((parameter) => (parameter === gl.MAX_SAMPLES ? 4 : null));
97
+ const target = createGlRenderTarget(state, {
98
+ width: 32,
99
+ height: 16,
100
+ colorAttachments: 2,
101
+ colorFormats: ['rgba8', 'rgba16f'],
102
+ sampleCount: 8,
103
+ depth: 'depth-stencil-sampled',
104
+ colorSpace: 'linear',
105
+ });
106
+
107
+ expect(target.requestedAxes).toEqual({
108
+ width: 32,
109
+ height: 16,
110
+ format: 'rgba8',
111
+ colorAttachments: 2,
112
+ colorFormats: ['rgba8', 'rgba16f'],
113
+ sampleCount: 8,
114
+ depth: 'depth-stencil-sampled',
115
+ colorSpace: 'linear',
116
+ });
117
+ expect(target.sampleCount).toBe(4);
118
+ expect(target.depth).toBe('depth-stencil');
119
+ });
120
+
121
+ it('falls back preferred float formats directly to rgba8 when EXT_color_buffer_float is unavailable', () => {
92
122
  const { state, gl } = makeState();
93
123
  // Simulate GL without float-render support (e.g. headless SwiftShader): a float target would be
94
124
  // framebuffer-incomplete and render black, so the effective format degrades to the renderable rgba8.
95
125
  (gl as unknown as { getExtension: (n: string) => unknown }).getExtension = (name: string) =>
96
126
  name === 'EXT_color_buffer_float' ? null : {};
97
- const target = createGlRenderTarget(state, { width: 32, height: 32, format: 'rgba16f' });
98
- expect(target.format).toBe('rgba8');
127
+ for (const format of ['rgba16f', 'rgba32f'] as const) {
128
+ const target = createGlRenderTarget(state, { width: 32, height: 32, format }, 'preferred');
129
+ expect(target.format).toBe('rgba8');
130
+ expect(target.colorFormats).toEqual(['rgba8']);
131
+ }
99
132
  });
100
133
 
101
134
  it('keeps a float format when EXT_color_buffer_float is available', () => {
@@ -104,6 +137,38 @@ describe('createGlRenderTarget', () => {
104
137
  const target = createGlRenderTarget(state, { width: 32, height: 32, format: 'rgba16f' });
105
138
  expect(target.format).toBe('rgba16f');
106
139
  });
140
+
141
+ it('refuses a required unsupported float format before allocating storage', () => {
142
+ const { state, gl } = makeState();
143
+ (gl as unknown as { getExtension: (n: string) => unknown }).getExtension = (name: string) =>
144
+ name === 'EXT_color_buffer_float' ? null : {};
145
+ vi.clearAllMocks();
146
+
147
+ const target = createGlRenderTarget(state, { width: 32, height: 32, format: 'rgba32f' }, 'required');
148
+
149
+ expect(target).toBeNull();
150
+ expect(gl.createFramebuffer).not.toHaveBeenCalled();
151
+ expect(gl.createTexture).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it('refuses required heterogeneous storage when any attachment format is unsupported', () => {
155
+ const { state, gl } = makeState();
156
+ (gl as unknown as { getExtension: (n: string) => unknown }).getExtension = (name: string) =>
157
+ name === 'EXT_color_buffer_float' ? null : {};
158
+
159
+ const target = createGlRenderTarget(
160
+ state,
161
+ {
162
+ width: 32,
163
+ height: 32,
164
+ colorAttachments: 2,
165
+ colorFormats: ['rgba8', 'rgba16f'],
166
+ },
167
+ 'required',
168
+ );
169
+
170
+ expect(target).toBeNull();
171
+ });
107
172
  });
108
173
 
109
174
  describe('declareGlRenderTargetColorSpace', () => {
@@ -114,6 +179,7 @@ describe('declareGlRenderTargetColorSpace', () => {
114
179
  beginGlRenderPass(state, target);
115
180
  expect(declareGlRenderTargetColorSpace(state, 'linear')).toBe(true);
116
181
  expect(target.colorSpace).toBe('linear');
182
+ expect(target.requestedAxes.colorSpace).toBe('linear');
117
183
  endGlRenderPass(state);
118
184
  });
119
185
 
@@ -188,6 +254,57 @@ describe('drawGlRenderTargetResult', () => {
188
254
  });
189
255
  });
190
256
 
257
+ describe('explainGlRenderTarget', () => {
258
+ it('reports every GL capability substitution without losing the request', () => {
259
+ const { state, gl } = makeState();
260
+ vi.mocked(gl.getParameter).mockImplementation((parameter) => (parameter === gl.MAX_SAMPLES ? 4 : null));
261
+ (gl as unknown as { getExtension: (name: string) => unknown }).getExtension = (name: string) =>
262
+ name === 'EXT_color_buffer_float' ? null : {};
263
+ const target = createGlRenderTarget(state, {
264
+ width: 32,
265
+ height: 16,
266
+ format: 'rgba16f',
267
+ sampleCount: 8,
268
+ depth: 'depth-stencil-sampled',
269
+ });
270
+
271
+ expect(explainGlRenderTarget(target).differences).toEqual([
272
+ { axis: 'format', effective: 'rgba8', requested: 'rgba16f' },
273
+ { axis: 'colorFormats', effective: ['rgba8'], requested: ['rgba16f'] },
274
+ { axis: 'sampleCount', effective: 4, requested: 8 },
275
+ { axis: 'depth', effective: 'depth-stencil', requested: 'depth-stencil-sampled' },
276
+ ]);
277
+ });
278
+
279
+ it('reports no differences when GL realizes every requested axis', () => {
280
+ const { state } = makeState();
281
+ const target = createGlRenderTarget(state, { width: 32, height: 16 });
282
+ expect(explainGlRenderTarget(target).differences).toEqual([]);
283
+ });
284
+ });
285
+
286
+ describe('isGlRenderTargetFormatSupported', () => {
287
+ it('reports rgba8 independently of float extension support', () => {
288
+ const { state, gl } = makeState();
289
+ const getExtension = vi.fn(() => null);
290
+ (gl as unknown as { getExtension: (name: string) => unknown }).getExtension = getExtension;
291
+
292
+ expect(isGlRenderTargetFormatSupported(state, 'rgba8')).toBe(true);
293
+ expect(getExtension).not.toHaveBeenCalled();
294
+ });
295
+
296
+ it('reports both float formats from EXT_color_buffer_float', () => {
297
+ const { state, gl } = makeState();
298
+ (gl as unknown as { getExtension: (name: string) => unknown }).getExtension = () => null;
299
+ expect(isGlRenderTargetFormatSupported(state, 'rgba16f')).toBe(false);
300
+ expect(isGlRenderTargetFormatSupported(state, 'rgba32f')).toBe(false);
301
+
302
+ (gl as unknown as { getExtension: (name: string) => unknown }).getExtension = () => ({});
303
+ expect(isGlRenderTargetFormatSupported(state, 'rgba16f')).toBe(true);
304
+ expect(isGlRenderTargetFormatSupported(state, 'rgba32f')).toBe(true);
305
+ });
306
+ });
307
+
191
308
  describe('resizeGlRenderTarget', () => {
192
309
  it('updates the target dimensions', () => {
193
310
  const { state } = makeState();
@@ -222,6 +339,44 @@ describe('resizeGlRenderTarget', () => {
222
339
  );
223
340
  expect(vi.mocked(gl.texImage2D)).toHaveBeenCalled();
224
341
  });
342
+
343
+ it('restores the tracked framebuffer after reallocating storage', () => {
344
+ const { state, gl } = makeState();
345
+ const target = createGlRenderTarget(state, { width: 64, height: 64 });
346
+ const previous = { name: 'previous-framebuffer' } as unknown as WebGLFramebuffer;
347
+ getGlRenderStateRuntime(state).currentFramebuffer = previous;
348
+ vi.clearAllMocks();
349
+
350
+ resizeGlRenderTarget(state, target, 128, 96);
351
+
352
+ expect(vi.mocked(gl.bindFramebuffer)).toHaveBeenLastCalledWith(gl.FRAMEBUFFER, previous);
353
+ expect(getGlRenderStateRuntime(state).currentFramebuffer).toBe(previous);
354
+ });
355
+
356
+ it('preserves heterogeneous color formats and sampled depth across resize', () => {
357
+ const { state, gl } = makeState();
358
+ (gl as unknown as { getExtension: (name: string) => unknown }).getExtension = () => ({});
359
+ const target = createGlRenderTarget(state, {
360
+ width: 64,
361
+ height: 64,
362
+ colorAttachments: 2,
363
+ colorFormats: ['rgba8', 'rgba32f'],
364
+ depth: 'depth-stencil-sampled',
365
+ });
366
+ vi.clearAllMocks();
367
+
368
+ resizeGlRenderTarget(state, target, 128, 96);
369
+
370
+ expect(target.colorAttachments).toBe(2);
371
+ expect(target.colorFormats).toEqual(['rgba8', 'rgba32f']);
372
+ expect(target.depth).toBe('depth-stencil-sampled');
373
+ expect(target.depthTexture).not.toBeNull();
374
+ expect(vi.mocked(gl.texImage2D).mock.calls.map((call) => call[2])).toEqual([
375
+ gl.RGBA8,
376
+ gl.RGBA32F,
377
+ gl.DEPTH24_STENCIL8,
378
+ ]);
379
+ });
225
380
  });
226
381
 
227
382
  describe('resolveGlRenderTarget', () => {
@@ -338,3 +493,31 @@ describe('resolveGlRenderTarget', () => {
338
493
  expect(gl.scissor).toHaveBeenLastCalledWith(3, 4, 16, 12);
339
494
  });
340
495
  });
496
+
497
+ describe('resolveGlRenderTargetAxes', () => {
498
+ it('queries effective axes without allocating target storage', () => {
499
+ const { state, gl } = makeState();
500
+ vi.mocked(gl.getParameter).mockImplementation((parameter) => (parameter === gl.MAX_SAMPLES ? 2 : null));
501
+ vi.clearAllMocks();
502
+
503
+ const axes = resolveGlRenderTargetAxes(state, { width: 32, height: 16, sampleCount: 4 });
504
+
505
+ expect(axes.sampleCount).toBe(2);
506
+ expect(gl.createFramebuffer).not.toHaveBeenCalled();
507
+ expect(gl.createTexture).not.toHaveBeenCalled();
508
+ });
509
+
510
+ it('applies preferred fallback and required refusal without allocating storage', () => {
511
+ const { state, gl } = makeState();
512
+ (gl as unknown as { getExtension: (name: string) => unknown }).getExtension = () => null;
513
+ vi.clearAllMocks();
514
+
515
+ expect(resolveGlRenderTargetAxes(state, { width: 32, height: 16, format: 'rgba32f' }, 'preferred')).toMatchObject({
516
+ format: 'rgba8',
517
+ colorFormats: ['rgba8'],
518
+ });
519
+ expect(resolveGlRenderTargetAxes(state, { width: 32, height: 16, format: 'rgba32f' }, 'required')).toBeNull();
520
+ expect(gl.createFramebuffer).not.toHaveBeenCalled();
521
+ expect(gl.createTexture).not.toHaveBeenCalled();
522
+ });
523
+ });
@@ -27,18 +27,123 @@ 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)', () => {
30
+ it('does not reuse a target with a different color space', () => {
31
31
  const { state } = createGlState();
32
32
  const pool = createGlRenderTargetPool();
33
33
  const first = acquireGlRenderTarget(state, pool, { width: 64, height: 48, colorSpace: 'linear' });
34
34
  expect(first.colorSpace).toBe('linear');
35
35
  releaseGlRenderTarget(pool, first);
36
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' });
37
+ const other = acquireGlRenderTarget(state, pool, { width: 64, height: 48, colorSpace: 'srgb' });
38
+ expect(other).not.toBe(first);
39
+ expect(pool.free).toContain(first);
40
+ });
41
+
42
+ it('does not reuse a target with different heterogeneous color formats', () => {
43
+ const { state } = createGlState();
44
+ const pool = createGlRenderTargetPool();
45
+ const first = acquireGlRenderTarget(state, pool, {
46
+ width: 64,
47
+ height: 48,
48
+ colorAttachments: 2,
49
+ colorFormats: ['rgba8', 'rgba8'],
50
+ });
51
+ releaseGlRenderTarget(pool, first);
52
+
53
+ const other = acquireGlRenderTarget(state, pool, {
54
+ width: 64,
55
+ height: 48,
56
+ colorAttachments: 2,
57
+ colorFormats: ['rgba8', 'rgba16f'],
58
+ });
59
+ expect(other).not.toBe(first);
60
+ expect(pool.free).toContain(first);
61
+ });
62
+
63
+ it('reuses the effective rgba8 target when a preferred float format is unsupported', () => {
64
+ const { state, gl } = createGlState();
65
+ vi.spyOn(gl, 'getExtension').mockReturnValue(null);
66
+ const pool = createGlRenderTargetPool();
67
+ const first = acquireGlRenderTarget(state, pool, { width: 64, height: 48, format: 'rgba8' });
68
+ releaseGlRenderTarget(pool, first);
69
+
70
+ const reused = acquireGlRenderTarget(state, pool, { width: 64, height: 48, format: 'rgba32f' }, 'preferred');
71
+ expect(reused).toBe(first);
72
+ expect(reused.format).toBe('rgba8');
73
+ expect(reused.requestedAxes.format).toBe('rgba32f');
74
+ expect(reused.requestedAxes.colorFormats).toEqual(['rgba32f']);
75
+ });
76
+
77
+ it('refuses an unsupported required float format without consuming a fallback target', () => {
78
+ const { state, gl } = createGlState();
79
+ vi.spyOn(gl, 'getExtension').mockReturnValue(null);
80
+ const pool = createGlRenderTargetPool();
81
+ const fallback = acquireGlRenderTarget(state, pool, { width: 64, height: 48, format: 'rgba8' });
82
+ releaseGlRenderTarget(pool, fallback);
83
+ const createFramebufferSpy = vi.spyOn(gl, 'createFramebuffer');
84
+ const clearSpy = vi.spyOn(gl, 'clear');
85
+ createFramebufferSpy.mockClear();
86
+ clearSpy.mockClear();
87
+
88
+ const refused = acquireGlRenderTarget(state, pool, { width: 64, height: 48, format: 'rgba32f' }, 'required');
89
+ expect(refused).toBeNull();
90
+ expect(pool.free).toEqual([fallback]);
91
+ expect(createFramebufferSpy).not.toHaveBeenCalled();
92
+ expect(clearSpy).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('allocates a supported required float format', () => {
96
+ const { state, gl } = createGlState();
97
+ vi.spyOn(gl, 'getExtension').mockReturnValue({} as never);
98
+ const pool = createGlRenderTargetPool();
99
+
100
+ const target = acquireGlRenderTarget(state, pool, { width: 64, height: 48, format: 'rgba16f' }, 'required');
101
+ expect(target).not.toBeNull();
102
+ expect(target?.format).toBe('rgba16f');
103
+ expect(target?.requestedAxes.format).toBe('rgba16f');
104
+ });
105
+
106
+ it('does not reuse a target with a different depth mode', () => {
107
+ const { state } = createGlState();
108
+ const pool = createGlRenderTargetPool();
109
+ const first = acquireGlRenderTarget(state, pool, { width: 64, height: 48, depth: 'none' });
110
+ releaseGlRenderTarget(pool, first);
111
+
112
+ const other = acquireGlRenderTarget(state, pool, { width: 64, height: 48, depth: 'depth-stencil' });
113
+ expect(other).not.toBe(first);
114
+ expect(pool.free).toContain(first);
115
+ });
116
+
117
+ it('re-stamps clear policy and requested axes on a compatible target', () => {
118
+ const { state } = createGlState();
119
+ const pool = createGlRenderTargetPool();
120
+ const first = acquireGlRenderTarget(state, pool, {
121
+ width: 64,
122
+ height: 48,
123
+ clearColors: [0xff0000ff],
124
+ clearDepth: 0.25,
125
+ });
126
+ releaseGlRenderTarget(pool, first);
127
+
128
+ const reused = acquireGlRenderTarget(state, pool, {
129
+ width: 64,
130
+ height: 48,
131
+ clearColors: [0x00ff00ff],
132
+ clearDepth: 0.75,
133
+ });
40
134
  expect(reused).toBe(first);
41
- expect(reused.colorSpace).toBe('srgb');
135
+ expect(reused.clearColors).toEqual([0x00ff00ff]);
136
+ expect(reused.clearDepth).toBe(0.75);
137
+ expect(reused.requestedAxes).toEqual({
138
+ width: 64,
139
+ height: 48,
140
+ format: 'rgba8',
141
+ colorAttachments: 1,
142
+ colorFormats: ['rgba8'],
143
+ sampleCount: 1,
144
+ depth: 'none',
145
+ colorSpace: 'srgb',
146
+ });
42
147
  });
43
148
 
44
149
  it('allocates a new target when the free target has different dimensions', () => {
@@ -1,5 +1,6 @@
1
1
  import { addLogSink, createMemoryLogSink, getMemoryLogSinkEntries, removeLogSink } from '@flighthq/log/contract';
2
2
  import type { RenderTexture } from '@flighthq/types/contract';
3
+ import { RenderTargetTextureSourceKind } from '@flighthq/types/contract';
3
4
 
4
5
  import { enableGlRenderTextureGuards } from './enableGlRenderTextureGuards';
5
6
  import { getGlRenderStateRuntime } from './glRenderState';
@@ -8,9 +9,12 @@ import {
8
9
  destroyGlRenderTexture,
9
10
  explainGlRenderTexture,
10
11
  getGlRenderTextureColorSpace,
12
+ getGlRenderTextureTarget,
13
+ invalidateGlRenderTexture,
11
14
  isGlRenderTextureReady,
12
15
  renderIntoGlRenderTexture,
13
16
  setGlRenderTextureGuard,
17
+ writeGlRenderTextureTarget,
14
18
  } from './glRenderTexture';
15
19
  import { createGlState } from './glTestHelper';
16
20
 
@@ -95,6 +99,32 @@ describe('getGlRenderTextureColorSpace', () => {
95
99
  });
96
100
  });
97
101
 
102
+ describe('getGlRenderTextureTarget', () => {
103
+ it('returns the hidden target only after a completed write', () => {
104
+ const { state } = createRenderTextureState();
105
+ const renderTexture = createRenderTexture({ height: 8, width: 8 });
106
+
107
+ expect(getGlRenderTextureTarget(state, renderTexture)).toBeNull();
108
+ writeGlRenderTextureTarget(state, renderTexture, () => {});
109
+ expect(getGlRenderTextureTarget(state, renderTexture)).not.toBeNull();
110
+ });
111
+ });
112
+
113
+ describe('invalidateGlRenderTexture', () => {
114
+ it('makes a previously published target unavailable without deleting it', () => {
115
+ const { state, gl } = createRenderTextureState();
116
+ const renderTexture = createRenderTexture({ height: 8, width: 8 });
117
+ writeGlRenderTextureTarget(state, renderTexture, () => {});
118
+ const deletes = vi.mocked(gl.deleteTexture).mock.calls.length;
119
+
120
+ invalidateGlRenderTexture(state, renderTexture);
121
+
122
+ expect(isGlRenderTextureReady(state, renderTexture)).toBe(false);
123
+ expect(getGlRenderTextureTarget(state, renderTexture)).toBeNull();
124
+ expect(gl.deleteTexture).toHaveBeenCalledTimes(deletes);
125
+ });
126
+ });
127
+
98
128
  describe('isGlRenderTextureReady', () => {
99
129
  it('becomes true only after a completed render', () => {
100
130
  const { state } = createRenderTextureState();
@@ -163,13 +193,29 @@ describe('renderIntoGlRenderTexture', () => {
163
193
  expect(bindGlRenderTexture(state, renderTexture)).toBeNull();
164
194
  });
165
195
 
196
+ it('keeps an outer writer active after a nested same-target pass is rejected', () => {
197
+ const { state } = createRenderTextureState();
198
+ const renderTexture = createRenderTexture({ height: 8, width: 8 });
199
+
200
+ renderIntoGlRenderTexture(state, renderTexture, () => {
201
+ getGlRenderStateRuntime(state).currentMaskDepth = 1;
202
+
203
+ expect(() => renderIntoGlRenderTexture(state, renderTexture, () => {})).toThrow(
204
+ 'cannot nest the active framebuffer while a contour clip is live',
205
+ );
206
+ expect(explainGlRenderTexture(state, renderTexture).status).toBe('writing');
207
+ });
208
+
209
+ expect(explainGlRenderTexture(state, renderTexture).status).toBe('ready');
210
+ });
211
+
166
212
  it('resizes the retained target when the source dimensions change', () => {
167
213
  const { state } = createRenderTextureState();
168
214
  const renderTexture = createRenderTexture({ height: 16, width: 32 });
169
215
  renderIntoGlRenderTexture(state, renderTexture, () => {});
170
216
 
171
- renderTexture.storage.target.width = 12;
172
- renderTexture.storage.target.height = 10;
217
+ renderTexture.source.width = 12;
218
+ renderTexture.source.height = 10;
173
219
  renderIntoGlRenderTexture(state, renderTexture, () => {});
174
220
 
175
221
  expect(explainGlRenderTexture(state, renderTexture)).toEqual({
@@ -209,6 +255,21 @@ describe('setGlRenderTextureGuard', () => {
209
255
  });
210
256
  });
211
257
 
258
+ describe('writeGlRenderTextureTarget', () => {
259
+ it('passes the hidden target to the producer and publishes the completed write', () => {
260
+ const { state } = createRenderTextureState();
261
+ const renderTexture = createRenderTexture({ height: 10, width: 12 });
262
+ const producer = vi.fn();
263
+
264
+ writeGlRenderTextureTarget(state, renderTexture, producer);
265
+
266
+ expect(producer).toHaveBeenCalledOnce();
267
+ expect(producer.mock.calls[0][0]).toBe(getGlRenderTextureTarget(state, renderTexture));
268
+ expect(renderTexture.version).toBe(1);
269
+ expect(isGlRenderTextureReady(state, renderTexture)).toBe(true);
270
+ });
271
+ });
272
+
212
273
  function createRenderTextureState(): ReturnType<typeof createGlState> {
213
274
  const fixture = createGlState();
214
275
  const { gl } = fixture;
@@ -237,15 +298,14 @@ function createRenderTexture(options: { depth?: boolean; height: number; width:
237
298
  uvOffset: { x: 0, y: 0 },
238
299
  uvRotation: 0,
239
300
  uvScale: { x: 1, y: 1 },
240
- storage: {
241
- dimension: '2d',
242
- image: null,
243
- target: {
244
- colorSpace: 'linear',
245
- depth: options.depth ? 'depth-stencil' : 'none',
246
- height: options.height,
247
- width: options.width,
248
- },
301
+ dimension: '2d',
302
+ source: {
303
+ colorSpace: 'linear',
304
+ depth: options.depth ? 'depth-stencil' : 'none',
305
+ height: options.height,
306
+ kind: RenderTargetTextureSourceKind,
307
+ version: 0,
308
+ width: options.width,
249
309
  },
250
310
  version: 0,
251
311
  } as unknown as RenderTexture;