@flighthq/displayobject-gl 0.2.0 → 0.2.1-next.410.a23f189

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/glBitmap.d.ts.map +1 -1
  2. package/dist/glBitmap.js +15 -31
  3. package/dist/glBitmap.js.map +1 -1
  4. package/dist/glCache.d.ts.map +1 -1
  5. package/dist/glCache.js +7 -3
  6. package/dist/glCache.js.map +1 -1
  7. package/dist/glColorAdjustment.d.ts.map +1 -1
  8. package/dist/glColorAdjustment.js +74 -0
  9. package/dist/glColorAdjustment.js.map +1 -1
  10. package/dist/glParticleEmitter.d.ts.map +1 -1
  11. package/dist/glParticleEmitter.js +4 -3
  12. package/dist/glParticleEmitter.js.map +1 -1
  13. package/dist/glQuadBatch.d.ts.map +1 -1
  14. package/dist/glQuadBatch.js +3 -2
  15. package/dist/glQuadBatch.js.map +1 -1
  16. package/dist/glRichText.js +1 -1
  17. package/dist/glRichText.js.map +1 -1
  18. package/dist/glShape.d.ts.map +1 -1
  19. package/dist/glShape.js +13 -10
  20. package/dist/glShape.js.map +1 -1
  21. package/dist/glShapeMesh.d.ts +10 -6
  22. package/dist/glShapeMesh.d.ts.map +1 -1
  23. package/dist/glShapeMesh.js +48 -29
  24. package/dist/glShapeMesh.js.map +1 -1
  25. package/dist/glSpriteBatch.d.ts +2 -2
  26. package/dist/glSpriteBatch.d.ts.map +1 -1
  27. package/dist/glSpriteBatch.js +2 -2
  28. package/dist/glSpriteBatch.js.map +1 -1
  29. package/dist/glSpriteRenderer.d.ts.map +1 -1
  30. package/dist/glSpriteRenderer.js +3 -2
  31. package/dist/glSpriteRenderer.js.map +1 -1
  32. package/dist/glTextInput.js +1 -1
  33. package/dist/glTextInput.js.map +1 -1
  34. package/dist/glTextLabel.d.ts.map +1 -1
  35. package/dist/glTextLabel.js +20 -11
  36. package/dist/glTextLabel.js.map +1 -1
  37. package/dist/glTilemap.d.ts.map +1 -1
  38. package/dist/glTilemap.js +3 -2
  39. package/dist/glTilemap.js.map +1 -1
  40. package/dist/glVelocity.js +5 -5
  41. package/dist/glVelocity.js.map +1 -1
  42. package/dist/glVideo.d.ts.map +1 -1
  43. package/dist/glVideo.js +34 -32
  44. package/dist/glVideo.js.map +1 -1
  45. package/package.json +18 -16
  46. package/src/enableGlColorAdjustmentGuards.test.ts +1 -1
  47. package/src/glBitmap.test.ts +11 -3
  48. package/src/glCache.test.ts +6 -2
  49. package/src/glColorAdjustment.test.ts +4 -3
  50. package/src/glShape.test.ts +2 -1
  51. package/src/glShapeMesh.test.ts +103 -3
  52. package/src/glSpriteBatch.test.ts +4 -3
  53. package/src/glTextLabel.test.ts +17 -12
  54. package/src/glVideo.test.ts +47 -14
package/dist/glVideo.js CHANGED
@@ -1,55 +1,57 @@
1
- import { createGlTexture, drawGlQuad, useGlProgram } from '@flighthq/render-gl';
2
- import { getGlRenderStateRuntime } from '@flighthq/render-gl';
3
- import { resolveGlShader } from '@flighthq/render-gl';
1
+ import { bindGlVideoTexture, drawGlQuad, getGlRenderStateRuntime, resolveGlShader, useGlProgram, } from '@flighthq/render-gl';
2
+ import { advanceVideoTexture, createVideoTexture } from '@flighthq/texture';
4
3
  import { flushGlSpriteBatch } from './glSpriteBatch';
5
4
  export function createGlVideoData(_state, _source) {
6
- return { lastElement: null };
5
+ return { source: null, videoTexture: null };
7
6
  }
