@flighthq/scene2d-wgpu 0.2.1-next.840.857425c

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 (144) hide show
  1. package/dist/enableWgpuColorAdjustmentGuards.d.ts +4 -0
  2. package/dist/enableWgpuColorAdjustmentGuards.d.ts.map +1 -0
  3. package/dist/enableWgpuColorAdjustmentGuards.js +21 -0
  4. package/dist/enableWgpuColorAdjustmentGuards.js.map +1 -0
  5. package/dist/index.d.ts +29 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +29 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/moduleMockTestHelper.d.ts +15 -0
  10. package/dist/moduleMockTestHelper.d.ts.map +1 -0
  11. package/dist/moduleMockTestHelper.js +25 -0
  12. package/dist/moduleMockTestHelper.js.map +1 -0
  13. package/dist/wgpuBitmap.d.ts +4 -0
  14. package/dist/wgpuBitmap.d.ts.map +1 -0
  15. package/dist/wgpuBitmap.js +84 -0
  16. package/dist/wgpuBitmap.js.map +1 -0
  17. package/dist/wgpuCache.d.ts +31 -0
  18. package/dist/wgpuCache.d.ts.map +1 -0
  19. package/dist/wgpuCache.js +244 -0
  20. package/dist/wgpuCache.js.map +1 -0
  21. package/dist/wgpuClip.d.ts +3 -0
  22. package/dist/wgpuClip.d.ts.map +1 -0
  23. package/dist/wgpuClip.js +44 -0
  24. package/dist/wgpuClip.js.map +1 -0
  25. package/dist/wgpuClipContours.d.ts +4 -0
  26. package/dist/wgpuClipContours.d.ts.map +1 -0
  27. package/dist/wgpuClipContours.js +174 -0
  28. package/dist/wgpuClipContours.js.map +1 -0
  29. package/dist/wgpuClipRectangle.d.ts +4 -0
  30. package/dist/wgpuClipRectangle.d.ts.map +1 -0
  31. package/dist/wgpuClipRectangle.js +80 -0
  32. package/dist/wgpuClipRectangle.js.map +1 -0
  33. package/dist/wgpuColorAdjustment.d.ts +3 -0
  34. package/dist/wgpuColorAdjustment.d.ts.map +1 -0
  35. package/dist/wgpuColorAdjustment.js +201 -0
  36. package/dist/wgpuColorAdjustment.js.map +1 -0
  37. package/dist/wgpuDefaultMaterial.d.ts +4 -0
  38. package/dist/wgpuDefaultMaterial.d.ts.map +1 -0
  39. package/dist/wgpuDefaultMaterial.js +49 -0
  40. package/dist/wgpuDefaultMaterial.js.map +1 -0
  41. package/dist/wgpuNode2D.d.ts +5 -0
  42. package/dist/wgpuNode2D.d.ts.map +1 -0
  43. package/dist/wgpuNode2D.js +41 -0
  44. package/dist/wgpuNode2D.js.map +1 -0
  45. package/dist/wgpuParticleEmitter2D.d.ts +4 -0
  46. package/dist/wgpuParticleEmitter2D.d.ts.map +1 -0
  47. package/dist/wgpuParticleEmitter2D.js +293 -0
  48. package/dist/wgpuParticleEmitter2D.js.map +1 -0
  49. package/dist/wgpuQuadBatch.d.ts +5 -0
  50. package/dist/wgpuQuadBatch.d.ts.map +1 -0
  51. package/dist/wgpuQuadBatch.js +90 -0
  52. package/dist/wgpuQuadBatch.js.map +1 -0
  53. package/dist/wgpuRegistration.d.ts +19 -0
  54. package/dist/wgpuRegistration.d.ts.map +1 -0
  55. package/dist/wgpuRegistration.js +53 -0
  56. package/dist/wgpuRegistration.js.map +1 -0
  57. package/dist/wgpuRenderStats.d.ts +20 -0
  58. package/dist/wgpuRenderStats.d.ts.map +1 -0
  59. package/dist/wgpuRenderStats.js +50 -0
  60. package/dist/wgpuRenderStats.js.map +1 -0
  61. package/dist/wgpuRendererData.d.ts +26 -0
  62. package/dist/wgpuRendererData.d.ts.map +1 -0
  63. package/dist/wgpuRendererData.js +29 -0
  64. package/dist/wgpuRendererData.js.map +1 -0
  65. package/dist/wgpuRichText.d.ts +8 -0
  66. package/dist/wgpuRichText.d.ts.map +1 -0
  67. package/dist/wgpuRichText.js +175 -0
  68. package/dist/wgpuRichText.js.map +1 -0
  69. package/dist/wgpuScale9Mapper.d.ts +3 -0
  70. package/dist/wgpuScale9Mapper.d.ts.map +1 -0
  71. package/dist/wgpuScale9Mapper.js +42 -0
  72. package/dist/wgpuScale9Mapper.js.map +1 -0
  73. package/dist/wgpuScale9Shape.d.ts +9 -0
  74. package/dist/wgpuScale9Shape.d.ts.map +1 -0
  75. package/dist/wgpuScale9Shape.js +123 -0
  76. package/dist/wgpuScale9Shape.js.map +1 -0
  77. package/dist/wgpuShape.d.ts +4 -0
  78. package/dist/wgpuShape.d.ts.map +1 -0
  79. package/dist/wgpuShape.js +164 -0
  80. package/dist/wgpuShape.js.map +1 -0
  81. package/dist/wgpuShapeMesh.d.ts +3 -0
  82. package/dist/wgpuShapeMesh.d.ts.map +1 -0
  83. package/dist/wgpuShapeMesh.js +224 -0
  84. package/dist/wgpuShapeMesh.js.map +1 -0
  85. package/dist/wgpuSprite.d.ts +3 -0
  86. package/dist/wgpuSprite.d.ts.map +1 -0
  87. package/dist/wgpuSprite.js +28 -0
  88. package/dist/wgpuSprite.js.map +1 -0
  89. package/dist/wgpuSpriteBatch.d.ts +12 -0
  90. package/dist/wgpuSpriteBatch.d.ts.map +1 -0
  91. package/dist/wgpuSpriteBatch.js +341 -0
  92. package/dist/wgpuSpriteBatch.js.map +1 -0
  93. package/dist/wgpuSpriteRenderer.d.ts +3 -0
  94. package/dist/wgpuSpriteRenderer.d.ts.map +1 -0
  95. package/dist/wgpuSpriteRenderer.js +55 -0
  96. package/dist/wgpuSpriteRenderer.js.map +1 -0
  97. package/dist/wgpuTextInput.d.ts +4 -0
  98. package/dist/wgpuTextInput.d.ts.map +1 -0
  99. package/dist/wgpuTextInput.js +44 -0
  100. package/dist/wgpuTextInput.js.map +1 -0
  101. package/dist/wgpuTextLabel.d.ts +4 -0
  102. package/dist/wgpuTextLabel.d.ts.map +1 -0
  103. package/dist/wgpuTextLabel.js +149 -0
  104. package/dist/wgpuTextLabel.js.map +1 -0
  105. package/dist/wgpuTilemap.d.ts +3 -0
  106. package/dist/wgpuTilemap.d.ts.map +1 -0
  107. package/dist/wgpuTilemap.js +81 -0
  108. package/dist/wgpuTilemap.js.map +1 -0
  109. package/dist/wgpuVelocity.d.ts +23 -0
  110. package/dist/wgpuVelocity.d.ts.map +1 -0
  111. package/dist/wgpuVelocity.js +365 -0
  112. package/dist/wgpuVelocity.js.map +1 -0
  113. package/dist/wgpuVideo.d.ts +6 -0
  114. package/dist/wgpuVideo.d.ts.map +1 -0
  115. package/dist/wgpuVideo.js +63 -0
  116. package/dist/wgpuVideo.js.map +1 -0
  117. package/package.json +60 -0
  118. package/src/enableWgpuColorAdjustmentGuards.test.ts +82 -0
  119. package/src/wgpuBitmap.test.ts +40 -0
  120. package/src/wgpuCache.test.ts +272 -0
  121. package/src/wgpuClip.test.ts +15 -0
  122. package/src/wgpuClipContours.test.ts +101 -0
  123. package/src/wgpuClipRectangle.test.ts +45 -0
  124. package/src/wgpuColorAdjustment.test.ts +120 -0
  125. package/src/wgpuDefaultMaterial.test.ts +21 -0
  126. package/src/wgpuNode2D.test.ts +36 -0
  127. package/src/wgpuParticleEmitter2D.test.ts +85 -0
  128. package/src/wgpuQuadBatch.test.ts +69 -0
  129. package/src/wgpuRegistration.test.ts +115 -0
  130. package/src/wgpuRenderStats.test.ts +89 -0
  131. package/src/wgpuRendererData.test.ts +29 -0
  132. package/src/wgpuRichText.test.ts +100 -0
  133. package/src/wgpuScale9Mapper.test.ts +20 -0
  134. package/src/wgpuScale9Shape.test.ts +97 -0
  135. package/src/wgpuShape.test.ts +96 -0
  136. package/src/wgpuShapeMesh.test.ts +164 -0
  137. package/src/wgpuSprite.test.ts +19 -0
  138. package/src/wgpuSpriteBatch.test.ts +272 -0
  139. package/src/wgpuSpriteRenderer.test.ts +63 -0
  140. package/src/wgpuTextInput.test.ts +37 -0
  141. package/src/wgpuTextLabel.test.ts +124 -0
  142. package/src/wgpuTilemap.test.ts +47 -0
  143. package/src/wgpuVelocity.test.ts +148 -0
  144. package/src/wgpuVideo.test.ts +59 -0
