@flighthq/scene2d-wgpu 0.3.0-next.906.07cea63 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/dist/contract.d.ts +8 -6
  2. package/dist/contract.d.ts.map +1 -1
  3. package/dist/contract.js +8 -6
  4. package/dist/contract.js.map +1 -1
  5. package/dist/enableWgpuColorAdjustmentGuards.js +2 -2
  6. package/dist/enableWgpuColorAdjustmentGuards.js.map +1 -1
  7. package/dist/enableWgpuStrokePathTessellation.d.ts +3 -0
  8. package/dist/enableWgpuStrokePathTessellation.d.ts.map +1 -0
  9. package/dist/enableWgpuStrokePathTessellation.js +8 -0
  10. package/dist/enableWgpuStrokePathTessellation.js.map +1 -0
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/wgpuBitmapText.d.ts.map +1 -1
  16. package/dist/wgpuBitmapText.js +20 -16
  17. package/dist/wgpuBitmapText.js.map +1 -1
  18. package/dist/wgpuCache.d.ts.map +1 -1
  19. package/dist/wgpuCache.js +19 -16
  20. package/dist/wgpuCache.js.map +1 -1
  21. package/dist/wgpuClipContours.js +3 -3
  22. package/dist/wgpuClipContours.js.map +1 -1
  23. package/dist/wgpuClipRectangle.js +3 -3
  24. package/dist/wgpuClipRectangle.js.map +1 -1
  25. package/dist/wgpuColorAdjustmentMaterialFeature.d.ts.map +1 -1
  26. package/dist/wgpuColorAdjustmentMaterialFeature.js +156 -67
  27. package/dist/wgpuColorAdjustmentMaterialFeature.js.map +1 -1
  28. package/dist/wgpuMeshShapeRenderer.d.ts +4 -0
  29. package/dist/wgpuMeshShapeRenderer.d.ts.map +1 -0
  30. package/dist/wgpuMeshShapeRenderer.js +92 -0
  31. package/dist/wgpuMeshShapeRenderer.js.map +1 -0
  32. package/dist/wgpuNode2D.js +2 -2
  33. package/dist/wgpuNode2D.js.map +1 -1
  34. package/dist/wgpuParticleEmitter2D.d.ts.map +1 -1
  35. package/dist/wgpuParticleEmitter2D.js +26 -10
  36. package/dist/wgpuParticleEmitter2D.js.map +1 -1
  37. package/dist/wgpuQuadBatch.d.ts +1 -1
  38. package/dist/wgpuQuadBatch.d.ts.map +1 -1
  39. package/dist/wgpuQuadBatch.js +18 -13
  40. package/dist/wgpuQuadBatch.js.map +1 -1
  41. package/dist/wgpuQuadBatchWriter.d.ts +12 -0
  42. package/dist/wgpuQuadBatchWriter.d.ts.map +1 -0
  43. package/dist/{wgpuSpriteBatch.js → wgpuQuadBatchWriter.js} +97 -72
  44. package/dist/wgpuQuadBatchWriter.js.map +1 -0
  45. package/dist/wgpuRasterShapeRenderer.d.ts +4 -0
  46. package/dist/wgpuRasterShapeRenderer.d.ts.map +1 -0
  47. package/dist/wgpuRasterShapeRenderer.js +110 -0
  48. package/dist/wgpuRasterShapeRenderer.js.map +1 -0
  49. package/dist/wgpuRichText.d.ts.map +1 -1
  50. package/dist/wgpuRichText.js +7 -4
  51. package/dist/wgpuRichText.js.map +1 -1
  52. package/dist/wgpuScale9Shape.d.ts +1 -3
  53. package/dist/wgpuScale9Shape.d.ts.map +1 -1
  54. package/dist/wgpuScale9Shape.js +36 -33
  55. package/dist/wgpuScale9Shape.js.map +1 -1
  56. package/dist/wgpuShape.d.ts +2 -1
  57. package/dist/wgpuShape.d.ts.map +1 -1
  58. package/dist/wgpuShape.js +19 -153
  59. package/dist/wgpuShape.js.map +1 -1
  60. package/dist/wgpuShapeData.d.ts +6 -0
  61. package/dist/wgpuShapeData.d.ts.map +1 -0
  62. package/dist/wgpuShapeData.js +81 -0
  63. package/dist/wgpuShapeData.js.map +1 -0
  64. package/dist/wgpuShapeMesh.d.ts +2 -1
  65. package/dist/wgpuShapeMesh.d.ts.map +1 -1
  66. package/dist/wgpuShapeMesh.js +74 -62
  67. package/dist/wgpuShapeMesh.js.map +1 -1
  68. package/dist/wgpuShapeRasterizer.d.ts +4 -0
  69. package/dist/wgpuShapeRasterizer.d.ts.map +1 -0
  70. package/dist/wgpuShapeRasterizer.js +11 -0
  71. package/dist/wgpuShapeRasterizer.js.map +1 -0
  72. package/dist/wgpuSprite.d.ts +3 -2
  73. package/dist/wgpuSprite.d.ts.map +1 -1
  74. package/dist/wgpuSprite.js +67 -25
  75. package/dist/wgpuSprite.js.map +1 -1
  76. package/dist/wgpuStandardMaterial.d.ts +1 -1
  77. package/dist/wgpuStandardMaterial.js +3 -3
  78. package/dist/wgpuStandardMaterial.js.map +1 -1
  79. package/dist/wgpuTextLabel.d.ts.map +1 -1
  80. package/dist/wgpuTextLabel.js +15 -12
  81. package/dist/wgpuTextLabel.js.map +1 -1
  82. package/dist/wgpuTilemap.d.ts.map +1 -1
  83. package/dist/wgpuTilemap.js +19 -18
  84. package/dist/wgpuTilemap.js.map +1 -1
  85. package/package.json +22 -18
  86. package/src/enableWgpuColorAdjustmentGuards.test.ts +4 -4
  87. package/src/enableWgpuStrokePathTessellation.test.ts +23 -0
  88. package/src/wgpuBitmapText.test.ts +2 -2
  89. package/src/wgpuCache.test.ts +14 -11
  90. package/src/wgpuColorAdjustmentMaterialFeature.test.ts +52 -46
  91. package/src/wgpuMeshShapeRenderer.test.ts +167 -0
  92. package/src/wgpuParticleEmitter2D.test.ts +16 -7
  93. package/src/wgpuQuadBatch.test.ts +3 -3
  94. package/src/{wgpuSpriteBatch.test.ts → wgpuQuadBatchWriter.test.ts} +79 -70
  95. package/src/wgpuRasterShapeRenderer.test.ts +156 -0
  96. package/src/wgpuScale9Shape.test.ts +0 -13
  97. package/src/wgpuShape.test.ts +169 -15
  98. package/src/wgpuShapeData.test.ts +97 -0
  99. package/src/wgpuShapeMesh.test.ts +170 -15
  100. package/src/wgpuShapeRasterizer.test.ts +36 -0
  101. package/src/wgpuSprite.test.ts +45 -10
  102. package/src/wgpuStandardMaterial.test.ts +3 -3
  103. package/src/wgpuTextLabel.test.ts +7 -7
  104. package/src/wgpuTilemap.test.ts +4 -4
  105. package/src/wgpuVelocity.test.ts +3 -3
  106. package/dist/wgpuBitmap.d.ts +0 -4
  107. package/dist/wgpuBitmap.d.ts.map +0 -1
  108. package/dist/wgpuBitmap.js +0 -84
  109. package/dist/wgpuBitmap.js.map +0 -1
  110. package/dist/wgpuRenderTargetNode2D.d.ts +0 -11
  111. package/dist/wgpuRenderTargetNode2D.d.ts.map +0 -1
  112. package/dist/wgpuRenderTargetNode2D.js +0 -100
  113. package/dist/wgpuRenderTargetNode2D.js.map +0 -1
  114. package/dist/wgpuSpriteBatch.d.ts +0 -12
  115. package/dist/wgpuSpriteBatch.d.ts.map +0 -1
  116. package/dist/wgpuSpriteBatch.js.map +0 -1
  117. package/dist/wgpuSpriteRenderer.d.ts +0 -3
  118. package/dist/wgpuSpriteRenderer.d.ts.map +0 -1
  119. package/dist/wgpuSpriteRenderer.js +0 -55
  120. package/dist/wgpuSpriteRenderer.js.map +0 -1
  121. package/dist/wgpuVideo.d.ts +0 -6
  122. package/dist/wgpuVideo.d.ts.map +0 -1
  123. package/dist/wgpuVideo.js +0 -63
  124. package/dist/wgpuVideo.js.map +0 -1
  125. package/src/wgpuBitmap.test.ts +0 -40
  126. package/src/wgpuRenderTargetNode2D.test.ts +0 -267
  127. package/src/wgpuSpriteRenderer.test.ts +0 -63
  128. package/src/wgpuVideo.test.ts +0 -59
