@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
@@ -0,0 +1,58 @@
1
+ import { addLogSink, createMemoryLogSink, getMemoryLogSinkEntries, removeLogSink } from '@flighthq/log/contract';
2
+ import { createTexture } from '@flighthq/texture/contract';
3
+ import type { Image } from '@flighthq/types/contract';
4
+
5
+ import { areGlTextureResolverGuardsEnabled, enableGlTextureResolverGuards } from './enableGlTextureResolverGuards';
6
+ import { createGlState } from './glTestHelper';
7
+ import { registerGlTextureResolver, resolveGlTexture } from './glTextureResolver';
8
+
9
+ describe('areGlTextureResolverGuardsEnabled', () => {
10
+ it('reports whether diagnostics were installed for the state', () => {
11
+ const { state } = createGlState();
12
+ expect(areGlTextureResolverGuardsEnabled(state)).toBe(false);
13
+
14
+ enableGlTextureResolverGuards(state);
15
+ expect(areGlTextureResolverGuardsEnabled(state)).toBe(true);
16
+ });
17
+ });
18
+
19
+ describe('enableGlTextureResolverGuards', () => {
20
+ it('is idempotent', () => {
21
+ const { state } = createGlState();
22
+ enableGlTextureResolverGuards(state);
23
+ enableGlTextureResolverGuards(state);
24
+ expect(areGlTextureResolverGuardsEnabled(state)).toBe(true);
25
+ });
26
+
27
+ it('warns once for a missing resolver and stays silent for a registered resolver returning null', () => {
28
+ const { state } = createGlState();
29
+ const missing = textureWithKind('acme.missing.gl');
30
+ const registered = textureWithKind('acme.registered.gl');
31
+ const sink = createMemoryLogSink(4);
32
+ addLogSink(sink.sink);
33
+ enableGlTextureResolverGuards(state);
34
+ registerGlTextureResolver(state, 'acme.registered.gl', () => null);
35
+ try {
36
+ expect(resolveGlTexture(state, missing)).toBeNull();
37
+ expect(resolveGlTexture(state, missing)).toBeNull();
38
+ expect(resolveGlTexture(state, registered)).toBeNull();
39
+
40
+ const entries = getMemoryLogSinkEntries(sink);
41
+ expect(entries).toHaveLength(1);
42
+ expect(entries[0]?.data).toMatchObject({
43
+ kind: 'acme.missing.gl',
44
+ message:
45
+ 'resolveGlTexture: texture source kind has no registered resolver — call registerGlTextureResolver(state, sourceKind, resolver), or copyGlRenderStateRegistrations(offscreenState, screenState) after a late screen registration',
46
+ });
47
+ } finally {
48
+ removeLogSink(sink.sink);
49
+ }
50
+ });
51
+ });
52
+
53
+ function textureWithKind(kind: string) {
54
+ return createTexture({
55
+ dimension: '2d',
56
+ source: { kind } as Image,
57
+ });
58
+ }
@@ -0,0 +1,32 @@
1
+ import { createEntity } from '@flighthq/entity/contract';
2
+ import { createTexture } from '@flighthq/texture/contract';
3
+ import type { TextureSource } from '@flighthq/types/contract';
4
+
5
+ import { explainGlTextureResolution } from './explainGlTextureResolution';
6
+ import { createGlState } from './glTestHelper';
7
+ import { registerGlTextureResolver } from './glTextureResolver';
8
+
9
+ describe('explainGlTextureResolution', () => {
10
+ it('distinguishes missing kinds, missing resolvers, and registered resolvers', () => {
11
+ const { state } = createGlState();
12
+ const texture = createTexture({
13
+ dimension: '2d',
14
+ source: createEntity({ height: 1, kind: 'acme.test', version: 0, width: 1 }) as TextureSource,
15
+ });
16
+
17
+ expect(explainGlTextureResolution(state, createTexture())).toEqual({
18
+ kind: null,
19
+ status: 'missing-kind',
20
+ });
21
+ expect(explainGlTextureResolution(state, texture)).toEqual({
22
+ kind: 'acme.test',
23
+ status: 'missing-resolver',
24
+ });
25
+
26
+ registerGlTextureResolver(state, 'acme.test', () => null);
27
+ expect(explainGlTextureResolution(state, texture)).toEqual({
28
+ kind: 'acme.test',
29
+ status: 'registered',
30
+ });
31
+ });
32
+ });
@@ -1,5 +1,5 @@
1
1
  import type { CompressedImage, GlCompressedTextureSupport, TextureContainer } from '@flighthq/types/contract';
