@forgeax/engine-runtime 0.1.20 → 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 (86) hide show
  1. package/README.md +105 -3
  2. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
  3. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/helpers/register-runtime-components.d.ts.map +1 -1
  5. package/dist/__tests__/helpers/register-scene-components.d.ts.map +1 -1
  6. package/dist/__tests__/lights-preservation.fixup.test.d.ts +2 -0
  7. package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
  8. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  9. package/dist/createRenderer.d.ts.map +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.mjs +4 -2
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/sprite-param-values.d.ts +1 -1
  14. package/package.json +30 -30
  15. package/src/__tests__/active-camera.unit.test.ts +6 -8
  16. package/src/__tests__/animation-graph-asset-union.test-d.ts +2 -0
  17. package/src/__tests__/asset-brand-and-name-retirement.test.ts +4 -3
  18. package/src/__tests__/asset-registry-scene.test-d.ts +1 -0
  19. package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
  20. package/src/__tests__/asset.unit.test.ts +5 -2
  21. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  22. package/src/__tests__/collect-transient-roundtrip.test.ts +14 -3
  23. package/src/__tests__/components.test-d.ts +19 -9
  24. package/src/__tests__/components.unit.test.ts +36 -26
  25. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  26. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  27. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  28. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +9 -1
  29. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  30. package/src/__tests__/errors.unit.test.ts +54 -20
  31. package/src/__tests__/extract-frames-merge.test.ts +51 -40
  32. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  33. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  34. package/src/__tests__/geometry.unit.test.ts +7 -10
  35. package/src/__tests__/helpers/register-runtime-components.ts +9 -1
  36. package/src/__tests__/helpers/register-scene-components.ts +9 -2
  37. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +10 -11
  38. package/src/__tests__/light-snapshot.test-d.ts +35 -1
  39. package/src/__tests__/lights-preservation.fixup.test.ts +125 -0
  40. package/src/__tests__/lights.unit.test.ts +223 -308
  41. package/src/__tests__/loader-name-survival.test.ts +8 -9
  42. package/src/__tests__/package-promotion.test.ts +7 -11
  43. package/src/__tests__/pbr-pipeline.unit.test.ts +22 -17
  44. package/src/__tests__/pipeline.unit.test.ts +68 -226
  45. package/src/__tests__/point-light-shadow.browser.test.ts +209 -83
  46. package/src/__tests__/point-light-shadow.unit.test.ts +24 -47
  47. package/src/__tests__/propagate-transforms.dawn.test.ts +15 -16
  48. package/src/__tests__/record-worldid-isolation.test.ts +16 -32
  49. package/src/__tests__/render-error-exhaustive.test-d.ts +73 -9
  50. package/src/__tests__/render-system-extract.test.ts +5 -5
  51. package/src/__tests__/render-system-mega.test.ts +30 -41
  52. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +5 -1
  53. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +5 -1
  54. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  55. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  56. package/src/__tests__/renderer-surface.unit.test.ts +229 -5
  57. package/src/__tests__/resolveName-unified-entry.test.ts +6 -6
  58. package/src/__tests__/resolveName.test.ts +14 -2
  59. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  60. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +1 -0
  61. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  62. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  63. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  64. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  65. package/src/__tests__/shadow-csm-tile-consistency.test.ts +1 -0
  66. package/src/__tests__/shadow-csm-ubo.test.ts +43 -45
  67. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  68. package/src/__tests__/shadow-fields-observable.dawn.test.ts +100 -24
  69. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +1 -1
  70. package/src/__tests__/skin-parented-double-transform.test.ts +5 -5
  71. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +4 -7
  72. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  73. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -13
  74. package/src/__tests__/ssao-bgl.test.ts +12 -17
  75. package/src/__tests__/ssao-passes.test.ts +19 -2
  76. package/src/__tests__/systems.unit.test.ts +69 -205
  77. package/src/__tests__/template-game-default-api-contract.test.ts +45 -23
  78. package/src/__tests__/tilemap-chunk-extract.propagate-order.test.ts +10 -4
  79. package/src/__tests__/transform-hierarchy-pixel-diff.dawn.test.ts +2 -2
  80. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  81. package/src/collect-scene-asset.ts +1 -1
  82. package/src/components/__tests__/transform-vec-schema.test.ts +2 -2
  83. package/src/createRenderer.ts +2 -0
  84. package/src/index.ts +1 -2
  85. package/src/sprite-param-values.ts +1 -1
  86. package/dist/.tsbuildinfo +0 -1