@@ -0,0 +1,96 @@
1
+ import { createImageResource } from '@flighthq/image';
2
+ import type * as FlightNodeModule from '@flighthq/node';
3
+ import { renderWgpuBackground, submitWgpuRenderPass } from '@flighthq/render-wgpu';
4
+ import { getWgpuRenderStateRuntime } from '@flighthq/render-wgpu';
5
+ import { createWgpuRenderStateForTest, installWgpuMock } from '@flighthq/render-wgpu';
6
+ import type { RenderProxy2D } from '@flighthq/types';
7
+ import { BatchFormat } from '@flighthq/types';
8
+
9
+ import { scopeModuleMocks } from './moduleMockTestHelper';
10
+ import { registerDefaultWgpuMaterial } from './wgpuDefaultMaterial';
11
+ import type * as WgpuShapeModule from './wgpuShape';
12
+
13
+ // @flighthq/node's bounds/revision queries expect a real BoundsNode; these tests drive drawWgpuShape
14
+ // with lightweight fake proxies, so the two queries are stubbed. scopeModuleMocks scopes the stub to
15
+ // this file (registry reset before the mock applies, unmock + reset after), so under a shared
16
+ // (isolate:false) worker it never leaks into the many real consumers of these functions (node,
17
+ // interaction, shape, text) — and a sibling that pre-evaluated ./wgpuShape still picks up the stub.
18
+ let defaultWgpuShapeRenderer: typeof WgpuShapeModule.defaultWgpuShapeRenderer;
19
+ let drawWgpuShape: typeof WgpuShapeModule.drawWgpuShape;
20
+
21
+ scopeModuleMocks(['@flighthq/node']);
22
+
23
+ beforeAll(async () => {
24
+ vi.doMock('@flighthq/node', async (importOriginal) => ({
25
+ ...(await importOriginal<typeof FlightNodeModule>()),
26
+ getNodeLocalBoundsRectangle: () => ({ x: 0, y: 0, width: 64, height: 48 }),
27
+ getNodeLocalContentRevision: (source: any) => source?.data?.version ?? 0,
28
+ }));
29
+ ({ defaultWgpuShapeRenderer, drawWgpuShape } = await import('./wgpuShape'));
30
+ installWgpuMock();
31
+ });
32
+
33
+ function makeShapeData() {
34
+ const canvas = document.createElement('canvas');
35
+ canvas.width = 1;
36
+ canvas.height = 1;
37
+ const ctx = canvas.getContext('2d')!;
38
+ return { canvas, ctx, image: createImageResource(canvas), lastContentId: -1, lastW: 0, lastH: 0 };
39
+ }
40
+
41
+ function makeShapeProxy(data: Record<string, unknown> = {}, rendererData: unknown = null): RenderProxy2D {
42
+ return {
43
+ source: { data: { commands: [], version: 0, ...data } },
44
+ blendMode: 0,
45
+ alpha: 1,
46
+ material: null,
47
+ materialData: null,
48
+ transform2D: { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 },
49
+ rendererData,
50
+ } as unknown as RenderProxy2D;
51
+ }
52
+
53
+ describe('defaultWgpuShapeRenderer', () => {
54
+ it('declares BatchFormat.Quad', () => {
55
+ expect(defaultWgpuShapeRenderer.format).toBe(BatchFormat.Quad);
56
+ });
57
+
58
+ it('has createData and submit functions', () => {
59
+ expect(typeof defaultWgpuShapeRenderer.createData).toBe('function');
60
+ expect(typeof defaultWgpuShapeRenderer.submit).toBe('function');
61
+ });
62
+ });
63
+
64
+ describe('drawWgpuShape', () => {
65
+ it('returns early without writing to batch when commands are empty', async () => {
66
+ const state = await createWgpuRenderStateForTest();
67
+ renderWgpuBackground(state);
68
+ registerDefaultWgpuMaterial(state);
69
+ drawWgpuShape(state, makeShapeProxy({ commands: [] }, makeShapeData()));
70
+ expect(getWgpuRenderStateRuntime(state).spriteBatchCount).toBe(0);
71
+ submitWgpuRenderPass(state);
72
+ });
73
+
74
+ it('returns early without writing to batch when rendererData is null', async () => {
75
+ const state = await createWgpuRenderStateForTest();
76
+ renderWgpuBackground(state);
77
+ registerDefaultWgpuMaterial(state);
78
+ drawWgpuShape(state, makeShapeProxy({ commands: [{}] }, null));
79
+ expect(getWgpuRenderStateRuntime(state).spriteBatchCount).toBe(0);
80
+ submitWgpuRenderPass(state);
81
+ });
82
+
83
+ it('does not throw when renderPass is null', async () => {
84
+ const state = await createWgpuRenderStateForTest();
85
+ expect(() => drawWgpuShape(state, makeShapeProxy({ commands: [{}] }, makeShapeData()))).not.toThrow();
86
+ });
87
+
88
+ it('writes one instance to the sprite batch when shape has valid commands and bounds', async () => {
89
+ const state = await createWgpuRenderStateForTest();
90
+ renderWgpuBackground(state);
91
+ registerDefaultWgpuMaterial(state);
92
+ drawWgpuShape(state, makeShapeProxy({ commands: [{}], version: 1 }, makeShapeData()));
93
+ expect(getWgpuRenderStateRuntime(state).spriteBatchCount).toBe(1);
94
+ submitWgpuRenderPass(state);
95
+ });
96
+ });
@@ -0,0 +1,164 @@
1
+ import { createMatrix } from '@flighthq/geometry';
2
+ import { getWgpuRenderStateRuntime } from '@flighthq/render-wgpu';
3
+ import { createWgpuRenderStateForTest, installWgpuMock } from '@flighthq/render-wgpu';
4
+ import type { RenderProxy2D, WgpuRenderState, WgpuShapeMeshBuffers } from '@flighthq/types';
5
+ import type { WgpuShapeMesh } from '@flighthq/types';
6
+ import { BlendMode } from '@flighthq/types';
7
+
8
+ import { drawWgpuShapeMeshes } from './wgpuShapeMesh';
9
+
10
+ beforeAll(() => {
11
+ installWgpuMock();
12
+ });
13
+
14
+ const TRIANGLE: WgpuShapeMesh = {
15
+ vertices: new Float32Array([0, 0, 10, 0, 0, 10]),
16
+ indices: new Uint16Array([0, 1, 2]),
17
+ color: 0xff8040,
18
+ alpha: 1,
19
+ };
20
+
21
+ function makeBuffers(): WgpuShapeMeshBuffers {
22
+ return {
23
+ vertexBuffer: null,
24
+ vertexCapacity: 0,
25
+ indexBuffer: null,
26
+ indexCapacity: 0,
27
+ uniformBuffer: null,
28
+ bindGroup: null,
29
+ };
30
+ }
31
+
32
+ function makePassSpy(): GPURenderPassEncoder {
33
+ return {
34
+ setPipeline: vi.fn(),
35
+ setBindGroup: vi.fn(),
36
+ setVertexBuffer: vi.fn(),
37
+ setIndexBuffer: vi.fn(),
38
+ setStencilReference: vi.fn(),
39
+ draw: vi.fn(),
40
+ drawIndexed: vi.fn(),
41
+ } as unknown as GPURenderPassEncoder;
42
+ }
43
+
44
+ function makeProxy(matrix = createMatrix(), alpha = 1, blendMode: BlendMode | null = null): RenderProxy2D {
45
+ return { alpha, blendMode, transform2D: matrix } as unknown as RenderProxy2D;
46
+ }
47
+
48
+ async function makeState(): Promise<WgpuRenderState> {
49
+ const state = await createWgpuRenderStateForTest();
50
+ getWgpuRenderStateRuntime(state).renderPass = makePassSpy();
51
+ return state;
52
+ }
53
+
54
+ describe('drawWgpuShapeMeshes', () => {
55
+ it('sets the shape-mesh pipeline and draws each mesh', async () => {
56
+ const state = await makeState();
57
+ const runtime = getWgpuRenderStateRuntime(state);
58
+ const pass = runtime.renderPass as unknown as {
59
+ setPipeline: ReturnType<typeof vi.fn>;
60
+ drawIndexed: ReturnType<typeof vi.fn>;
61
+ };
62
+
63
+ drawWgpuShapeMeshes(state, makeProxy(), [TRIANGLE, TRIANGLE], makeBuffers());
64
+
65
+ expect(pass.setPipeline).toHaveBeenCalled();
66
+ expect(runtime.shapeMeshPipelines?.size ?? 0).toBeGreaterThan(0);
67
+ expect(pass.drawIndexed).toHaveBeenCalledTimes(2);
68
+ expect(pass.drawIndexed).toHaveBeenCalledWith(3);
69
+ });
70
+
71
+ it('retires grown geometry buffers until the recorded frame is submitted', async () => {
72
+ const state = await makeState();
73
+ const buffers = makeBuffers();
74
+ const createBuffer = state.device.createBuffer.bind(state.device);
75
+ vi.spyOn(state.device, 'createBuffer').mockImplementation((descriptor) => {
76
+ const buffer = createBuffer(descriptor);
77
+ buffer.destroy = vi.fn();
78
+ return buffer;
79
+ });
80
+ const larger: WgpuShapeMesh = {
81
+ ...TRIANGLE,
82
+ vertices: new Float32Array([...TRIANGLE.vertices, 10, 10]),
83
+ indices: new Uint16Array([0, 1, 2, 1, 3, 2]),
84
+ };
85
+
86
+ drawWgpuShapeMeshes(state, makeProxy(), [TRIANGLE, larger], buffers);
87
+
88
+ const retired = getWgpuRenderStateRuntime(state).retiredBuffers!;
89
+ expect(retired).toHaveLength(2);
90
+ for (const buffer of retired) {
91
+ expect(buffer.destroy).not.toHaveBeenCalled();
92
+ }
93
+ });
94
+
95
+ it('gates the fill by the active contour-clip stencil reference', async () => {
96
+ const state = await makeState();
97
+ const runtime = getWgpuRenderStateRuntime(state);
98
+ runtime.currentMaskDepth = 2;
99
+ const pass = runtime.renderPass as unknown as { setStencilReference: ReturnType<typeof vi.fn> };
100
+
101
+ drawWgpuShapeMeshes(state, makeProxy(), [TRIANGLE], makeBuffers());
102
+
103
+ expect(pass.setStencilReference).toHaveBeenCalledWith(2);
104
+ });
105
+
106
+ it('bakes the node blend mode into a distinct shape pipeline', async () => {
107
+ const state = await makeState();
108
+
109
+ drawWgpuShapeMeshes(state, makeProxy(createMatrix(), 1, BlendMode.Normal), [TRIANGLE], makeBuffers());
110
+ drawWgpuShapeMeshes(state, makeProxy(createMatrix(), 1, BlendMode.Add), [TRIANGLE], makeBuffers());
111
+
112
+ const pipelines = [...getWgpuRenderStateRuntime(state).shapeMeshPipelines!.values()].map(
113
+ (entry) => entry.pipeline,
114
+ ) as unknown as { __descriptor: GPURenderPipelineDescriptor }[];
115
+ expect(pipelines).toHaveLength(2);
116
+ const blends = pipelines.map((pipeline) => [...pipeline.__descriptor.fragment!.targets][0]!.blend!.color);
117
+ expect(blends).toContainEqual({ srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' });
118
+ expect(blends).toContainEqual({ srcFactor: 'one', dstFactor: 'one', operation: 'add' });
119
+ });
120
+
121
+ it('writes premultiplied color (color * alpha) into the uniform buffer', async () => {
122
+ const state = await makeState();
123
+ const buffers = makeBuffers();
124
+ const writes = new Map<GPUBuffer, Float32Array>();
125
+ state.device.queue.writeBuffer = vi.fn((buffer: GPUBuffer, _offset: number, data: BufferSource) => {
126
+ const view = ArrayBuffer.isView(data) ? data : new Uint8Array(data as ArrayBuffer);
127
+ // Only the uniform write is inspected; record a copy keyed by its destination buffer.
128
+ if (view.byteLength % 4 === 0) {
129
+ writes.set(buffer, new Float32Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)));
130
+ }
131
+ }) as unknown as GPUQueue['writeBuffer'];
132
+
133
+ drawWgpuShapeMeshes(state, makeProxy(createMatrix(), 0.5), [{ ...TRIANGLE, color: 0xffffff }], buffers);
134
+
135
+ const uniformData = writes.get(buffers.uniformBuffer!);
136
+ expect(uniformData).toBeDefined();
137
+ // Color occupies floats 12..15 (after the mat3x3f columns): premultiplied (0.5, 0.5, 0.5, 0.5).
138
+ const color = uniformData!.slice(12, 16);
139
+ expect(color[0]).toBeCloseTo(0.5);
140
+ expect(color[1]).toBeCloseTo(0.5);
141
+ expect(color[2]).toBeCloseTo(0.5);
142
+ expect(color[3]).toBeCloseTo(0.5);
143
+ });
144
+
145
+ it('skips fully transparent meshes', async () => {
146
+ const state = await makeState();
147
+ const pass = getWgpuRenderStateRuntime(state).renderPass as unknown as { drawIndexed: ReturnType<typeof vi.fn> };
148
+
149
+ drawWgpuShapeMeshes(state, makeProxy(), [{ ...TRIANGLE, alpha: 0 }], makeBuffers());
150
+
151
+ expect(pass.drawIndexed).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it('is a no-op for an empty mesh list', async () => {
155
+ const state = await makeState();
156
+ const runtime = getWgpuRenderStateRuntime(state);
157
+ const pass = runtime.renderPass as unknown as { setPipeline: ReturnType<typeof vi.fn> };
158
+
159
+ drawWgpuShapeMeshes(state, makeProxy(), [], makeBuffers());
160
+
161
+ expect(pass.setPipeline).not.toHaveBeenCalled();
162
+ expect(runtime.shapeMeshPipelines?.size ?? 0).toBe(0);
163
+ });
164
+ });
@@ -0,0 +1,19 @@
1
+ import { renderWgpuBackground, submitWgpuRenderPass } from '@flighthq/render-wgpu';
2
+ import { createWgpuRenderStateForTest, installWgpuMock } from '@flighthq/render-wgpu';
3
+ import { createSprite } from '@flighthq/sprite';
4
+
5
+ import { renderWgpuSprite } from './wgpuSprite';
6
+
7
+ beforeAll(() => {
8
+ installWgpuMock();
9
+ });
10
+
11
+ describe('renderWgpuSprite', () => {
12
+ it('traverses a sprite node without error', async () => {
13
+ const state = await createWgpuRenderStateForTest();
14
+ renderWgpuBackground(state);
15
+ const root = createSprite();
16
+ expect(() => renderWgpuSprite(state, root)).not.toThrow();
17
+ submitWgpuRenderPass(state);
18
+ });
19
+ });
@@ -0,0 +1,272 @@
1
+ import { createImageResource } from '@flighthq/image';
2
+ import { renderWgpuBackground, submitWgpuRenderPass } from '@flighthq/render-wgpu';
3
+ import { getWgpuRenderStateRuntime } from '@flighthq/render-wgpu';
4
+ import { createWgpuRenderStateForTest, installWgpuMock } from '@flighthq/render-wgpu';
5
+ import type { ColorTransform, Material } from '@flighthq/types';
6
+ import { BlendMode } from '@flighthq/types';
7
+
8
+ import { enableWgpuColorAdjustment } from './wgpuColorAdjustment';
9
+ import { defaultWgpuMaterialRenderer } from './wgpuDefaultMaterial';
10
+ import {
11
+ ensureWgpuQuadBatchResources,
12
+ flushWgpuSpriteBatch,
13
+ getWgpuQuadBatchPipeline,
14
+ getWgpuQuadBatchPreludeWGSL,
15
+ packWgpuSpriteBatchMaterialInstance,
16
+ prepareWgpuSpriteBatchWrite,
17
+ recordWgpuSpriteBatchColorTransform,
18
+ resetWgpuSpriteBatchBufferPool,
19
+ } from './wgpuSpriteBatch';
20
+
21
+ beforeAll(() => {
22
+ installWgpuMock();
23
+ });
24
+
25
+ function makeMaterial(): Material {
26
+ return { kind: 'TestMaterial' } as Material;
27
+ }
28
+
29
+ function ct(
30
+ redMultiplier = 1,
31
+ greenMultiplier = 1,
32
+ blueMultiplier = 1,
33
+ alphaMultiplier = 1,
34
+ redOffset = 0,
35
+ greenOffset = 0,
36
+ blueOffset = 0,
37
+ alphaOffset = 0,
38
+ ): ColorTransform {
39
+ return {
40
+ redMultiplier,
41
+ greenMultiplier,
42
+ blueMultiplier,
43
+ alphaMultiplier,
44
+ redOffset,
45
+ greenOffset,
46
+ blueOffset,
47
+ alphaOffset,
48
+ } as ColorTransform;
49
+ }
50
+
51
+ const CT_MODE_NONE = 0;
52
+ const CT_MODE_UNIFORM = 1;
53
+
54
+ describe('ensureWgpuQuadBatchResources', () => {
55
+ it('returns resources with bind group layouts and a pipelines WeakMap', async () => {
56
+ const state = await createWgpuRenderStateForTest();
57
+ const resources = ensureWgpuQuadBatchResources(state);
58
+ expect(resources.instanceBindGroupLayout).toBeDefined();
59
+ expect(resources.materialBindGroupLayout).toBeDefined();
60
+ expect(resources.basePipelineLayout).toBeDefined();
61
+ expect(resources.materialPipelineLayout).toBeDefined();
62
+ expect(resources.pipelines).toBeInstanceOf(WeakMap);
63
+ });
64
+
65
+ it('is idempotent — returns the same resources on repeated calls', async () => {
66
+ const state = await createWgpuRenderStateForTest();
67
+ const r1 = ensureWgpuQuadBatchResources(state);
68
+ const r2 = ensureWgpuQuadBatchResources(state);
69
+ expect(r1).toBe(r2);
70
+ });
71
+ });
72
+
73
+ describe('flushWgpuSpriteBatch', () => {
74
+ it('does nothing when batch count is zero', async () => {
75
+ const state = await createWgpuRenderStateForTest();
76
+ renderWgpuBackground(state);
77
+ expect(() => flushWgpuSpriteBatch(state)).not.toThrow();
78
+ submitWgpuRenderPass(state);
79
+ });
80
+
81
+ it('resets state after flush', async () => {
82
+ const state = await createWgpuRenderStateForTest();
83
+ renderWgpuBackground(state);
84
+ const runtime = getWgpuRenderStateRuntime(state);
85
+ const tex = createImageResource(document.createElement('img'));
86
+
87
+ prepareWgpuSpriteBatchWrite(state, tex, null, null, defaultWgpuMaterialRenderer, 1);
88
+ runtime.spriteBatchCount = 1;
89
+ flushWgpuSpriteBatch(state);
90
+
91
+ expect(runtime.spriteBatchCount).toBe(0);
92
+ expect(runtime.spriteBatchTexture).toBeNull();
93
+ expect(runtime.spriteBatchBlendMode).toBeNull();
94
+ expect(runtime.spriteBatchMaterial).toBeNull();
95
+ submitWgpuRenderPass(state);
96
+ });
97
+
98
+ it('claims a distinct buffer per flush so deferred draws never share one', async () => {
99
+ // The canvas pass is submitted once at end of frame; if successive flushes reused one instance
100
+ // buffer, every draw would read the last flush's data and the batch would collapse to one spot.
101
+ const state = await createWgpuRenderStateForTest();
102
+ renderWgpuBackground(state);
103
+ const runtime = getWgpuRenderStateRuntime(state);
104
+ const tex1 = createImageResource(document.createElement('img'));
105
+ const tex2 = createImageResource(document.createElement('img'));
106
+
107
+ prepareWgpuSpriteBatchWrite(state, tex1, null, null, defaultWgpuMaterialRenderer, 1);
108
+ runtime.spriteBatchCount = 1;
109
+ flushWgpuSpriteBatch(state);
110
+
111
+ prepareWgpuSpriteBatchWrite(state, tex2, null, null, defaultWgpuMaterialRenderer, 1);
112
+ runtime.spriteBatchCount = 1;
113
+ flushWgpuSpriteBatch(state);
114
+
115
+ expect(runtime.spriteBatchBufferCursor).toBe(2);
116
+ expect(runtime.spriteBatchBufferPool[0].instanceBuffer).not.toBeNull();
117
+ expect(runtime.spriteBatchBufferPool[0].instanceBuffer).not.toBe(runtime.spriteBatchBufferPool[1].instanceBuffer);
118
+ submitWgpuRenderPass(state);
119
+ });
120
+ });
121
+
122
+ describe('getWgpuQuadBatchPipeline', () => {
123
+ it('creates and caches a pipeline for a given shader module', async () => {
124
+ const state = await createWgpuRenderStateForTest();
125
+ const resources = ensureWgpuQuadBatchResources(state);
126
+ const module = state.device.createShaderModule({ code: '' });
127
+ const pipeline = getWgpuQuadBatchPipeline(state, resources, module, false, null);
128
+ expect(pipeline).toBeDefined();
129
+ const pipeline2 = getWgpuQuadBatchPipeline(state, resources, module, false, null);
130
+ expect(pipeline2).toBe(pipeline);
131
+ });
132
+
133
+ it('uses the shared fixed-function blend table for every batch mode', async () => {
134
+ const state = await createWgpuRenderStateForTest();
135
+ const resources = ensureWgpuQuadBatchResources(state);
136
+ const module = state.device.createShaderModule({ code: '' });
137
+ const blendOf = (mode: BlendMode) => {
138
+ const pipeline = getWgpuQuadBatchPipeline(state, resources, module, false, mode) as unknown as {
139
+ __descriptor: GPURenderPipelineDescriptor;
140
+ };
141
+ return [...pipeline.__descriptor.fragment!.targets][0]!.blend!.color;
142
+ };
143
+
144
+ expect(blendOf(BlendMode.Multiply)).toEqual({
145
+ srcFactor: 'dst',
146
+ dstFactor: 'one-minus-src-alpha',
147
+ operation: 'add',
148
+ });
149
+ expect(blendOf(BlendMode.Screen)).toEqual({
150
+ srcFactor: 'one',
151
+ dstFactor: 'one-minus-src',
152
+ operation: 'add',
153
+ });
154
+ expect(blendOf(BlendMode.Darken).operation).toBe('min');
155
+ expect(blendOf(BlendMode.Lighten).operation).toBe('max');
156
+ });
157
+ });
158
+
159
+ describe('getWgpuQuadBatchPreludeWGSL', () => {
160
+ it('returns the shared WGSL prelude string containing the base structs', () => {
161
+ const wgsl = getWgpuQuadBatchPreludeWGSL();
162
+ expect(typeof wgsl).toBe('string');
163
+ expect(wgsl).toContain('struct Uniforms');
164
+ expect(wgsl).toContain('quadBaseVertex');
165
+ expect(wgsl).toContain('straightTextureAlpha : u32');
166
+ });
167
+ });
168
+
169
+ describe('packWgpuSpriteBatchMaterialInstance', () => {
170
+ it('is a no-op when no per-instance material data is active', async () => {
171
+ const state = await createWgpuRenderStateForTest();
172
+ expect(() => packWgpuSpriteBatchMaterialInstance(state, null, 0)).not.toThrow();
173
+ });
174
+ });
175
+
176
+ describe('prepareWgpuSpriteBatchWrite', () => {
177
+ it('returns float index 0 for an empty batch', async () => {
178
+ const state = await createWgpuRenderStateForTest();
179
+ const tex = createImageResource(document.createElement('img'));
180
+
181
+ const base = prepareWgpuSpriteBatchWrite(state, tex, null, null, defaultWgpuMaterialRenderer, 1);
182
+ expect(base).toBe(0);
183
+ });
184
+
185
+ it('flushes when texture changes', async () => {
186
+ const state = await createWgpuRenderStateForTest();
187
+ renderWgpuBackground(state);
188
+ const runtime = getWgpuRenderStateRuntime(state);
189
+ const tex1 = createImageResource(document.createElement('img'));
190
+ const tex2 = createImageResource(document.createElement('img'));
191
+
192
+ prepareWgpuSpriteBatchWrite(state, tex1, null, null, defaultWgpuMaterialRenderer, 1);
193
+ runtime.spriteBatchCount = 1;
194
+ prepareWgpuSpriteBatchWrite(state, tex2, null, null, defaultWgpuMaterialRenderer, 1);
195
+
196
+ expect(runtime.spriteBatchTexture).toBe(tex2);
197
+ expect(runtime.spriteBatchCount).toBe(0);
198
+ submitWgpuRenderPass(state);
199
+ });
200
+
201
+ it('flushes when material changes', async () => {
202
+ const state = await createWgpuRenderStateForTest();
203
+ renderWgpuBackground(state);
204
+ const runtime = getWgpuRenderStateRuntime(state);
205
+ const tex = createImageResource(document.createElement('img'));
206
+ const materialA = makeMaterial();
207
+ const materialB = makeMaterial();
208
+
209
+ prepareWgpuSpriteBatchWrite(state, tex, null, materialA, defaultWgpuMaterialRenderer, 1);
210
+ runtime.spriteBatchCount = 1;
211
+ prepareWgpuSpriteBatchWrite(state, tex, null, materialB, defaultWgpuMaterialRenderer, 1);
212
+
213
+ expect(runtime.spriteBatchMaterial).toBe(materialB);
214
+ expect(runtime.spriteBatchCount).toBe(0);
215
+ submitWgpuRenderPass(state);
216
+ });
217
+ });
218
+
219
+ describe('recordWgpuSpriteBatchColorTransform', () => {
220
+ it('skips the tint and records no fold state when color adjustment is not enabled', async () => {
221
+ const state = await createWgpuRenderStateForTest();
222
+ const runtime = getWgpuRenderStateRuntime(state);
223
+ recordWgpuSpriteBatchColorTransform(state, ct(0.5), 0);
224
+ expect(runtime.spriteBatchColorTransformMode ?? CT_MODE_NONE).toBe(CT_MODE_NONE);
225
+ });
226
+
227
+ it('is a no-op for an untinted instance whether or not the fold is enabled', async () => {
228
+ const state = await createWgpuRenderStateForTest();
229
+ const runtime = getWgpuRenderStateRuntime(state);
230
+ expect(() => recordWgpuSpriteBatchColorTransform(state, null, 0)).not.toThrow();
231
+ expect(runtime.spriteBatchColorTransformMode ?? CT_MODE_NONE).toBe(CT_MODE_NONE);
232
+ });
233
+
234
+ it('delegates to the installed fold when color adjustment is enabled', async () => {
235
+ const state = await createWgpuRenderStateForTest();
236
+ const runtime = getWgpuRenderStateRuntime(state);
237
+ enableWgpuColorAdjustment(state);
238
+ recordWgpuSpriteBatchColorTransform(state, ct(0.5), 0);
239
+ expect(runtime.spriteBatchColorTransformMode).toBe(CT_MODE_UNIFORM);
240
+ });
241
+
242
+ it('an untinted batch on flush uses the lean material module (no fold)', async () => {
243
+ const state = await createWgpuRenderStateForTest();
244
+ enableWgpuColorAdjustment(state);
245
+ renderWgpuBackground(state);
246
+ const runtime = getWgpuRenderStateRuntime(state);
247
+ const tex = createImageResource(document.createElement('img'));
248
+ prepareWgpuSpriteBatchWrite(state, tex, null, null, defaultWgpuMaterialRenderer, 1);
249
+ recordWgpuSpriteBatchColorTransform(state, null, 0);
250
+ runtime.spriteBatchCount = 1;
251
+ expect(() => flushWgpuSpriteBatch(state)).not.toThrow();
252
+ submitWgpuRenderPass(state);
253
+ });
254
+ });
255
+
256
+ describe('resetWgpuSpriteBatchBufferPool', () => {
257
+ it('rewinds the pool cursor so slots are reclaimed next frame', async () => {
258
+ const state = await createWgpuRenderStateForTest();
259
+ renderWgpuBackground(state);
260
+ const runtime = getWgpuRenderStateRuntime(state);
261
+ const tex = createImageResource(document.createElement('img'));
262
+
263
+ prepareWgpuSpriteBatchWrite(state, tex, null, null, defaultWgpuMaterialRenderer, 1);
264
+ runtime.spriteBatchCount = 1;
265
+ flushWgpuSpriteBatch(state);
266
+ expect(runtime.spriteBatchBufferCursor).toBe(1);
267
+
268
+ resetWgpuSpriteBatchBufferPool(state);
269
+ expect(runtime.spriteBatchBufferCursor).toBe(0);
270
+ submitWgpuRenderPass(state);
271
+ });
272
+ });
@@ -0,0 +1,63 @@
1
+ import { createImageResource } from '@flighthq/image';
2
+ import { getRenderProxy2D, prepareScene2DRender } from '@flighthq/render';
3
+ import { getWgpuRenderStateRuntime, renderWgpuBackground, submitWgpuRenderPass } from '@flighthq/render-wgpu';
4
+ import { createWgpuRenderStateForTest, installWgpuMock } from '@flighthq/render-wgpu';
5
+ import { createSprite } from '@flighthq/sprite';
6
+ import type { TextureAtlas } from '@flighthq/types';
7
+
8
+ import { registerDefaultWgpuMaterial } from './wgpuDefaultMaterial';
9
+ import { flushWgpuSpriteBatch } from './wgpuSpriteBatch';
10
+ import { defaultWgpuSpriteRenderer } from './wgpuSpriteRenderer';
11
+
12
+ beforeAll(() => {
13
+ installWgpuMock();
14
+ });
15
+
16
+ describe('defaultWgpuSpriteRenderer', () => {
17
+ it('has a createData function', () => {
18
+ expect(typeof defaultWgpuSpriteRenderer.createData).toBe('function');
19
+ });
20
+
21
+ it('has a submit function', () => {
22
+ expect(typeof defaultWgpuSpriteRenderer.submit).toBe('function');
23
+ });
24
+ });
25
+
26
+ describe('defaultWgpuSpriteRenderer.submit', () => {
27
+ it('does not throw when atlas is null', async () => {
28
+ const state = await createWgpuRenderStateForTest();
29
+ renderWgpuBackground(state);
30
+
31
+ const sprite = createSprite();
32
+ prepareScene2DRender(state, sprite);
33
+ const renderProxy = getRenderProxy2D(state, sprite)!;
34
+
35
+ expect(() => {
36
+ defaultWgpuSpriteRenderer.submit(state, renderProxy);
37
+ flushWgpuSpriteBatch(state as any);
38
+ }).not.toThrow();
39
+ submitWgpuRenderPass(state);
40
+ });
41
+
42
+ it('folds the region pivot through the sprite transform into batch translation', async () => {
43
+ const state = await createWgpuRenderStateForTest();
44
+ renderWgpuBackground(state);
45
+ registerDefaultWgpuMaterial(state);
46
+ const atlas = {
47
+ image: createImageResource(document.createElement('img')),
48
+ regions: [{ height: 32, id: 0, name: null, pivotX: 7, pivotY: 9, width: 32, x: 0, y: 0 }],
49
+ };
50
+ const sprite = createSprite();
51
+ sprite.data.atlas = atlas as unknown as TextureAtlas;
52
+ prepareScene2DRender(state, sprite);
53
+ const renderProxy = getRenderProxy2D(state, sprite)!;
54
+ Object.assign(renderProxy.transform2D, { a: 2, b: 3, c: 4, d: 5, tx: 100, ty: 200 });
55
+
56
+ defaultWgpuSpriteRenderer.submit(state, renderProxy);
57
+
58
+ const data = getWgpuRenderStateRuntime(state).spriteBatchInstanceData;
59
+ expect(data[4]).toBe(50);
60
+ expect(data[5]).toBe(134);
61
+ submitWgpuRenderPass(state);
62
+ });
63
+ });
@@ -0,0 +1,37 @@
1
+ import { getOrCreateRenderProxy2D, prepareScene2DRender } from '@flighthq/render';
2
+ import { renderWgpuBackground, submitWgpuRenderPass } from '@flighthq/render-wgpu';
3
+ import { createWgpuRenderStateForTest, installWgpuMock } from '@flighthq/render-wgpu';
4
+ import { createRichText } from '@flighthq/text';
5
+ import { enableTextInput } from '@flighthq/textinput';
6
+
7
+ import { drawWgpuRichText } from './wgpuRichText';
8
+ import { drawWgpuTextInputOverlay, enableWgpuTextInput } from './wgpuTextInput';
9
+
10
+ beforeAll(() => {
11
+ installWgpuMock();
12
+ });
13
+
14
+ describe('drawWgpuTextInputOverlay', () => {
15
+ it('is the installed overlay function', () => {
16
+ expect(typeof drawWgpuTextInputOverlay).toBe('function');
17
+ });
18
+
19
+ it('rasterizes a focused editable field without throwing', async () => {
20
+ enableWgpuTextInput();
21
+ const state = await createWgpuRenderStateForTest();
22
+ renderWgpuBackground(state);
23
+ const input = createRichText({ data: { height: 40, text: 'hi', width: 100 } });
24
+ enableTextInput(input).focused = true;
25
+ prepareScene2DRender(state, input);
26
+ const renderProxy = getOrCreateRenderProxy2D(state, input);
27
+
28
+ expect(() => drawWgpuRichText(state, renderProxy)).not.toThrow();
29
+ submitWgpuRenderPass(state);
30
+ });
31
+ });
32
+
33
+ describe('enableWgpuTextInput', () => {
34
+ it('installs the overlay without throwing', () => {
35
+ expect(() => enableWgpuTextInput()).not.toThrow();
36
+ });
37
+ });