@forgeax/engine-runtime 0.1.21 → 0.1.23

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 (57) hide show
  1. package/README.md +3 -3
  2. package/dist/__tests__/helpers/register-runtime-components.d.ts.map +1 -1
  3. package/dist/__tests__/helpers/register-scene-components.d.ts.map +1 -1
  4. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  5. package/dist/createRenderer.d.ts.map +1 -1
  6. package/dist/index.mjs +3 -2
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/sprite-param-values.d.ts +1 -1
  9. package/package.json +30 -30
  10. package/src/__tests__/animation-graph-asset-union.test-d.ts +2 -0
  11. package/src/__tests__/asset-brand-and-name-retirement.test.ts +4 -3
  12. package/src/__tests__/asset-registry-scene.test-d.ts +1 -0
  13. package/src/__tests__/asset.unit.test.ts +5 -2
  14. package/src/__tests__/collect-transient-roundtrip.test.ts +14 -3
  15. package/src/__tests__/components.test-d.ts +9 -6
  16. package/src/__tests__/components.unit.test.ts +23 -19
  17. package/src/__tests__/errors.unit.test.ts +3 -0
  18. package/src/__tests__/extract-frames-merge.test.ts +3 -0
  19. package/src/__tests__/helpers/register-runtime-components.ts +9 -1
  20. package/src/__tests__/helpers/register-scene-components.ts +9 -2
  21. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +8 -10
  22. package/src/__tests__/light-snapshot.test-d.ts +27 -1
  23. package/src/__tests__/lights-preservation.fixup.test.ts +19 -21
  24. package/src/__tests__/lights.unit.test.ts +2076 -1
  25. package/src/__tests__/loader-name-survival.test.ts +8 -9
  26. package/src/__tests__/package-promotion.test.ts +7 -11
  27. package/src/__tests__/pbr-pipeline.unit.test.ts +16 -16
  28. package/src/__tests__/pipeline.unit.test.ts +20 -24
  29. package/src/__tests__/point-light-shadow.browser.test.ts +1 -0
  30. package/src/__tests__/point-light-shadow.unit.test.ts +18 -98
  31. package/src/__tests__/propagate-transforms.dawn.test.ts +15 -16
  32. package/src/__tests__/record-worldid-isolation.test.ts +16 -32
  33. package/src/__tests__/render-error-exhaustive.test-d.ts +10 -0
  34. package/src/__tests__/render-system-extract.test.ts +5 -5
  35. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +1 -0
  36. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +1 -0
  37. package/src/__tests__/renderer-surface.unit.test.ts +0 -2
  38. package/src/__tests__/resolveName-unified-entry.test.ts +6 -6
  39. package/src/__tests__/resolveName.test.ts +14 -2
  40. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +1 -0
  41. package/src/__tests__/shadow-csm-tile-consistency.test.ts +1 -0
  42. package/src/__tests__/shadow-csm-ubo.test.ts +3 -1
  43. package/src/__tests__/shadow-fields-observable.dawn.test.ts +15 -4
  44. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +1 -1
  45. package/src/__tests__/skin-parented-double-transform.test.ts +5 -5
  46. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +1 -6
  47. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -13
  48. package/src/__tests__/ssao-bgl.test.ts +2 -1
  49. package/src/__tests__/systems.unit.test.ts +25 -174
  50. package/src/__tests__/template-game-default-api-contract.test.ts +45 -23
  51. package/src/__tests__/tilemap-chunk-extract.propagate-order.test.ts +10 -4
  52. package/src/__tests__/transform-hierarchy-pixel-diff.dawn.test.ts +2 -2
  53. package/src/collect-scene-asset.ts +1 -1
  54. package/src/components/__tests__/transform-vec-schema.test.ts +2 -2
  55. package/src/createRenderer.ts +1 -0
  56. package/src/sprite-param-values.ts +1 -1
  57. package/dist/.tsbuildinfo +0 -1
@@ -13,11 +13,11 @@
13
13
  // after loadByGuid (parseAssetPayload sync branch) resolveName
14
14
  // returns each stored name.
15
15
  // AC-08 async — a texture in its own (single-asset) package; after loadByGuid
16
- // (texture upstream async branch) resolveName returns the
17
- // basename derived from its package path. Without the entry ->
18
- // Package wiring the texture would land in a null package and
19
- // resolveName would be '' -- so basename here still proves the
20
- // async load path registered the package post-roundtrip.
16
+ // (texture upstream async branch) resolveName preserves the
17
+ // explicit entry name. Without the entry -> Package wiring the
18
+ // texture would land in a null package and resolveName would be
19
+ // '' -- so the named result proves the async load path registered
20
+ // the package post-roundtrip.
21
21
  // FALSIFY — the same pack with the entry name stripped degrades to
22
22
  // basename(path), proving the assertion is name-sensitive (not
23
23
  // a tautology). The FALSIFY block does not gate CI; it documents
