@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
@@ -436,22 +436,24 @@ type __MergedKeep =
436
436
  const fn = accessParseScenePayload(reg);
437
437
  const refs = [GUID_A, GUID_B];
438
438
  const payload = {
439
- entities: [
440
- {
441
- localId: 0,
439
+ entities: {
440
+ 'entity-0': {
442
441
  components: {
443
442
  MeshFilter: { assetHandle: 0 },
444
443
  MeshRenderer: { materials: [1] },
445
444
  },
446
445
  },
447
- ],
446
+ },
448
447
  };
449
448
  const asset = fn('scene', payload, refs);
450
449
  expect(asset).toBeDefined();
451
450
  if (!asset) return;
452
451
  expect(asset.kind).toBe('scene');
453
- expect(asset.entities.length).toBe(1);
454
- const comp = asset.entities[0]?.components as Record<string, Record<string, unknown>>;
452
+ expect(Object.keys(asset.entities).length).toBe(1);
453
+ const comp = asset.entities['entity-0']?.components as Record<
454
+ string,
455
+ Record<string, unknown>
456
+ >;
455
457
  expect(comp.MeshFilter?.assetHandle).toBe(GUID_A); // refs[0]
456
458
  expect(comp.MeshRenderer?.materials).toEqual([GUID_B]); // refs[1]
457
459
  });
@@ -461,17 +463,15 @@ type __MergedKeep =
461
463
  const fn = accessParseScenePayload(reg);
462
464
  const refs = [GUID_A];
463
465
  const payload = {
464
- entities: [
465
- {
466
- localId: 0,
467
- components: { MeshFilter: { assetHandle: 0 } },
468
- },
469
- ],
466
+ entities: { 'entity-0': { components: { MeshFilter: { assetHandle: 0 } } } },
470
467
  };
471
468
  const asset = fn('scene', payload, refs);
472
469
  expect(asset).toBeDefined();
473
470
  if (!asset) return;
474
- const comp = asset.entities[0]?.components as Record<string, Record<string, unknown>>;
471
+ const comp = asset.entities['entity-0']?.components as Record<
472
+ string,
473
+ Record<string, unknown>
474
+ >;
475
475
  expect(comp.MeshFilter?.assetHandle).toBe(GUID_A);
476
476
  });
477
477
 
@@ -480,20 +480,22 @@ type __MergedKeep =
480
480
  const fn = accessParseScenePayload(reg);
481
481
  const refs = [GUID_A];