@@ -349,7 +349,7 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
349
349
  });
350
350
  });
351
351
 
352
- describe('t2 - AC-15 ImageErrorCode exhaustive switch (new member)', () => {
352
+ describe('t2 - AC-15 ImageErrorCode exhaustive switch (new members)', () => {
353
353
  it('ImageErrorCode includes image-hdr-decode-failed', () => {
354
354
  const code: ImageErrorCode = 'image-hdr-decode-failed';
355
355
  expect(code).toBe('image-hdr-decode-failed');
@@ -378,10 +378,17 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
378
378
  return 'atlas size exceeded';
379
379
  case 'atlas-region-mismatch':
380
380
  return 'atlas region mismatch';
381
+ // feat-20260829-deep-agent-game-feedback-resolution M5 —
382
+ // PixelSurface authoring errors remain part of the closed image
383
+ // error vocabulary and must stay covered by every exhaustive
384
+ // consumer switch.
385
+ case 'image-surface-invalid':
386
+ return 'surface invalid';
381
387
  }
382
388
  }
383
389
  expect(exhaustive('image-decode-failed')).toBe('decode failed');
384
390
  expect(exhaustive('image-hdr-decode-failed')).toBe('hdr decode failed');
391
+ expect(exhaustive('image-surface-invalid')).toBe('surface invalid');
385
392
  });
386
393
  });
387
394
  });
@@ -420,6 +427,8 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
420
427
  return 'cleanup failed';
421
428
  case 'scene-data-unavailable':
422
429
  return 'scene data unavailable';
430
+ case 'standard-profile-invalid':
431
+ return 'standard profile invalid';
423
432
  case 'frame-receipt-stale':
424
433
  return 'frame receipt stale';
425
434
  case 'renderer-contract-failed':
@@ -464,14 +473,24 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
464
473
  return 'mesh ssbo capacity exceeded';
465
474
  case 'mesh-ssbo-ceiling-reached':
466
475
  return 'mesh ssbo ceiling reached';
467
- case 'hdrp-light-budget-exceeded':
468
- return 'hdrp light budget exceeded';
469
- case 'hdrp-index-list-overflow':
470
- return 'hdrp index list overflow';
471
- // feat-20260612-hdrp-deferred-shading-learn-render-5-8 M1 / w6:
472
- // 3 new deferred-path error codes.
473
- case 'hdrp-deferred-caps-insufficient':
474
- return 'hdrp deferred caps insufficient';
476
+ case 'render-target-descriptor-invalid':
477
+ return 'render target descriptor invalid';
478
+ case 'render-target-capability-missing':
479
+ return 'render target capability missing';
480
+ case 'render-target-state-invalid':
481
+ return 'render target state invalid';
482
+ case 'render-target-operation-failed':
483
+ return 'render target operation failed';
484
+ case 'reflection-probe-budget-exceeded':
485
+ return 'reflection probe budget exceeded';
486
+ case 'render-intent-invalid':
487
+ return 'render intent invalid';
488
+ case 'standard-light-budget-exceeded':
489
+ return 'standard light budget exceeded';
490
+ case 'standard-cluster-index-overflow':
491
+ return 'standard cluster index overflow';
492
+ case 'standard-cluster-transport-unavailable':
493
+ return 'standard cluster transport unavailable';
475
494
  // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w17 (D-5):
476
495
  // bidirectional Skin <-> pbr-skin material mismatch detected at extract.
477
496
  case 'skin-material-mismatch':
@@ -528,6 +547,8 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
528
547
  return 'points lines budget exceeded';