2
- import { CompressedImageTextureBackingKind } from '@flighthq/types/contract';
2
+ import { CompressedImageTextureSourceKind } from '@flighthq/types/contract';
3
3
 
4
4
  import {
5
5
  detectGlCompressedTextureSupport,
@@ -51,7 +51,7 @@ function uploadableCompressedImage(containerOverrides?: Partial<TextureContainer
51
51
  return {
52
52
  compressed: { container: { ...makeContainer(), ...containerOverrides }, payload: new Uint8Array(16) },
53
53
  height: 4,
54
- kind: CompressedImageTextureBackingKind,
54
+ kind: CompressedImageTextureSourceKind,
55
55
  version: 1,
56
56
  width: 4,
57
57
  } as unknown as CompressedImage;
@@ -1,10 +1,11 @@
1
- import type { Bitmap, CompressedImage, ImageResource, SamplerLike, Texture } from '@flighthq/types/contract';
1
+ import { getTextureSource } from '@flighthq/texture/contract';
2
+ import type { Bitmap, CompressedImage, Image, SamplerLike, Texture } from '@flighthq/types/contract';
2
3
  import {
3
4
  AdvancedBlendMode,
4
- BitmapTextureBackingKind,
5
+ BitmapTextureSourceKind,
5
6
  BlendMode,
6
- CompressedImageTextureBackingKind,
7
- ImageTextureBackingKind,
7
+ CompressedImageTextureSourceKind,
8
+ ImageTextureSourceKind,
8
9
  } from '@flighthq/types/contract';
9
10
 
10
11
  import { registerGlCompressedTextureDecoder, registerGlCompressedTextureUpload } from './glCompressedTexture';
@@ -50,7 +51,7 @@ function compressedBc3Image(): CompressedImage {
50
51
  width: 4,
51
52
  height: 4,
52
53
  version: 1,
53
- kind: CompressedImageTextureBackingKind,
54
+ kind: CompressedImageTextureSourceKind,
54
55
  } as unknown as CompressedImage;
55
56
  }
56
57
 
@@ -61,7 +62,7 @@ function bitmap(size: number, version: number): Bitmap {
61
62
  data: new Uint8ClampedArray(size * size * 4),
62
63
  format: 'rgba8unorm',
63
64
  height: size,
64
- kind: BitmapTextureBackingKind,
65
+ kind: BitmapTextureSourceKind,
65
66
  version,
66
67
  width: size,
67
68
  } as unknown as Bitmap;
@@ -291,9 +292,9 @@ describe('bindGlImageResourceTexture', () => {
291
292
  source: document.createElement('img'),
292
293
  width: 1,
293
294
  height: 1,
294
- kind: ImageTextureBackingKind,
295
+ kind: ImageTextureSourceKind,
295
296
  version: 1,
296
- } as unknown as ImageResource;
297
+ } as unknown as Image;
297
298
  bindGlImageResourceTexture(state, image);
298
299
  expect(gl.texImage2D).toHaveBeenCalled();
299
300
  });
