@forgeax/engine-runtime 0.1.27 → 0.1.29

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 (114) hide show
  1. package/README.md +98 -0
  2. package/dist/collect-scene-asset.d.ts.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +480 -15
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/scene-utils/mount-override-fold.d.ts +3 -1
  8. package/dist/scene-utils/mount-override-fold.d.ts.map +1 -1
  9. package/package.json +37 -30
  10. package/src/__tests__/animation-graph-collect-roundtrip.test.ts +4 -2
  11. package/src/__tests__/asset-registry-aabb.cases.unit.test.ts +5 -4
  12. package/src/__tests__/asset-registry-guid-reverse.test.ts +22 -27
  13. package/src/__tests__/asset-registry-mounts.test.ts +174 -734
  14. package/src/__tests__/asset-registry-scene.cases.unit.test.ts +9 -18
  15. package/src/__tests__/asset-registry.recursive.spec.ts +23 -29
  16. package/src/__tests__/camera-ortho.cases.unit.test.ts +12 -3
  17. package/src/__tests__/collect-scene-asset.test.ts +96 -113
  18. package/src/__tests__/collect-scene-mount-collapse.test.ts +64 -66
  19. package/src/__tests__/collect-transient-children-absent.test.ts +11 -17
  20. package/src/__tests__/collect-transient-roundtrip.test.ts +12 -13
  21. package/src/__tests__/collect-transient-skip.test.ts +20 -27
  22. package/src/__tests__/components.test-d.ts +17 -1
  23. package/src/__tests__/components.unit.test.ts +75 -27
  24. package/src/__tests__/composite-skybox-cross-world.browser.test.ts +4 -2
  25. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +8 -4
  26. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +4 -1
  27. package/src/__tests__/dawn/instances-uniform-fallback.dawn.test.ts +66 -92
  28. package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +200 -4
  29. package/src/__tests__/errors.unit.test.ts +13 -0
  30. package/src/__tests__/feature-depth-material.dawn.test.ts +23 -0
  31. package/src/__tests__/feature-depth-material.fixture.ts +473 -0
  32. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +14 -14
  33. package/src/__tests__/geometry.unit.test.ts +42 -43
  34. package/src/__tests__/gpu-resource-release.cases.unit.test.ts +16 -9
  35. package/src/__tests__/handle-migration.test.ts +0 -1
  36. package/src/__tests__/instances-renderer-lifecycle.integration.test.ts +20 -14
  37. package/src/__tests__/instances.test-d.ts +13 -52
  38. package/src/__tests__/instantiate-mount-deferred-wiring.test.ts +42 -80
  39. package/src/__tests__/lazy-catalog.cases.unit.test.ts +17 -3
  40. package/src/__tests__/loader-registry.cases.unit.test.ts +4 -4
  41. package/src/__tests__/material-publication.browser.test.ts +29 -0
  42. package/src/__tests__/material-publication.commands.ts +23 -0
  43. package/src/__tests__/material-publication.dawn.test.ts +24 -0
  44. package/src/__tests__/material-publication.fixture.ts +349 -0
  45. package/src/__tests__/material-publication.server.ts +109 -0
  46. package/src/__tests__/parse-scene-payload-refs.cases.unit.test.ts +73 -96
  47. package/src/__tests__/pbr-pipeline.unit.test.ts +25 -11
  48. package/src/__tests__/point-light-shadow.browser.test.ts +8 -2
  49. package/src/__tests__/point-light-shadow.unit.test.ts +37 -8
  50. package/src/__tests__/record-all-topology.cases.unit.test.ts +52 -0
  51. package/src/__tests__/record-draw-branch.cases.unit.test.ts +1 -0
  52. package/src/__tests__/record-worldid-isolation.test.ts +47 -28
  53. package/src/__tests__/render-error-exhaustive.test-d.ts +24 -0
  54. package/src/__tests__/render-system-extract-dispatch-ordering.test.ts +15 -4
  55. package/src/__tests__/render-system-extract.test.ts +61 -0
  56. package/src/__tests__/render-system-extract.unit.test.ts +21 -7
  57. package/src/__tests__/render-system-fold-bucket.unit.test.ts +33 -1
  58. package/src/__tests__/render-system-fold-material.unit.test.ts +32 -6
  59. package/src/__tests__/render-system-fold-modegate.unit.test.ts +26 -4
  60. package/src/__tests__/render-system-fold.dawn.test.ts +4 -1
  61. package/src/__tests__/render-system-mega.test.ts +50 -62
  62. package/src/__tests__/renderer-device-loss-provider-feasibility.dawn.test.ts +0 -1
  63. package/src/__tests__/renderer-device-loss-recovery.dawn.test.ts +0 -3
  64. package/src/__tests__/renderer-draw-world.cases.unit.test.ts +16 -513
  65. package/src/__tests__/resolve-scene-guids.cases.unit.test.ts +48 -43
  66. package/src/__tests__/rhi-null-command-flow.unit.test.ts +9 -7
  67. package/src/__tests__/roots-to-scene-asset.test.ts +27 -28
  68. package/src/__tests__/roundtrip-primitive.test.ts +24 -23
  69. package/src/__tests__/scene-equirect-roundtrip.test.ts +5 -16
  70. package/src/__tests__/scene-instantiate-cycle-runtime.test.ts +13 -29
  71. package/src/__tests__/scene-mount-fixed-point.test.ts +104 -227
  72. package/src/__tests__/scene-mount-roundtrip.test.ts +95 -961
  73. package/src/__tests__/scene-mount-wrapper-roundtrip.test.ts +36 -182
  74. package/src/__tests__/scene-sugar-instantiate.test.ts +20 -15
  75. package/src/__tests__/serialize-mounts-roundtrip.test.ts +108 -492
  76. package/src/__tests__/serialize-scene-asset.test.ts +36 -50
  77. package/src/__tests__/shadow-caster-empty-schema.test.ts +3 -1
  78. package/src/__tests__/shadow-fields-observable.dawn.test.ts +34 -19
  79. package/src/__tests__/shadow-mesh-ssbo-order.test.ts +5 -12
  80. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +6 -4
  81. package/src/__tests__/skin-m2-extract-happy.cases.unit.test.ts +50 -2
  82. package/src/__tests__/skin-m2-extract-joint-error.cases.unit.test.ts +50 -2
  83. package/src/__tests__/skin-m2-extract-null-assets.cases.unit.test.ts +50 -2
  84. package/src/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.ts +50 -2
  85. package/src/__tests__/skin-palette-extract.cases.unit.test.ts +2 -0
  86. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +93 -48
  87. package/src/__tests__/skylight-pipeline-layout.cases.unit.test.ts +6 -2
  88. package/src/__tests__/sparse-tag-scene-roundtrip.test.ts +1 -1
  89. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -3
  90. package/src/__tests__/template-game-default-api-contract.test.ts +21 -21
  91. package/src/__tests__/tonemap-hdr-target.cases.unit.test.ts +8 -5
  92. package/src/__tests__/tonemap-pipeline-split.cases.unit.test.ts +3 -0
  93. package/src/__tests__/transient-fields-collect.test.ts +4 -4
  94. package/src/__tests__/transient-runtime-behavior.test.ts +9 -9
  95. package/src/__tests__/vfx-depth-material.dawn.test.ts +42 -0
  96. package/src/__tests__/vfx-mesh-lighting.browser.test.ts +16 -0
  97. package/src/__tests__/vfx-mesh-lighting.commands.ts +14 -0
  98. package/src/__tests__/vfx-mesh-lighting.dawn.test.ts +16 -0
  99. package/src/__tests__/vfx-mesh-lighting.fixture.ts +883 -0
  100. package/src/__tests__/wave1-dynamic-geometry.browser.test.ts +326 -0
  101. package/src/__tests__/wave1-rendering-materials.browser.test.ts +617 -0
  102. package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
  103. package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
  104. package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
  105. package/src/__tests__/world-scene-despawn.test.ts +8 -8
  106. package/src/__tests__/world-scene-instantiate.test.ts +23 -29
  107. package/src/__tests__/world-scene-nested.test.ts +90 -163
  108. package/src/__tests__/world-scene-override.test.ts +17 -16
  109. package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
  110. package/src/collect-scene-asset.ts +637 -19
  111. package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
  112. package/src/index.ts +1 -0
  113. package/src/scene-utils/mount-override-fold.ts +5 -14
  114. package/src/__tests__/node-shims.d.ts +0 -31