8
- // Frees the GPU texture uploaded for this video's element when the node is torn down via
9
- // disposeDisplayObjectRender. The element-keyed textureCache entry would otherwise leak.
7
+ // Frees the GPU texture the VideoTexture uploaded through when the node is torn down via
8
+ // disposeDisplayObjectRender. The VideoTexture-keyed videoTextureCache entry would otherwise leak.
10
9
  export function destroyGlVideoData(state, data) {
11
10
  const runtime = getGlRenderStateRuntime(state);
12
- const { lastElement } = data;
13
- if (lastElement === null)
11
+ const { videoTexture } = data;
12
+ if (videoTexture === null)
14
13
  return;
15
- const texture = runtime.textureCache.get(lastElement);
16
- if (texture !== undefined) {
17
- state.gl.deleteTexture(texture);
18
- runtime.textureCache.delete(lastElement);
14
+ const cache = runtime.videoTextureCache;
15
+ const entry = cache?.get(videoTexture);
16
+ if (entry !== undefined) {
17
+ state.gl.deleteTexture(entry.texture);
18
+ cache.delete(videoTexture);
19
19
  }
20
20
  }
21
21
  export function drawGlVideo(state, renderProxy) {
22
- const runtime = getGlRenderStateRuntime(state);
23
22
  flushGlSpriteBatch(state);
24
23
  const source = renderProxy.source;
25
- const element = source.data.source?.element;
26
- if (element === undefined || element === null || element.readyState < 2)
24
+ const resource = source.data.source ?? null;
25
+ const element = resource?.element ?? null;
26
+ if (resource === null || element === null || element.readyState < 2)
27
27
  return;
28
28
  const vw = element.videoWidth;
29
29
  const vh = element.videoHeight;
30
30
  if (vw === 0 || vh === 0)
31
31
  return;
32
- if (renderProxy.rendererData !== null) {
33
- renderProxy.rendererData.lastElement = element;
32
+ const data = renderProxy.rendererData;
33
+ // Rebuild the VideoTexture when the node's stream swaps (or on the first draw), so the new stream's
34
+ // frameId gate starts fresh and its first frame uploads.
35
+ let videoTexture;
36
+ if (data !== null && data.videoTexture !== null && data.source === resource) {
37
+ videoTexture = data.videoTexture;
38
+ }
39
+ else {
40
+ videoTexture = createVideoTexture(resource);
41
+ if (data !== null) {
42
+ data.source = resource;
43
+ data.videoTexture = videoTexture;
44
+ }
34
45
  }
35
- const gl = state.gl;
36
- const { textureCache } = runtime;
46
+ // The element decodes a new frame each rendered tick; bump the revision so bindGlVideoTexture's gate
47
+ // re-uploads this frame. A frame that has not advanced (a driver that only bumps on a real decode)
48
+ // would skip the upload — here the display node advances every draw to match the element's live pixels.
49
+ advanceVideoTexture(videoTexture);
37
50
  const shader = resolveGlShader(state, renderProxy);
38
51
  useGlProgram(state, shader);
39
52
  state.applyBlendMode?.(state, renderProxy.blendMode);
40
- let texture = textureCache.get(element);
41
- if (!texture) {
42
- texture = createGlTexture(state);
43
- textureCache.set(element, texture);
44
- }
45
- else {
46
- gl.bindTexture(gl.TEXTURE_2D, texture);
47
- runtime.currentTexture = texture;
48
- }
49
- // Upload current frame every time — video content changes each frame.
50
- gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
51
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, element);
52
- shader.bind(gl, state, renderProxy);
53
+ bindGlVideoTexture(state, videoTexture);
54
+ shader.bind(state.gl, state, renderProxy);
53
55
  drawGlQuad(state, 0, 0, vw, vh, 0, 0, 1, 1);
54
56
  }
55
57
  export const defaultGlVideoRenderer = {
@@ -1 +1 @@
1
- {"version":3,"file":"glVideo.js","sourceRoot":"","sources":["../src/glVideo.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"glVideo.js","sourceRoot":"","sources":["../src/glVideo.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flighthq/displayobject-gl",
3
- "version": "0.2.0",
3
+ "version": "0.2.1-next.410.a23f189",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/flighthq/flight.git",
@@ -32,21 +32,23 @@
32
32
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
33
33
  },
34
34
  "dependencies": {
35
- "@flighthq/displayobject": "0.2.0",
36
- "@flighthq/displayobject-canvas": "0.2.0",
37
- "@flighthq/geometry": "0.2.0",
38
- "@flighthq/log": "0.2.0",
39
- "@flighthq/materials": "0.2.0",
40
- "@flighthq/node": "0.2.0",
41
- "@flighthq/path": "0.2.0",
42
- "@flighthq/render": "0.2.0",
43
- "@flighthq/render-gl": "0.2.0",
44
- "@flighthq/shape": "0.2.0",
45
- "@flighthq/text": "0.2.0",
46
- "@flighthq/textinput": "0.2.0",
47
- "@flighthq/textlayout": "0.2.0",
48
- "@flighthq/types": "0.2.0",
49
- "@flighthq/velocity": "0.2.0"
35
+ "@flighthq/color": "0.2.1-next.410.a23f189",
36
+ "@flighthq/displayobject": "0.2.1-next.410.a23f189",
37
+ "@flighthq/displayobject-canvas": "0.2.1-next.410.a23f189",
38
+ "@flighthq/geometry": "0.2.1-next.410.a23f189",
39
+ "@flighthq/image": "0.2.1-next.410.a23f189",
40
+ "@flighthq/log": "0.2.1-next.410.a23f189",
41
+ "@flighthq/node": "0.2.1-next.410.a23f189",
42
+ "@flighthq/path": "0.2.1-next.410.a23f189",
43
+ "@flighthq/render": "0.2.1-next.410.a23f189",
44
+ "@flighthq/render-gl": "0.2.1-next.410.a23f189",
45
+ "@flighthq/shape": "0.2.1-next.410.a23f189",
46
+ "@flighthq/text": "0.2.1-next.410.a23f189",
47
+ "@flighthq/textinput": "0.2.1-next.410.a23f189",
48
+ "@flighthq/textlayout": "0.2.1-next.410.a23f189",
49
+ "@flighthq/texture": "0.2.1-next.410.a23f189",
50
+ "@flighthq/types": "0.2.1-next.410.a23f189",
51
+ "@flighthq/velocity": "0.2.1-next.410.a23f189"
50
52
  },
51
53
  "devDependencies": {
52
54
  "@flighthq/particleemitter": "*",
@@ -66,7 +66,7 @@ describe('enableGlColorAdjustmentGuards', () => {
66
66
  try {
67
67
  enableGlColorAdjustmentGuards(state);
68
68
  // Simulate the fold being installed so the dispatcher never reaches the guard branch.
69
- runtime.glColorAdjustmentFold = { flush: () => false, record: () => {} };
69
+ runtime.glColorAdjustmentFold = { drawShapeMeshes: () => {}, flush: () => false, record: () => {} };
70
70
  recordGlSpriteBatchColorTransform(state, ct(), 0);
71
71
  expect(getMemoryLogSinkEntries(sink).length).toBe(0);
72
72
  } finally {
@@ -23,8 +23,8 @@ function makeRenderProxy(image: unknown = null, rendererData: unknown = makeBitm
23
23
  } as unknown as RenderProxy2D;
24
24
  }
25
25
 
26
- function makeImageResource(source: unknown = null, width = 32, height = 32) {
27
- return { source, width, height };
26
+ function makeImageResource(source: unknown = null, width = 32, height = 32, data: Uint8ClampedArray | null = null) {
27
+ return { source, data, compressed: null, width, height, version: 1, alphaType: 'straight' };
28
28
  }
29
29
 
30
30
  describe('defaultGlBitmapRenderer', () => {
@@ -53,13 +53,21 @@ describe('drawGlBitmap', () => {
53
53
  expect(getGlRenderStateRuntime(state).spriteBatchCount).toBe(0);
54
54
  });
55
55
 
56
- it('returns early without writing to batch when image.source is null', () => {
56
+ it('returns early without writing to batch when the image has neither source nor data', () => {
57
57
  const { state } = createGlState();
58
58
  registerDefaultGlMaterial(state);
59
59
  drawGlBitmap(state, makeRenderProxy(makeImageResource(null)));
60
60
  expect(getGlRenderStateRuntime(state).spriteBatchCount).toBe(0);
61
61
  });
62
62
 
63
+ it('writes one instance for a data-only image (a generated Surface with no element)', () => {
64
+ const { state } = createGlState();
65
+ registerDefaultGlMaterial(state);
66
+ const data = new Uint8ClampedArray(32 * 32 * 4);
67
+ drawGlBitmap(state, makeRenderProxy(makeImageResource(null, 32, 32, data)));
68
+ expect(getGlRenderStateRuntime(state).spriteBatchCount).toBe(1);
69
+ });
70
+
63
71
  it('writes one instance to the sprite batch when image is valid', () => {
64
72
  const { state } = createGlState();
65
73
  registerDefaultGlMaterial(state);
@@ -47,7 +47,8 @@ beforeAll(async () => {
47
47
  const actual = await importOriginal<typeof GlRenderGlModule>();
48
48
  return {
49
49
  ...actual,
50
- beginGlRenderTarget: vi.fn(),
50
+ beginGlRenderPass: vi.fn(),
51
+ setGlRenderTransform2D: vi.fn(),
51
52
  createGlRenderTarget: vi.fn((_state: unknown, descriptor: { width: number; height: number }): GlRenderTarget => {
52
53
  const texture = {} as WebGLTexture;
53
54
  return {
@@ -59,6 +60,9 @@ beforeAll(async () => {
59
60
  colorRenderbuffers: [],
60
61
  depthStencilRenderbuffer: null,
61
62
  format: 'rgba8',
63
+ colorSpace: 'srgb',
64
+ clearColors: [],
65
+ clearDepth: 1,
62
66
  sampleCount: 1,
63
67
  width: descriptor.width,
64
68
  height: descriptor.height,
@@ -66,7 +70,7 @@ beforeAll(async () => {
66
70
  }),
67
71
  destroyGlRenderTarget: vi.fn(),
68
72
  drawGlRenderTargetResult: vi.fn(),
69
- endGlRenderTarget: vi.fn(),
73
+ endGlRenderPass: vi.fn(),
70
74
  resizeGlRenderTarget: vi.fn((_state: unknown, target: GlRenderTarget, width: number, height: number) => {
71
75
  target.width = width;
72
76
  target.height = height;
@@ -1,13 +1,14 @@
1
+ import { createImageResource } from '@flighthq/image';
1
2
  import { getGlRenderStateRuntime } from '@flighthq/render-gl';
2
- import type { ColorTransform } from '@flighthq/types';
3
+ import type { ColorTransform, ImageResource } from '@flighthq/types';
3
4
 
4
5
  import { enableGlColorAdjustment } from './glColorAdjustment';
5
6
  import { defaultGlMaterialRenderer } from './glDefaultMaterial';
6
7
  import { flushGlSpriteBatch, prepareGlSpriteBatchWrite, recordGlSpriteBatchColorTransform } from './glSpriteBatch';
7
8
  import { createGlState } from './glTestHelper';
8
9
 
9
- function makeTexture(): HTMLImageElement {
10
- return document.createElement('img');
10
+ function makeTexture(): ImageResource {
11
+ return createImageResource(document.createElement('img'));
11
12
  }
12
13
 
13
14
  function ct(
@@ -1,3 +1,4 @@
1
+ import { createImageResource } from '@flighthq/image';
1
2
  import type * as FlightNodeModule from '@flighthq/node';
2
3
  import { getGlRenderStateRuntime } from '@flighthq/render-gl';
3
4
  import type { RenderProxy2D } from '@flighthq/types';
@@ -33,7 +34,7 @@ function makeShapeData() {
33
34
  canvas.width = 1;
34
35
  canvas.height = 1;
35
36
  const ctx = canvas.getContext('2d')!;
36
- return { canvas, ctx, lastContentId: -1, lastW: 0, lastH: 0 };
37
+ return { canvas, ctx, image: createImageResource(canvas), lastContentId: -1, lastW: 0, lastH: 0 };
37
38
  }
38
39
 
39
40
  function makeShapeNode(data: Record<string, unknown> = {}, rendererData: unknown = null): RenderProxy2D {
@@ -1,17 +1,42 @@
1
1
  import { getGlRenderStateRuntime } from '@flighthq/render-gl';
2
- import type { RenderProxy2D } from '@flighthq/types';
2
+ import type { ColorTransform, RenderProxy2D } from '@flighthq/types';
3
3
 
4
- import { drawGlShapeMeshes } from './glShapeMesh';
4
+ import { enableGlColorAdjustment } from './glColorAdjustment';
5
+ import { drawGlShapeMeshBatch, drawGlShapeMeshes, ensureGlShapeMeshProgram } from './glShapeMesh';
5
6
  import { createGlState } from './glTestHelper';
6
7
 
7
- function makeProxy(): RenderProxy2D {
8
+ function makeProxy(overrides?: Partial<RenderProxy2D>): RenderProxy2D {
8
9
  return {
9
10
  alpha: 1,
10
11
  blendMode: null,
12
+ colorTransform: null,
11
13
  transform2D: { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 },
14
+ ...overrides,
12
15
  } as unknown as RenderProxy2D;
13
16
  }
14
17
 
18
+ function ct(
19
+ redMultiplier = 1,
20
+ greenMultiplier = 1,
21
+ blueMultiplier = 1,
22
+ alphaMultiplier = 1,
23
+ redOffset = 0,
24
+ greenOffset = 0,
25
+ blueOffset = 0,
26
+ alphaOffset = 0,
27
+ ): ColorTransform {
28
+ return {
29
+ redMultiplier,
30
+ greenMultiplier,
31
+ blueMultiplier,
32
+ alphaMultiplier,
33
+ redOffset,
34
+ greenOffset,
35
+ blueOffset,
36
+ alphaOffset,
37
+ } as ColorTransform;
38
+ }
39
+
15
40
  const TRIANGLE = {
16
41
  vertices: new Float32Array([0, 0, 10, 0, 0, 10]),
17
42
  indices: new Uint16Array([0, 1, 2]),
@@ -19,6 +44,25 @@ const TRIANGLE = {
19
44
  alpha: 1,
20
45
  };
21
46
 
47
+ describe('drawGlShapeMeshBatch', () => {
48
+ it('runs onProgramBound after the matrix upload and before the draw loop', () => {
49
+ const { state, gl } = createGlState();
50
+ const calls: string[] = [];
51
+ (gl.uniformMatrix3fv as ReturnType<typeof vi.fn>).mockImplementation(() => calls.push('matrix'));
52
+ (gl.drawElements as ReturnType<typeof vi.fn>).mockImplementation(() => calls.push('draw'));
53
+
54
+ drawGlShapeMeshBatch(state, makeProxy(), [TRIANGLE], ensureGlShapeMeshProgram(state), () => calls.push('bound'));
55
+
56
+ expect(calls).toEqual(['matrix', 'bound', 'draw']);
57
+ });
58
+
59
+ it('is a no-op for an empty mesh list', () => {
60
+ const { state, gl } = createGlState();
61
+ drawGlShapeMeshBatch(state, makeProxy(), [], ensureGlShapeMeshProgram(state));
62
+ expect(gl.useProgram).not.toHaveBeenCalled();
63
+ });
64
+ });
65
+
22
66
  describe('drawGlShapeMeshes', () => {
23
67
  it('binds the mesh program and draws each mesh', () => {
24
68
  const { state, gl } = createGlState();
@@ -61,4 +105,60 @@ describe('drawGlShapeMeshes', () => {
61
105
 
62
106
  expect(gl.useProgram).not.toHaveBeenCalled();
63
107
  });
108
+
109
+ it('ignores a color transform when color adjustment is not enabled (default path pays nothing)', () => {
110
+ const { state } = createGlState();
111
+
112
+ drawGlShapeMeshes(state, makeProxy({ colorTransform: ct(0.5) }), [TRIANGLE]);
113
+
114
+ // The tinted program is compiled only through the opt-in fold; the base path never touches it.
115
+ expect(getGlRenderStateRuntime(state).shapeMeshColorTransformShader).toBeUndefined();
116
+ });
117
+
118
+ it('tints solid-fill meshes through the fold with the same uniforms as the quad-batch path', () => {
119
+ const { state, gl } = createGlState();
120
+ enableGlColorAdjustment(state);
121
+
122
+ // White fill, half-brightness multiplier, +128 red offset — mirrors the Path-B uniform upload.
123
+ drawGlShapeMeshes(state, makeProxy({ colorTransform: ct(0.5, 0.5, 0.5, 1, 128, 0, 0, 0) }), [
124
+ { ...TRIANGLE, color: 0xffffff, alpha: 1 },
125
+ ]);
126
+
127
+ const shader = getGlRenderStateRuntime(state).shapeMeshColorTransformShader!;
128
+ expect(shader).toBeDefined();
129
+ // Multiplier uploaded verbatim; offsets normalized by 255 — identical to glColorAdjustment's
130
+ // bindGlSpriteBatchUniformColorTransform.
131
+ expect(gl.uniform4f).toHaveBeenCalledWith(shader.colorMultiplierLocation, 0.5, 0.5, 0.5, 1);
132
+ expect(gl.uniform4f).toHaveBeenCalledWith(shader.colorOffsetLocation, 128 / 255, 0, 0, 0);
133
+ // Flat mesh color still uploaded premultiplied (white, alpha 1); the shader un/re-premultiplies.
134
+ expect(gl.uniform4f).toHaveBeenCalledWith(shader.colorLocation, 1, 1, 1, 1);
135
+ expect(gl.drawElements).toHaveBeenCalled();
136
+ });
137
+
138
+ it('falls back to the lean path through the fold when the node carries no transform', () => {
139
+ const { state } = createGlState();
140
+ enableGlColorAdjustment(state);
141
+
142
+ drawGlShapeMeshes(state, makeProxy({ colorTransform: null }), [TRIANGLE]);
143
+
144
+ // No transform → the fold is not consulted (gated on non-null), so no tint shader is compiled.
145
+ expect(getGlRenderStateRuntime(state).shapeMeshColorTransformShader).toBeUndefined();
146
+ });
147
+ });
148
+
149
+ describe('ensureGlShapeMeshProgram', () => {
150
+ it('compiles once and caches the binding per context', () => {
151
+ const { state } = createGlState();
152
+ const first = ensureGlShapeMeshProgram(state);
153
+ const second = ensureGlShapeMeshProgram(state);
154
+ expect(second).toBe(first);
155
+ });
156
+
157
+ it('exposes shared vertex and index buffers for the draw driver', () => {
158
+ const { state } = createGlState();
159
+ const binding = ensureGlShapeMeshProgram(state);
160
+ expect(binding.vertexBuffer).toBeDefined();
161
+ expect(binding.indexBuffer).toBeDefined();
162
+ expect(binding.vertexBuffer).not.toBe(binding.indexBuffer);
163
+ });
64
164
  });
@@ -1,5 +1,6 @@
1
+ import { createImageResource } from '@flighthq/image';
1
2
  import { getGlRenderStateRuntime } from '@flighthq/render-gl';
2
- import type { ColorTransform, Material } from '@flighthq/types';
3
+ import type { ColorTransform, ImageResource, Material } from '@flighthq/types';
3
4
 
4
5
  import { enableGlColorAdjustment } from './glColorAdjustment';
5
6
  import { defaultGlMaterialRenderer } from './glDefaultMaterial';
@@ -15,8 +16,8 @@ import {
15
16
  } from './glSpriteBatch';
16
17
  import { createGlState } from './glTestHelper';
17
18
 
18
- function makeTexture(): HTMLImageElement {
19
- return document.createElement('img');
19
+ function makeTexture(): ImageResource {
20
+ return createImageResource(document.createElement('img'));
20
21
  }
21
22
 
22
23
  function makeMaterial(): Material {
@@ -1,3 +1,4 @@
1
+ import { createImageResource } from '@flighthq/image';
1
2
  import { getGlRenderStateRuntime } from '@flighthq/render-gl';
2
3
  import { createTextLabel, setTextLabelString } from '@flighthq/text';
3
4
  import type { RenderProxy2D, TextLabel } from '@flighthq/types';
@@ -45,7 +46,7 @@ function makeTextData() {
45
46
  canvas.width = 1;
46
47
  canvas.height = 1;
47
48
  const ctx = canvas.getContext('2d')!;
48
- return { canvas, ctx, lastHash: '', logW: 0, logH: 0 };
49
+ return { canvas, ctx, image: createImageResource(canvas), lastHash: '', logW: 0, logH: 0 };
49
50
  }
50
51
 
51
52
  function makeTextProxy(text = '', rendererData: unknown = null): RenderProxy2D {
@@ -118,34 +119,38 @@ describe('drawGlTextLabel', () => {
118
119
  it('skips layout and rasterization on repeated calls when the content version is unchanged', () => {
119
120
  const { state } = createGlState();
120
121
  registerDefaultGlMaterial(state);
121
- const proxy = makeTextProxy('hello', makeTextData());
122
- const deleteSpy = vi.spyOn(getGlRenderStateRuntime(state).textureCache, 'delete');
122
+ const data = makeTextData();
123
+ const proxy = makeTextProxy('hello', data);
123
124
  drawGlTextLabel(state, proxy);
125
+ // Rasterization bumps the canvas resource's version (setImageResourceSource); a skipped raster leaves
126
+ // it untouched. First draw rasterizes (version → 1); the repeat is skipped.
127
+ const rasterized = data.image.version;
124
128
  drawGlTextLabel(state, proxy);
125
- // textureCache.delete only happens during rasterization (first call); skipped on second.
126
- expect(deleteSpy).toHaveBeenCalledTimes(1);
129
+ expect(data.image.version).toBe(rasterized);
127
130
  });
128
131
 
129
132
  it('re-rasterizes when the content version is bumped', () => {
130
133
  const { state } = createGlState();
131
134
  registerDefaultGlMaterial(state);
132
- const proxy = makeTextProxy('hello', makeTextData());
133
- const deleteSpy = vi.spyOn(getGlRenderStateRuntime(state).textureCache, 'delete');
135
+ const data = makeTextData();
136
+ const proxy = makeTextProxy('hello', data);
134
137
  drawGlTextLabel(state, proxy);
138
+ const rasterized = data.image.version;
135
139
  setTextLabelString(proxy.source as TextLabel, 'world');
136
140
  drawGlTextLabel(state, proxy);
137
- expect(deleteSpy).toHaveBeenCalledTimes(2);
141
+ expect(data.image.version).toBeGreaterThan(rasterized);
138
142
  });
139
143
 
140
144
  it('does not re-rasterize when only alpha changes (version unchanged)', () => {
141
145
  const { state } = createGlState();
142
146
  registerDefaultGlMaterial(state);
143
- const proxy = makeTextProxy('hello', makeTextData());
144
- const deleteSpy = vi.spyOn(getGlRenderStateRuntime(state).textureCache, 'delete');
147
+ const data = makeTextData();
148
+ const proxy = makeTextProxy('hello', data);
145
149
  drawGlTextLabel(state, proxy);
150
+ const rasterized = data.image.version;
146
151
  proxy.alpha = 0.5;
147
152
  drawGlTextLabel(state, proxy);
148
- // Alpha is applied per-instance in the batch; the expensive raster cache is untouched.
149
- expect(deleteSpy).toHaveBeenCalledTimes(1);
153
+ // Alpha is applied per-instance in the batch; the expensive raster (and its version bump) is untouched.
154
+ expect(data.image.version).toBe(rasterized);
150
155
  });
151
156
  });
@@ -1,5 +1,5 @@
1
- import { getGlRenderStateRuntime } from '@flighthq/render-gl';
2
- import type { RendererData, RenderProxy2D } from '@flighthq/types';
1
+ import { getGlRenderStateRuntime } from '@flighthq/render-gl';
2
+ import type { RendererData, RenderProxy2D, VideoTexture } from '@flighthq/types';
3
3
 
4
4
  import { createGlState } from './glTestHelper';
5
5
  import { createGlVideoData, defaultGlVideoRenderer, destroyGlVideoData, drawGlVideo } from './glVideo';
@@ -10,15 +10,27 @@ function makeVideoNode(element: HTMLVideoElement | null = null): RenderProxy2D {
10
10
  blendMode: 0,
11
11
  alpha: 1,
12
12
  transform2D: { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 },
13
- rendererData: { lastElement: null },
13
+ rendererData: { source: null, videoTexture: null },
14
14
  } as unknown as RenderProxy2D;
15
15
  }
16
16
 
17
+ function makeReadyElement(): HTMLVideoElement {
18
+ const element = document.createElement('video');
19
+ Object.defineProperty(element, 'readyState', { value: 4, configurable: true });
20
+ Object.defineProperty(element, 'videoWidth', { value: 320, configurable: true });
21
+ Object.defineProperty(element, 'videoHeight', { value: 240, configurable: true });
22
+ return element;
23
+ }
24
+
17
25
  describe('createGlVideoData', () => {
18
- it('allocates per-node data with no element recorded yet', () => {
26
+ it('allocates per-node data with no stream or texture recorded yet', () => {
19
27
  const { state } = createGlState();
20
- const data = createGlVideoData(state, {} as never) as unknown as { lastElement: HTMLVideoElement | null };
21
- expect(data.lastElement).toBeNull();
28
+ const data = createGlVideoData(state, {} as never) as unknown as {
29
+ source: unknown;
30
+ videoTexture: VideoTexture | null;
31
+ };
32
+ expect(data.source).toBeNull();
33
+ expect(data.videoTexture).toBeNull();
22
34
  });
23
35
  });
24
36
 
@@ -30,22 +42,23 @@ describe('defaultGlVideoRenderer', () => {
30
42
  });
31
43
 
32
44
  describe('destroyGlVideoData', () => {
33
- it('deletes the cached GPU texture for the recorded element', () => {
45
+ it('deletes the cached GPU texture for the recorded VideoTexture', () => {
34
46
  const { state, gl } = createGlState();
35
47
  const runtime = getGlRenderStateRuntime(state);
36
- const element = document.createElement('video');
37
- const texture = gl.createTexture();
38
- runtime.textureCache.set(element, texture as WebGLTexture);
48
+ const videoTexture = { frameId: -1 } as unknown as VideoTexture;
49
+ const texture = gl.createTexture() as WebGLTexture;
50
+ runtime.videoTextureCache = new WeakMap();
51
+ runtime.videoTextureCache.set(videoTexture, { texture, uploadedFrameId: -1 });
39
52
  const deleteSpy = vi.spyOn(gl, 'deleteTexture');
40
- destroyGlVideoData(state, { lastElement: element } as unknown as RendererData);
53
+ destroyGlVideoData(state, { source: null, videoTexture } as unknown as RendererData);
41
54
  expect(deleteSpy).toHaveBeenCalledWith(texture);
42
- expect(runtime.textureCache.has(element)).toBe(false);
55
+ expect(runtime.videoTextureCache.has(videoTexture)).toBe(false);
43
56
  });
44
57
 
45
- it('is a no-op when no element was recorded', () => {
58
+ it('is a no-op when no VideoTexture was recorded', () => {
46
59
  const { state, gl } = createGlState();
47
60
  const deleteSpy = vi.spyOn(gl, 'deleteTexture');
48
- destroyGlVideoData(state, { lastElement: null } as unknown as RendererData);
61
+ destroyGlVideoData(state, { source: null, videoTexture: null } as unknown as RendererData);
49
62
  expect(deleteSpy).not.toHaveBeenCalled();
50
63
  });
51
64
  });
@@ -62,4 +75,24 @@ describe('drawGlVideo', () => {
62
75
  drawGlVideo(state, makeVideoNode(document.createElement('video')));
63
76
  expect(gl.drawElements).not.toHaveBeenCalled();
64
77
  });
78
+
79
+ it('builds a VideoTexture and uploads the ready frame through the gated bind', () => {
80
+ const { state, gl } = createGlState();
81
+ const node = makeVideoNode(makeReadyElement());
82
+ drawGlVideo(state, node);
83
+ const data = node.rendererData as unknown as { videoTexture: VideoTexture | null };
84
+ expect(data.videoTexture).not.toBeNull();
85
+ expect(gl.texImage2D).toHaveBeenCalled();
86
+ expect(gl.drawElements).toHaveBeenCalled();
87
+ });
88
+
89
+ it('reuses the VideoTexture across draws of the same stream', () => {
90
+ const { state } = createGlState();
91
+ const node = makeVideoNode(makeReadyElement());
92
+ drawGlVideo(state, node);
93
+ const first = (node.rendererData as unknown as { videoTexture: VideoTexture | null }).videoTexture;
94
+ drawGlVideo(state, node);
95
+ const second = (node.rendererData as unknown as { videoTexture: VideoTexture | null }).videoTexture;
96
+ expect(second).toBe(first);
97
+ });
65
98
  });