@@ -9,7 +9,6 @@ import {
9
9
  destroyWgpuScale9ShapeData,
10
10
  drawWgpuScale9Shape,
11
11
  drawWgpuScale9ShapeMask,
12
- remapWgpuScale9Commands,
13
12
  } from './wgpuScale9Shape';
14
13
 
15
14
  const grid = { height: 80, width: 80, x: 10, y: 10 };
@@ -83,15 +82,3 @@ describe('drawWgpuScale9ShapeMask', () => {
83
82
  submitWgpuRenderPass(state);
84
83
  });
85
84
  });
86
-
87
- describe('remapWgpuScale9Commands', () => {
88
- it('remaps coordinates with a compatible mapper', () => {
89
- const out: unknown[] = [];
90
- remapWgpuScale9Commands(out, ['drawRectangle', 4, 10, 20, 50, 30], {
91
- mapX: (x: number) => x * 2,
92
- mapY: (y: number) => y * 3,
93
- });
94
-
95
- expect(out).toEqual(['drawRectangle', 4, 20, 60, 100, 90]);
96
- });
97
- });
@@ -3,18 +3,33 @@ import type * as FlightNodeModule from '@flighthq/node/contract';
3
3
  import { renderWgpuBackground, submitWgpuRenderPass } from '@flighthq/render-wgpu/contract';
4
4
  import { getWgpuRenderStateRuntime } from '@flighthq/render-wgpu/contract';
5
5
  import { createWgpuRenderStateForTest, installWgpuMock } from '@flighthq/render-wgpu/contract';
6
+ import {
7
+ appendShapeBeginFill,
8
+ appendShapeEndFill,
9
+ appendShapePath,
10
+ appendShapeLineStyle,
11
+ appendShapeLineTo,
12
+ appendShapeMoveTo,
13
+ appendShapeRectangle,
14
+ createShape,
15
+ } from '@flighthq/shape/contract';
6
16
  import type { RenderProxy2D } from '@flighthq/types/contract';
7
- import { BatchFormat } from '@flighthq/types/contract';
17
+ import { BatchFormat, PathCommand } from '@flighthq/types/contract';
8
18
 
19
+ import { enableWgpuStrokePathTessellation } from './enableWgpuStrokePathTessellation';
9
20
  import { scopeModuleMocks } from './moduleMockTestHelper';