@@ -2,7 +2,8 @@
2
2
  //
3
3
  // Verifies that every cache write/read site in the record stage that was
4
4
  // identified in plan-strategy D-1a as needing worldEntityKey compositing
5
- // actually uses worldEntityKey (not bare entityKey / cacheKey).
5
+ // actually uses a world-aware key (worldEntityKey or
6
+ // instanceCollectionCacheKey), not a bare entityKey / cacheKey.
6
7
  //
7
8
  // Anchors:
8
9
  // plan-tasks.json m2 (patch assignment)
@@ -15,7 +16,7 @@
15
16
  // part of the contract and must not make this gate stale after extraction.
16
17
  //
17
18
  // Each site check uses a line-range window: we read the source file and verify
18
- // that within the line range, a `worldEntityKey(...)` call appears on a line
19
+ // that within the line range, a world-aware key call appears on a line
19
20
  // that contains the target key expression. This directly falsifies the bug
20
21
  // "site uses bare key" — when a site uses a bare key, the worldEntityKey
21
22
  // check will fail.
@@ -27,12 +28,12 @@ import { describe, expect, it } from 'vitest';
27
28
  // ── Helpers ─────────────────────────────────────────────────────────────────
28
29
 
29
30
  /**
30
- * Verify that a cache site uses a worldEntityKey composite key.
31
+ * Verify that a cache site uses a world-aware composite key.
31
32
  * Scans the source file around the given line (within a window) for both
32
33
  * `worldEntityKey` and the cache operation (`targetPattern` such as
33
34
  * `instanceBuffers.get`, etc.).
34
35
  */