@@ -187,10 +187,9 @@ describe('loader name survival (AC-08)', () => {
187
187
 
188
188
  const r = await reg.loadByGuid(parseGuid(TEX));
189
189
  expect(r.ok).toBe(true);
190
- // Single-asset texture package -> basename(path) (AC-01). The wiring is what
191
- // makes this non-empty: without entry -> Package the texture lands in a null
192
- // package and resolveName would be ''.
193
- expect(reg.resolveName(TEX)).toBe('diffuse.pack.json');
190
+ // An explicit entry name wins even for a single-asset package. The wiring
191
+ // makes this name survive the async JSON-roundtrip path.
192
+ expect(reg.resolveName(TEX)).toBe('Diffuse');
194
193
  });
195
194
 
196
195
  it('FALSIFY: stripped entry.name degrades to basename, proving name-sensitivity', async () => {
@@ -7,13 +7,10 @@
7
7
  // asset's derived basename is frozen as its stored name so it keeps
8
8
  // the same resolveName (now via the multi-asset branch), with no error
9
9
  // and no caller awareness. Promotion is idempotent: re-registering the
10
- // same path does not overwrite an already-frozen name.
10
+ // same path does not overwrite an already-frozen name. An explicitly
11
+ // authored name is already stable and is preserved through promotion.
11
12
  //
12
- // The promotion is observable through `package.xor-invariant-violated`: when the
13
- // original asset already carries a stored name at promotion time (the abnormal
14
- // single-asset-with-name state, D-4), the freeze must not overwrite it and the
15
- // soft-violation counter increments. Without w11 there is no promotion branch,
16
- // so the counter never fires (the falsifying assertion).
13
+ // The promotion must not emit a violation for an explicitly named first asset.
17
14
 
18
15
  import { AssetRegistry } from '@forgeax/engine-assets-runtime';
19
16
  import { AssetGuid } from '@forgeax/engine-pack/guid';
@@ -70,19 +67,18 @@ describe('1->N package promotion (AC-05)', () => {
70
67
  expect(reg.resolveName(G2)).toBe('SecondAsset');
71
68
  });
72
69
 
73
- it('abnormal single-asset-with-name state bumps the xor counter and is not overwritten', () => {
70
+ it('preserves an explicitly authored first-asset name without a false violation', () => {
74
71
  const reg = makeRegistry();
75
72
  const metrics = createEngineMetrics();
76
73
  reg.setMetrics(metrics);
77
- // Abnormal: a single-asset package whose lone asset already carries a name.
78
74
  reg.catalog(parseGuid(G1), sampler);
79
75
  reg._registerPackage(PATH, [G1], new Map([[G1, 'PreNamed']]));
80
76
  reg.catalog(parseGuid(G2), sampler);
81
- // Promotion fires: the pre-existing name is preserved (not overwritten by
82
- // basename) and the soft-violation counter records the abnormal state.
77
+ // Promotion preserves the authored name rather than replacing it with the
78
+ // package basename.
83
79
  reg._registerPackage(PATH, [G2], new Map([[G2, 'SecondAsset']]));
84
80
 
85
81
  expect(reg.resolveName(G1)).toBe('PreNamed');
86
- expect(metrics.snapshot()['package.xor-invariant-violated']).toBe(1);
82
+ expect(metrics.snapshot()['package.xor-invariant-violated'] ?? 0).toBe(0);
87
83
  });
88
84
  });
@@ -44,34 +44,29 @@ function makeSpec(): PipelineSpec {
44
44
 
45
45
  describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', () => {
46
46
  describe('pbr-view', () => {
47
- it('storageBuffer=true: 12 entries with read-only-storage on bindings 1+2 and projector bindings 11+12', () => {
47
+ it('storageBuffer=true: 10 entries with projector bindings 11+12', () => {
48
48
  const spec = makeSpec();
49
49
  const out = buildBindGroupLayoutDescriptor(spec, {
50
50
  kind: 'pbr-view',
51
- caps: { storageBuffer: true },
51
+ caps: { storageBuffer: true, extendedLighting: false },
52
52
  });
53
53
  const expected = {
54
54
  label: 'pbr-view-bgl',
55
- entries: buildPbrViewBglEntries({ storageBuffer: true }),
55
+ entries: buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false }),
56
56
  };
57
57
  expect(out).toEqual(expected);
58
- // feat-20260625-spot-light-shadow-mapping: binding 8 (spot shadow atlas,
59
- // w14) raised the view BGL to 9 entries (always-on, D-5). The
60
- // Points/Lines viewport UBO occupies binding 10, while w25 (scope-amend
61
- // webkit-fallback) folded the former binding 9 spotLightViewProj matrices
62
- // UBO into the View UBO (`view.spotLightViewProj`), so the BGL stays at 9
63
- // entries (bindings 0..8 plus optional projector 11+12 and binding 10)
64
- // — keeping the WebGL2 fallback fragment uniform-buffer count <= 11
65
- // (GLES 3.0), while the projector remains available on the default
66
- // WebGPU path.
67
- expect(out.entries.length).toBe(12);
58
+ // Binding 8 is the always-on spot shadow atlas, binding 10 is the
59
+ // Points/Lines viewport UBO, and bindings 11/12 are the optional
60
+ // projector resources. Local lights are exclusively carried by the
61
+ // Standard Cluster group(2), so the view group has no point/spot slots.
62
+ expect(out.entries.length).toBe(10);
68
63
  expect(out.entries.find((entry) => entry.binding === 9)).toBeUndefined();
69
64
  expect(out.entries.find((entry) => entry.binding === 10)?.buffer?.type).toBe('uniform');
70
65
  expect(out.entries.find((entry) => entry.binding === 11)?.texture?.viewDimension).toBe('2d');
71
66
  expect(out.entries.find((entry) => entry.binding === 12)?.sampler?.type).toBe('filtering');
72
67
  });
73
68
 
74
- it('storageBuffer=false: bindings 1+2 fall back to uniform', () => {
69
+ it('storageBuffer=false: view group still has no local-light bindings', () => {
75
70
  const spec = makeSpec();
76
71
  const out = buildBindGroupLayoutDescriptor(spec, {
77
72
  kind: 'pbr-view',
@@ -82,17 +77,22 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
82
77
  entries: buildPbrViewBglEntries({ storageBuffer: false }),
83
78
  };
84
79
  expect(out).toEqual(expected);
80
+ expect(out.entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(false);
85
81
  });
86
82
 
87
83
  it('projectorAvailable=false: omits optional projector bindings for the 16-texture limit', () => {
88
84
  const spec = makeSpec();
89
85
  const out = buildBindGroupLayoutDescriptor(spec, {
90
86
  kind: 'pbr-view',
91
- caps: { storageBuffer: true, projectorAvailable: false },
87
+ caps: { storageBuffer: true, projectorAvailable: false, extendedLighting: false },
92
88
  });
93
89
  expect(out).toEqual({
94
90
  label: 'pbr-view-bgl',
95
- entries: buildPbrViewBglEntries({ storageBuffer: true, projectorAvailable: false }),
91
+ entries: buildPbrViewBglEntries({
92
+ storageBuffer: true,
93
+ projectorAvailable: false,
94
+ extendedLighting: false,
95
+ }),
96
96
  });
97
97
  expect(out.entries.find((entry) => entry.binding === 11)).toBeUndefined();
98
98
  expect(out.entries.find((entry) => entry.binding === 12)).toBeUndefined();
@@ -904,16 +904,14 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
904
904
  expect(result.value).toBe(false);
905
905
  });
906
906
 
907
- it('buildPbrViewBglEntries with UNIFORM_CAPS yields uniform for bindings 1 and 2', () => {
907
+ it('buildPbrViewBglEntries with UNIFORM_CAPS has no local-light bindings', () => {
908
908
  const entries = buildPbrViewBglEntries(UNIFORM_CAPS);
909
- expect(entries[1]?.buffer?.type).toBe('uniform');
910
- expect(entries[2]?.buffer?.type).toBe('uniform');
909
+ expect(entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(false);
911
910
  });
912
911
 
913
- it('buildPbrViewBglEntries with STORAGE_CAPS yields read-only-storage (regression guard)', () => {
912
+ it('buildPbrViewBglEntries with STORAGE_CAPS has no local-light bindings', () => {
914
913
  const entries = buildPbrViewBglEntries(STORAGE_CAPS);
915
- expect(entries[1]?.buffer?.type).toBe('read-only-storage');
916
- expect(entries[2]?.buffer?.type).toBe('read-only-storage');
914
+ expect(entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(false);
917
915
  });
918
916
  });
919
917
 
@@ -990,14 +988,15 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
990
988
  expect(instBgl?.entries[0]?.buffer?.type).toBe('uniform');
991
989
  });
992
990
 
993
- it('uniform fallback: view BGL bindings 1+2 are uniform', () => {
991
+ it('uniform fallback: view BGL has no local-light bindings', () => {
994
992
  const device = makeMockDevice();
995
993
  // biome-ignore lint/suspicious/noExplicitAny: structural mock
996
994
  buildPbrPipelineLayouts(device as any, UNIFORM_CAPS);
997
995
  const viewBgl = device.capturedBgls.find((b) => b.label === 'pbr-view-bgl');
998
996
  expect(viewBgl).toBeDefined();
999
- expect(viewBgl?.entries[1]?.buffer?.type).toBe('uniform');
1000
- expect(viewBgl?.entries[2]?.buffer?.type).toBe('uniform');
997
+ expect(viewBgl?.entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(
998
+ false,
999
+ );
1001
1000
  });
1002
1001
 
1003
1002
  it('storage path: mesh-array BGL entry type is read-only-storage (regression guard)', () => {
@@ -5090,26 +5089,23 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
5090
5089
 
5091
5090
  // ── Standard cluster index-overflow RuntimeError class shape ─────────────────
5092
5091
 
5093
- // ── AC-03 guardrail: URP LIGHT_ARRAY_MAX_SLOTS=4 + 5 PointLights ─────────────
5094
- //
5095
- // This test verifies that the existing URP `render-system-multi-light` warn-once
5096
- // still fires when PointLights exceed the first-slice cap (AC-03).
5097
- // The budget constant LIGHT_ARRAY_MAX_SLOTS must remain at 4.
5092
+ // ── Unified Cluster light ABI guardrails ─────────────────────────────────
5098
5093
 
5099
- describe('URP light budget warn-once guardrail (AC-03)', () => {
5100
- it('LIGHT_ARRAY_MAX_SLOTS is still 4 (URP first-slice cap)', async () => {
5101
- const { LIGHT_ARRAY_MAX_SLOTS } = await import('../../../render/src/light-buffer-layout');
5102
- expect(LIGHT_ARRAY_MAX_SLOTS).toBe(4);
5094
+ describe('Standard Cluster light ABI guardrail', () => {
5095
+ it('BYTES_PER_DIRECT_LIGHT_SLOT matches the unified URP and HDRP ABI', async () => {
5096
+ const { BYTES_PER_DIRECT_LIGHT_SLOT } = await import(
5097
+ '../../../render/src/light-buffer-layout'
5098
+ );
5099
+ expect(BYTES_PER_DIRECT_LIGHT_SLOT).toBe(80);
5103
5100
  });
5104
5101
 
5105
- it('POINT_LIGHT_STD430_BYTES and SPOT_LIGHT_STD430_BYTES match the current URP payloads', async () => {
5106
- const { POINT_LIGHT_STD430_BYTES, SPOT_LIGHT_STD430_BYTES } = await import(
5102
+ it('direct-light payload byte size matches the Cluster volume transport', async () => {
5103
+ const { BYTES_PER_DIRECT_LIGHT_SLOT } = await import(
5107
5104
  '../../../render/src/light-buffer-layout'
5108
5105
  );
5109
- expect(POINT_LIGHT_STD430_BYTES).toBe(32);
5110
- // SpotLight's projector receiver controls and shadow intensity occupy
5111
- // the appended fifth vec4 column; point-light layout remains unchanged.
5112
- expect(SPOT_LIGHT_STD430_BYTES).toBe(80);
5106
+ // Point, Spot, and Rect volume receivers share the same five-row
5107
+ // DirectLightSlot transport as the Standard surface Cluster path.
5108
+ expect(BYTES_PER_DIRECT_LIGHT_SLOT).toBe(80);
5113
5109
  });
5114
5110
  });
5115
5111
  }
@@ -400,4 +400,5 @@ it.skipIf(!browserReady)(
400
400
  canvas.remove();
401
401
  }
402
402
  },
403
+ 60_000,
403
404
  );
@@ -19,10 +19,9 @@ import { describe, expect, it } from 'vitest';
19
19
  import { validatePointLightShadowData } from '../../../render/src/components/light-helpers';
20
20
  import { ShadowInvalidConfigError } from '../../../render/src/errors/render';
21
21
  import {
22
- BYTES_PER_LIGHT_SLOT,
23
- LIGHTSLOT_LAYOUT,
24
- packLightSlot,
25
- SPOT_LIGHT_PROJECTOR_FLAG,
22
+ BYTES_PER_DIRECT_LIGHT_SLOT,
23
+ DIRECT_LIGHT_SLOT_LAYOUT,
24
+ packDirectLightSlot,
26
25
  } from '../../../render/src/light-buffer-layout';
27
26
  import { buildPointShadowMatrices } from '../../../render/src/render-system-extract';
28
27
  import {
@@ -551,7 +550,7 @@ function spawnValidatedPointShadow(
551
550
  });
552
551
 
553
552
  describe('standard point shadow light-slot packing (AC-05)', () => {
554
- it('AC-13: packLightSlot writes shadow info onto LightSlot pad lanes (byte 52..64)', () => {
553
+ it('AC-13: packDirectLightSlot writes point shadow identity metadata', () => {
555
554
  const snap = {
556
555
  kind: 'point' as const,
557
556
  position: vec3.create(1, 2, 3),
@@ -559,26 +558,18 @@ function spawnValidatedPointShadow(
559
558
  intensity: 1,
560
559
  invRangeSquared: 0.04,
561
560
  };
562
- // Without shadow info: layer = -1 sentinel; near/far = 0.
563
- const noShadow = packLightSlot(snap);
561
+ // Without shadow info: layer = -1 sentinel in metadata word 17.
562
+ const noShadow = packDirectLightSlot(snap);
564
563
  const i32NoShadow = new Int32Array(noShadow.buffer);
565
- expect(i32NoShadow[13]).toBe(-1); // sentinel
566
- expect(noShadow[14]).toBe(0); // near
567
- expect(noShadow[15]).toBe(0); // far
568
-
569
- // With shadow info: layer 2, near 0.1, far 25 — matches PointLightShadow defaults.
570
- const withShadow = packLightSlot(snap, {
571
- shadowAtlasLayer: 2,
572
- near: 0.1,
573
- far: 25,
574
- });
564
+ expect(i32NoShadow[17]).toBe(-1); // sentinel
565
+
566
+ // Extract joins the PointLightShadow layer before packing.
567
+ const withShadow = packDirectLightSlot({ ...snap, shadowAtlasLayer: 2 });
575
568
  const i32Shadow = new Int32Array(withShadow.buffer);
576
- expect(i32Shadow[13]).toBe(2);
577
- expect(withShadow[14]).toBeCloseTo(0.1, 5);
578
- expect(withShadow[15]).toBe(25);
569
+ expect(i32Shadow[17]).toBe(2);
579
570
  });
580
571
 
581
- it('AC-13: spot light packLightSlot writes its shadow defaults into the tail lanes', () => {
572
+ it('AC-13: unified Spot packing writes shadow metadata', () => {
582
573
  const spotSnap = {
583
574
  kind: 'spot' as const,
584
575
  entity: 0,
@@ -596,86 +587,15 @@ function spawnValidatedPointShadow(
596
587
  farPlane: 50,
597
588
  shadowAtlasTile: -1,
598
589
  };
599
- // SpotLight uses the former pad lanes for the shadow intensity and
600
- // PCF-kernel defaults. The atlas tile remains the -1 sentinel while
601
- // projector/shadow consumers read the appended payload fields.
602
- const packed = packLightSlot(spotSnap, {
603
- shadowAtlasLayer: 0,
604
- near: 0.1,
605
- far: 25,
606
- });
590
+ const packed = packDirectLightSlot(spotSnap);
607
591
  const i32 = new Int32Array(packed.buffer);
608
- expect(i32[13]).toBe(-1); // sentinel; spot lights stay shadowless
609
- expect(packed[14]).toBeCloseTo(1, 5);
610
- expect(packed[15]).toBeCloseTo(3, 5);
592
+ expect(i32[17]).toBe(-1);
611
593
  });
612
594
 
613
- it('keeps authored spot shadow intensity and PCF width in the cluster lanes', () => {
614
- const spotSnap = {
615
- kind: 'spot' as const,
616
- entity: 0,
617
- position: vec3.create(0, 0, 0),
618
- direction: vec3.create(0, -1, 0),
619
- color: vec3.create(1, 1, 1),
620
- intensity: 1,
621
- invRangeSquared: 0,
622
- cosInner: 0.95,
623
- cosOuter: 0.85,
624
- castShadow: true,
625
- lightViewProj: undefined,
626
- mapSize: 2048,
627
- nearPlane: 0.1,
628
- farPlane: 50,
629
- shadowAtlasTile: 2,
630
- shadowIntensity: 0.35,
631
- pcfKernelSize: 5,
632
- };
633
- const packed = packLightSlot(spotSnap);
634
- const i32 = new Int32Array(packed.buffer);
635
- expect(i32[13]).toBe(2);
636
- expect(packed[14]).toBeCloseTo(0.35, 5);
637
- expect(packed[15]).toBeCloseTo(5, 5);
638
- });
639
-
640
- it('marks the accepted projector without losing a shadow tile', () => {
641
- const spotSnap = {
642
- kind: 'spot' as const,
643
- entity: 7,
644
- position: vec3.create(0, 2, 0),
645
- direction: vec3.create(0, -1, 0),
646
- color: vec3.create(1, 1, 1),
647
- intensity: 1,
648
- invRangeSquared: 0.04,
649
- cosInner: 0.95,
650
- cosOuter: 0.85,
651
- castShadow: true,
652
- lightViewProj: new Float32Array(16),
653
- mapSize: 512,
654
- nearPlane: 0.1,
655
- farPlane: 25,
656
- shadowAtlasTile: 2,
657
- projectorAsset: { kind: 'texture' } as never,
658
- };
659
- const packed = packLightSlot(spotSnap, undefined, true);
660
- const encoded = new Int32Array(packed.buffer)[13] ?? 0;
661
- expect(encoded & SPOT_LIGHT_PROJECTOR_FLAG).toBe(SPOT_LIGHT_PROJECTOR_FLAG);
662
- expect(encoded & (SPOT_LIGHT_PROJECTOR_FLAG - 1)).toBe(2);
663
-
664
- const projectorOnly = packLightSlot({ ...spotSnap, shadowAtlasTile: -1 }, undefined, true);
665
- const projectorOnlyEncoded = new Int32Array(projectorOnly.buffer)[13] ?? 0;
666
- expect(projectorOnlyEncoded).toBe(SPOT_LIGHT_PROJECTOR_FLAG);
667
- });
668
-
669
- it('AC-05: LIGHTSLOT_LAYOUT byteSize unchanged at 64 (no-regression for standard)', () => {
670
- // The pad-lane re-purposing must not change the total LightSlot size:
671
- // the std430 vec4 stride is preserved; only the field semantics within
672
- // bytes 52..64 changed (u32 pad -> i32 layer + f32 near + f32 far).
673
- expect(BYTES_PER_LIGHT_SLOT).toBe(64);
674
- expect(LIGHTSLOT_LAYOUT.byteSize).toBe(64);
675
- expect(LIGHTSLOT_LAYOUT.shadowAtlasLayerOffset).toBe(52);
676
- expect(LIGHTSLOT_LAYOUT.shadowPayloadOffset).toBe(52);
677
- expect(LIGHTSLOT_LAYOUT.shadowNearOffset).toBe(56);
678
- expect(LIGHTSLOT_LAYOUT.shadowFarOffset).toBe(60);
595
+ it('AC-05: DIRECT_LIGHT_SLOT_LAYOUT keeps the five-row ABI', () => {
596
+ expect(BYTES_PER_DIRECT_LIGHT_SLOT).toBe(80);
597
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.byteSize).toBe(80);
598
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.shadowByteOffset).toBe(68);
679
599
  });
680
600
  });
681
601
 
@@ -5,13 +5,12 @@
5
5
  // Environment: node + setupFiles ./vitest.setup-webgpu.ts injects
6
6
  // globalThis.navigator.gpu (provided by dawn.node native binding).
7
7
  //
8
- // feat-20260601 M4: GlobalTransform is retired. propagateTransforms now writes
9
- // the resolved world mat4 into the `Transform.world` column (column-major 16
10
- // floats). Tests read that column via the ECS array view and assert the
11
- // translation column (m[12,13,14]).
8
+ // The derived world mat4 lives in the paired `GlobalTransform.world` column
9
+ // (column-major 16 floats). Tests read that column via the ECS array view and
10
+ // assert the translation column (m[12,13,14]).
12
11
  //
13
12
  // Scope (w6 acceptanceCheck):
14
- // (a) root-only entity: Transform.world translation = local translation
13
+ // (a) root-only entity: GlobalTransform.world translation = local translation
15
14
  // (b) 2-level hierarchy: child.world = parent.world x child local
16
15
  // (c) stale ChildOf ref: Result.err(RhiError({ code: 'hierarchy-broken' }))
17
16
  // (d) N=3 deep chain: composition stacks correctly
@@ -24,7 +23,7 @@
24
23
  import type { EntityHandle } from '@forgeax/engine-ecs';
25
24
  import { World } from '@forgeax/engine-ecs';
26
25
  import { readRenderArrayView } from '@forgeax/engine-ecs/projection';
27
- import { ChildOf, propagateTransforms, Transform } from '@forgeax/engine-scene';
26
+ import { ChildOf, GlobalTransform, propagateTransforms, Transform } from '@forgeax/engine-scene';
28
27
  import { describe, expect, it } from 'vitest';
29
28
  import { TileLayer, Tilemap } from '../../../render/src/components';
30
29
 
@@ -36,16 +35,17 @@ function identityTransformData() {
36
35
  };
37
36
  }
38
37
 
39
- // Read the resolved world mat4 (column-major 16 floats) from the Transform
40
- // world column array view. Translation lives in column 3 (m[12,13,14]).
38
+ // Read the resolved world mat4 (column-major 16 floats) from the
39
+ // GlobalTransform world column array view. Translation lives in column 3
40
+ // (m[12,13,14]).
41
41
  function worldOf(world: World, entity: EntityHandle): Float32Array {
42
- const view = readRenderArrayView(world, entity, Transform, 'world');
43
- if (view === undefined) throw new Error('Transform.world view missing');
42
+ const view = readRenderArrayView(world, entity, GlobalTransform, 'world');
43
+ if (view === undefined) throw new Error('GlobalTransform.world view missing');
44
44
  return view as Float32Array;
45
45
  }
46
46
 
47
47
  describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (AC-04)', () => {
48
- it('root entity: Transform.world translation = local translation (identity chain)', () => {
48
+ it('root entity: GlobalTransform.world translation = local translation (identity chain)', () => {
49
49
  const world = new World();
50
50
  const root = world
51
51
  .spawn({
@@ -153,7 +153,7 @@ describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (A
153
153
 
154
154
  // AC-07 (tweak-20260714-tilemap-layer-childed-render-entities M5): TileLayer
155
155
  // as an identity middle node must be transparent to propagateTransforms -- the
156
- // layer's Transform.world (16 f32) must equal its Tilemap parent's world mat4
156
+ // layer's GlobalTransform.world (16 f32) must equal its Tilemap parent's world mat4
157
157
  // byte-for-byte. This proves M1's coAttach injection produces a genuinely
158
158
  // identity TRS (pos=[0,0,0], quat=[0,0,0,1], scale=[1,1,1]) and the compose
159
159
  // (parent.world x identity) is a no-op in mat4 form.
@@ -165,7 +165,7 @@ describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (A
165
165
  // asserted here because the M1 contract is precisely "identity default,
166
166
  // callers may override" -- the interesting invariant is transparency in the
167
167
  // default case.
168
- it('AC-07: identity TileLayer middle node -> Transform.world byte-identical to Tilemap parent', () => {
168
+ it('AC-07: identity TileLayer middle node -> GlobalTransform.world byte-identical to Tilemap parent', () => {
169
169
  const world = new World();
170
170
  for (const component of [ChildOf, TileLayer, Tilemap, Transform]) {
171
171
  world.components.register(component).unwrap();
@@ -189,9 +189,8 @@ describe('propagate-transforms.dawn - root-down DFS + stale ChildOf fail-fast (A
189
189
  },
190
190
  )
191
191
  .unwrap();
192
- // TileLayer carries an explicit identity Transform in the current ECS
193
- // component vocabulary. This mirrors the demo spawn pattern in
194
- // apps/hello/tilemap/** and keeps the hierarchy contract explicit.
192
+ // TileLayer carries an identity Transform; the generic Transform requirement
193
+ // materializes its GlobalTransform world column at the structural boundary.
195
194
  const layer = world
196
195
  .spawn(
197
196
  {
@@ -6,10 +6,11 @@
6
6
  //
7
7
  // Anchors:
8
8
  // plan-tasks.json m2 (patch assignment)
9
- // plan-strategy D-1a #1 (instanceBuffers positive half) and #3 (instancesBgPerEntity)
9
+ // plan-strategy D-1a #1 (instanceBuffers positive half); bind groups are now
10
+ // shared by opaque buffer handles after the probe/identity layout split.
10
11
  // requirements AC-07
11
12
  //
12
- // The 13 sites under test (plus 2 read-side instancesBgPerEntity reads) are
13
+ // The production instance-buffer sites and the shared bind-group resolver are
13
14
  // located by their stable cache operation below; source line numbers are not
14
15
  // part of the contract and must not make this gate stale after extraction.
15
16
  //
@@ -29,7 +30,7 @@ import { describe, expect, it } from 'vitest';
29
30
  * Verify that a cache site uses a worldEntityKey composite key.
30
31
  * Scans the source file around the given line (within a window) for both
31
32
  * `worldEntityKey` and the cache operation (`targetPattern` such as
32
- * `instanceBuffers.get`, `instancesBgPerEntity` as second arg, etc.).
33
+ * `instanceBuffers.get`, etc.).
33
34
  */
34
35
  function expectWorldEntityKeyAt(
35
36
  filePath: string,
@@ -95,19 +96,16 @@ describe('production-path geometry instanceBuffers worldEntityKey', () => {
95
96
  });
96
97
  });
97
98
 
98
- // ── Sprite identity BG instancesBgPerEntity (D-1a #3) ──────────────────────
99
-
100
99
  const SPRITE_FILE = fileURLToPath(
101
100
  new URL('../../../render/src/record/main-pass-sprite-draws.ts', import.meta.url),
102
101
  );
103
102
 
104
- describe('production-path sprite identity instancesBgPerEntity worldEntityKey', () => {
105
- it('identityInstBg entityKey uses worldEntityKey', () => {
106
- expectWorldEntityKeyAt(
107
- SPRITE_FILE,
108
- findLine(SPRITE_FILE, 'frameState.instancesBgPerEntity', 0),
109
- 'main-pass-sprite-draws instancesBgPerEntity write',
110
- );
103
+ describe('production-path shared instance bind-group resolver', () => {
104
+ it('sprite identity and sprite-instance paths use the shared resolver', () => {
105
+ const src = readFileSync(SPRITE_FILE, 'utf8');
106
+ expect(src).toContain('const identityInstBg = resolveGeometryInstancesBindGroup(');
107
+ expect(src).toContain('const spriteInstBg = resolveGeometryInstancesBindGroup(');
108
+ expect(src).toContain('const spriteInstancesBg = resolveGeometryInstancesBindGroup(');
111
109
  });
112
110
  });
113
111
 
@@ -131,18 +129,6 @@ describe('production-path sprite Instances instanceBuffers worldEntityKey', () =
131
129
  });
132
130
  });
133
131
 
134
- // ── Sprite pass instancesBgPerEntity (D-1a #3) ─────────────────────────────
135
-
136
- describe('production-path sprite pass instancesBgPerEntity worldEntityKey', () => {
137
- it('spriteInstancesBg entityKey write uses worldEntityKey', () => {
138
- expectWorldEntityKeyAt(
139
- SPRITE_FILE,
140
- findLine(SPRITE_FILE, 'frameState.instancesBgPerEntity', 1),
141
- 'main-pass-sprite-draws sprite pass instancesBgPerEntity write',
142
- );
143
- });
144
- });
145
-
146
132
  // ── SpriteInstances instanceBuffers (D-1a #1) ──────────────────────────────
147
133
 
148
134
  describe('production-path SpriteInstances instanceBuffers worldEntityKey', () => {
@@ -197,15 +183,13 @@ describe('production-path shadow instanceBuffers worldEntityKey', () => {
197
183
  });
198
184
  });
199
185
 
200
- // ── Shadow pass instancesBgPerEntity (D-1a #3) ─────────────────────────────
201
-
202
- describe('production-path shadow instancesBgPerEntity worldEntityKey', () => {
203
- it('shadowInstancesBg entityKey write uses worldEntityKey', () => {
204
- expectWorldEntityKeyAt(
205
- SHADOW_FILE,
206
- findLine(SHADOW_FILE, 'c.frameState.instancesBgPerEntity', 0),
207
- 'shadow-pass instancesBgPerEntity write',
186
+ describe('production-path shadow shared instance bind-group resolver', () => {
187
+ it('shadow pass resolves the bind group from the world-aware buffer cache', () => {
188
+ const src = readFileSync(SHADOW_FILE, 'utf8');
189
+ expect(src).toContain(
190
+ 'const shadowInstancesBg = resolveGeometryInstancesBindGroup(c, shadowInstanceBuffer);',
208
191
  );
192
+ expect(src).toContain('worldEntityKey(entry.source.worldId, shadowInst.cacheKey)');
209
193
  });
210
194
  });
211
195
 
@@ -128,6 +128,7 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
128
128
  return code;
129
129
  case 'points-lines-prepare-failed':
130
130
  return code;
131
+ case 'light-resource-unavailable':
131
132
  case 'projector-binding-failed':
132
133
  return code;
133
134
  case 'volume-owner-conflict':
@@ -392,6 +393,15 @@ function narrowRenderError(err: RenderError): void {
392
393
  void err.detail.owner;
393
394
  void err.detail.generation;
394
395
  break;
396
+ case 'light-resource-unavailable':
397
+ void err.detail.entity;
398
+ void err.detail.feature;
399
+ void err.detail.generation;
400
+ void err.detail.sourceKey;
401
+ void err.detail.reason;
402
+ void err.expected;
403
+ void err.hint;
404
+ break;
395
405
  case 'transmission-capability-missing':
396
406
  void err.detail.material;
397
407
  void err.detail.capability;
@@ -40,22 +40,22 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
40
40
  {
41
41
  // w9 -- render-system-extract consumer migration unit tests (M3, AC-07 / AC-15).
42
42
  //
43
- // Post-migration extract reads the single resolved `Transform.world` mat4
43
+ // Post-migration extract reads the single resolved `GlobalTransform.world` mat4
44
44
  // (written by propagateTransforms) for every world-space consumer; the legacy
45
45
  // GlobalTransform-column-switch + per-snapshot `mat4.compose` are gone.
46
46
  //
47
47
  // Five consumer classes covered:
48
48
  // 1. mesh-walk -> RenderableSnapshot.transform.world is the 16-float
49
49
  // column-major world mat4 (parent x child), copied straight from the
50
- // Transform.world view; no per-snapshot compose.
50
+ // GlobalTransform.world view; no per-snapshot compose.
51
51
  // 2. frustum cull -> the cull AABB follows the world mat4 (a child placed
52
52
  // off-screen by its parent is culled; in-screen is kept) -- same-source
53
53
  // with the rendered world (AC-05 carried forward).
54
54
  // 3. camera view -> CameraSnapshot.position is the world-space translation
55
- // (mat4.getTranslation of Transform.world); a child camera reflects
55
+ // (mat4.getTranslation of GlobalTransform.world); a child camera reflects
56
56
  // parent x child.
57
57
  // 4. light position -> point / spot light position is the world-space
58
- // translation extracted from Transform.world (mat4.getTranslation).
58
+ // translation extracted from GlobalTransform.world (mat4.getTranslation).
59
59
  // 5. AC-15 zero-materialization -> the mesh-walk reads world through the
60
60
  // column-level array view; it does NOT call `world.get` per renderable
61
61
  // to materialize a `{}` whole-component object.
@@ -209,7 +209,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
209
209
  expect(w?.[12]).toBeCloseTo(12, 5);
210
210
  expect(w?.[13]).toBeCloseTo(3, 5);
211
211
  expect(w?.[14]).toBeCloseTo(0, 5);
212
- // The snapshot world must equal the entity's Transform.world column byte-for-byte.
212
+ // The snapshot world must equal the entity's GlobalTransform.world column byte-for-byte.
213
213
  });
214
214
 
215
215
  it('mesh-walk: flat root world mat4 equals compose(local)', () => {
@@ -291,6 +291,7 @@ async function importEcs(): Promise<{
291
291
 
292
292
  async function importComponents(): Promise<{
293
293
  Transform: unknown;
294
+ GlobalTransform: unknown;
294
295
  MeshFilter: unknown;
295
296
  MeshRenderer: unknown;
296
297
  Camera: unknown;
@@ -232,6 +232,7 @@ async function importEcs(): Promise<{
232
232
  }
233
233
  async function importComponents(): Promise<{
234
234
  Transform: unknown;
235
+ GlobalTransform: unknown;
235
236
  MeshFilter: unknown;
236
237
  MeshRenderer: unknown;
237
238
  Camera: unknown;
@@ -180,8 +180,6 @@ function makeFrameState(): MockObj {
180
180
  transientInstanceBuffers: [],
181
181
  warnedZeroLightStandard: false,
182
182
  warnedMultiLightDirectional: false,
183
- warnedMultiLightPoint: false,
184
- warnedMultiLightSpot: false,
185
183
  warnedSkyboxTonemapNone: false,
186
184
  warnedMissingBaseColorTextureHandles: new Set<number>(),
187
185
  warnedNineSliceScaleEntities: new Set<number>(),