10
21
  import type * as WgpuShapeModule from './wgpuShape';
11
- import { registerStandardWgpuMaterial } from './wgpuStandardMaterial';
22
+ import { registerWgpuShapeRasterizer } from './wgpuShapeRasterizer';
23
+ import { registerWgpuStandardMaterial } from './wgpuStandardMaterial';
24
+
25
+ const noopRasterizer = (): void => {};
12
26
 
13
27
  // @flighthq/node's bounds/revision queries expect a real BoundsNode; these tests drive drawWgpuShape
14
28
  // with lightweight fake proxies, so the two queries are stubbed. scopeModuleMocks scopes the stub to
15
29
  // this file (registry reset before the mock applies, unmock + reset after), so under a shared
16
30
  // (isolate:false) worker it never leaks into the many real consumers of these functions (node,
17
31
  // interaction, shape, text) — and a sibling that pre-evaluated ./wgpuShape still picks up the stub.
32
+ let defaultWgpuMorphShapeRenderer: typeof WgpuShapeModule.defaultWgpuMorphShapeRenderer;
18
33
  let defaultWgpuShapeRenderer: typeof WgpuShapeModule.defaultWgpuShapeRenderer;
19
34
  let drawWgpuShape: typeof WgpuShapeModule.drawWgpuShape;
20
35
 
@@ -26,16 +41,32 @@ beforeAll(async () => {
26
41
  getNodeLocalBoundsRectangle: () => ({ x: 0, y: 0, width: 64, height: 48 }),
27
42
  getNodeLocalContentRevision: (source: any) => source?.data?.version ?? 0,
28
43
  }));
29
- ({ defaultWgpuShapeRenderer, drawWgpuShape } = await import('./wgpuShape'));
44
+ ({ defaultWgpuMorphShapeRenderer, defaultWgpuShapeRenderer, drawWgpuShape } = await import('./wgpuShape'));
30
45
  installWgpuMock();
31
46
  });
32
47
 
48
+ // Mirrors createWgpuShapeData: the rasterization surface is absent until a shape actually needs one,
49
+ // so a scene drawn entirely through the mesh path carries no canvases.
33
50
  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 };
51
+ return {
52
+ surface: null,
53
+ lastContentId: -1,
54
+ lastPixelRatio: 0,
55
+ lastW: 0,
56
+ lastH: 0,
57
+ meshVersion: -1,
58
+ meshes: null,
59
+ meshBuffers: {
60
+ vertexBuffers: [],
61
+ vertexCapacities: [],
62
+ indexBuffers: [],
63
+ indexCapacities: [],
64
+ uniformBuffers: [],
65
+ bindGroups: [],
66
+ colorScaleBiasUniformBuffers: [],
67
+ colorScaleBiasBindGroups: [],
68
+ },
69
+ };
39
70
  }
40
71
 
41
72
  function makeShapeProxy(data: Record<string, unknown> = {}, rendererData: unknown = null): RenderProxy2D {
@@ -50,7 +81,24 @@ function makeShapeProxy(data: Record<string, unknown> = {}, rendererData: unknow
50
81
  } as unknown as RenderProxy2D;
51
82
  }
52
83
 
