@flighthq/scene3d-wgpu 0.3.0 → 0.3.1-next.1041.35b950c

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 (66) hide show
  1. package/dist/customShaderWgpuMeshMaterialRenderer.d.ts.map +1 -1
  2. package/dist/customShaderWgpuMeshMaterialRenderer.js +6 -9
  3. package/dist/customShaderWgpuMeshMaterialRenderer.js.map +1 -1
  4. package/dist/drawWgpuScene3D.d.ts.map +1 -1
  5. package/dist/drawWgpuScene3D.js +4 -2
  6. package/dist/drawWgpuScene3D.js.map +1 -1
  7. package/dist/explainWgpuScene3DCoverage.d.ts +2 -2
  8. package/dist/explainWgpuScene3DCoverage.d.ts.map +1 -1
  9. package/dist/explainWgpuScene3DCoverage.js +62 -26
  10. package/dist/explainWgpuScene3DCoverage.js.map +1 -1
  11. package/dist/wgpuClassicPrelude.js +2 -2
  12. package/dist/wgpuClassicPrelude.js.map +1 -1
  13. package/dist/wgpuMeshMaterialRegistry.d.ts.map +1 -1
  14. package/dist/wgpuMeshMaterialRegistry.js +15 -11
  15. package/dist/wgpuMeshMaterialRegistry.js.map +1 -1
  16. package/dist/wgpuMeshPipeline.d.ts +1 -1
  17. package/dist/wgpuMeshPipeline.d.ts.map +1 -1
  18. package/dist/wgpuMeshPipeline.js +39 -12
  19. package/dist/wgpuMeshPipeline.js.map +1 -1
  20. package/dist/wgpuMeshUpload.d.ts +1 -1
  21. package/dist/wgpuMeshUpload.d.ts.map +1 -1
  22. package/dist/wgpuMeshUpload.js +37 -12
  23. package/dist/wgpuMeshUpload.js.map +1 -1
  24. package/dist/wgpuParticleEmitter3D.d.ts.map +1 -1
  25. package/dist/wgpuParticleEmitter3D.js +9 -6
  26. package/dist/wgpuParticleEmitter3D.js.map +1 -1
  27. package/dist/wgpuPbrPipelineCache.d.ts.map +1 -1
  28. package/dist/wgpuPbrPipelineCache.js +2 -2
  29. package/dist/wgpuPbrPipelineCache.js.map +1 -1
  30. package/dist/wgpuScene3DRuntime.d.ts.map +1 -1
  31. package/dist/wgpuScene3DRuntime.js +18 -10
  32. package/dist/wgpuScene3DRuntime.js.map +1 -1
  33. package/dist/wgpuScene3DTestHelper.d.ts.map +1 -1
  34. package/dist/wgpuScene3DTestHelper.js +126 -6
  35. package/dist/wgpuScene3DTestHelper.js.map +1 -1
  36. package/dist/wgpuShadedModifierSnippet.d.ts.map +1 -1
  37. package/dist/wgpuShadedModifierSnippet.js +8 -11
  38. package/dist/wgpuShadedModifierSnippet.js.map +1 -1
  39. package/dist/wgpuShadedPrelude.d.ts +5 -3
  40. package/dist/wgpuShadedPrelude.d.ts.map +1 -1
  41. package/dist/wgpuShadedPrelude.js +34 -13
  42. package/dist/wgpuShadedPrelude.js.map +1 -1
  43. package/dist/wgpuShadowMap.d.ts.map +1 -1
  44. package/dist/wgpuShadowMap.js +22 -6
  45. package/dist/wgpuShadowMap.js.map +1 -1
  46. package/dist/wgpuSkinPalette.d.ts +4 -1
  47. package/dist/wgpuSkinPalette.d.ts.map +1 -1
  48. package/dist/wgpuSkinPalette.js +227 -27
  49. package/dist/wgpuSkinPalette.js.map +1 -1
  50. package/dist/wgpuWireframeUpload.d.ts.map +1 -1
  51. package/dist/wgpuWireframeUpload.js +12 -6
  52. package/dist/wgpuWireframeUpload.js.map +1 -1
  53. package/package.json +20 -15
  54. package/src/customShaderWgpuMeshMaterialRenderer.test.ts +25 -1
  55. package/src/explainWgpuScene3DCoverage.test.ts +44 -10
  56. package/src/wgpuMeshMaterialRegistry.test.ts +22 -0
  57. package/src/wgpuMeshPipeline.test.ts +34 -1
  58. package/src/wgpuMeshUpload.test.ts +65 -3
  59. package/src/wgpuParticleEmitter3D.test.ts +23 -0
  60. package/src/wgpuScene3DRuntime.test.ts +8 -4
  61. package/src/wgpuScene3DTestHelper.test.ts +84 -0
  62. package/src/wgpuShadedModifierSnippet.test.ts +20 -6
  63. package/src/wgpuShadedPrelude.test.ts +6 -6
  64. package/src/wgpuShadowMap.test.ts +30 -0
  65. package/src/wgpuSkinPalette.test.ts +134 -19
  66. package/src/wgpuWireframeUpload.test.ts +29 -1