@@ -516,18 +517,16 @@ describe('bindGlVideoTexture', () => {
516
517
  function videoTexture(version: number, readyState = 4, videoWidth = 320, videoHeight = 240): Texture {
517
518
  return {
518
519
  sampler: makeSampler(),
519
- storage: {
520
- dimension: '2d',
521
- image: {
522
- source: { readyState, videoWidth, videoHeight } as unknown as HTMLVideoElement,
523
- version,
524
- },
520
+ dimension: '2d',
521
+ source: {
522
+ source: { readyState, videoWidth, videoHeight } as unknown as HTMLVideoElement,
523
+ version,
525
524
  },
526
525
  version,
527
526
  } as unknown as Texture;
528
527
  }
529
528
 
530
- it('creates a texture, uploads the current frame, and caches by backing identity', () => {
529
+ it('creates a texture, uploads the current frame, and caches by source identity', () => {
531
530
  const { state, gl } = createGlState();
532
531
  const vt = videoTexture(3);
533
532
  const t1 = bindGlVideoTexture(state, vt);
@@ -538,20 +537,20 @@ describe('bindGlVideoTexture', () => {
538
537
  gl.RGBA,
539
538
  gl.RGBA,
540
539
  gl.UNSIGNED_BYTE,
541
- (vt.storage.image as ImageResource).source,
540
+ (getTextureSource(vt) as Image).source,
542
541
  );
543
542
  const t2 = bindGlVideoTexture(state, vt);
544
543
  expect(t2).toBe(t1);
545
544
  });
546
545
 
547
- it('re-uploads only when the backing version advances (the dirty-gate)', () => {
546
+ it('re-uploads only when the source version advances (the dirty-gate)', () => {
548
547
  const { state, gl } = createGlState();
549
548
  const vt = videoTexture(1);
550
549
  bindGlVideoTexture(state, vt);
551
550
  const uploads = (gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length;
552
551
  bindGlVideoTexture(state, vt);
553
552
  expect((gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length).toBe(uploads);
554
- vt.storage.image!.version = 2;
553
+ getTextureSource(vt)!.version = 2;
555
554
  bindGlVideoTexture(state, vt);
556
555
  expect((gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length).toBe(uploads + 1);
557
556
  });
@@ -1,5 +1,5 @@
1
- import { getTextureBackingKind } from '@flighthq/texture/contract';
2
- import { ExternalTextureBackingKind } from '@flighthq/types/contract';
1
+ import { getTextureSourceKind } from '@flighthq/texture/contract';
2
+ import { ExternalTextureSourceKind } from '@flighthq/types/contract';
3
3
 
4
4
  import { createExternalGlTexture, disposeExternalGlTexture } from './glExternalTexture';
5
5
  import { createGlState } from './glTestHelper';
@@ -12,7 +12,7 @@ describe('createExternalGlTexture', () => {
12
12
  const deleteTexture = vi.spyOn(state.gl, 'deleteTexture');
13
13
  const texture = createExternalGlTexture(state, handle, { height: 16, width: 32 });
14
14
 
15
- expect(getTextureBackingKind(texture)).toBe(ExternalTextureBackingKind);
15
+ expect(getTextureSourceKind(texture)).toBe(ExternalTextureSourceKind);
16
16
  expect(resolveGlTexture(state, texture)).toBe(handle);
17
17
  expect(disposeExternalGlTexture(state, texture)).toBe(true);
18
18
  expect(resolveGlTexture(state, texture)).toBeNull();
@@ -16,9 +16,22 @@ void main() {
16
16
 
17
17
  function makeTarget(framebuffer: WebGLFramebuffer, width = 32, height = 16): GlRenderTarget {
18
18
  return {
19
+ requestedAxes: {
20
+ width,
21
+ height,
22
+ format: 'rgba8',
23
+ colorAttachments: 1,
24
+ colorFormats: ['rgba8'],
25
+ sampleCount: 1,
26
+ depth: 'none',
27
+ colorSpace: 'srgb',
28
+ },
19
29
  width,
20
30
  height,
21
31
  format: 'rgba8',
32
+ colorAttachments: 1,
33
+ colorFormats: ['rgba8'],
34
+ depth: 'none',
22
35
  colorSpace: 'srgb',
23
36
  clearColors: [],
24
37
  clearDepth: 1,
@@ -5,9 +5,22 @@ import { createGlState } from './glTestHelper';
5
5
 
6
6
  function makeTarget(framebuffer: WebGLFramebuffer, texture: WebGLTexture, width = 32, height = 16): GlRenderTarget {
7
7
  return {
8
+ requestedAxes: {
9
+ width,
10
+ height,
11
+ format: 'rgba16f',
12
+ colorAttachments: 1,
13
+ colorFormats: ['rgba16f'],
14
+ sampleCount: 1,
15
+ depth: 'none',
16
+ colorSpace: 'linear',
17
+ },
8
18
  width,
9
19
  height,
10
20
  format: 'rgba16f',
21
+ colorAttachments: 1,
22
+ colorFormats: ['rgba16f'],
23
+ depth: 'none',
11
24
  colorSpace: 'linear',
12
25
  clearColors: [],
13
26
  clearDepth: 1,
@@ -4,10 +4,24 @@ import { presentGlRenderTarget } from './glPresentRenderTarget';
4
4
  import { createGlState } from './glTestHelper';
5
5
 
6
6
  function makeTarget(colorSpace: 'linear' | 'srgb', texture: WebGLTexture): GlRenderTarget {
7
+ const format = colorSpace === 'linear' ? 'rgba16f' : 'rgba8';
7
8
  return {
9
+ requestedAxes: {
10
+ width: 32,
11
+ height: 16,
12
+ format,
13
+ colorAttachments: 1,
14
+ colorFormats: [format],
15
+ sampleCount: 1,
16
+ depth: 'none',
17
+ colorSpace,
18
+ },
8
19
  width: 32,
9
20
  height: 16,
10
- format: colorSpace === 'linear' ? 'rgba16f' : 'rgba8',
21
+ format,
22
+ colorAttachments: 1,
23
+ colorFormats: [format],
24
+ depth: 'none',
11
25
  colorSpace,
12
26
  clearColors: [],
13
27
  clearDepth: 1,
@@ -5,9 +5,22 @@ import { createGlState } from './glTestHelper';
5
5
 
6
6
  function makeTarget(overrides?: Partial<GlRenderTarget>): GlRenderTarget {
7
7
  return {
8
+ requestedAxes: {
9
+ width: 4,
10
+ height: 4,
11
+ format: 'rgba8',
12
+ colorAttachments: 1,
13
+ colorFormats: ['rgba8'],
14
+ sampleCount: 1,
15
+ depth: 'none',
16
+ colorSpace: 'srgb',
17
+ },
8
18
  width: 4,
9
19
  height: 4,
10
20
  format: 'rgba8',
21
+ colorAttachments: 1,
22
+ colorFormats: ['rgba8'],
23
+ depth: 'none',
11
24
  colorSpace: 'srgb',
12
25
  sampleCount: 1,
13
26
  framebuffer: {} as WebGLFramebuffer,
@@ -1,4 +1,6 @@
1
1
  import { createMatrix } from '@flighthq/geometry/contract';
2
+ import { getOrCreateRenderProxy2D } from '@flighthq/render/contract';
3
+ import { createDisplayObject } from '@flighthq/scene2d/contract';
2
4
  import type { GlRenderTarget, Viewport } from '@flighthq/types/contract';
3
5
 
4
6
  import { beginGlRenderPass, endGlRenderPass, setGlRenderTransform2D } from './glRenderPass';
@@ -8,9 +10,22 @@ import { createGlState } from './glTestHelper';
8
10
  function makeTarget(overrides?: Partial<GlRenderTarget>): GlRenderTarget {
9
11
  const texture = { id: 'c0' } as unknown as WebGLTexture;
10
12
  return {
13
+ requestedAxes: {
14
+ width: 32,
15
+ height: 16,
16
+ format: 'rgba8',
17
+ colorAttachments: 1,
18
+ colorFormats: ['rgba8'],
19
+ sampleCount: 1,
20
+ depth: 'depth-stencil',
21
+ colorSpace: 'srgb',
22
+ },
11
23
  width: 32,
12
24
  height: 16,
13
25
  format: 'rgba8',
26
+ colorAttachments: 1,
27
+ colorFormats: ['rgba8'],
28
+ depth: 'depth-stencil',
14
29
  colorSpace: 'srgb',
15
30
  clearColors: [],
16
31
  clearDepth: 1,
@@ -161,6 +176,44 @@ describe('beginGlRenderPass', () => {
161
176
  expect(runtime.currentRenderTarget).toBe(outer);
162
177
  expect(runtime.renderTargetViewport).toEqual({ height: 48, width: 64, x: 0, y: 0 });
163
178
  });
179
+
180
+ it('shares pass ownership between render states using the same context', () => {
181
+ const outerFixture = createGlState();
182
+ const innerFixture = createGlState();
183
+ const { gl, state: outerState } = outerFixture;
184
+ const innerState = innerFixture.state;
185
+ (innerState as { gl: WebGL2RenderingContext }).gl = gl;
186
+ const outer = makeTarget({ width: 64, height: 48 });
187
+ const inner = makeTarget({ width: 32, height: 24 });
188
+
189
+ beginGlRenderPass(outerState, outer);
190
+ beginGlRenderPass(innerState, inner);
191
+ endGlRenderPass(innerState);
192
+
193
+ expect(vi.mocked(gl.bindFramebuffer).mock.calls.at(-1)?.[1]).toBe(outer.framebuffer);
194
+ expect(getGlRenderStateRuntime(outerState).currentRenderTarget).toBe(outer);
195
+ expect(getGlRenderStateRuntime(innerState).currentRenderTarget).toBeNull();
196
+ endGlRenderPass(outerState);
197
+ });
198
+
199
+ it('rejects a same-target contour pass across render states sharing one context', () => {
200
+ const outerFixture = createGlState();
201
+ const innerFixture = createGlState();
202
+ const { gl, state: outerState } = outerFixture;
203
+ const innerState = innerFixture.state;
204
+ (innerState as { gl: WebGL2RenderingContext }).gl = gl;
205
+ const target = makeTarget({ width: 64, height: 48 });
206
+
207
+ beginGlRenderPass(outerState, target);
208
+ getGlRenderStateRuntime(outerState).currentMaskDepth = 1;
209
+
210
+ expect(() => beginGlRenderPass(innerState, target)).toThrow(
211
+ 'cannot nest the active framebuffer while a contour clip is live',
212
+ );
213
+ expect(getGlRenderStateRuntime(outerState).currentRenderTarget).toBe(target);
214
+ expect(getGlRenderStateRuntime(innerState).currentRenderTarget).toBeNull();
215
+ endGlRenderPass(outerState);
216
+ });
164
217
  });
165
218
 
166
219
  describe('endGlRenderPass', () => {
@@ -306,4 +359,14 @@ describe('setGlRenderTransform2D', () => {
306
359
 
307
360
  expect(state.renderTransform2D).toBe(original);
308
361
  });
362
+
363
+ it('dirties existing state-local proxy transforms for repeated offscreen captures', () => {
364
+ const { state } = createGlState();
365
+ const proxy = getOrCreateRenderProxy2D(state, createDisplayObject());
366
+ proxy.lastLocalTransformId = 7;
367
+
368
+ setGlRenderTransform2D(state, createMatrix());
369
+
370
+ expect(proxy.lastLocalTransformId).toBe(-1);
371
+ });
309
372
  });
@@ -1,6 +1,18 @@
1
+ import { addLogSink, createMemoryLogSink, getMemoryLogSinkEntries, removeLogSink } from '@flighthq/log/contract';
2
+ import {
3
+ copyAllRenderersFromRenderState,
4
+ getRenderStateRuntime,
5
+ prepareScene2DRender,
6
+ registerRenderer,
7
+ } from '@flighthq/render/contract';
8
+ import { createDisplayObject } from '@flighthq/scene2d/contract';
1
9
  import { BlendMode } from '@flighthq/types/contract';
2
10
 
11
+ import { enableGlRenderStateGuards } from './enableGlRenderStateGuards';
12
+ import { registerGlMaterialRenderer } from './glMaterialRegistry';
3
13
  import {
14
+ copyGlRenderStateRegistrations,
15
+ createGlOffscreenRenderState,
4
16
  createGlRenderState,
5
17
  createGlRenderStateRuntime,
6
18
  destroyGlRenderState,
@@ -8,6 +20,7 @@ import {
8
20
  invalidateGlRenderStateCache,
9
21
  } from './glRenderState';
10
22
  import { makeGL } from './glTestHelper';
23
+ import { registerGlTextureResolver } from './glTextureResolver';
11
24
 
12
25
  function makeCanvas() {
13
26
  const canvas = document.createElement('canvas');
@@ -18,6 +31,108 @@ function makeCanvas() {
18
31
  return { canvas, gl };
19
32
  }
20
33
 
34
+ describe('copyGlRenderStateRegistrations', () => {
35
+ it('copies late GL registrations only when explicitly requested', () => {
36
+ const { canvas } = makeCanvas();
37
+ const screen = createGlRenderState(canvas);
38
+ const offscreen = createGlOffscreenRenderState(screen);
39
+ const resolver = vi.fn(() => null);
40
+ registerGlTextureResolver(screen, 'acme.LateTexture', resolver);
41
+
42
+ expect(getGlRenderStateRuntime(offscreen).glTextureResolverRegistry?.has('acme.LateTexture')).not.toBe(true);
43
+ copyGlRenderStateRegistrations(offscreen, screen);
44
+ expect(getGlRenderStateRuntime(offscreen).glTextureResolverRegistry?.get('acme.LateTexture')).toBe(resolver);
45
+ });
46
+ });
47
+
48
+ describe('createGlOffscreenRenderState', () => {
49
+ it('shares context resources while snapshotting registration policy into independent maps', () => {
50
+ const { canvas } = makeCanvas();
51
+ const screen = createGlRenderState(canvas);
52
+ const renderer = { createData: () => null, submit: vi.fn() };
53
+ const materialRenderer = { getBatchData: vi.fn(), getBatchFloats: vi.fn() } as never;
54
+ const paddingResolver = vi.fn(() => ({ bottom: 1, left: 1, right: 1, top: 1 }));
55
+ const textureResolver = vi.fn(() => null);
56
+ registerRenderer(screen, 'acme.Node', renderer);
57
+ registerGlMaterialRenderer(screen, 'acme.Material', materialRenderer);
58
+ registerGlTextureResolver(screen, 'acme.Texture', textureResolver);
59
+ getRenderStateRuntime(screen).renderEffectPaddingResolverRegistry = new Map([['acme.Effect', paddingResolver]]);
60
+ getGlRenderStateRuntime(screen).glRenderTextureCache = new WeakMap();
61
+
62
+ const offscreen = createGlOffscreenRenderState(screen);
63
+ const screenRuntime = getGlRenderStateRuntime(screen);
64
+ const offscreenRuntime = getGlRenderStateRuntime(offscreen);
65
+
66
+ expect(offscreen.gl).toBe(screen.gl);
67
+ expect(offscreen.canvas).toBe(screen.canvas);
68
+ expect(offscreenRuntime.textureCache).toBe(screenRuntime.textureCache);
69
+ expect(offscreenRuntime.textureSourcePremultipliedTextureCache).toBe(
70
+ screenRuntime.textureSourcePremultipliedTextureCache,
71
+ );
72
+ expect(offscreenRuntime.glRenderTextureCache).toBe(screenRuntime.glRenderTextureCache);
73
+ expect(offscreenRuntime.quadIndexBuffer).toBe(screenRuntime.quadIndexBuffer);
74
+ expect(offscreenRuntime.rendererMap).not.toBe(screenRuntime.rendererMap);
75
+ expect(offscreenRuntime.materialRendererMap).not.toBe(screenRuntime.materialRendererMap);
76
+ expect(offscreenRuntime.glTextureResolverRegistry).not.toBe(screenRuntime.glTextureResolverRegistry);
77
+ expect(offscreenRuntime.renderEffectPaddingResolverRegistry).not.toBe(
78
+ screenRuntime.renderEffectPaddingResolverRegistry,
79
+ );
80
+ expect(offscreenRuntime.rendererMap.get('acme.Node')).toBe(renderer);
81
+ expect(offscreenRuntime.materialRendererMap?.get('acme.Material')).toBe(materialRenderer);
82
+ expect(offscreenRuntime.glTextureResolverRegistry?.get('acme.Texture')).toBe(textureResolver);
83
+ expect(offscreenRuntime.renderEffectPaddingResolverRegistry?.get('acme.Effect')).toBe(paddingResolver);
84
+
85
+ screenRuntime.currentProgram = {} as WebGLProgram;
86
+ expect(offscreenRuntime.currentProgram).toBe(screenRuntime.currentProgram);
87
+ });
88
+
89
+ it('requires explicit re-copy for registrations added after derivation', () => {
90
+ const { canvas } = makeCanvas();
91
+ const screen = createGlRenderState(canvas);
92
+ const offscreen = createGlOffscreenRenderState(screen);
93
+ const renderer = { createData: () => null, submit: vi.fn() };
94
+ const paddingResolver = vi.fn(() => ({ bottom: 2, left: 2, right: 2, top: 2 }));
95
+ const resolver = vi.fn(() => null);
96
+ registerRenderer(screen, 'acme.LateNode', renderer);
97
+ registerGlTextureResolver(screen, 'acme.LateTexture', resolver);
98
+ getRenderStateRuntime(screen).renderEffectPaddingResolverRegistry = new Map([['acme.LateEffect', paddingResolver]]);
99
+
100
+ expect(getRenderStateRuntime(offscreen).rendererMap.has('acme.LateNode')).toBe(false);
101
+ expect(getGlRenderStateRuntime(offscreen).glTextureResolverRegistry?.has('acme.LateTexture')).not.toBe(true);
102
+ expect(getRenderStateRuntime(offscreen).renderEffectPaddingResolverRegistry?.has('acme.LateEffect')).not.toBe(true);
103
+
104
+ copyAllRenderersFromRenderState(offscreen, screen);
105
+ copyGlRenderStateRegistrations(offscreen, screen);
106
+ expect(getRenderStateRuntime(offscreen).rendererMap.get('acme.LateNode')).toBe(renderer);
107
+ expect(getGlRenderStateRuntime(offscreen).glTextureResolverRegistry?.get('acme.LateTexture')).toBe(resolver);
108
+ expect(getRenderStateRuntime(offscreen).renderEffectPaddingResolverRegistry?.get('acme.LateEffect')).toBe(
109
+ paddingResolver,
110
+ );
111
+ });
112
+
113
+ it('keeps proxy trees independent and destroys derived renderer data without freeing shared context resources', () => {
114
+ const { canvas, gl } = makeCanvas();
115
+ const screen = createGlRenderState(canvas);
116
+ const destroyData = vi.fn();
117
+ const root = createDisplayObject();
118
+ registerRenderer(screen, root.kind, {
119
+ createData: () => createEntity({}),
120
+ destroyData,
121
+ submit: vi.fn(),
122
+ });
123
+ const offscreen = createGlOffscreenRenderState(screen);
124
+ prepareScene2DRender(offscreen, root);
125
+
126
+ expect(getRenderStateRuntime(offscreen).renderProxyMap).not.toBe(getRenderStateRuntime(screen).renderProxyMap);
127
+ destroyGlRenderState(offscreen);
128
+ expect(destroyData).toHaveBeenCalledOnce();
129
+ expect(gl.deleteProgram).not.toHaveBeenCalled();
130
+
131
+ destroyGlRenderState(screen);
132
+ expect(gl.deleteProgram).toHaveBeenCalled();
133
+ });
134
+ });
135
+
21
136
  describe('createGlRenderState', () => {
22
137
  it('throws when Gl2 context is unavailable', () => {
23
138
  const canvas = document.createElement('canvas');
@@ -141,6 +256,25 @@ describe('destroyGlRenderState', () => {
141
256
  });
142
257
  });
143
258
 
259
+ describe('enableGlRenderStateGuards', () => {
260
+ it('warns when one GL pipeline prepares two different roots', () => {
261
+ const { canvas } = makeCanvas();
262
+ const state = createGlRenderState(canvas);
263
+ const sink = createMemoryLogSink(4);
264
+ addLogSink(sink.sink);
265
+ enableGlRenderStateGuards(state);
266
+ try {
267
+ prepareScene2DRender(state, createDisplayObject());
268
+ prepareScene2DRender(state, createDisplayObject());
269
+ const entries = getMemoryLogSinkEntries(sink);
270
+ expect(entries).toHaveLength(1);
271
+ expect((entries[0].data as { message: string }).message).toContain('createGlOffscreenRenderState');
272
+ } finally {
273
+ removeLogSink(sink.sink);
274
+ }
275
+ });
276
+ });
277
+
144
278
  describe('getGlRenderStateRuntime', () => {
145
279
  it('returns the runtime attached by createGlRenderState', () => {
146
280
  const { canvas } = makeCanvas();
@@ -184,3 +318,4 @@ describe('invalidateGlRenderStateCache', () => {
184
318
  expect(runtime.renderTargetViewport).toBeNull();
185
319
  });
186
320
  });
321
+ import { createEntity } from '@flighthq/entity/contract';