482
482
  const payload = {
483
- entities: [
484
- {
485
- localId: 0,
483
+ entities: {
484
+ 'entity-0': {
486
485
  components: {
487
486
  Transform: { pos: [1.5, 2.7, -3.2] },
488
487
  DirectionalLight: { color: 'white', intensity: 0.8 },
489
488
  },
490
489
  },
491
- ],
490
+ },
492
491
  };
493
492
  const asset = fn('scene', payload, refs);
494
493
  expect(asset).toBeDefined();
495
494
  if (!asset) return;
496
- const comp = asset.entities[0]?.components as Record<string, Record<string, unknown>>;
495
+ const comp = asset.entities['entity-0']?.components as Record<
496
+ string,
497
+ Record<string, unknown>
498
+ >;
497
499
  // Non-integer float values are not refs indices — passed through unchanged.
498
500
  expect(comp.Transform?.pos).toEqual([1.5, 2.7, -3.2]);
499
501
  expect(comp.DirectionalLight?.color).toBe('white');
@@ -505,23 +507,25 @@ type __MergedKeep =
505
507
  const fn = accessParseScenePayload(reg);
506
508
  const refs = [GUID_A];
507
509
  const payload = {
508
- entities: [
509
- {
510
- localId: 0,
510
+ entities: {
511
+ 'entity-0': {
511
512
  components: {
512
- ChildOf: { parent: 0 },
513
+ ChildOf: { parent: 'entity-0' },
513
514
  Transform: { pos: [0, 0, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
514
515
  MeshFilter: { assetHandle: 0 },
515
516
  },
516
517
  },
517
- ],
518
+ },
518
519
  };
519
520
  const asset = fn('scene', payload, refs);
520
521
  expect(asset).toBeDefined();
521
522
  if (!asset) return;
522
- const comp = asset.entities[0]?.components as Record<string, Record<string, unknown>>;
523
+ const comp = asset.entities['entity-0']?.components as Record<
524
+ string,
525
+ Record<string, unknown>
526
+ >;
523
527
  // Non-handle integer fields are kept as-is (M1-fixup F-1)
524
- expect(comp.ChildOf?.parent).toBe(0);
528
+ expect(comp.ChildOf?.parent).toBe('entity-0');
525
529
  expect(comp.Transform?.pos).toEqual([0, 0, 0]);
526
530
  expect(comp.Transform?.quat).toEqual([0, 0, 0, 1]);
527
531
  expect(comp.Transform?.scale).toEqual([1, 1, 1]);
@@ -534,28 +538,28 @@ type __MergedKeep =
534
538
  const fn = accessParseScenePayload(reg);
535
539
  const refs = [GUID_A, GUID_B];
536
540
  const payload = {
537
- entities: [
538
- {
539
- localId: 0,
540
- components: { MeshFilter: { assetHandle: 0 } },
541
- },
542
- {
543
- localId: 1,
544
- components: { MeshFilter: { assetHandle: 1 } },
545
- },
546
- {
547
- localId: 2,
548
- components: { MeshRenderer: { materials: [0] } },
549
- },
550
- ],
541
+ entities: {
542
+ 'entity-0': { components: { MeshFilter: { assetHandle: 0 } } },
543
+ 'entity-1': { components: { MeshFilter: { assetHandle: 1 } } },
544
+ 'entity-2': { components: { MeshRenderer: { materials: [0] } } },
545
+ },
551
546
  };
552
547
  const asset = fn('scene', payload, refs);
553
548
  expect(asset).toBeDefined();
554
549
  if (!asset) return;
555
- expect(asset.entities.length).toBe(3);
556
- const comp0 = asset.entities[0]?.components as Record<string, Record<string, unknown>>;
557
- const comp1 = asset.entities[1]?.components as Record<string, Record<string, unknown>>;
558
- const comp2 = asset.entities[2]?.components as Record<string, Record<string, unknown>>;
550
+ expect(Object.keys(asset.entities).length).toBe(3);
551
+ const comp0 = asset.entities['entity-0']?.components as Record<
552
+ string,
553
+ Record<string, unknown>
554
+ >;
555
+ const comp1 = asset.entities['entity-1']?.components as Record<
556
+ string,
557
+ Record<string, unknown>
558
+ >;
559
+ const comp2 = asset.entities['entity-2']?.components as Record<
560
+ string,
561
+ Record<string, unknown>
562
+ >;
559
563
  expect(comp0.MeshFilter?.assetHandle).toBe(GUID_A);
560
564
  expect(comp1.MeshFilter?.assetHandle).toBe(GUID_B);
561
565
  expect(comp2.MeshRenderer?.materials).toEqual([GUID_A]);
@@ -568,12 +572,7 @@ type __MergedKeep =
568
572
  const fn = accessParseScenePayload(reg);
569
573
  const refs = [GUID_A]; // length=1, valid indices: 0 only
570
574
  const payload = {
571
- entities: [
572
- {
573
- localId: 0,
574
- components: { MeshFilter: { assetHandle: 1 } }, // N=1 >= refs.length
575
- },
576
- ],
575
+ entities: { 'entity-0': { components: { MeshFilter: { assetHandle: 1 } } } },
577
576
  };
578
577
  const result = fn('scene', payload, refs);
579
578
  expect(result).toBeUndefined();
@@ -584,12 +583,7 @@ type __MergedKeep =
584
583
  const fn = accessParseScenePayload(reg);
585
584
  const refs: string[] = [];
586
585
  const payload = {
587
- entities: [
588
- {
589
- localId: 0,
590
- components: { MeshFilter: { assetHandle: 0 } }, // N=0 >= refs.length (0 >= 0)
591
- },
592
- ],
586
+ entities: { 'entity-0': { components: { MeshFilter: { assetHandle: 0 } } } },
593
587
  };
594
588
  const result = fn('scene', payload, refs);
595
589
  expect(result).toBeUndefined();
@@ -600,16 +594,10 @@ type __MergedKeep =
600
594
  const fn = accessParseScenePayload(reg);
601
595
  const refs = [GUID_A]; // length=1
602
596
  const payload = {
603
- entities: [
604
- {
605
- localId: 0,
606
- components: { MeshFilter: { assetHandle: 1 } }, // first node: out of bounds
607
- },
608
- {
609
- localId: 1,
610
- components: { MeshFilter: { assetHandle: 0 } }, // second node: valid, but should not be reached
611
- },
612
- ],
597
+ entities: {
598
+ 'entity-0': { components: { MeshFilter: { assetHandle: 1 } } },
599
+ 'entity-1': { components: { MeshFilter: { assetHandle: 0 } } },
600
+ },
613
601
  };
614
602
  // parseScenePayload returns undefined on first error, so result is undefined
615
603
  // regardless of the second node's validity
@@ -622,12 +610,7 @@ type __MergedKeep =
622
610
  const fn = accessParseScenePayload(reg);
623
611
  const refs = [GUID_A];
624
612
  const payload = {
625
- entities: [
626
- {
627
- localId: 0,
628
- components: { MeshFilter: { assetHandle: -1 } },
629
- },
630
- ],
613
+ entities: { 'entity-0': { components: { MeshFilter: { assetHandle: -1 } } } },
631
614
  };
632
615
  const result = fn('scene', payload, refs);
633
616
  expect(result).toBeUndefined();
@@ -637,21 +620,23 @@ type __MergedKeep =
637
620
  const reg = new AssetRegistry(makeMockShaderRegistry());
638
621
  const fn = accessParseScenePayload(reg);
639
622
  const payload = {
640
- entities: [
641
- {
642
- localId: 0,
623
+ entities: {
624
+ 'entity-0': {
643
625
  components: {
644
626
  MeshFilter: { assetHandle: 42 },
645
627
  MeshRenderer: { materials: [1024] },
646
628
  },
647
629
  },
648
- ],
630
+ },
649
631
  };
650
632
  // No refs passed -- old call signature, numbers should pass through
651
633
  const asset = fn('scene', payload);
652
634
  expect(asset).toBeDefined();
653
635
  if (!asset) return;
654
- const comp = asset.entities[0]?.components as Record<string, Record<string, unknown>>;
636
+ const comp = asset.entities['entity-0']?.components as Record<
637
+ string,
638
+ Record<string, unknown>
639
+ >;
655
640
  expect(comp.MeshFilter?.assetHandle).toBe(42);
656
641
  expect(comp.MeshRenderer?.materials).toEqual([1024]);
657
642
  });
@@ -661,17 +646,19 @@ type __MergedKeep =
661
646
  const fn = accessParseScenePayload(reg);
662
647
  const refs = [GUID_A];
663
648
  const payload = {
664
- entities: [
665
- {
666
- localId: 0,
649
+ entities: {
650
+ 'entity-0': {
667
651
  components: { Transform: { pos: [0, 0, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] } },
668
652
  },
669
- ],
653
+ },
670
654
  };
671
655
  const asset = fn('scene', payload, refs);
672
656
  expect(asset).toBeDefined();
673
657
  if (!asset) return;
674
- const comp = asset.entities[0]?.components as Record<string, Record<string, unknown>>;
658
+ const comp = asset.entities['entity-0']?.components as Record<
659
+ string,
660
+ Record<string, unknown>
661
+ >;
675
662
  // M1-fixup F-1: non-handle integer fields (Transform) are preserved as-is.
676
663
  // Only fields in the HANDLE_FIELD_NAMES allowlist get refs resolution.
677
664
  expect(comp.Transform?.pos).toEqual([0, 0, 0]);
@@ -690,12 +677,7 @@ type __MergedKeep =
690
677
  // parseAssetPayload returns { ok: false, error }, then fetchPackFile
691
678
  // wraps it as asset-parse-failed.
692
679
  const badPayload = {
693
- entities: [
694
- {
695
- localId: 0,
696
- components: { MeshFilter: { assetHandle: 5 } }, // refs length is 3, 5 is out of bounds
697
- },
698
- ],
680
+ entities: { 'entity-0': { components: { MeshFilter: { assetHandle: 5 } } } },
699
681
  };
700
682
  const packEntry = {
701
683
  guid: '00000000-0000-7000-8000-000000000099',
@@ -724,7 +706,7 @@ type __MergedKeep =
724
706
  // the LoaderOutput { ok: false, error } arm (no instance-slot side effect).
725
707
  const result = internal.parseAssetPayload('scene', badPayload, packEntry.refs) as {
726
708
  ok: false;
727
- error: { index: number; refsLength: number };
709
+ error: { entityKey: string; index: number; refsLength: number };
728
710
  };
729
711
  expect(result.ok).toBe(false);
730
712
  expect(result.error.index).toBe(5);
@@ -735,12 +717,7 @@ type __MergedKeep =
735
717
  // biome-ignore lint/suspicious/noExplicitAny: private method access
736
718
  const internal = reg as any;
737
719
  const badPayload = {
738
- entities: [
739
- {
740
- localId: 42,
741
- components: { MeshFilter: { assetHandle: 5 } },
742
- },
743
- ],
720
+ entities: { 'entity-42': { components: { MeshFilter: { assetHandle: 5 } } } },
744
721
  };
745
722
  const refs = ['guid-a', 'guid-b']; // length=2, 5 is out of bounds
746
723
  // F21: the structured error is the return value's `error` field, not a
@@ -748,7 +725,7 @@ type __MergedKeep =
748
725
  const result = internal.parseAssetPayload('scene', badPayload, refs) as {
749
726
  ok: false;
750
727
  error: {
751
- localId: number;
728
+ entityKey: string;
752
729
  component: string;
753
730
  field: string;
754
731
  index: number;
@@ -757,7 +734,7 @@ type __MergedKeep =
757
734
  };
758
735
  expect(result.ok).toBe(false);
759
736
  const err = result.error;
760
- expect(err.localId).toBe(42);
737
+ expect(err.entityKey).toBe('entity-42');
761
738
  expect(err.component).toBe('MeshFilter');
762
739
  expect(err.field).toBe('assetHandle');
763
740
  expect(err.index).toBe(5);
@@ -102,7 +102,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
102
102
  });
103
103
 
104
104
  describe('pbr-material-merged', () => {
105
- it('24 entries: user-region 15 (derived) + ibl 7 + transmission 2', () => {
105
+ it('25 entries: user-region 15 + ibl 7 + transmission 2 + scene material 1', () => {
106
106
  const spec = makeSpec();
107
107
  const out = buildBindGroupLayoutDescriptor(spec, {
108
108
  kind: 'pbr-material-merged',
@@ -114,10 +114,18 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
114
114
  const afterIbl = [...userRegion, ...appendInjection(userRegion, 'ibl')];
115
115
  const expected = {
116
116
  label: 'pbr-material-skylight-bgl',
117
- entries: [...afterIbl, ...appendInjection(afterIbl, 'transmission')],
117
+ entries: [
118
+ ...afterIbl,
119
+ ...appendInjection(afterIbl, 'transmission'),
120
+ {
121
+ binding: 46,
122
+ visibility: 0x1 | 0x2,
123
+ buffer: { type: 'read-only-storage', hasDynamicOffset: false },
124
+ },
125
+ ],
118
126
  };
119
127
  expect(out).toEqual(expected);
120
- expect(out.entries.length).toBe(24);
128
+ expect(out.entries.length).toBe(25);
121
129
  });
122
130
 
123
131
  it('adds only authored physical map pairs after the canonical injection chain', () => {
@@ -131,10 +139,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
131
139
  kind: 'pbr-material-merged',
132
140
  materialParamSchema: schema,
133
141
  });
134
- expect(out.entries.map((entry) => entry.binding)).toEqual([
135
- ...Array.from({ length: 28 }, (_, index) => index),
136
- ]);
137
- expect(out.entries.slice(24).map((entry) => entry.binding)).toEqual([24, 25, 26, 27]);
142
+ expect(out.entries.slice(24).map((entry) => entry.binding)).toEqual([26, 27, 30, 31, 46]);
138
143
  });
139
144
  });
140
145
 
@@ -168,7 +173,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
168
173
  });
169
174
 
170
175
  describe('pbr-instances', () => {
171
- it('storageBuffer=true: 1 entry with read-only-storage, no dynamic offset', () => {
176
+ it('storageBuffer=true: one vertex-visible read-only-storage entry', () => {
172
177
  const spec = makeSpec();
173
178
  const out = buildBindGroupLayoutDescriptor(spec, {
174
179
  kind: 'pbr-instances',
@@ -186,13 +191,22 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
186
191
  });
187
192
  });
188
193
 
189
- it('storageBuffer=false: falls back to uniform', () => {
194
+ it('storageBuffer=false: one entry falls back to uniform', () => {
190
195
  const spec = makeSpec();
191
196
  const out = buildBindGroupLayoutDescriptor(spec, {
192
197
  kind: 'pbr-instances',
193
198
  caps: { storageBuffer: false },
194
199
  });
195
- expect(out.entries[0]?.buffer?.type).toBe('uniform');
200
+ expect(out).toEqual({
201
+ label: 'pbr-instances-bgl',
202
+ entries: [
203
+ {
204
+ binding: 0,
205
+ visibility: 0x3,
206
+ buffer: { type: 'uniform', hasDynamicOffset: false },
207
+ },
208
+ ],
209
+ });
196
210
  });
197
211
  });
198
212
 
@@ -208,7 +222,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
208
222
  entries: [
209
223
  {
210
224
  binding: 0,
211
- visibility: 0x1,
225
+ visibility: 0x3,
212
226
  buffer: { type: 'read-only-storage', hasDynamicOffset: true },
213
227
  },
214
228
  {
@@ -232,7 +232,11 @@ function spawnPointShadowScene(world: World, withShadow: boolean): void {
232
232
  .unwrap();
233
233
  world
234
234
  .spawn(
235
- { component: Transform, data: { pos: [0, -1.65, 0], scale: [3.5, 0.15, 3.5] } },
235
+ // A receiver wall sits behind the cube so the point light's occlusion
236
+ // lands on a camera-visible surface. The old floor-only witness could
237
+ // miss the shadow entirely at the fixed camera pose and make a valid
238
+ // shadow pass look identical to the no-shadow baseline.
239
+ { component: Transform, data: { pos: [0, 0, -0.65], scale: [5, 5, 0.1] } },
236
240
  { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
237
241
  { component: MeshRenderer, data: { materials: [material] } },
238
242
  )
@@ -246,7 +250,9 @@ function spawnPointShadowScene(world: World, withShadow: boolean): void {
246
250
  world
247
251
  .spawn({
248
252
  component: DirectionalLight,
249
- data: { direction: [-0.4, -0.8, -1], intensity: 0.35, castShadow: false },
253
+ // Keep the point light as the only direct source; a directional fill
254
+ // would mask a broken point-shadow factor in this pixel witness.
255
+ data: { direction: [-0.4, -0.8, -1], intensity: 0, castShadow: false },
250
256
  })
251
257
  .unwrap();
252
258
  const point = [1.8, 2.6, 2.2] as const;
@@ -270,18 +270,49 @@ function spawnValidatedPointShadow(
270
270
  }
271
271
  });
272
272
 
273
- it('AC-03: lightPos=(0,0,0) near=0.1 far=10 face +X projects (1,0,0) into NDC z=1 (far) at depth-far plane', () => {
273
+ it('projects off-axis casters to the texel addressed by a raw cube direction on all six faces', () => {
274
+ const position = vec3.create(2, 3, 4);
275
+ const shadowMatrices = buildPointShadowMatrices(position, 0.1, 25);
276
+ // WebGPU cube face table: each direction addresses UV=(0.25,0.75).
277
+ // Independent of either camera/matrix helper, and not on a face axis.
278
+ const directions = [
279
+ [2, -1, 1],
280
+ [-2, -1, -1],
281
+ [-1, 2, 1],
282
+ [-1, -2, -1],
283
+ [-1, -1, 2],
284
+ [1, -1, -2],
285
+ ] as const;
286
+ for (const [index, matrix] of shadowMatrices.entries()) {
287
+ const direction = directions[index];
288
+ if (direction === undefined) throw new Error('missing cube direction');
289
+ const point = [
290
+ (position[0] ?? 0) + direction[0],
291
+ (position[1] ?? 0) + direction[1],
292
+ (position[2] ?? 0) + direction[2],
293
+ 1,
294
+ ];
295
+ const clip = [0, 1, 2, 3].map((row) =>
296
+ point.reduce((sum, value, column) => sum + value * (matrix[column * 4 + row] ?? 0), 0),
297
+ );
298
+ const w = clip[3] ?? 1;
299
+ expect(((clip[0] ?? 0) / w) * 0.5 + 0.5).toBeCloseTo(0.25, 6);
300
+ expect(0.5 - ((clip[1] ?? 0) / w) * 0.5).toBeCloseTo(0.75, 6);
301
+ }
302
+ });
303
+
304
+ it('cube +X projects world -X far plane to NDC z=1', () => {
274
305
  // Reference: WebGPU [0, 1] NDC. far point on the face axis maps to ndc_z=1.
275
306
  const ms = buildPointShadowMatrices(vec3.create(0, 0, 0), 0.1, 10);
276
307
  const faceM = ms[0] ?? new Float32Array(16);
277
- // A point at (10, 0, 0) — exactly far distance along +X — should project
308
+ // A point at (+10, 0, 0) — far distance along cube +X — should project
278
309
  // to NDC z = 1 (WebGPU convention, far plane).
279
310
  const p = projectPoint(faceM, 10, 0, 0);
280
311
  const ndcZ = p.z / p.w;
281
312
  expect(ndcZ).toBeCloseTo(1.0, 5);
282
313
  });
283
314
 
284
- it('AC-03: face +X projects (0.1, 0, 0) — near point to NDC z=0', () => {
315
+ it('cube +X projects world -X near plane to NDC z=0', () => {
285
316
  const ms = buildPointShadowMatrices(vec3.create(0, 0, 0), 0.1, 10);
286
317
  const faceM = ms[0] ?? new Float32Array(16);
287
318
  const p = projectPoint(faceM, 0.1, 0, 0);
@@ -291,8 +322,7 @@ function spawnValidatedPointShadow(
291
322
 
292
323
  it('AC-03: each face projects its forward axis point at far depth to NDC z=1', () => {
293
324
  const ms = buildPointShadowMatrices(vec3.create(0, 0, 0), 0.1, 10);
294
- // For each face, the "far" point along the look direction (in world
295
- // space) should map to NDC z = 1.
325
+ // Directions are in the negated-world cube basis used for sampling.
296
326
  const lookDirs: ReadonlyArray<readonly [number, number, number]> = [
297
327
  [10, 0, 0], // +X
298
328
  [-10, 0, 0], // -X
@@ -330,7 +360,7 @@ function spawnValidatedPointShadow(
330
360
  });
331
361
 
332
362
  it('AC-03: WebGPU [0,1] NDC depth — out-of-frustum mid-distance maps to ndc_z in (0, 1)', () => {
333
- // Pick a mid-point along +X face axis (5 units, between near=0.1 and far=10).
363
+ // Pick a mid-point along cube +X (world +X).
334
364
  const ms = buildPointShadowMatrices(vec3.create(0, 0, 0), 0.1, 10);
335
365
  const faceM = ms[0] ?? new Float32Array(16);
336
366
  const p = projectPoint(faceM, 5, 0, 0);
@@ -343,8 +373,7 @@ function spawnValidatedPointShadow(
343
373
  it('AC-03: lightPos translation — origin reference point at (lightPos + far*lookDir) maps to ndc_z=1', () => {
344
374
  const lightPos = vec3.create(2, 3, 4);
345
375
  const ms = buildPointShadowMatrices(lightPos, 0.1, 10);
346
- // Face +X: world point at lightPos + (10, 0, 0) = (12, 3, 4) is at far
347
- // plane along the +X face look direction.
376
+ // Cube +X: world point lightPos + (10, 0, 0) is on the far plane.
348
377
  // biome-ignore lint/style/noNonNullAssertion: ms.length === 6
349
378
  const faceM = ms[0]!;
350
379
  const p = projectPoint(faceM, 12, 3, 4);
@@ -604,5 +604,57 @@ type __MergedKeep =
604
604
  expect(errors).toEqual([]);
605
605
  });
606
606
  }
607
+
608
+ it('vertex-only mesh uses draw() without an index buffer', async () => {
609
+ const spies = makePassSpies();
610
+ const { renderer } = await setupRenderer(spies);
611
+ const errors: string[] = [];
612
+ renderer.onError((e) => errors.push(e.code));
613
+
614
+ const world = await spawnScene(renderer, meshForTopology('line-list'));
615
+ if (!(renderer as unknown as RendererType).attach(world as WorldType).ok) {
616
+ throw new Error('World attachment failed');
617
+ }
618
+ (world as WorldType).update().unwrap();
619
+ renderer.draw([world], { cameraOwner: 0, resourceOwner: 0 });
620
+ await new Promise((resolve) => setTimeout(resolve, 0));
621
+ spies.draw.mockClear();
622
+ spies.drawIndexed.mockClear();
623
+ spies.setIndexBuffer.mockClear();
624
+ spies.setVertexBuffer.mockClear();
625
+ renderer.draw([world], { cameraOwner: 0, resourceOwner: 0 });
626
+
627
+ expect(spies.draw).toHaveBeenCalled();
628
+ expect(spies.drawIndexed).not.toHaveBeenCalled();
629
+ expect(spies.setIndexBuffer).not.toHaveBeenCalled();
630
+ expect(spies.setVertexBuffer).toHaveBeenCalled();
631
+ expect(spies.draw.mock.calls[0]?.[0]).toBe(3);
632
+ expect(errors).toEqual([]);
633
+ });
634
+
635
+ it('indexed mesh uses setIndexBuffer() and drawIndexed()', async () => {
636
+ const spies = makePassSpies();
637
+ const { renderer } = await setupRenderer(spies);
638
+ const errors: string[] = [];
639
+ renderer.onError((e) => errors.push(e.code));
640
+
641
+ const world = await spawnScene(renderer, meshForTopology('triangle-list'));
642
+ if (!(renderer as unknown as RendererType).attach(world as WorldType).ok) {
643
+ throw new Error('World attachment failed');
644
+ }
645
+ (world as WorldType).update().unwrap();
646
+ renderer.draw([world], { cameraOwner: 0, resourceOwner: 0 });
647
+ await new Promise((resolve) => setTimeout(resolve, 0));
648
+ spies.draw.mockClear();
649
+ spies.drawIndexed.mockClear();
650
+ spies.setIndexBuffer.mockClear();
651
+ spies.setVertexBuffer.mockClear();
652
+ renderer.draw([world], { cameraOwner: 0, resourceOwner: 0 });
653
+
654
+ expect(spies.setIndexBuffer).toHaveBeenCalled();
655
+ expect(spies.drawIndexed).toHaveBeenCalled();
656
+ expect(spies.setVertexBuffer).toHaveBeenCalled();
657
+ expect(errors).toEqual([]);
658
+ });
607
659
  });
608
660
  }
@@ -1,3 +1,4 @@
1
+ // @perf-budget-skip: intentional full mock-GPU renderer setup for indexed/non-indexed draw dispatch.
1
2
  // @ts-nocheck — merged file: cross-source node:fs/node:path imports outside @types/node coverage in runtime tsconfig
2
3
  // Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
3
4
  // biome-ignore-all lint/complexity/noUselessLoneBlockStatements: block-scope isolation between merged source files (consolidation paradigm)