35
- function expectWorldEntityKeyAt(
36
+ function expectWorldAwareCacheKeyAt(
36
37
  filePath: string,
37
38
  line: number,
38
39
  description: string,
@@ -56,7 +57,7 @@ function expectWorldEntityKeyAt(
56
57
  nearby.push(` ${i + 1}: ${lines[i]}`);
57
58
  }
58
59
  expect.fail(
59
- `${description}: worldEntityKey NOT found near line ${line} in ${filePath}\nNearby lines:\n${nearby.join('\n')}`,
60
+ `${description}: world-aware cache key NOT found near line ${line} in ${filePath}\nNearby lines:\n${nearby.join('\n')}`,
60
61
  );
61
62
  }
62
63
 
@@ -74,21 +75,21 @@ function findLine(filePath: string, needle: string, occurrence = 0): number {
74
75
 
75
76
  // ── Geometry pass instanceBuffers (D-1a #1) ────────────────────────────────
76
77
 
77
- describe('production-path geometry instanceBuffers worldEntityKey', () => {
78
+ describe('production-path geometry instanceBuffers world-aware key', () => {
78
79
  const FILE = fileURLToPath(
79
80
  new URL('../../../render/src/record/main-pass-geometry.ts', import.meta.url),
80
81
  );
81
82
 
82
- it('inst.cacheKey get uses worldEntityKey', () => {
83
- expectWorldEntityKeyAt(
83
+ it('inst.cacheKey get uses a world-aware key', () => {
84
+ expectWorldAwareCacheKeyAt(
84
85
  FILE,
85
86
  findLine(FILE, 'frameState.instanceBuffers.get('),
86
87
  'main-pass-geometry instanceBuffers.get',
87
88
  );
88
89
  });
89
90
 
90
- it('inst.cacheKey set uses worldEntityKey', () => {
91
- expectWorldEntityKeyAt(
91
+ it('inst.cacheKey set uses a world-aware key', () => {
92
+ expectWorldAwareCacheKeyAt(
92
93
  FILE,
93
94
  findLine(FILE, 'frameState.instanceBuffers.set('),
94
95
  'main-pass-geometry instanceBuffers.set',
@@ -110,17 +111,17 @@ describe('production-path shared instance bind-group resolver', () => {
110
111
 
111
112
  // ── Sprite Instances instanceBuffers (D-1a #1) ─────────────────────────────
112
113
 
113
- describe('production-path sprite Instances instanceBuffers worldEntityKey', () => {
114
- it('spriteInst.cacheKey get uses worldEntityKey', () => {
115
- expectWorldEntityKeyAt(
114
+ describe('production-path sprite Instances instanceBuffers world-aware key', () => {
115
+ it('spriteInst.cacheKey get uses a world-aware key', () => {
116
+ expectWorldAwareCacheKeyAt(
116
117
  SPRITE_FILE,
117
118
  findLine(SPRITE_FILE, 'frameState.instanceBuffers.get(', 1),
118
119
  'main-pass-sprite-draws sprite instanceBuffers.get',
119
120
  );
120
121
  });
121
122
 
122
- it('spriteInst.cacheKey set uses worldEntityKey', () => {
123
- expectWorldEntityKeyAt(
123
+ it('spriteInst.cacheKey set uses a world-aware key', () => {
124
+ expectWorldAwareCacheKeyAt(
124
125
  SPRITE_FILE,
125
126
  findLine(SPRITE_FILE, 'frameState.instanceBuffers.set(', 1),
126
127
  'main-pass-sprite-draws sprite instanceBuffers.set',
@@ -130,17 +131,17 @@ describe('production-path sprite Instances instanceBuffers worldEntityKey', () =
130
131
 
131
132
  // ── SpriteInstances instanceBuffers (D-1a #1) ──────────────────────────────
132
133
 
133
- describe('production-path SpriteInstances instanceBuffers worldEntityKey', () => {
134
- it('spriteInstancesSnap.cacheKey get uses worldEntityKey', () => {
135
- expectWorldEntityKeyAt(
134
+ describe('production-path SpriteInstances instanceBuffers world-aware key', () => {
135
+ it('spriteInstancesSnap.cacheKey get uses a world-aware key', () => {
136
+ expectWorldAwareCacheKeyAt(
136
137
  SPRITE_FILE,
137
138
  findLine(SPRITE_FILE, 'frameState.instanceBuffers.get(', 2),
138
139
  'main-pass-sprite-draws spriteInstances instanceBuffers.get',
139
140
  );
140
141
  });
141
142
 
142
- it('spriteInstancesSnap.cacheKey set uses worldEntityKey', () => {
143
- expectWorldEntityKeyAt(
143
+ it('spriteInstancesSnap.cacheKey set uses a world-aware key', () => {
144
+ expectWorldAwareCacheKeyAt(
144
145
  SPRITE_FILE,
145
146
  findLine(SPRITE_FILE, 'frameState.instanceBuffers.set(', 2),
146
147
  'main-pass-sprite-draws spriteInstances instanceBuffers.set',
@@ -154,12 +155,21 @@ const SHADOW_FILE = fileURLToPath(
154
155
  new URL('../../../render/src/record/shadow-pass.ts', import.meta.url),
155
156
  );
156
157
 
157
- describe('production-path shadow instanceBuffers worldEntityKey', () => {
158
- it('shares the world-aware geometry instance residency owner', () => {
159
- const src = readFileSync(SHADOW_FILE, 'utf8');
160
- expect(src).toMatch(/resolveGeometryInstanceBuffer\(\s*c,\s*entry,/);
161
- expect(src).not.toContain('instanceBuffers.get(');
162
- expect(src).not.toContain('instanceBuffers.set(');
158
+ describe('production-path shadow instanceBuffers world-aware key', () => {
159
+ it('shadowInst.cacheKey get uses a world-aware key', () => {
160
+ expectWorldAwareCacheKeyAt(
161
+ SHADOW_FILE,
162
+ findLine(SHADOW_FILE, 'c.frameState.instanceBuffers.get(', 0),
163
+ 'shadow-pass instanceBuffers.get',
164
+ );
165
+ });
166
+
167
+ it('shadowInst.cacheKey set uses a world-aware key', () => {
168
+ expectWorldAwareCacheKeyAt(
169
+ SHADOW_FILE,
170
+ findLine(SHADOW_FILE, 'c.frameState.instanceBuffers.set('),
171
+ 'shadow-pass instanceBuffers.set',
172
+ );
163
173
  });
164
174
 
165
175
  it('spot shadow uses the shared world-aware caster recorder', () => {
@@ -167,9 +177,18 @@ describe('production-path shadow instanceBuffers worldEntityKey', () => {
167
177
  const start = src.indexOf('export function encodeSpotShadowPass(');
168
178
  expect(start).toBeGreaterThanOrEqual(0);
169
179
  expect(src.slice(start)).toContain('recordShadowCasterDraws(');
170
- expect(src.slice(start)).toContain(
171
- "buildMatchedRenderableIndices(c.dispatch, { LightMode: ['ShadowCaster'] })",
180
+ expect(src.slice(start)).toContain('buildMatchedRenderableIndices(');
181
+ expect(src.slice(start)).toContain("{ LightMode: ['ShadowCaster'] }");
182
+ });
183
+ });
184
+
185
+ describe('production-path shadow shared instance bind-group resolver', () => {
186
+ it('shadow pass resolves the bind group from the world-aware buffer cache', () => {
187
+ const src = readFileSync(SHADOW_FILE, 'utf8');
188
+ expect(src).toContain(
189
+ 'const shadowInstancesBg = resolveGeometryInstancesBindGroup(c, instanceDraw.buffer);',
172
190
  );
191
+ expect(src).toContain('instanceCollectionCacheKey(entry.source.worldId, shadowInst)');
173
192
  });
174
193
  });
175
194
 
@@ -14,6 +14,14 @@ import type { RenderError, RenderErrorCode } from '@forgeax/engine-render';
14
14
 
15
15
  function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
16
16
  switch (code) {
17
+ case 'auto-exposure-invalid-parameter':
18
+ return code;
19
+ case 'auto-exposure-capability-unavailable':
20
+ return code;
21
+ case 'auto-exposure-stale-generation':
22
+ return code;
23
+ case 'auto-exposure-stage-failed':
24
+ return code;
17
25
  case 'lifecycle-construction-failed':
18
26
  return code;
19
27
  case 'world-lease-invalid':
@@ -148,6 +156,22 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
148
156
 
149
157
  function narrowRenderError(err: RenderError): void {
150
158
  switch (err.code) {
159
+ case 'auto-exposure-invalid-parameter':
160
+ void err.detail.field;
161
+ void err.detail.value;
162
+ break;
163
+ case 'auto-exposure-capability-unavailable':
164
+ void err.detail.capability;
165
+ void err.detail.generation;
166
+ break;
167
+ case 'auto-exposure-stale-generation':
168
+ void err.detail.expectedGeneration;
169
+ void err.detail.actualGeneration;
170
+ break;
171
+ case 'auto-exposure-stage-failed':
172
+ void err.detail.stage;
173
+ void err.detail.operation;
174
+ break;
151
175
  case 'lifecycle-construction-failed':
152
176
  void err.detail.owner;
153
177
  void err.detail.generation;
@@ -51,7 +51,13 @@
51
51
 
52
52
  import { AssetRegistry, HANDLE_QUAD } from '@forgeax/engine-assets-runtime';
53
53
  import { World } from '@forgeax/engine-ecs';
54
- import { Camera, Instances, MeshFilter, MeshRenderer } from '@forgeax/engine-render';
54
+ import {
55
+ Camera,
56
+ InstanceCollectionStore,
57
+ Instances,
58
+ MeshFilter,
59
+ MeshRenderer,
60
+ } from '@forgeax/engine-render';
55
61
  import { propagateTransforms, Transform } from '@forgeax/engine-scene';
56
62
  import type { MaterialAsset } from '@forgeax/engine-types';
57
63
  import { describe, expect, it } from 'vitest';
@@ -245,13 +251,15 @@ describe('render-system-extract M2.5 dispatch/renderable slot pairing (AC-06)',
245
251
  instanceTransforms[n * 16 + 10] = 1;
246
252
  instanceTransforms[n * 16 + 15] = 1;
247
253
  }
254
+ const instanceCollections = new InstanceCollectionStore();
255
+ const collection = instanceCollections.create({ transforms: instanceTransforms }).unwrap();
248
256
 
249
257
  world
250
258
  .spawn(
251
259
  { component: Transform, data: transformAtZ(100) },
252
260
  { component: MeshFilter, data: { assetHandle: HANDLE_QUAD } },
253
261
  { component: MeshRenderer, data: { materials: [matBlue] } },
254
- { component: Instances, data: { transforms: instanceTransforms } },
262
+ { component: Instances, data: { collectionId: collection.collectionId } },
255
263
  )
256
264
  .unwrap();
257
265
  world
@@ -259,12 +267,15 @@ describe('render-system-extract M2.5 dispatch/renderable slot pairing (AC-06)',
259
267
  { component: Transform, data: identityTransform() },
260
268
  { component: MeshFilter, data: { assetHandle: HANDLE_QUAD } },
261
269
  { component: MeshRenderer, data: { materials: [matYellow] } },
262
- { component: Instances, data: { transforms: instanceTransforms } },
270
+ { component: Instances, data: { collectionId: collection.collectionId } },
263
271
  )
264
272
  .unwrap();
265
273
 
266
274
  expect(propagateTransforms(world).ok).toBe(true);
267
- const frame = extractFrame(world, prepareExtractContext(world, { assets }));
275
+ const frame = extractFrame(
276
+ world,
277
+ prepareExtractContext(world, { assets, instanceCollections }),
278
+ );
268
279
 
269
280
  expect(frame.renderables).toHaveLength(1);
270
281
  expect(frame.renderables[0]?.instances).toBeDefined();
@@ -17,6 +17,7 @@ import {
17
17
  MeshFilter,
18
18
  MeshRenderer,
19
19
  PointLight,
20
+ PointLightShadow,
20
21
  SpotLight,
21
22
  } from '@forgeax/engine-render';
22
23
  import { ChildOf, propagateTransforms, Transform } from '@forgeax/engine-scene';
@@ -35,6 +36,7 @@ import {
35
36
  extractFrames,
36
37
  prepareExtractContext,
37
38
  } from '../../../render/src/render-system-extract';
39
+ import { PersistentRenderScene } from '../../../render/src/scene/render-scene';
38
40
  import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
39
41
 
40
42
  // ─── from render-system-extract.test.ts ───
@@ -539,6 +541,65 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
539
541
  expect(frame.frustumStats).toEqual({ culled: 1, total: 1 });
540
542
  });
541
543
 
544
+ it('retains off-camera casters only for shadow dispatch and only inside a light frustum', () => {
545
+ const world = new World();
546
+ spawnCamera(world);
547
+ world
548
+ .spawn(
549
+ { component: Transform, data: { ...identity(), pos: [0, 0, 2] } },
550
+ { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
551
+ { component: MeshRenderer, data: {} },
552
+ )
553
+ .unwrap();
554
+ const light = world
555
+ .spawn(
556
+ { component: Transform, data: { ...identity(), pos: [0, 0, 5] } },
557
+ { component: PointLight, data: { intensity: 10, range: 10 } },
558
+ { component: PointLightShadow, data: { nearPlane: 0.1, farPlane: 10 } },
559
+ )
560
+ .unwrap();
561
+ propagateTransforms(world).unwrap();
562
+ const frame = extractFrame(world, prepareExtractContext(world));
563
+ expect(frame.frustumStats).toEqual({ culled: 1, total: 1 });
564
+ expect(frame.renderables).toHaveLength(1);
565
+ expect(frame.dispatch.map((entry) => entry.tags.LightMode)).toEqual(['ShadowCaster']);
566
+ expect(frame.dispatch.every((entry) => entry.renderableIndex === 0)).toBe(true);
567
+ world.set(light, PointLightShadow, { farPlane: 1 }).unwrap();
568
+ expect(extractFrame(world, prepareExtractContext(world)).renderables).toHaveLength(0);
569
+ world.despawn(light).unwrap();
570
+ expect(extractFrame(world, prepareExtractContext(world)).renderables).toHaveLength(0);
571
+ });
572
+
573
+ it('persistent composition retains shadow-only casters lit from another World', () => {
574
+ const sceneWorld = new World();
575
+ spawnCamera(sceneWorld);
576
+ sceneWorld
577
+ .spawn(
578
+ { component: Transform, data: { ...identity(), pos: [0, 0, 2] } },
579
+ { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
580
+ { component: MeshRenderer, data: {} },
581
+ )
582
+ .unwrap();
583
+ const lightWorld = new World();
584
+ lightWorld
585
+ .spawn(
586
+ { component: Transform, data: { ...identity(), pos: [0, 0, 5] } },
587
+ { component: PointLight, data: { intensity: 10, range: 10 } },
588
+ { component: PointLightShadow, data: { nearPlane: 0.1, farPlane: 10 } },
589
+ )
590
+ .unwrap();
591
+ for (const world of [sceneWorld, lightWorld]) propagateTransforms(world).unwrap();
592
+ const worlds = [sceneWorld, lightWorld];
593
+ const owner = { cameraOwner: 0, resourceOwner: 0 };
594
+ const scene = new PersistentRenderScene();
595
+ const frame = scene.extractComposition(worlds, owner, 0, () =>
596
+ extractFrames(worlds, owner, undefined, undefined, undefined, { cull: 'none' }),
597
+ );
598
+ expect(frame.frustumStats).toEqual({ culled: 1, total: 1 });
599
+ expect(frame.renderables).toHaveLength(1);
600
+ expect(frame.dispatch.map((entry) => entry.tags.LightMode)).toEqual(['ShadowCaster']);
601
+ });
602
+
542
603
  it('cross-world extract keeps frustum culling against the surfaced camera-owner camera', () => {
543
604
  const cameraWorld = new World();
544
605
  cameraWorld
@@ -24,9 +24,9 @@
24
24
  // The 4 sub-assertions below RED before w10 lands the 3 _routeError fires
25
25
  // + SpriteInstancesSnapshot population in `render-system-extract.ts`. They
26
26
  // GO GREEN once w10 wires the validation into the QueryRow loop at the
27
- // same layer as the existing 'instance-transforms-stride-mismatch' fail-fast
28
- // (see packages/runtime/src/__tests__/render-system-mega.test.ts §w14 for
29
- // the structural sibling).
27
+ // same extract boundary as renderer-owned InstanceCollectionError validation
28
+ // (see packages/runtime/src/__tests__/render-system-mega.test.ts for the
29
+ // collection association coverage).
30
30
  //
31
31
  // Anchors:
32
32
  // - requirements AC-03 (3 sprite-instances error codes fire at extract)
@@ -41,6 +41,7 @@ import { World } from '@forgeax/engine-ecs';
41
41
  import {
42
42
  Camera,
43
43
  DirectionalLight,
44
+ InstanceCollectionStore,
44
45
  Instances,
45
46
  MeshFilter,
46
47
  MeshRenderer,
@@ -162,6 +163,7 @@ function makeScene(): {
162
163
  mesh: Handle<'MeshAsset', 'shared'>;
163
164
  spriteMat: Handle<'MaterialAsset', 'shared'>;
164
165
  unlitMat: Handle<'MaterialAsset', 'shared'>;
166
+ instanceCollections: InstanceCollectionStore;
165
167
  } {
166
168
  const collected: CollectedError[] = [];
167
169
  const world = new World();
@@ -182,24 +184,36 @@ function makeScene(): {
182
184
  passes: [UNLIT_PASS],
183
185
  values: {},
184
186
  } as MaterialAsset);
185
- return { world, assets, collected, mesh, spriteMat, unlitMat };
187
+ return {
188
+ world,
189
+ assets,
190
+ collected,
191
+ mesh,
192
+ spriteMat,
193
+ unlitMat,
194
+ instanceCollections: new InstanceCollectionStore(),
195
+ };
186
196
  }
187
197
 
188
198
  describe('render-system-extract SpriteInstances validation (w8, AC-03 + Edge Cases)', () => {
189
199
  it('(a) entity carries both Instances + SpriteInstances -> fires sprite-instances-mutually-exclusive-with-instances + entity skipped', () => {
190
- const { world, assets, collected, mesh, spriteMat } = makeScene();
200
+ const { world, assets, collected, mesh, spriteMat, instanceCollections } = makeScene();
191
201
  const transforms = new Float32Array(16);
192
202
  const regions = new Float32Array(4);
203
+ const collection = instanceCollections.create({ transforms }).unwrap();
193
204
  world
194
205
  .spawn(
195
206
  { component: Transform, data: identityTransform() },
196
207
  { component: MeshFilter, data: { assetHandle: mesh } },
197
208
  { component: MeshRenderer, data: { materials: [spriteMat] } },
198
- { component: Instances, data: { transforms } },
209
+ { component: Instances, data: { collectionId: collection.collectionId } },
199
210
  { component: SpriteInstances, data: { transforms, regions } },
200
211
  )
201
212
  .unwrap();
202
- const frame = extractFrame(world, prepareExtractContext(world, { assets }));
213
+ const frame = extractFrame(
214
+ world,
215
+ prepareExtractContext(world, { assets, instanceCollections }),
216
+ );
203
217
  const fired = collected.filter(
204
218
  (c) => c.code === 'sprite-instances-mutually-exclusive-with-instances',
205
219
  );
@@ -28,6 +28,7 @@ function mockEntry(opts: {
28
28
  renderableIndex: number;
29
29
  materialHandle: number;
30
30
  layer: number;
31
+ materialShaderId?: string;
31
32
  }): DispatchEntry {
32
33
  return {
33
34
  entityIndex: opts.renderableIndex,
@@ -41,7 +42,7 @@ function mockEntry(opts: {
41
42
  defines: undefined,
42
43
  vertexEntry: undefined,
43
44
  fragmentEntry: undefined,
44
- materialShaderId: undefined,
45
+ materialShaderId: opts.materialShaderId ?? 'forgeax::sprite',
45
46
  paramSnapshot: undefined,
46
47
  };
47
48
  }
@@ -185,6 +186,37 @@ describe('foldDispatchBuckets — mode 0 basic paths (w1)', () => {
185
186
  // 'sprite'` to `transparent === true` (the M3 ablation collapsed the
186
187
  // sprite discriminator into the generic transparent flag).
187
188
  describe('foldDispatchBuckets — transparent-only gate (PR #502 fix + feat-20260625 R2 fix-up)', () => {
189
+ it('transparent Standard PBR entries remain singleton while sprite shaders fold', () => {
190
+ const standardEntries = [0, 1].map((renderableIndex) =>
191
+ mockEntry({
192
+ renderableIndex,
193
+ materialHandle: 13,
194
+ layer: 0,
195
+ materialShaderId: 'forgeax::default-standard-pbr',
196
+ }),
197
+ );
198
+ const standardRenderables = standardEntries.map(() => mockRenderable(1.0, true));
199
+ const standardBuckets = foldDispatchBuckets(
200
+ standardEntries,
201
+ TRANSPARENT_SORT_MODE_LAYER_Z,
202
+ standardRenderables,
203
+ );
204
+ expect(standardBuckets.map((bucket) => bucket.bucketSize)).toEqual([1, 1]);
205
+
206
+ for (const materialShaderId of ['forgeax::sprite', 'forgeax::sprite-lit']) {
207
+ const spriteEntries = [0, 1].map((renderableIndex) =>
208
+ mockEntry({ renderableIndex, materialHandle: 13, layer: 0, materialShaderId }),
209
+ );
210
+ const spriteBuckets = foldDispatchBuckets(
211
+ spriteEntries,
212
+ TRANSPARENT_SORT_MODE_LAYER_Z,
213
+ standardRenderables,
214
+ );
215
+ expect(spriteBuckets).toHaveLength(1);
216
+ expect(spriteBuckets[0]?.bucketSize).toBe(2);
217
+ }
218
+ });
219
+
188
220
  it('non-transparent (unlit-shading) entries never form multi-entry buckets', () => {
189
221
  // 4 entries sharing (layer, posZ, materialHandle) — pre-fix would have
190
222
  // folded into a single bucketSize=4 bucket. With the transparent-only
@@ -19,6 +19,7 @@ function mockEntry(opts: {
19
19
  renderableIndex: number;
20
20
  materialHandle: number;
21
21
  layer: number;
22
+ materialShaderId?: string;
22
23
  }): DispatchEntry {
23
24
  return {
24
25
  entityIndex: opts.renderableIndex,
@@ -32,7 +33,7 @@ function mockEntry(opts: {
32
33
  defines: undefined,
33
34
  vertexEntry: undefined,
34
35
  fragmentEntry: undefined,
35
- materialShaderId: undefined,
36
+ materialShaderId: opts.materialShaderId,
36
37
  paramSnapshot: undefined,
37
38
  };
38
39
  }
@@ -60,11 +61,36 @@ describe('foldDispatchBuckets — AC-10 multi-material boundary (w2)', () => {
60
61
  // [M1, M1, M1, M2, M2] — 3 entries with materialHandle=11 followed by
61
62
  // 2 with materialHandle=22, all same layer=5 and same posZ=3.0.
62
63
  const entries: DispatchEntry[] = [
63
- mockEntry({ renderableIndex: 0, materialHandle: 11, layer: 5 }),
64
- mockEntry({ renderableIndex: 1, materialHandle: 11, layer: 5 }),
65
- mockEntry({ renderableIndex: 2, materialHandle: 11, layer: 5 }),
66
- mockEntry({ renderableIndex: 3, materialHandle: 22, layer: 5 }),
67
- mockEntry({ renderableIndex: 4, materialHandle: 22, layer: 5 }),
64
+ mockEntry({
65
+ renderableIndex: 0,
66
+ materialHandle: 11,
67
+ layer: 5,
68
+ materialShaderId: 'forgeax::sprite',
69
+ }),
70
+ mockEntry({
71
+ renderableIndex: 1,
72
+ materialHandle: 11,
73
+ layer: 5,
74
+ materialShaderId: 'forgeax::sprite',
75
+ }),
76
+ mockEntry({
77
+ renderableIndex: 2,
78
+ materialHandle: 11,
79
+ layer: 5,
80
+ materialShaderId: 'forgeax::sprite',
81
+ }),
82
+ mockEntry({
83
+ renderableIndex: 3,
84
+ materialHandle: 22,
85
+ layer: 5,
86
+ materialShaderId: 'forgeax::sprite',
87
+ }),
88
+ mockEntry({
89
+ renderableIndex: 4,
90
+ materialHandle: 22,
91
+ layer: 5,
92
+ materialShaderId: 'forgeax::sprite',
93
+ }),
68
94
  ];
69
95
  const renderables: ReturnType<typeof mockRenderable>[] = [
70
96
  mockRenderable(3.0),
@@ -30,6 +30,7 @@ function mockEntry(opts: {
30
30
  renderableIndex: number;
31
31
  materialHandle: number;
32
32
  layer: number;
33
+ materialShaderId?: string;
33
34
  }): DispatchEntry {
34
35
  return {
35
36
  entityIndex: opts.renderableIndex,
@@ -43,7 +44,7 @@ function mockEntry(opts: {
43
44
  defines: undefined,
44
45
  vertexEntry: undefined,
45
46
  fragmentEntry: undefined,
46
- materialShaderId: undefined,
47
+ materialShaderId: opts.materialShaderId,
47
48
  paramSnapshot: undefined,
48
49
  };
49
50
  }
@@ -80,7 +81,14 @@ describe('foldDispatchBuckets — mode-gate (D-5, w3)', () => {
80
81
  const entries: DispatchEntry[] = [];
81
82
  const renderables: ReturnType<typeof mockRenderable>[] = [];
82
83
  for (let i = 0; i < N; i++) {
83
- entries.push(mockEntry({ renderableIndex: i, materialHandle: 1, layer: 0 }));
84
+ entries.push(
85
+ mockEntry({
86
+ renderableIndex: i,
87
+ materialHandle: 1,
88
+ layer: 0,
89
+ materialShaderId: 'forgeax::sprite',
90
+ }),
91
+ );
84
92
  renderables.push(mockRenderable(0, 0));
85
93
  }
86
94
  return { entries, renderables };
@@ -113,7 +121,14 @@ describe('foldDispatchBuckets — mode-gate (D-5, w3)', () => {
113
121
  const entries: DispatchEntry[] = [];
114
122
  const renderables: ReturnType<typeof mockRenderable>[] = [];
115
123
  for (let i = 0; i < N; i++) {
116
- entries.push(mockEntry({ renderableIndex: i, materialHandle: 1, layer: 0 }));
124
+ entries.push(
125
+ mockEntry({
126
+ renderableIndex: i,
127
+ materialHandle: 1,
128
+ layer: 0,
129
+ materialShaderId: 'forgeax::sprite',
130
+ }),
131
+ );
117
132
  renderables.push(mockRenderable(0, i * 16)); // posY = 0, 16, 32, 48
118
133
  }
119
134
  const buckets = foldDispatchBuckets(entries, TRANSPARENT_SORT_MODE_LAYER_Y, renderables);
@@ -127,7 +142,14 @@ describe('foldDispatchBuckets — mode-gate (D-5, w3)', () => {
127
142
  const entries: DispatchEntry[] = [];
128
143
  const renderables: ReturnType<typeof mockRenderable>[] = [];
129
144
  for (let i = 0; i < N; i++) {
130
- entries.push(mockEntry({ renderableIndex: i, materialHandle: 1, layer: 0 }));
145
+ entries.push(
146
+ mockEntry({
147
+ renderableIndex: i,
148
+ materialHandle: 1,
149
+ layer: 0,
150
+ materialShaderId: 'forgeax::sprite',
151
+ }),
152
+ );
131
153
  renderables.push(mockRenderable(0, 3.0));
132
154
  }
133
155
  const buckets = foldDispatchBuckets(entries, TRANSPARENT_SORT_MODE_LAYER_Y, renderables);
@@ -71,7 +71,10 @@ function mockDispatchEntry(opts: {
71
71
  defines: undefined,
72
72
  vertexEntry: undefined,
73
73
  fragmentEntry: undefined,
74
- materialShaderId: undefined,
74
+ // Fold admission is intentionally limited to the transparent sprite
75
+ // owners; these fixtures model sprite/tilemap draws rather than generic
76
+ // transparent Standard PBR geometry.
77
+ materialShaderId: 'forgeax::sprite',
75
78
  paramSnapshot: undefined,
76
79
  };
77
80
  }