529
548
  case 'points-lines-prepare-failed':
530
549
  return 'points lines prepare failed';
550
+ case 'light-resource-unavailable':
551
+ return 'light resource unavailable';
531
552
  case 'transmission-capability-missing':
532
553
  return 'transmission capability missing';
533
554
  case 'projector-binding-failed':
@@ -551,16 +572,15 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
551
572
  });
552
573
 
553
574
  /*
554
- * feat-20260612-hdrp-deferred-shading-learn-render-5-8 M1 / w3:
555
- * RuntimeErrorCode +3 members — exhaustive narrow test.
575
+ * Standard lighting closed error codes — exhaustive narrow test.
556
576
  *
557
- * AC-07: three new error codes must be members of RuntimeErrorCode closed union.
577
+ * AC-07: three Standard lighting codes are members of the closed render union.
558
578
  * Each error code narrows without a default branch in switch (err.code).
559
579
  */
560
- describe('RuntimeErrorCode +3 new members (w3)', () => {
561
- it('hdrp-deferred-caps-insufficient is a valid RuntimeErrorCode', () => {
562
- const code: RuntimeLayerErrorCode = 'hdrp-deferred-caps-insufficient';
563
- expect(code).toBe('hdrp-deferred-caps-insufficient');
580
+ describe('Standard lighting error members', () => {
581
+ it('standard-cluster-transport-unavailable is a valid RenderErrorCode', () => {
582
+ const code: RuntimeLayerErrorCode = 'standard-cluster-transport-unavailable';
583
+ expect(code).toBe('standard-cluster-transport-unavailable');
564
584
  });
565
585
 
566
586
  it('exhaustive switch covers all 3 new members alongside existing members', () => {
@@ -591,6 +611,7 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
591
611
  case 'cleanup-failed':
592
612
  return 'ok';
593
613
  case 'scene-data-unavailable':
614
+ case 'standard-profile-invalid':
594
615
  return 'ok';
595
616
  case 'frame-receipt-stale':
596
617
  return 'ok';
@@ -636,9 +657,9 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
636
657
  return 'ok';
637
658
  case 'mesh-ssbo-ceiling-reached':
638
659
  return 'ok';
639
- case 'hdrp-light-budget-exceeded':
660
+ case 'standard-light-budget-exceeded':
640
661
  return 'ok';
641
- case 'hdrp-index-list-overflow':
662
+ case 'standard-cluster-index-overflow':
642
663
  return 'ok';
643
664
  case 'skin-material-mismatch':
644
665
  return 'ok';
@@ -650,7 +671,19 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
650
671
  return 'ok';
651
672
  case 'joint-entity-dangling':
652
673
  return 'ok';
653
- case 'hdrp-deferred-caps-insufficient':
674
+ case 'standard-cluster-transport-unavailable':
675
+ return 'ok';
676
+ case 'render-target-descriptor-invalid':
677
+ return 'ok';
678
+ case 'render-target-capability-missing':
679
+ return 'ok';
680
+ case 'render-target-state-invalid':
681
+ return 'ok';
682
+ case 'render-target-operation-failed':
683
+ return 'ok';
684
+ case 'reflection-probe-budget-exceeded':
685
+ return 'ok';
686
+ case 'render-intent-invalid':
654
687
  return 'ok';
655
688
  case 'point-shadow-atlas-uninitialized':
656
689
  return 'ok';
@@ -688,6 +721,7 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
688
721
  return 'ok';
689
722
  case 'points-lines-prepare-failed':
690
723
  return 'ok';
724
+ case 'light-resource-unavailable':
691
725
  case 'transmission-capability-missing':
692
726
  return 'ok';
693
727
  case 'projector-binding-failed':
@@ -702,7 +736,7 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
702
736
  return 'ok';
703
737
  }
704
738
  }
705
- expect(exhaustive('hdrp-deferred-caps-insufficient')).toBe('ok');
739
+ expect(exhaustive('standard-cluster-transport-unavailable')).toBe('ok');
706
740
  expect(exhaustive('point-shadow-atlas-uninitialized')).toBe('ok');
707
741
  expect(exhaustive('point-shadow-atlas-bounds-violation')).toBe('ok');
708
742
  });
@@ -161,62 +161,39 @@ function simulateMerge(
161
161
  // AC-04: lights — point[]/spot[] concat; directional first-hit in worlds[] order; directionalCount sum
162
162
  const point: PointLightSnapshot[] = [];
163
163
  const spot: SpotLightSnapshot[] = [];
164
+ const rect: ExtractedLights['rect'][number][] = [];
164
165
  let directional: DirectionalLightSnapshot | undefined;
165
166
  let directionalCount = 0;
166
167
  for (const f of frames) {
167
168
  for (const p of f.lights.point) point.push(p);
168
169
  for (const s of f.lights.spot) spot.push(s);
170
+ for (const r of f.lights.rect) rect.push(r);
169
171
  if (directional === undefined && f.lights.directional !== undefined) {
170
172
  directional = f.lights.directional;
171
173
  }
172
174
  directionalCount += f.lights.directionalCount;
173
175
  }
176
+ const firstDirectionalLights = frames.find(
177
+ (frame) => frame.lights.directional !== undefined,
178
+ )?.lights;
174
179
  const lights: ExtractedLights = {
175
180
  directional,
176
181
  directionalCount,
177
182
  point,
178
183
  spot,
179
- lightViewProj:
180
- directional !== undefined
181
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.lightViewProj
182
- : undefined,
183
- splitPlanes:
184
- directional !== undefined
185
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.splitPlanes
186
- : undefined,
187
- cascadeCount:
188
- directional !== undefined
189
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.cascadeCount
190
- : undefined,
191
- cascadeBlend:
192
- directional !== undefined
193
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.cascadeBlend
194
- : undefined,
195
- shadowMapSize:
196
- directional !== undefined
197
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.shadowMapSize
198
- : undefined,
199
- depthBias:
200
- directional !== undefined
201
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.depthBias
202
- : undefined,
203
- normalBias:
204
- directional !== undefined
205
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.normalBias
206
- : undefined,
207
- pcfKernelSize:
208
- directional !== undefined
209
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.pcfKernelSize
210
- : undefined,
184
+ rect,
185
+ lightViewProj: firstDirectionalLights?.lightViewProj,
186
+ splitPlanes: firstDirectionalLights?.splitPlanes,
187
+ cascadeCount: firstDirectionalLights?.cascadeCount,
188
+ cascadeBlend: firstDirectionalLights?.cascadeBlend,
189
+ shadowMapSize: firstDirectionalLights?.shadowMapSize,
190
+ depthBias: firstDirectionalLights?.depthBias,
191
+ normalBias: firstDirectionalLights?.normalBias,
192
+ directionalShadowQuality: firstDirectionalLights?.directionalShadowQuality,
193
+ directionalShadowError: firstDirectionalLights?.directionalShadowError,
211
194
  pointShadow: frames.flatMap((f) => f.lights.pointShadow),
212
- directionalCsmConfig:
213
- directional !== undefined
214
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.directionalCsmConfig
215
- : undefined,
216
- directionalCsmDirection:
217
- directional !== undefined
218
- ? frames.find((f) => f.lights.directional !== undefined)?.lights.directionalCsmDirection
219
- : undefined,
195
+ directionalCsmConfig: firstDirectionalLights?.directionalCsmConfig,
196
+ directionalCsmDirection: firstDirectionalLights?.directionalCsmDirection,
220
197
  };
221
198
 
222
199
  // AC-05: singletons from owner world only
@@ -433,6 +410,36 @@ describe('extractFrames merge semantics (m2-t1, AC-04/05/06)', () => {
433
410
  expect(frameB.lights.directionalCount).toBe(1);
434
411
  });
435
412
 
413
+ it('AC-04: first directional owner error is preserved across the merge', () => {
414
+ const worldA = new World();
415
+ worldA
416
+ .spawn(
417
+ { component: Transform, data: identityTransform() },
418
+ {
419
+ component: DirectionalLight,
420
+ data: {
421
+ direction: [0, -1, 0],
422
+ shadowFilter: 4,
423
+ shadowAngularRadius: 999,
424
+ maxPenumbraTexels: 32,
425
+ },
426
+ },
427
+ )
428
+ .unwrap();
429
+
430
+ const worldB = makeWorldWithDirectionalLight();
431
+ const frameA = extractFrame(worldA, prepareExtractContext(worldA));
432
+ const frameB = extractFrame(worldB, prepareExtractContext(worldB));
433
+
434
+ expect(frameA.lights.directionalShadowError?.code).toBe('shadow-invalid-config');
435
+ expect(frameB.lights.directionalShadowError).toBeUndefined();
436
+ const merged = simulateMerge([frameA, frameB], 0);
437
+ expect(merged.lights.directionalShadowError?.detail).toMatchObject({
438
+ field: 'shadowAngularRadius',
439
+ actual: 999,
440
+ });
441
+ });
442
+
436
443
  // ── AC-05: singleton resources from owner only ─────────────────────────────
437
444
 
438
445
  it('AC-05: skylight is taken from owner world only, non-owner ignored', () => {
@@ -624,6 +631,9 @@ describe('extractFrames cross-world directional CSM (bug-20260710)', () => {
624
631
  }
625
632
  // splitPlanes reach the component shadowDistance on the last cascade.
626
633
  expect(frame.lights.splitPlanes).toBeDefined();
634
+ expect(frame.lights.splitPlanes).toHaveLength(16);
635
+ expect(frame.lights.splitPlanes?.[1]).toBeGreaterThan(0);
636
+ expect(frame.lights.splitPlanes?.[2]).toBeGreaterThan(0);
627
637
  });
628
638
 
629
639
  it('single world (camera + light together) still yields non-zero lightViewProj', () => {
@@ -652,5 +662,6 @@ describe('extractFrames cross-world directional CSM (bug-20260710)', () => {
652
662
  for (let i = 0; i < (frame.lights.cascadeCount ?? 0); i++) {
653
663
  expect(isNonZeroMat(lvp?.[i])).toBe(true);
654
664
  }
665
+ expect(frame.lights.splitPlanes).toHaveLength(16);
655
666
  });
656
667
  });
@@ -276,7 +276,7 @@ describe('render-system-extract Skin / pbr-skin mismatch (AC-07 / w19)', () => {
276
276
  expect(frame.renderables).toHaveLength(0);
277
277
  });
278
278
 
279
- it('(c) Skin + pbr-skin material + skinned mesh -> no error routed', () => {
279
+ it('(c) Skin + pbr-skin material without a palette allocator -> renderable and dispatch skipped', () => {
280
280
  const world = new World();
281
281
  const assets = makeAssetRegistry();
282
282
  const meshHandle = registerSkinnedMesh(world);
@@ -288,9 +288,13 @@ describe('render-system-extract Skin / pbr-skin mismatch (AC-07 / w19)', () => {
288
288
 
289
289
  const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
290
290
 
291
- extractFrame(world, prepareExtractContext(world, { assets }));
291
+ const frame = extractFrame(world, prepareExtractContext(world, { assets }));
292
292
 
293
293
  expect(errorSpy).not.toHaveBeenCalled();
294
+ expect(frame.renderables).toHaveLength(0);
295
+ expect(frame.dispatch.some((entry) => entry.materialShaderId === 'forgeax::pbr-skin')).toBe(
296
+ false,
297
+ );
294
298
  });
295
299
 
296
300
  it('(d) D-5 continue semantics: bad-skin entity skipped does NOT abort sibling extract', () => {
@@ -25,4 +25,17 @@ describe('frame targets shadow removal', () => {
25
25
 
26
26
  expect(resolveShadowMapSize(internals as never, lights as never)).toBe(1024);
27
27
  });
28
+
29
+ it('does not resolve a Directional atlas for the disabled topology sentinel', () => {
30
+ const internals = {
31
+ device: { limits: { maxTextureDimension2D: 2048 } },
32
+ };
33
+ const lights = {
34
+ directional: 'disabled',
35
+ shadowMapSize: 2048,
36
+ cascadeCount: undefined,
37
+ };
38
+
39
+ expect(resolveShadowMapSize(internals as never, lights as never)).toBeUndefined();
40
+ });
28
41
  });
@@ -2390,20 +2390,17 @@ function drawPublished(renderer: RendererType, world: WorldType) {
2390
2390
  spies.drawIndexed.mockClear();
2391
2391
  drawPublished(renderer, world as WorldType);
2392
2392
 
2393
- // All submesh draws share the same instanceCount (D-8).
2394
- // Verify drawIndexed calls carry instanceCount parameter at index 1.
2393
+ // Mesh indexed draws carry the Instances cardinality. Fullscreen graph
2394
+ // draws are a separate topology concern and intentionally use one
2395
+ // instance; do not mix those two contracts in one equality assertion.
2395
2396
  const indexedCalls = spies.drawIndexed.mock.calls as Array<
2396
2397
  [number, number, number, number, number]
2397
2398
  >;
2398
2399
  const drawCalls = spies.draw.mock.calls as Array<[number, number, number, number]>;
2399
- const allInstanceCounts = [...indexedCalls.map((c) => c[1]), ...drawCalls.map((c) => c[1])];
2400
- // All instanceCounts should be identical (shared across submeshes, D-8).
2401
- if (allInstanceCounts.length >= 2) {
2402
- const first = allInstanceCounts[0];
2403
- for (const ic of allInstanceCounts) {
2404
- expect(ic).toBe(first);
2405
- }
2406
- }
2400
+ expect(indexedCalls.length).toBeGreaterThan(0);
2401
+ expect(indexedCalls.map((call) => call[1])).toEqual(expect.arrayContaining([4]));
2402
+ expect(indexedCalls.every((call) => call[1] === 4)).toBe(true);
2403
+ expect(drawCalls.every((call) => call[1] === 1)).toBe(true);
2407
2404
  expect(errors).toEqual([]);
2408
2405
  });
2409
2406
 
@@ -24,7 +24,14 @@ import {
24
24
  TileLayer,
25
25
  Tilemap,
26
26
  } from '@forgeax/engine-render/authoring';
27
- import { ChildOf, Children, MorphWeights, Name, Transform } from '@forgeax/engine-scene';
27
+ import {
28
+ ChildOf,
29
+ Children,
30
+ GlobalTransform,
31
+ MorphWeights,
32
+ Name,
33
+ Transform,
34
+ } from '@forgeax/engine-scene';
28
35
 
29
36
  const RUNTIME_COMPONENTS: readonly Component[] = [
30
37
  Camera,
@@ -51,6 +58,7 @@ const RUNTIME_COMPONENTS: readonly Component[] = [
51
58
  TileLayer,
52
59
  Tilemap,
53
60
  Transform,
61
+ GlobalTransform,
54
62
  VideoPlayer,
55
63
  Visibility,
56
64
  ];
@@ -1,10 +1,17 @@
1
1
  import type { Component, World } from '@forgeax/engine-ecs';
2
2
  import { SceneInstance } from '@forgeax/engine-render';
3
- import { ChildOf, Children, Transform } from '@forgeax/engine-scene';
3
+ import { ChildOf, Children, GlobalTransform, Transform } from '@forgeax/engine-scene';
4
4
 
5
5
  /** Register the scene vocabulary explicitly for an isolated test World. */
6
6
  export function registerSceneComponents(world: World, extras: readonly Component[] = []): void {
7
- for (const component of [SceneInstance, ChildOf, Children, Transform, ...extras]) {
7
+ for (const component of [
8
+ SceneInstance,
9
+ ChildOf,
10
+ Children,
11
+ Transform,
12
+ GlobalTransform,
13
+ ...extras,
14
+ ]) {
8
15
  world.components.register(component).unwrap();
9
16
  }
10
17
  }
@@ -7,8 +7,8 @@
7
7
  // lock the four light GPU pack paths' byte output so the collapse is proven
8
8
  // byte-neutral end to end:
9
9
  // 1. View UBO (DirectionalLight direction/color lanes) -> writeViewUbo
10
- // 2. PointLightBuffer (color lanes) -> packPointLight
11
- // 3. SpotLightBuffer (direction + color lanes) -> packSpotLight
10
+ // 2. PointLightBuffer (color lanes) -> packDirectLightSlot
11
+ // 3. SpotLightBuffer (direction + color lanes) -> packDirectLightSlot
12
12
  // 4. Skylight uniform/ambient (color destructure) -> SkylightSnapshot
13
13
  // (Camera clear pass is M3, not here.)
14
14
 
@@ -16,7 +16,7 @@ import { vec3 } from '@forgeax/engine-math';
16
16
  import type { Buffer, RhiQueue } from '@forgeax/engine-rhi';
17
17
  import { ok } from '@forgeax/engine-rhi';
18
18
  import { describe, expect, it } from 'vitest';
19
- import { packPointLight, packSpotLight } from '../../../render/src/light-buffer-layout';
19
+ import { packDirectLightSlot } from '../../../render/src/light-buffer-layout';
20
20
  import { writeViewUbo } from '../../../render/src/record/view-ubo';
21
21
  import type { CameraSnapshot } from '../../../render/src/render-contract';
22
22
  import type {
@@ -57,13 +57,14 @@ const emptyLights: ExtractedLights = {
57
57
  directionalCount: 0,
58
58
  point: [],
59
59
  spot: [],
60
+ directionalShadowError: undefined,
60
61
  lightViewProj: undefined,
61
62
  splitPlanes: undefined,
62
63
  cascadeCount: undefined,
63
64
  cascadeBlend: undefined,
64
65
  depthBias: undefined,
65
66
  normalBias: undefined,
66
- pcfKernelSize: undefined,
67
+ directionalShadowQuality: undefined,
67
68
  } as unknown as ExtractedLights;
68
69
 
69
70
  /** Capture the single writeViewUbo payload as a Float32Array. */
@@ -102,7 +103,7 @@ describe('w7 -- light GPU pack byte-neutral (AC-11)', () => {
102
103
  expect(payload[22]).toBeCloseTo(0.7, 5);
103
104
  });
104
105
 
105
- it('packPointLight writes color into slots 4..6 byte-for-byte (32 B)', () => {
106
+ it('packDirectLightSlot writes Point color into slots 4..6 byte-for-byte (80 B)', () => {
106
107
  const snap: PointLightSnapshot = {
107
108
  kind: 'point',
108
109
  position: vec3.create(1.5, -2.25, 0.125),
@@ -110,14 +111,14 @@ describe('w7 -- light GPU pack byte-neutral (AC-11)', () => {
110
111
  intensity: 2,
111
112
  invRangeSquared: 0.04,
112
113
  };
113
- const out = packPointLight(snap);
114
- expect(out.byteLength).toBe(32);
114
+ const out = packDirectLightSlot(snap);
115
+ expect(out.byteLength).toBe(80);
115
116
  expect(out[4]).toBeCloseTo(0.4, 6);
116
117
  expect(out[5]).toBeCloseTo(0.5, 6);
117
118
  expect(out[6]).toBeCloseTo(0.6, 6);
118
119
  });
119
120
 
120
- it('packSpotLight writes color slots 4..6 + direction slots 8..10 byte-for-byte (80 B)', () => {
121
+ it('packDirectLightSlot writes Spot color slots 4..6 + direction slots 8..10 byte-for-byte (80 B)', () => {
121
122
  const snap: SpotLightSnapshot = {
122
123
  kind: 'spot',
123
124
  position: vec3.create(0, 5, 0),
@@ -134,9 +135,7 @@ describe('w7 -- light GPU pack byte-neutral (AC-11)', () => {
134
135
  farPlane: 50,
135
136
  shadowAtlasTile: -1,
136
137
  };
137
- const out = packSpotLight(snap);
138
- // SpotLight carries projector receiver controls and the shadow intensity
139
- // tail in the current std430 payload.
138
+ const out = packDirectLightSlot(snap);
140
139
  expect(out.byteLength).toBe(80);
141
140
  expect(out[4]).toBeCloseTo(0.3, 6);
142
141
  expect(out[5]).toBeCloseTo(0.4, 6);
@@ -21,11 +21,18 @@
21
21
  // covers any .test-d.ts file in any package).
22
22
 
23
23
  import { expectTypeOf, test } from 'vitest';
24
+ import type { DirectionalShadowQuality } from '../../../render/src/components/directional-shadow-filter';
25
+ import type { LightValidationError } from '../../../render/src/components/light-helpers';
26
+ import type {
27
+ DirectLightSlotKind,
28
+ DirectLightSnapshot,
29
+ } from '../../../render/src/light-buffer-layout';
24
30
  import type {
25
31
  DirectionalLightSnapshot,
26
32
  ExtractedLights,
27
33
  LightSnapshot,
28
34
  PointLightSnapshot,
35
+ RectAreaDirectLightSnapshot,
29
36
  SpotLightSnapshot,
30
37
  } from '../../../render/src/render-system-extract';
31
38
 
@@ -44,6 +51,8 @@ function consumeSnapshot(s: LightSnapshot): number {
44
51
  return s.invRangeSquared;
45
52
  case 'spot':
46
53
  return s.cosInner + s.cosOuter;
54
+ case 'rect-area':
55
+ return s.halfWidth + s.halfHeight;
47
56
  default:
48
57
  return assertNever(s);
49
58
  }
@@ -51,7 +60,7 @@ function consumeSnapshot(s: LightSnapshot): number {
51
60
 
52
61
  test('LightSnapshot is a discriminated union on kind', () => {
53
62
  expectTypeOf<LightSnapshot>().toEqualTypeOf<
54
- DirectionalLightSnapshot | PointLightSnapshot | SpotLightSnapshot
63
+ DirectionalLightSnapshot | PointLightSnapshot | SpotLightSnapshot | RectAreaDirectLightSnapshot
55
64
  >();
56
65
  // Smoke that the consumer compiles (run-time noop; compile-time exhaustive).
57
66
  void consumeSnapshot;
@@ -81,4 +90,29 @@ test('ExtractedLights three-bucket shape', () => {
81
90
  expectTypeOf<ExtractedLights['directionalCount']>().toEqualTypeOf<number>();
82
91
  expectTypeOf<ExtractedLights['point']>().toEqualTypeOf<readonly PointLightSnapshot[]>();
83
92
  expectTypeOf<ExtractedLights['spot']>().toEqualTypeOf<readonly SpotLightSnapshot[]>();
93
+ expectTypeOf<ExtractedLights['rect']>().toEqualTypeOf<readonly RectAreaDirectLightSnapshot[]>();
94
+ });
95
+
96
+ function consumeDirectLight(snapshot: DirectLightSnapshot): number {
97
+ switch (snapshot.kind) {
98
+ case 'point':
99
+ return snapshot.invRangeSquared;
100
+ case 'spot':
101
+ return snapshot.cosInner + snapshot.cosOuter;
102
+ case 'rect-area':
103
+ return snapshot.halfWidth + snapshot.halfHeight;
104
+ default:
105
+ return assertNever(snapshot);
106
+ }
107
+ }
108
+
109
+ test('DirectLightSlot kind is closed and includes only finite direct lights', () => {
110
+ expectTypeOf<DirectLightSlotKind>().toEqualTypeOf<'point' | 'spot' | 'rect-area'>();
111
+ void consumeDirectLight;
112
+ expectTypeOf<ExtractedLights['directionalShadowQuality']>().toEqualTypeOf<
113
+ DirectionalShadowQuality | undefined
114
+ >();
115
+ expectTypeOf<ExtractedLights['directionalShadowError']>().toEqualTypeOf<
116
+ LightValidationError | undefined
117
+ >();
84
118
  });