84
+ function makeMeshPassSpy(): GPURenderPassEncoder {
85
+ return {
86
+ setPipeline: vi.fn(),
87
+ setBindGroup: vi.fn(),
88
+ setVertexBuffer: vi.fn(),
89
+ setIndexBuffer: vi.fn(),
90
+ setStencilReference: vi.fn(),
91
+ draw: vi.fn(),
92
+ drawIndexed: vi.fn(),
93
+ end: vi.fn(),
94
+ } as unknown as GPURenderPassEncoder;
95
+ }
96
+
53
97
  describe('defaultWgpuShapeRenderer', () => {
98
+ it('provides the MorphShape renderer alias', () => {
99
+ expect(defaultWgpuMorphShapeRenderer).toBe(defaultWgpuShapeRenderer);
100
+ });
101
+
54
102
  it('declares BatchFormat.Quad', () => {
55
103
  expect(defaultWgpuShapeRenderer.format).toBe(BatchFormat.Quad);
56
104
  });
@@ -62,35 +110,141 @@ describe('defaultWgpuShapeRenderer', () => {
62
110
  });
63
111
 
64
112
  describe('drawWgpuShape', () => {
113
+ it('draws a solid fill and open solid stroke as GPU meshes in one shape', async () => {
114
+ const state = await createWgpuRenderStateForTest();
115
+ registerWgpuShapeRasterizer(state, noopRasterizer);
116
+ renderWgpuBackground(state);
117
+ getWgpuRenderStateRuntime(state).renderPass = makeMeshPassSpy();
118
+ const shape = createShape();
119
+ appendShapeBeginFill(shape, 0x00cc00);
120
+ appendShapeRectangle(shape, 8, 8, 32, 24);
121
+ appendShapeEndFill(shape);
122
+ appendShapeLineStyle(shape, 4, 0xff0000);
123
+ appendShapeMoveTo(shape, 8, 4);
124
+ appendShapeLineTo(shape, 40, 4);
125
+ const rendererData = defaultWgpuShapeRenderer.createData!(state, shape)!;
126
+
127
+ drawWgpuShape(state, makeShapeProxy({ commands: shape.data.commands, version: 1 }, rendererData));
128
+
129
+ const pass = getWgpuRenderStateRuntime(state).renderPass as unknown as {
130
+ drawIndexed: ReturnType<typeof vi.fn>;
131
+ };
132
+ expect(pass.drawIndexed).toHaveBeenCalledTimes(2);
133
+ expect(getWgpuRenderStateRuntime(state).quadBatchWriterCount).toBe(0);
134
+ submitWgpuRenderPass(state);
135
+ });
136
+
137
+ it('draws a closed solid stroke ring as one GPU mesh', async () => {
138
+ const state = await createWgpuRenderStateForTest();
139
+ registerWgpuShapeRasterizer(state, noopRasterizer);
140
+ enableWgpuStrokePathTessellation(state);
141
+ renderWgpuBackground(state);
142
+ getWgpuRenderStateRuntime(state).renderPass = makeMeshPassSpy();
143
+ const shape = createShape();
144
+ appendShapeLineStyle(shape, 8, 0xff0000);
145
+ appendShapeRectangle(shape, 8, 8, 32, 24);
146
+ const rendererData = defaultWgpuShapeRenderer.createData!(state, shape)!;
147
+
148
+ drawWgpuShape(state, makeShapeProxy({ commands: shape.data.commands, version: 1 }, rendererData));
149
+
150
+ const pass = getWgpuRenderStateRuntime(state).renderPass as unknown as {
151
+ drawIndexed: ReturnType<typeof vi.fn>;
152
+ };
153
+ expect(pass.drawIndexed).toHaveBeenCalledTimes(1);
154
+ expect(getWgpuRenderStateRuntime(state).quadBatchWriterCount).toBe(0);
155
+ submitWgpuRenderPass(state);
156
+ });
157
+
158
+ it('keeps a closed stroke on the raster lane until stroke-path tessellation is enabled', async () => {
159
+ const state = await createWgpuRenderStateForTest();
160
+ registerWgpuShapeRasterizer(state, noopRasterizer);
161
+ renderWgpuBackground(state);
162
+ registerWgpuStandardMaterial(state);
163
+ const pass = makeMeshPassSpy();
164
+ getWgpuRenderStateRuntime(state).renderPass = pass;
165
+ const shape = createShape();
166
+ appendShapeLineStyle(shape, 8, 0xff0000);
167
+ appendShapeRectangle(shape, 8, 8, 32, 24);
168
+ const rendererData = defaultWgpuShapeRenderer.createData!(state, shape)!;
169
+
170
+ drawWgpuShape(state, makeShapeProxy({ commands: shape.data.commands, version: 1 }, rendererData));
171
+
172
+ expect(pass.drawIndexed).not.toHaveBeenCalled();
173
+ expect(getWgpuRenderStateRuntime(state).quadBatchWriterCount).toBe(1);
174
+ submitWgpuRenderPass(state);
175
+ });
176
+
177
+ it('falls back to the raster quad for a self-intersecting stroke centerline', async () => {
178
+ const state = await createWgpuRenderStateForTest();
179
+ registerWgpuShapeRasterizer(state, noopRasterizer);
180
+ enableWgpuStrokePathTessellation(state);
181
+ renderWgpuBackground(state);
182
+ registerWgpuStandardMaterial(state);
183
+ const pass = makeMeshPassSpy();
184
+ getWgpuRenderStateRuntime(state).renderPass = pass;
185
+ const shape = createShape();
186
+ appendShapeLineStyle(shape, 8, 0xff0000);
187
+ appendShapePath(
188
+ shape,
189
+ [PathCommand.MOVE_TO, PathCommand.LINE_TO, PathCommand.LINE_TO, PathCommand.LINE_TO, PathCommand.CLOSE],
190
+ [8, 8, 40, 40, 8, 40, 40, 8],
191
+ 'nonZero',
192
+ );
193
+ const rendererData = defaultWgpuShapeRenderer.createData!(state, shape)!;
194
+ const proxy = makeShapeProxy({ commands: shape.data.commands, version: 1 }, rendererData);
195
+
196
+ drawWgpuShape(state, proxy);
197
+ // The first draw rasterized, so the surface exists by now — it is allocated on demand, not with the node.
198
+ const canvas = (rendererData as unknown as { surface: { canvas: HTMLCanvasElement } }).surface.canvas;
199
+ let canvasWidth = canvas.width;
200
+ let canvasHeight = canvas.height;
201
+ const setWidth = vi.fn((value: number) => (canvasWidth = value));
202
+ const setHeight = vi.fn((value: number) => (canvasHeight = value));
203
+ Object.defineProperty(canvas, 'width', { configurable: true, get: () => canvasWidth, set: setWidth });
204
+ Object.defineProperty(canvas, 'height', { configurable: true, get: () => canvasHeight, set: setHeight });
205
+ (proxy.source as unknown as { data: { version: number } }).data.version = 2;
206
+ drawWgpuShape(state, proxy);
207
+
208
+ expect(pass.drawIndexed).not.toHaveBeenCalled();
209
+ expect(getWgpuRenderStateRuntime(state).quadBatchWriterCount).toBe(2);
210
+ expect(setWidth).not.toHaveBeenCalled();
211
+ expect(setHeight).not.toHaveBeenCalled();
212
+ submitWgpuRenderPass(state);
213
+ });
214
+
65
215
  it('returns early without writing to batch when commands are empty', async () => {
66
216
  const state = await createWgpuRenderStateForTest();
217
+ registerWgpuShapeRasterizer(state, noopRasterizer);
67
218
  renderWgpuBackground(state);
68
- registerStandardWgpuMaterial(state);
219
+ registerWgpuStandardMaterial(state);
69
220
  drawWgpuShape(state, makeShapeProxy({ commands: [] }, makeShapeData()));
70
- expect(getWgpuRenderStateRuntime(state).spriteBatchCount).toBe(0);
221
+ expect(getWgpuRenderStateRuntime(state).quadBatchWriterCount).toBe(0);
71
222
  submitWgpuRenderPass(state);
72
223
  });
73
224
 
74
225
  it('returns early without writing to batch when rendererData is null', async () => {
75
226
  const state = await createWgpuRenderStateForTest();
227
+ registerWgpuShapeRasterizer(state, noopRasterizer);
76
228
  renderWgpuBackground(state);
77
- registerStandardWgpuMaterial(state);
229
+ registerWgpuStandardMaterial(state);
78
230
  drawWgpuShape(state, makeShapeProxy({ commands: [{}] }, null));
79
- expect(getWgpuRenderStateRuntime(state).spriteBatchCount).toBe(0);
231
+ expect(getWgpuRenderStateRuntime(state).quadBatchWriterCount).toBe(0);
80
232
  submitWgpuRenderPass(state);
81
233
  });
82
234
 
83
235
  it('does not throw when renderPass is null', async () => {
84
236
  const state = await createWgpuRenderStateForTest();
237
+ registerWgpuShapeRasterizer(state, noopRasterizer);
85
238
  expect(() => drawWgpuShape(state, makeShapeProxy({ commands: [{}] }, makeShapeData()))).not.toThrow();
86
239
  });
87
240
 
88
- it('writes one instance to the sprite batch when shape has valid commands and bounds', async () => {
241
+ it('writes one instance to the quad-batch writer when shape has valid commands and bounds', async () => {
89
242
  const state = await createWgpuRenderStateForTest();
243
+ registerWgpuShapeRasterizer(state, noopRasterizer);
90
244
  renderWgpuBackground(state);
91
- registerStandardWgpuMaterial(state);
245
+ registerWgpuStandardMaterial(state);
92
246
  drawWgpuShape(state, makeShapeProxy({ commands: [{}], version: 1 }, makeShapeData()));
93
- expect(getWgpuRenderStateRuntime(state).spriteBatchCount).toBe(1);
247
+ expect(getWgpuRenderStateRuntime(state).quadBatchWriterCount).toBe(1);
94
248
  submitWgpuRenderPass(state);
95
249
  });
96
250
  });
@@ -0,0 +1,97 @@
1
+ import { getWgpuRenderStateRuntime } from '@flighthq/render-wgpu/contract';
2
+ import { createWgpuRenderStateForTest, installWgpuMock } from '@flighthq/render-wgpu/contract';
3
+ import type { WgpuShapeRendererData } from '@flighthq/types/contract';
4
+
5
+ import {
6
+ acquireWgpuShapeRasterSurface,
7
+ createWgpuShapeData,
8
+ destroyWgpuShapeData,
9
+ getWgpuShapeData,
10
+ } from './wgpuShapeData';
11
+
12
+ beforeAll(() => {
13
+ installWgpuMock();
14
+ });
15
+
16
+ function emptyData(): WgpuShapeRendererData {
17
+ return {
18
+ surface: null,
19
+ lastContentId: -1,
20
+ lastPixelRatio: 0,
21
+ lastW: 0,
22
+ lastH: 0,
23
+ meshVersion: -1,
24
+ meshes: null,
25
+ meshBuffers: {
26
+ vertexBuffers: [],
27
+ vertexCapacities: [],
28
+ indexBuffers: [],
29
+ indexCapacities: [],
30
+ uniformBuffers: [],
31
+ bindGroups: [],
32
+ colorScaleBiasUniformBuffers: [],
33
+ colorScaleBiasBindGroups: [],
34
+ },
35
+ };
36
+ }
37
+
38
+ describe('acquireWgpuShapeRasterSurface', () => {
39
+ it('allocates once and returns the same surface thereafter', () => {
40
+ const data = emptyData();
41
+ const first = acquireWgpuShapeRasterSurface(data);
42
+ expect(data.surface).toBe(first);
43
+ expect(acquireWgpuShapeRasterSurface(data)).toBe(first);
44
+ });
45
+
46
+ it('wraps the canvas as an Image so the quad batch treats it like any other texture source', () => {
47
+ const surface = acquireWgpuShapeRasterSurface(emptyData());
48
+ expect(surface.image.source).toBe(surface.canvas);
49
+ });
50
+ });
51
+
52
+ describe('createWgpuShapeData', () => {
53
+ it('allocates no canvas up front, so a mesh-only scene carries none', async () => {
54
+ const state = await createWgpuRenderStateForTest();
55
+ const data = getWgpuShapeData(createWgpuShapeData(state, {} as never))!;
56
+ expect(data.surface).toBeNull();
57
+ expect(data.meshes).toBeNull();
58
+ expect(data.meshVersion).toBe(-1);
59
+ });
60
+ });
61
+
62
+ describe('destroyWgpuShapeData', () => {
63
+ it('does nothing when the shape only ever tessellated, since there is no surface to free', async () => {
64
+ const state = await createWgpuRenderStateForTest();
65
+ const data = createWgpuShapeData(state, {} as never);
66
+ expect(() => destroyWgpuShapeData(state, data)).not.toThrow();
67
+ });
68
+
69
+ it('destroys the cached GPU texture keyed on the raster surface, and the mesh buffers', async () => {
70
+ const state = await createWgpuRenderStateForTest();
71
+ const data = createWgpuShapeData(state, {} as never);
72
+ const shapeData = getWgpuShapeData(data)!;
73
+ const surface = acquireWgpuShapeRasterSurface(shapeData);
74
+ const destroy = vi.fn();
75
+ getWgpuRenderStateRuntime(state).textureSourcePremultipliedTextureCache.set(surface.image, {
76
+ texture: { destroy },
77
+ } as never);
78
+ const bufferDestroy = vi.fn();
79
+ shapeData.meshBuffers.vertexBuffers.push({ destroy: bufferDestroy } as never);
80
+ shapeData.meshBuffers.colorScaleBiasUniformBuffers.push({ destroy: bufferDestroy } as never);
81
+
82
+ destroyWgpuShapeData(state, data);
83
+
84
+ expect(destroy).toHaveBeenCalled();
85
+ expect(bufferDestroy).toHaveBeenCalled();
86
+ expect(shapeData.meshBuffers.vertexBuffers).toHaveLength(0);
87
+ expect(shapeData.meshBuffers.colorScaleBiasUniformBuffers).toHaveLength(0);
88
+ });
89
+ });
90
+
91
+ describe('getWgpuShapeData', () => {
92
+ it('reads the shape data back out of the opaque RendererData slot', async () => {
93
+ const state = await createWgpuRenderStateForTest();
94
+ const data = createWgpuShapeData(state, {} as never);
95
+ expect(getWgpuShapeData(data)?.meshVersion).toBe(-1);
96
+ });
97
+ });
@@ -1,11 +1,18 @@
1
1
  import { createMatrix } from '@flighthq/geometry/contract';
2
2
  import { getWgpuRenderStateRuntime } from '@flighthq/render-wgpu/contract';
3
3
  import { createWgpuRenderStateForTest, installWgpuMock } from '@flighthq/render-wgpu/contract';
4
- import type { RenderProxy2D, WgpuRenderState, WgpuShapeMeshBuffers } from '@flighthq/types/contract';
4
+ import type {
5
+ ColorScaleBias,
6
+ RenderProxy2D,
7
+ WgpuRenderState,
8
+ WgpuShapeMeshBuffers,
9
+ WgpuShapeMeshPipeline,
10
+ } from '@flighthq/types/contract';
5
11
  import type { WgpuShapeMesh } from '@flighthq/types/contract';
6
12
  import { BlendMode } from '@flighthq/types/contract';
7
13
 
8
- import { drawWgpuShapeMeshes } from './wgpuShapeMesh';
14
+ import { registerWgpuColorAdjustmentMaterialFeature } from './wgpuColorAdjustmentMaterialFeature';
15
+ import { drawWgpuShapeMeshBatch, drawWgpuShapeMeshes } from './wgpuShapeMesh';
9
16
 
10
17
  beforeAll(() => {
11
18
  installWgpuMock();
@@ -20,12 +27,14 @@ const TRIANGLE: WgpuShapeMesh = {
20
27
 
21
28
  function makeBuffers(): WgpuShapeMeshBuffers {
22
29
  return {
23
- vertexBuffer: null,
24
- vertexCapacity: 0,
25
- indexBuffer: null,
26
- indexCapacity: 0,
27
- uniformBuffer: null,
28
- bindGroup: null,
30
+ vertexBuffers: [],
31
+ vertexCapacities: [],
32
+ indexBuffers: [],
33
+ indexCapacities: [],
34
+ uniformBuffers: [],
35
+ bindGroups: [],
36
+ colorScaleBiasUniformBuffers: [],
37
+ colorScaleBiasBindGroups: [],
29
38
  };
30
39
  }
31
40
 
@@ -41,8 +50,37 @@ function makePassSpy(): GPURenderPassEncoder {
41
50
  } as unknown as GPURenderPassEncoder;
42
51
  }
43
52
 
44
- function makeProxy(matrix = createMatrix(), alpha = 1, blendMode: BlendMode | null = null): RenderProxy2D {
45
- return { alpha, blendMode, transform2D: matrix } as unknown as RenderProxy2D;
53
+ function makeProxy(overrides?: Partial<RenderProxy2D>): RenderProxy2D {
54
+ return {
55
+ alpha: 1,
56
+ blendMode: null,
57
+ colorMatrix: null,
58
+ colorScaleBias: null,
59
+ transform2D: createMatrix(),
60
+ ...overrides,
61
+ } as unknown as RenderProxy2D;
62
+ }
63
+
64
+ function ct(
65
+ redScale = 1,
66
+ greenScale = 1,
67
+ blueScale = 1,
68
+ alphaScale = 1,
69
+ redBias = 0,
70
+ greenBias = 0,
71
+ blueBias = 0,
72
+ alphaBias = 0,
73
+ ): ColorScaleBias {
74
+ return {
75
+ redScale,
76
+ greenScale,
77
+ blueScale,
78
+ alphaScale,
79
+ redBias,
80
+ greenBias,
81
+ blueBias,
82
+ alphaBias,
83
+ } as ColorScaleBias;
46
84
  }
47
85
 
48
86
  async function makeState(): Promise<WgpuRenderState> {
@@ -51,6 +89,42 @@ async function makeState(): Promise<WgpuRenderState> {
51
89
  return state;
52
90
  }
53
91
 
92
+ describe('drawWgpuShapeMeshBatch', () => {
93
+ it('writes only the shared matrix and color fields, preserving a feature-owned uniform tail', async () => {
94
+ const state = await makeState();
95
+ const buffers = makeBuffers();
96
+ const uniformBuffers: GPUBuffer[] = [];
97
+ const bindGroups: GPUBindGroup[] = [];
98
+ const uniformData = new Float32Array(24);
99
+ uniformData.set([0.25, 0.5, 0.75, 1, 0.1, 0.2, 0.3, 0.4], 16);
100
+ const pipelineEntry: WgpuShapeMeshPipeline = {
101
+ bindGroupLayout: {} as GPUBindGroupLayout,
102
+ pipeline: {} as GPURenderPipeline,
103
+ };
104
+ let upload: Float32Array | undefined;
105
+ state.device.queue.writeBuffer = vi.fn((buffer: GPUBuffer, _offset: number, data: BufferSource) => {
106
+ if (buffer !== uniformBuffers[0]) return;
107
+ const view = ArrayBuffer.isView(data) ? data : new Uint8Array(data as ArrayBuffer);
108
+ upload = new Float32Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength));
109
+ }) as unknown as GPUQueue['writeBuffer'];
110
+
111
+ drawWgpuShapeMeshBatch(
112
+ state,
113
+ makeProxy({ alpha: 0.5 }),
114
+ [{ ...TRIANGLE, color: 0xffffff }],
115
+ buffers,
116
+ pipelineEntry,
117
+ uniformBuffers,
118
+ bindGroups,
119
+ uniformData,
120
+ );
121
+
122
+ expect(uniformBuffers).toHaveLength(1);
123
+ expect(upload?.slice(12, 16)).toEqual(new Float32Array([0.5, 0.5, 0.5, 0.5]));
124
+ expect(upload?.slice(16, 24)).toEqual(new Float32Array([0.25, 0.5, 0.75, 1, 0.1, 0.2, 0.3, 0.4]));
125
+ });
126
+ });
127
+
54
128
  describe('drawWgpuShapeMeshes', () => {
55
129
  it('sets the shape-mesh pipeline and draws each mesh', async () => {
56
130
  const state = await makeState();
@@ -68,6 +142,20 @@ describe('drawWgpuShapeMeshes', () => {
68
142
  expect(pass.drawIndexed).toHaveBeenCalledWith(3);
69
143
  });
70
144
 
145
+ it('retains distinct GPU storage for every mesh recorded before submit', async () => {
146
+ const state = await makeState();
147
+ const buffers = makeBuffers();
148
+
149
+ drawWgpuShapeMeshes(state, makeProxy(), [TRIANGLE, { ...TRIANGLE, color: 0x0080ff }], buffers);
150
+
151
+ expect(buffers.vertexBuffers).toHaveLength(2);
152
+ expect(buffers.indexBuffers).toHaveLength(2);
153
+ expect(buffers.uniformBuffers).toHaveLength(2);
154
+ expect(buffers.vertexBuffers[0]).not.toBe(buffers.vertexBuffers[1]);
155
+ expect(buffers.indexBuffers[0]).not.toBe(buffers.indexBuffers[1]);
156
+ expect(buffers.uniformBuffers[0]).not.toBe(buffers.uniformBuffers[1]);
157
+ });
158
+
71
159
  it('retires grown geometry buffers until the recorded frame is submitted', async () => {
72
160
  const state = await makeState();
73
161
  const buffers = makeBuffers();
@@ -83,7 +171,8 @@ describe('drawWgpuShapeMeshes', () => {
83
171
  indices: new Uint16Array([0, 1, 2, 1, 3, 2]),
84
172
  };
85
173
 
86
- drawWgpuShapeMeshes(state, makeProxy(), [TRIANGLE, larger], buffers);
174
+ drawWgpuShapeMeshes(state, makeProxy(), [TRIANGLE], buffers);
175
+ drawWgpuShapeMeshes(state, makeProxy(), [larger], buffers);
87
176
 
88
177
  const retired = getWgpuRenderStateRuntime(state).retiredBuffers!;
89
178
  expect(retired).toHaveLength(2);
@@ -106,8 +195,8 @@ describe('drawWgpuShapeMeshes', () => {
106
195
  it('bakes the node blend mode into a distinct shape pipeline', async () => {
107
196
  const state = await makeState();
108
197
 
109
- drawWgpuShapeMeshes(state, makeProxy(createMatrix(), 1, BlendMode.Normal), [TRIANGLE], makeBuffers());
110
- drawWgpuShapeMeshes(state, makeProxy(createMatrix(), 1, BlendMode.Add), [TRIANGLE], makeBuffers());
198
+ drawWgpuShapeMeshes(state, makeProxy({ blendMode: BlendMode.Normal }), [TRIANGLE], makeBuffers());
199
+ drawWgpuShapeMeshes(state, makeProxy({ blendMode: BlendMode.Add }), [TRIANGLE], makeBuffers());
111
200
 
112
201
  const pipelines = [...getWgpuRenderStateRuntime(state).shapeMeshPipelines!.values()].map(
113
202
  (entry) => entry.pipeline,
@@ -130,9 +219,9 @@ describe('drawWgpuShapeMeshes', () => {
130
219
  }
131
220
  }) as unknown as GPUQueue['writeBuffer'];
132
221
 
133
- drawWgpuShapeMeshes(state, makeProxy(createMatrix(), 0.5), [{ ...TRIANGLE, color: 0xffffff }], buffers);
222
+ drawWgpuShapeMeshes(state, makeProxy({ alpha: 0.5 }), [{ ...TRIANGLE, color: 0xffffff }], buffers);
134
223
 
135
- const uniformData = writes.get(buffers.uniformBuffer!);
224
+ const uniformData = writes.get(buffers.uniformBuffers[0]);
136
225
  expect(uniformData).toBeDefined();
137
226
  // Color occupies floats 12..15 (after the mat3x3f columns): premultiplied (0.5, 0.5, 0.5, 0.5).
138
227
  const color = uniformData!.slice(12, 16);
@@ -161,4 +250,70 @@ describe('drawWgpuShapeMeshes', () => {
161
250
  expect(pass.setPipeline).not.toHaveBeenCalled();
162
251
  expect(runtime.shapeMeshPipelines?.size ?? 0).toBe(0);
163
252
  });
253
+
254
+ it('ignores ColorScaleBias when the opt-in feature is not registered', async () => {
255
+ const state = await makeState();
256
+ const buffers = makeBuffers();
257
+
258
+ drawWgpuShapeMeshes(state, makeProxy({ colorScaleBias: ct(0.5) }), [TRIANGLE], buffers);
259
+
260
+ expect(buffers.uniformBuffers).toHaveLength(1);
261
+ expect(buffers.colorScaleBiasUniformBuffers).toHaveLength(0);
262
+ });
263
+
264
+ it('keeps registered but unadjusted meshes on the lean uniform path', async () => {
265
+ const state = await makeState();
266
+ const buffers = makeBuffers();
267
+ registerWgpuColorAdjustmentMaterialFeature(state);
268
+
269
+ drawWgpuShapeMeshes(state, makeProxy(), [TRIANGLE], buffers);
270
+
271
+ expect(buffers.uniformBuffers).toHaveLength(1);
272
+ expect(buffers.colorScaleBiasUniformBuffers).toHaveLength(0);
273
+ });
274
+
275
+ it('folds ColorScaleBias through a separate 96-byte uniform and shader', async () => {
276
+ const state = await makeState();
277
+ const buffers = makeBuffers();
278
+ registerWgpuColorAdjustmentMaterialFeature(state);
279
+ const createShaderModule = vi.spyOn(state.device, 'createShaderModule');
280
+ const writes = new Map<GPUBuffer, Float32Array>();
281
+ state.device.queue.writeBuffer = vi.fn((buffer: GPUBuffer, _offset: number, data: BufferSource) => {
282
+ const view = ArrayBuffer.isView(data) ? data : new Uint8Array(data as ArrayBuffer);
283
+ if (view.byteLength % 4 === 0) {
284
+ writes.set(buffer, new Float32Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)));
285
+ }
286
+ }) as unknown as GPUQueue['writeBuffer'];
287
+
288
+ drawWgpuShapeMeshes(
289
+ state,
290
+ makeProxy({ alpha: 0.5, colorScaleBias: ct(0.25, 0.5, 0.75, 0.8, 0.1, 0.2, 0.3, 0.4) }),
291
+ [{ ...TRIANGLE, color: 0xffffff }],
292
+ buffers,
293
+ );
294
+
295
+ expect(buffers.uniformBuffers).toHaveLength(0);
296
+ expect(buffers.colorScaleBiasUniformBuffers).toHaveLength(1);
297
+ const uniform = writes.get(buffers.colorScaleBiasUniformBuffers[0]);
298
+ expect(uniform).toHaveLength(24);
299
+ expect(uniform?.slice(12, 16)).toEqual(new Float32Array([0.5, 0.5, 0.5, 0.5]));
300
+ expect(uniform?.slice(16, 24)).toEqual(new Float32Array([0.25, 0.5, 0.75, 0.8, 0.1, 0.2, 0.3, 0.4]));
301
+ const shader = createShaderModule.mock.calls
302
+ .map(([descriptor]) => descriptor.code)
303
+ .find((code) => code.includes('struct ShapeMeshUniforms'));
304
+ expect(shader).toContain('u.color.rgb / u.color.a');
305
+ expect(shader).toContain('applyFlightColorAdjustment(color, u.colorScale, u.colorBias)');
306
+ expect(shader).toContain('color.rgb * color.a');
307
+ });
308
+
309
+ it('leaves the full color-matrix path outside the bounded solid-mesh fold', async () => {
310
+ const state = await makeState();
311
+ const buffers = makeBuffers();
312
+ registerWgpuColorAdjustmentMaterialFeature(state);
313
+
314
+ drawWgpuShapeMeshes(state, makeProxy({ colorMatrix: new Array(20).fill(0) }), [TRIANGLE], buffers);
315
+
316
+ expect(buffers.uniformBuffers).toHaveLength(1);
317
+ expect(buffers.colorScaleBiasUniformBuffers).toHaveLength(0);
318
+ });
164
319
  });
@@ -0,0 +1,36 @@
1
+ import { createWgpuRenderStateForTest, installWgpuMock } from '@flighthq/render-wgpu/contract';
2
+ import { createRenderState, enableRenderRegistryGuards, explainRenderRegistryMisses } from '@flighthq/render/contract';
3
+ import { RenderRegistry } from '@flighthq/types/contract';
4
+
5
+ import { getWgpuShapeRasterizer, registerWgpuShapeRasterizer } from './wgpuShapeRasterizer';
6
+
7
+ beforeAll(() => {
8
+ installWgpuMock();
9
+ });
10
+
11
+ describe('getWgpuShapeRasterizer', () => {
12
+ it('reports none until one is registered', async () => {
13
+ const state = await createWgpuRenderStateForTest();
14
+ expect(getWgpuShapeRasterizer(state)).toBeNull();
15
+ });
16
+ });
17
+
18
+ describe('registerWgpuShapeRasterizer', () => {
19
+ it('installs the rasterizer the shape path draws non-solid fills through', async () => {
20
+ const state = await createWgpuRenderStateForTest();
21
+ const rasterizer = (): void => {};
22
+
23
+ registerWgpuShapeRasterizer(state, rasterizer);
24
+
25
+ expect(getWgpuShapeRasterizer(state)).toBe(rasterizer);
26
+ });
27
+
28
+ it('removes one again, so a state can drop back to tessellation only', async () => {
29
+ const state = await createWgpuRenderStateForTest();
30
+ registerWgpuShapeRasterizer(state, (): void => {});
31
+
32
+ registerWgpuShapeRasterizer(state, null);
33
+
34
+ expect(getWgpuShapeRasterizer(state)).toBeNull();
35
+ });
36
+ });