@@ -6,12 +6,14 @@ import { createTexture } from '@flighthq/texture/contract';
6
6
  import type {
7
7
  ImageResourceReference,
8
8
  Scene3DKindUsage,
9
+ SceneCoverageCatalog,
9
10
  SceneCoverageEntry,
10
11
  WgpuRenderState,
11
12
  } from '@flighthq/types/contract';
12
13
  import {
13
14
  ImageResourceReferenceKind,
14
15
  RenderRegistry,
16
+ RequirementFacet,
15
17
  ResourceResolutionState,
16
18
  SceneCoverage,
17
19
  StandardMaterialKind,
@@ -22,8 +24,27 @@ import { explainWgpuScene3DCoverage, hasWgpuScene3DCoverage } from './explainWgp
22
24
  import { registerWgpuMeshMaterialRenderer } from './wgpuMeshMaterialRegistry';
23
25
  import { makeWgpuScene3DState } from './wgpuScene3DTestHelper';
24
26
 
27
+ const coverageCatalog: SceneCoverageCatalog = [
28
+ {
29
+ kind: 'ShadedMaterial',
30
+ registrations: [{ module: '@flighthq/scene3d-wgpu', registrar: 'registerWgpuShadedMaterial' }],
31
+ registry: RenderRegistry.MaterialRenderer,
32
+ },
33
+ {
34
+ kind: 'image',
35
+ registrations: [{ module: '@flighthq/scene3d-wgpu', registrar: 'registerWgpuImageTextureResolver' }],
36
+ registry: RenderRegistry.TextureResolver,
37
+ },
38
+ {
39
+ kind: 'RimModifier',
40
+ registrations: [{ module: '@flighthq/scene3d-wgpu', registrar: 'registerWgpuRimModifier' }],
41
+ registry: RenderRegistry.ModifierSnippet,
42
+ },
43
+ ];
44
+
25
45
  function shadedUsage(withModifier = false): Scene3DKindUsage {
26
46
  const ref: ImageResourceReference = {
47
+ alphaType: 'straight',
27
48
  bytes: new Uint8Array([1, 2, 3]),
28
49
  failure: null,
29
50
  kind: ImageResourceReferenceKind.Embedded,
@@ -42,23 +63,29 @@ function shadedUsage(withModifier = false): Scene3DKindUsage {
42
63
 
43
64
  function entries(state: WgpuRenderState, withModifier = false): SceneCoverageEntry[] {
44
65
  const out: SceneCoverageEntry[] = [];
45
- explainWgpuScene3DCoverage(out, state, shadedUsage(withModifier));
66
+ explainWgpuScene3DCoverage(out, state, shadedUsage(withModifier), coverageCatalog);
46
67
  return out;
47
68
  }
48
69
 
49
70
  const renderer = { bind() {}, draw() {} } as never;
50
71
 
51
72
  describe('explainWgpuScene3DCoverage', () => {
52
- it('reports a bare state as missing both the material renderer and the texture resolver', () => {
73
+ it('reports a bare state with remedies for both unregistered requirements', () => {
53
74
  const found = entries(makeWgpuScene3DState().state);
54
75
  expect(found).toContainEqual({
55
- coverage: SceneCoverage.Missing,
76
+ coverage: SceneCoverage.Unregistered,
77
+ facet: RequirementFacet.SceneMaterialKind,
56
78
  kind: 'ShadedMaterial',
79
+ module: '@flighthq/scene3d-wgpu',
80
+ registrar: 'registerWgpuShadedMaterial',
57
81
  registry: RenderRegistry.MaterialRenderer,
58
82
  });
59
83
  expect(found).toContainEqual({
60
- coverage: SceneCoverage.Missing,
84
+ coverage: SceneCoverage.Unregistered,
85
+ facet: RequirementFacet.SceneTextureSourceKind,
61
86
  kind: 'image',
87
+ module: '@flighthq/scene3d-wgpu',
88
+ registrar: 'registerWgpuImageTextureResolver',
62
89
  registry: RenderRegistry.TextureResolver,
63
90
  });
64
91
  });
@@ -67,8 +94,11 @@ describe('explainWgpuScene3DCoverage', () => {
67
94
  const { state } = makeWgpuScene3DState();
68
95
  registerWgpuMeshMaterialRenderer(state, StandardMaterialKind, renderer);
69
96
  expect(entries(state)).toContainEqual({
70
- coverage: SceneCoverage.Fallback,
97
+ coverage: SceneCoverage.FallbackRemediable,
98
+ facet: RequirementFacet.SceneMaterialKind,
71
99
  kind: 'ShadedMaterial',
100
+ module: '@flighthq/scene3d-wgpu',
101
+ registrar: 'registerWgpuShadedMaterial',
72
102
  registry: RenderRegistry.MaterialRenderer,
73
103
  });
74
104
  });
@@ -78,17 +108,21 @@ describe('explainWgpuScene3DCoverage', () => {
78
108
  registerWgpuMeshMaterialRenderer(state, 'ShadedMaterial', renderer);
79
109
  expect(entries(state)).toContainEqual({
80
110
  coverage: SceneCoverage.Satisfied,
111
+ facet: RequirementFacet.SceneMaterialKind,
81
112
  kind: 'ShadedMaterial',
82
113
  registry: RenderRegistry.MaterialRenderer,
83
114
  });
84
115
  });
85
116
 
86
- it('reports an unregistered modifier snippet as Missing, never a fallback', () => {
117
+ it('reports an unregistered modifier snippet as actionable, never a fallback', () => {
87
118
  // The shaded compiler assembles base + modifiers into one program, so an absent snippet fails the
88
119
  // whole material rather than degrading it.
89
120
  expect(entries(makeWgpuScene3DState().state, true)).toContainEqual({
90
- coverage: SceneCoverage.Missing,
121
+ coverage: SceneCoverage.Unregistered,
122
+ facet: RequirementFacet.SceneModifierKind,
91
123
  kind: 'RimModifier',
124
+ module: '@flighthq/scene3d-wgpu',
125
+ registrar: 'registerWgpuRimModifier',
92
126
  registry: RenderRegistry.ModifierSnippet,
93
127
  });
94
128
  });
@@ -101,9 +135,9 @@ describe('explainWgpuScene3DCoverage', () => {
101
135
  const { state } = makeWgpuScene3DState();
102
136
  const usage = shadedUsage();
103
137
  const out: SceneCoverageEntry[] = [];
104
- explainWgpuScene3DCoverage(out, state, usage);
138
+ explainWgpuScene3DCoverage(out, state, usage, coverageCatalog);
105
139
  const first = out.length;
106
- explainWgpuScene3DCoverage(out, state, usage);
140
+ explainWgpuScene3DCoverage(out, state, usage, coverageCatalog);
107
141
  expect(out).toHaveLength(first);
108
142
  });
109
143
  });
@@ -124,7 +158,7 @@ describe('hasWgpuScene3DCoverage', () => {
124
158
  registerWgpuMeshMaterialRenderer(state, 'ShadedMaterial', renderer);
125
159
  const usage = shadedUsage();
126
160
  const out: SceneCoverageEntry[] = [];
127
- explainWgpuScene3DCoverage(out, state, usage);
161
+ explainWgpuScene3DCoverage(out, state, usage, coverageCatalog);
128
162
  const gaps = out.filter((e) => e.coverage !== SceneCoverage.Satisfied);
129
163
  expect(hasWgpuScene3DCoverage(state, usage)).toBe(gaps.length === 0);
130
164
  });
@@ -1,4 +1,6 @@
1
1
  import { createStandardPbrMaterial } from '@flighthq/materials/contract';
2
+ import { getRegistryTableEntry } from '@flighthq/registry/contract';
3
+ import { copyWgpuRenderStateRegistrations, getWgpuRenderStateRuntime } from '@flighthq/render-wgpu/contract';
2
4
  import type { WgpuMeshMaterialRenderer } from '@flighthq/types/contract';
3
5
  import { StandardMaterialKind, StandardPbrMaterialKind } from '@flighthq/types/contract';
4
6
 
@@ -7,6 +9,7 @@ import {
7
9
  registerWgpuMeshMaterialRenderer,
8
10
  resolveWgpuMeshMaterialRenderer,
9
11
  } from './wgpuMeshMaterialRegistry';
12
+ import { getWgpuScene3DRuntime } from './wgpuScene3DRuntime';
10
13
  import { makeWgpuScene3DState } from './wgpuScene3DTestHelper';
11
14
 
12
15
  function makeRenderer(): WgpuMeshMaterialRenderer {
@@ -30,6 +33,25 @@ describe('registerWgpuMeshMaterialRenderer', () => {
30
33
  registerWgpuMeshMaterialRenderer(state, StandardPbrMaterialKind, renderer);
31
34
  expect(getWgpuMeshMaterialRenderer(state, StandardPbrMaterialKind)).toBe(renderer);
32
35
  });
36
+
37
+ it('replaces the persistent table while an explicitly copied state retains its snapshot', () => {
38
+ const { state: screen } = makeWgpuScene3DState();
39
+ const { state: derived } = makeWgpuScene3DState();
40
+ const renderer = makeRenderer();
41
+ const replacement = makeRenderer();
42
+ registerWgpuMeshMaterialRenderer(screen, StandardPbrMaterialKind, renderer);
43
+ const snapshot = getWgpuRenderStateRuntime(screen).registries.meshMaterialRenderers;
44
+
45
+ copyWgpuRenderStateRegistrations(derived, screen);
46
+ getWgpuScene3DRuntime(derived);
47
+ registerWgpuMeshMaterialRenderer(screen, StandardPbrMaterialKind, replacement);
48
+
49
+ expect(getWgpuRenderStateRuntime(derived).registries.meshMaterialRenderers).toBe(snapshot);
50
+ expect(getWgpuRenderStateRuntime(screen).registries.meshMaterialRenderers).not.toBe(snapshot);
51
+ expect(getRegistryTableEntry(snapshot, StandardPbrMaterialKind)).toBe(renderer);
52
+ expect(getWgpuMeshMaterialRenderer(derived, StandardPbrMaterialKind)).toBe(renderer);
53
+ expect(getWgpuMeshMaterialRenderer(screen, StandardPbrMaterialKind)).toBe(replacement);
54
+ });
33
55
  });
34
56
 
35
57
  describe('resolveWgpuMeshMaterialRenderer', () => {
@@ -1,7 +1,7 @@
1
1
  import { createCamera3D } from '@flighthq/camera/contract';
2
2
  import { createMatrix3, createMatrix4 } from '@flighthq/geometry/contract';
3
3
  import { createStandardPbrMaterial } from '@flighthq/materials/contract';
4
- import { createBoxMeshGeometry } from '@flighthq/mesh/contract';
4
+ import { createBoxMeshGeometry, createMeshGeometry } from '@flighthq/mesh/contract';
5
5
  import { getWgpuRenderStateRuntime } from '@flighthq/render-wgpu/contract';
6
6
  import { createTexture, setTextureUvOffset, setTextureUvScale } from '@flighthq/texture/contract';
7
7
  import type {
@@ -277,6 +277,39 @@ describe('drawWgpuMeshSubset', () => {
277
277
  expect(draw!.args[0]).toBe(proxy.subset.indexCount);
278
278
  });
279
279
 
280
+ // The defect this covers: non-indexed geometry issued NO draw call at all, so a valid imported mesh
281
+ // rendered nothing and reported nothing. A backend that silently drops geometry is worse than one
282
+ // that draws it wrong, because nothing announces the loss.
283
+ it('issues a non-indexed draw for geometry without indices', () => {
284
+ const { fake, state } = makeWgpuScene3DState();
285
+ ensureWgpuFrameBindGroup(state);
286
+ beginWgpuMeshDraw(state, makePipeline(state));
287
+ const geometry = createMeshGeometry({
288
+ indices: null,
289
+ layout: {
290
+ attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }],
291
+ stride: 12,
292
+ },
293
+ vertices: new Float32Array(9),
294
+ });
295
+ const proxy: Scene3DRenderProxy = {
296
+ material: createStandardPbrMaterial(),
297
+ normalMatrix: createMatrix3(),
298
+ subset: geometry.subsets[0],
299
+ worldMatrix: createMatrix4(),
300
+ };
301
+
302
+ drawWgpuMeshSubset(state, proxy, geometry);
303
+
304
+ const draw = fake.calls.find((c) => c.name === 'draw');
305
+ expect(draw).toBeDefined();
306
+ expect(draw!.args[0]).toBe(proxy.subset.indexCount);
307
+ expect(draw!.args[2]).toBe(proxy.subset.indexOffset);
308
+ // It must not try to bind an index buffer it does not have.
309
+ expect(fake.calls.some((c) => c.name === 'setIndexBuffer')).toBe(false);
310
+ expect(fake.calls.some((c) => c.name === 'drawIndexed')).toBe(false);
311
+ });
312
+
280
313
  it('is a no-op when no pipeline is active', () => {
281
314
  const { fake, state } = makeWgpuScene3DState();
282
315
  drawWgpuMeshSubset(state, makeProxy(), createBoxMeshGeometry());
@@ -44,6 +44,28 @@ describe('ensureWgpuMeshUpload', () => {
44
44
  expect(fake.calls.filter((c) => c.name === 'writeBuffer').length).toBeGreaterThanOrEqual(2);
45
45
  });
46
46
 
47
+ it('pads an odd-length Uint16 index upload to four-byte alignment without changing the index count', () => {
48
+ const { fake, state } = makeWgpuScene3DState();
49
+ const geometry = createMeshGeometry({
50
+ indices: new Uint16Array([0, 1, 2]),
51
+ layout: POSITION_LAYOUT,
52
+ vertices: new Float32Array(9),
53
+ });
54
+
55
+ const upload = ensureWgpuMeshUpload(state, geometry);
56
+ const writes = fake.calls.filter((call) => call.name === 'writeBuffer');
57
+ const indexWrite = writes[writes.length - 1]!;
58
+ const uploadedIndices = new Uint16Array(
59
+ indexWrite.args[2] as ArrayBuffer,
60
+ indexWrite.args[3] as number,
61
+ (indexWrite.args[4] as number) / Uint16Array.BYTES_PER_ELEMENT,
62
+ );
63
+
64
+ expect(indexWrite.args[4]).toBe(8);
65
+ expect(Array.from(uploadedIndices.subarray(0, 3))).toEqual([0, 1, 2]);
66
+ expect(upload.indexCount).toBe(3);
67
+ });
68
+
47
69
  it('returns the cached upload without re-uploading when version is unchanged', () => {
48
70
  const { fake, state } = makeWgpuScene3DState();
49
71
  const geometry = createBoxMeshGeometry();
@@ -80,6 +102,8 @@ describe('ensureWgpuMeshUpload', () => {
80
102
  positions: new Float32Array([1, 2, 3]),
81
103
  skinnedNormals: new Float32Array(3),
82
104
  skinnedPositions: new Float32Array(3),
105
+ skinnedTangents: new Float32Array(0),
106
+ tangents: new Float32Array(0),
83
107
  weights: new Float32Array([1, 0, 0, 0]),
84
108
  });
85
109
 
@@ -135,6 +159,8 @@ describe('ensureWgpuMeshUpload', () => {
135
159
  positions: new Float32Array([1, 0, 0]),
136
160
  skinnedNormals: new Float32Array(3),
137
161
  skinnedPositions: new Float32Array(3),
162
+ skinnedTangents: new Float32Array(0),
163
+ tangents: new Float32Array(0),
138
164
  weights: new Float32Array([1, 0, 0, 0]),
139
165
  });
140
166
  const morph: MeshMorph = {
@@ -162,7 +188,32 @@ describe('ensureWgpuMeshUpload', () => {
162
188
  expect(meshRuntime.webgpuData as unknown).toBe(upload);
163
189
  });
164
190
 
165
- it('returns null for non-indexed geometry', () => {
191
+ // glTF primitives may legitimately omit indices and the importer preserves that, so refusing to
192
+ // upload them made valid meshes vanish on this backend with nothing rendered and nothing thrown.
193
+ // The GL sibling has always uploaded them; this mirrors it.
194
+ it('uploads non-indexed geometry with the vertex count in indexCount', () => {
195
+ const { state } = makeWgpuScene3DState();
196
+ const geometry = createMeshGeometry({
197
+ indices: null,
198
+ layout: {
199
+ attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }],
200
+ stride: 12,
201
+ },
202
+ vertices: new Float32Array(9),
203
+ });
204
+
205
+ const upload = ensureWgpuMeshUpload(state, geometry);
206
+
207
+ expect(upload.vertexBuffer).toBeDefined();
208
+ expect(upload.indexBuffer).toBeNull();
209
+ // 9 floats / 3 per vertex = 3 vertices, the count a non-indexed draw needs.
210
+ expect(upload.indexCount).toBe(3);
211
+ expect(getWgpuScene3DRuntime(state).uploadCache.get(geometry)).toBe(upload);
212
+ });
213
+
214
+ // No index buffer exists, so no element format describes one. Null says that; a stand-in 'uint16'
215
+ // would read as a fact about a buffer that is not there.
216
+ it('reports a null index format for non-indexed geometry', () => {
166
217
  const { state } = makeWgpuScene3DState();
167
218
  const geometry = createMeshGeometry({
168
219
  indices: null,
@@ -172,7 +223,18 @@ describe('ensureWgpuMeshUpload', () => {
172
223
  },
173
224
  vertices: new Float32Array(9),
174
225
  });
175
- expect(ensureWgpuMeshUpload(state, geometry)).toBeNull();
176
- expect(getWgpuScene3DRuntime(state).uploadCache.get(geometry)).toBeUndefined();
226
+
227
+ expect(ensureWgpuMeshUpload(state, geometry).indexFormat).toBeNull();
228
+ });
229
+
230
+ it('reports zero vertices when the layout has no stride to divide by', () => {
231
+ const { state } = makeWgpuScene3DState();
232
+ const geometry = createMeshGeometry({
233
+ indices: null,
234
+ layout: { attributes: [], stride: 0 },
235
+ vertices: new Float32Array(9),
236
+ });
237
+
238
+ expect(ensureWgpuMeshUpload(state, geometry).indexCount).toBe(0);
177
239
  });
178
240
  });
@@ -157,6 +157,29 @@ describe('drawWgpuScene3DParticleEmitter3Ds', () => {
157
157
  expect(pipelineDescriptors(fake.calls).some((d) => hasTextureConstant(d) === 1)).toBe(true);
158
158
  });
159
159
 
160
+ it('copies atlas pixels with straight alpha for post-decode shader premultiplication', () => {
161
+ const { state, fake } = makeAtlasWgpuScene3DState();
162
+ const scene = createNode3D(Node3DKind);
163
+ addNodeChild(scene, makeAtlasEmitter(64, 64));
164
+ drawWgpuScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
165
+ const copy = fake.calls.find((c) => c.name === 'copyExternalImageToTexture');
166
+ expect(copy).toBeDefined();
167
+ expect((copy!.args[1] as { premultipliedAlpha?: boolean }).premultipliedAlpha).toBe(false);
168
+ });
169
+
170
+ it('premultiplies sampled atlas color after decode in the fragment shader', () => {
171
+ const { state, fake } = makeAtlasWgpuScene3DState();
172
+ const scene = createNode3D(Node3DKind);
173
+ addNodeChild(scene, makeAtlasEmitter(64, 64));
174
+ drawWgpuScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
175
+ const shaderSource = fake.calls
176
+ .filter((c) => c.name === 'createShaderModule')
177
+ .map((c) => String((c.args[0] as { code: string }).code))
178
+ .find((source) => source.includes('@fragment fn fs_main'));
179
+ expect(shaderSource).toContain('let alpha = tex.a * in.color.a;');
180
+ expect(shaderSource).toContain('vec4f(tex.rgb * in.color.rgb * alpha, alpha)');
181
+ });
182
+
160
183
  it('compiles the untextured pipeline variant for an atlas-less emitter', () => {
161
184
  const { state, fake } = makeWgpuScene3DState();
162
185
  const scene = createNode3D(Node3DKind);
@@ -11,17 +11,21 @@ describe('getWgpuScene3DRuntime', () => {
11
11
  const a = getWgpuScene3DRuntime(state);
12
12
  const b = getWgpuScene3DRuntime(state);
13
13
  expect(a).toBe(b);
14
- expect(a.materialRegistry).toBeInstanceOf(Map);
15
14
  expect(a.pipelineCache).toBeInstanceOf(Map);
16
15
  expect(a.activeBlendMode).toBeNull();
17
16
  expect(a.activeMeshPipeline).toBeNull();
18
17
  });
19
18
 
20
- it('surfaces the registry and upload cache through the header runtime slots', () => {
19
+ it('surfaces its upload cache without replacing persistent material dispatch policy', () => {
21
20
  const { state } = makeWgpuScene3DState();
22
- const scene = getWgpuScene3DRuntime(state);
23
21
  const stateRuntime = state[EntityRuntimeKey] as WgpuRenderStateRuntime;
24
- expect(stateRuntime.sceneMeshMaterialRegistry).toBe(scene.materialRegistry);
22
+ const materials = stateRuntime.registries.meshMaterialRenderers;
23
+ const modifierSnippets = stateRuntime.registries.modifierSnippets;
24
+ const modifierSnippetRevision = stateRuntime.registries.modifierSnippetRevision;
25
+ const scene = getWgpuScene3DRuntime(state);
26
+ expect(stateRuntime.registries.meshMaterialRenderers).toBe(materials);
27
+ expect(stateRuntime.registries.modifierSnippets).toBe(modifierSnippets);
28
+ expect(stateRuntime.registries.modifierSnippetRevision).toBe(modifierSnippetRevision);
25
29
  expect(stateRuntime.sceneMeshUploadCache).toBe(scene.uploadCache);
26
30
  });
27
31
  });
@@ -0,0 +1,84 @@
1
+ import { getWgpuRenderStateRuntime } from '@flighthq/render-wgpu/contract';
2
+
3
+ import { makeWgpuScene3DState } from './wgpuScene3DTestHelper';
4
+
5
+ describe('makeWgpuScene3DState', () => {
6
+ it('records aligned writeBuffer data using typed-array element units', () => {
7
+ const { fake, state } = makeWgpuScene3DState();
8
+
9
+ state.device.queue.writeBuffer({} as GPUBuffer, 0, new Uint16Array([0, 1, 2]), 0, 2);
10
+
11
+ expect(fake.calls.filter((call) => call.name === 'writeBuffer')).toHaveLength(1);
12
+ });
13
+
14
+ it('rejects a writeBuffer ArrayBuffer byte size that is not a multiple of four', () => {
15
+ const { fake, state } = makeWgpuScene3DState();
16
+ const data = new Uint16Array([0, 1, 2]);
17
+
18
+ expect(() =>
19
+ state.device.queue.writeBuffer({} as GPUBuffer, 0, data.buffer, data.byteOffset, data.byteLength),
20
+ ).toThrow(expect.objectContaining({ name: 'OperationError' }));
21
+ expect(fake.calls.some((call) => call.name === 'writeBuffer')).toBe(false);
22
+ });
23
+
24
+ it('rejects a writeBuffer typed-array element count whose bytes are not a multiple of four', () => {
25
+ const { state } = makeWgpuScene3DState();
26
+
27
+ expect(() => state.device.queue.writeBuffer({} as GPUBuffer, 0, new Uint16Array([0, 1, 2]))).toThrow(
28
+ expect.objectContaining({ name: 'OperationError' }),
29
+ );
30
+ });
31
+
32
+ it('rejects a writeBuffer destination offset that is not aligned to four bytes', () => {
33
+ const { state } = makeWgpuScene3DState();
34
+
35
+ expect(() => state.device.queue.writeBuffer({} as GPUBuffer, 2, new Uint32Array([0]))).toThrow(
36
+ /destination offset must be aligned to 4 bytes/,
37
+ );
38
+ });
39
+
40
+ it('rejects misaligned or out-of-range index buffer bindings', () => {
41
+ const { fake, state } = makeWgpuScene3DState();
42
+ const buffer = state.device.createBuffer({ size: 8, usage: GPUBufferUsage.INDEX });
43
+ const pass = getWgpuRenderStateRuntime(state).renderPass!;
44
+
45
+ expect(() => pass.setIndexBuffer(buffer, 'uint16', -2)).toThrow(/offset and size must be non-negative/);
46
+ expect(() => pass.setIndexBuffer(buffer, 'uint32', 2)).toThrow(/offset must be aligned to 4 bytes/);
47
+ expect(() => pass.setIndexBuffer(buffer, 'uint16', 6, 4)).toThrow(/range exceeds the buffer size/);
48
+ expect(fake.calls.some((call) => call.name === 'setIndexBuffer')).toBe(false);
49
+
50
+ pass.setIndexBuffer(buffer, 'uint16', 2, 4);
51
+ expect(fake.calls.filter((call) => call.name === 'setIndexBuffer')).toHaveLength(1);
52
+ });
53
+
54
+ it('rejects misaligned or out-of-range vertex buffer bindings', () => {
55
+ const { fake, state } = makeWgpuScene3DState();
56
+ const buffer = state.device.createBuffer({ size: 8, usage: GPUBufferUsage.VERTEX });
57
+ const pass = getWgpuRenderStateRuntime(state).renderPass!;
58
+
59
+ expect(() => pass.setVertexBuffer(0, buffer, 0, -4)).toThrow(/offset and size must be non-negative/);
60
+ expect(() => pass.setVertexBuffer(0, buffer, 2)).toThrow(/offset must be aligned to 4 bytes/);
61
+ expect(() => pass.setVertexBuffer(0, buffer, 4, 8)).toThrow(/range exceeds the buffer size/);
62
+ expect(fake.calls.some((call) => call.name === 'setVertexBuffer')).toBe(false);
63
+
64
+ pass.setVertexBuffer(0, buffer, 4, 4);
65
+ expect(fake.calls.filter((call) => call.name === 'setVertexBuffer')).toHaveLength(1);
66
+ });
67
+
68
+ it('rejects writeTexture data smaller than an rgba8 copy requires', () => {
69
+ const { fake, state } = makeWgpuScene3DState();
70
+ const texture = state.device.createTexture({
71
+ size: [2, 2, 1],
72
+ format: 'rgba8unorm',
73
+ usage: GPUTextureUsage.COPY_DST,
74
+ });
75
+
76
+ expect(() =>
77
+ state.device.queue.writeTexture({ texture }, new Uint8Array(15), { bytesPerRow: 8, rowsPerImage: 2 }, [2, 2, 1]),
78
+ ).toThrow(expect.objectContaining({ name: 'OperationError' }));
79
+ expect(fake.calls.some((call) => call.name === 'writeTexture')).toBe(false);
80
+
81
+ state.device.queue.writeTexture({ texture }, new Uint8Array(16), { bytesPerRow: 8, rowsPerImage: 2 }, [2, 2, 1]);
82
+ expect(fake.calls.filter((call) => call.name === 'writeTexture')).toHaveLength(1);
83
+ });
84
+ });
@@ -15,13 +15,25 @@ function makeSnippet(overrides?: Partial<WgpuModifierSnippet>): WgpuModifierSnip
15
15
  }
16
16
 
17
17
  describe('registerWgpuModifierSnippet', () => {
18
- it('lazily allocates a state-scoped registry and is last-write-wins', () => {
19
- const { state } = makeWgpuScene3DState();
20
- expect(getWgpuScene3DRuntime(state).modifierSnippetRegistry).toBeNull();
21
- registerWgpuModifierSnippet(state, makeSnippet());
18
+ it('replaces the table while an explicitly copied state retains its snapshot through lazy scene init', () => {
19
+ const { state: screen } = makeWgpuScene3DState();
20
+ const { state: derived } = makeWgpuScene3DState();
21
+ const initial = makeSnippet();
22
22
  const override = makeSnippet({ contribution: () => ({ source: '// override' }) });
23
- registerWgpuModifierSnippet(state, override);
24
- expect(resolveWgpuModifierSnippet(state, 'acme.Test')).toBe(override);
23
+ registerWgpuModifierSnippet(screen, initial);
24
+ const snapshot = getWgpuRenderStateRuntime(screen).registries.modifierSnippets;
25
+
26
+ copyWgpuRenderStateRegistrations(derived, screen);
27
+ getWgpuScene3DRuntime(derived);
28
+ registerWgpuModifierSnippet(screen, override);
29
+
30
+ expect(getWgpuRenderStateRuntime(derived).registries.modifierSnippets).toBe(snapshot);
31
+ expect(getWgpuRenderStateRuntime(derived).registries.modifierSnippetRevision).toBe(1);
32
+ expect(getWgpuRenderStateRuntime(screen).registries.modifierSnippets).not.toBe(snapshot);
33
+ expect(getWgpuRenderStateRuntime(screen).registries.modifierSnippetRevision).toBe(2);
34
+ expect(getRegistryTableEntry(snapshot, 'acme.Test')).toBe(initial);
35
+ expect(resolveWgpuModifierSnippet(derived, 'acme.Test')).toBe(initial);
36
+ expect(resolveWgpuModifierSnippet(screen, 'acme.Test')).toBe(override);
25
37
  });
26
38
  });
27
39
 
@@ -31,3 +43,5 @@ describe('resolveWgpuModifierSnippet', () => {
31
43
  expect(resolveWgpuModifierSnippet(state, 'acme.Missing')).toBeNull();
32
44
  });
33
45
  });
46
+ import { getRegistryTableEntry } from '@flighthq/registry/contract';
47
+ import { copyWgpuRenderStateRegistrations, getWgpuRenderStateRuntime } from '@flighthq/render-wgpu/contract';
@@ -1,4 +1,4 @@
1
- import { registerWgpuImageTextureResolver } from '@flighthq/render-wgpu/contract';
1
+ import { getWgpuRenderStateRuntime, registerWgpuImageTextureResolver } from '@flighthq/render-wgpu/contract';
2
2
  import {
3
3
  createAnimatedNormalModifier,
4
4
  createDissolveModifier,
@@ -173,7 +173,7 @@ describe('getWgpuShadedModuleSource', () => {
173
173
  createToonModifier({ steps: 3 }),
174
174
  ],
175
175
  });
176
- const source = getWgpuShadedModuleSource(material, getWgpuScene3DRuntime(state).modifierSnippetRegistry!);
176
+ const source = getWgpuShadedModuleSource(material, getWgpuRenderStateRuntime(state).registries.modifierSnippets);
177
177
  expect(source).toContain('animatedNormal');
178
178
  expect(source).toContain('emissiveTerm');
179
179
  expect(source).toContain('rimFactor');
@@ -195,7 +195,7 @@ describe('getWgpuShadedModuleSource', () => {
195
195
  }),
196
196
  ],
197
197
  });
198
- const source = getWgpuShadedModuleSource(material, getWgpuScene3DRuntime(state).modifierSnippetRegistry!);
198
+ const source = getWgpuShadedModuleSource(material, getWgpuRenderStateRuntime(state).registries.modifierSnippets);
199
199
  expect(source.indexOf('localPosition = vec4f(localPosition.xyz')).toBeLessThan(
200
200
  source.indexOf('let world = draw.world * localPosition'),
201
201
  );
@@ -212,7 +212,7 @@ describe('getWgpuShadedModuleSource', () => {
212
212
  const registered = { kind: 'acme.Glow', slot: ModifierSlot.Effect } as Modifier;
213
213
  const missing = { kind: 'acme.Missing', slot: ModifierSlot.Effect } as Modifier;
214
214
  const material = createShadedMaterial({ modifiers: [registered, missing] });
215
- const registry = getWgpuScene3DRuntime(state).modifierSnippetRegistry!;
215
+ const registry = getWgpuRenderStateRuntime(state).registries.modifierSnippets;
216
216
  const source = getWgpuShadedModuleSource(material, registry);
217
217
  expect(source).toContain('radiance = radiance + vec3f(0.125)');
218
218
  expect(buildWgpuShadedCacheKey(material, registry)).toContain('acme.Missing');
@@ -249,7 +249,7 @@ describe('getWgpuShadedModuleSource', () => {
249
249
  });
250
250
  modifiers.push({ kind, slot: slots[i] });
251
251
  }
252
- const registry = getWgpuScene3DRuntime(state).modifierSnippetRegistry!;
252
+ const registry = getWgpuRenderStateRuntime(state).registries.modifierSnippets;
253
253
  const source = getWgpuShadedModuleSource(createShadedMaterial({ modifiers }), registry);
254
254
  for (let i = 0; i < slots.length; i++) expect(source).toContain(`slot_marker_${i}`);
255
255
  expect(source).toMatch(/\/\/ slot_marker_1\s+diffuse = diffuse;\s+if \(ALPHA_MASK/);
@@ -277,7 +277,7 @@ describe('getWgpuShadedModuleSource', () => {
277
277
  { kind: 'acme.DeclarationB', slot: ModifierSlot.Effect },
278
278
  ],
279
279
  });
280
- const source = getWgpuShadedModuleSource(material, getWgpuScene3DRuntime(state).modifierSnippetRegistry!);
280
+ const source = getWgpuShadedModuleSource(material, getWgpuRenderStateRuntime(state).registries.modifierSnippets);
281
281
  expect(source.match(/fn shadedValueNoise/g)).toHaveLength(1);
282
282
  expect(source).toContain('fn vendorA() {}\nfn vendorB() {}');
283
283
  });
@@ -214,6 +214,36 @@ describe('drawWgpuScene3DShadowMap', () => {
214
214
  expect(fake.calls.filter((call) => call.name === 'beginRenderPass')).toHaveLength(passCount);
215
215
  });
216
216
 
217
+ // The sibling of the forward-pass defect: the shadow pass also bound an index buffer and issued
218
+ // drawIndexed unconditionally, so a non-indexed caster rendered (once the forward path was fixed)
219
+ // but cast no shadow. glShadowMap has always branched to drawArrays for this case.
220
+ it('issues a non-indexed draw for a caster without indices', () => {
221
+ const { fake, state } = makeWgpuScene3DState();
222
+ const scene = createNode3D(Node3DKind);
223
+ addNodeChild(
224
+ scene,
225
+ createMesh(
226
+ createMeshGeometry({
227
+ indices: null,
228
+ layout: {
229
+ attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }],
230
+ stride: 12,
231
+ },
232
+ vertices: new Float32Array(9),
233
+ }),
234
+ [],
235
+ ),
236
+ );
237
+
238
+ drawWgpuScene3DShadowMap(state, scene, makeShadowCamera(), SHADOW_LIGHT);
239
+
240
+ const draw = fake.calls.find((c) => c.name === 'draw');
241
+ expect(draw).toBeDefined();
242
+ expect(draw!.args[0]).toBe(3);
243
+ expect(fake.calls.some((c) => c.name === 'drawIndexed')).toBe(false);
244
+ expect(fake.calls.some((c) => c.name === 'setIndexBuffer')).toBe(false);
245
+ });
246
+
217
247
  it('opens a depth-only pass and renders each caster mesh depth', () => {
218
248
  const { fake, state } = makeWgpuScene3DState();
219
249
  drawWgpuScene3DShadowMap(state, makeShadowScene3D(), makeShadowCamera(), SHADOW_LIGHT);