@forgeax/engine-runtime 0.1.28 → 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 (113) 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 -32
  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-rendering-materials.browser.test.ts +617 -0
  101. package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
  102. package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
  103. package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
  104. package/src/__tests__/world-scene-despawn.test.ts +8 -8
  105. package/src/__tests__/world-scene-instantiate.test.ts +23 -29
  106. package/src/__tests__/world-scene-nested.test.ts +90 -163
  107. package/src/__tests__/world-scene-override.test.ts +17 -16
  108. package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
  109. package/src/collect-scene-asset.ts +637 -19
  110. package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
  111. package/src/index.ts +1 -0
  112. package/src/scene-utils/mount-override-fold.ts +5 -14
  113. package/src/__tests__/node-shims.d.ts +0 -31
@@ -72,7 +72,7 @@ import {
72
72
  propagateTransforms,
73
73
  Transform,
74
74
  } from '@forgeax/engine-scene';
75
- import type { Handle, LocalEntityId, SceneAsset, SceneEntity } from '@forgeax/engine-types';
75
+ import type { Handle, SceneAsset, SceneEntity } from '@forgeax/engine-types';
76
76
  import { toShared } from '@forgeax/engine-types';
77
77
  import { describe, expect, expectTypeOf, it } from 'vitest';
78
78
  import { tonemapFromF32 } from '../../../render/src/components/camera';
@@ -182,9 +182,9 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
182
182
  expect(schemaRecord.materials).toBe('array<shared<MaterialAsset>>');
183
183
  });
184
184
 
185
- it('Camera has 21 fields (17 f32 + historyVersion u32 + clearColor array<f32,4> + autoAspect bool + target)', () => {
185
+ it('Camera has 29 fields including target, exposure, and color grading columns', () => {
186
186
  expect(Camera.name).toBe('Camera');
187
- expect(Object.keys(componentSchema(Camera)).length).toBe(21);
187
+ expect(Object.keys(componentSchema(Camera)).length).toBe(29);
188
188
  expect(componentSchema(Camera).fov).toBe('f32');
189
189
  expect(componentSchema(Camera).aspect).toBe('f32');
190
190
  expect(componentSchema(Camera).near).toBe('f32');
@@ -215,6 +215,14 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
215
215
  // feat-20260617-host-engine-contract-and-video-cutscene / M3: aspect-sync
216
216
  // opt-out flag (bool column tier, not f32).
217
217
  expect(componentSchema(Camera).autoAspect).toBe('bool');
218
+ expect(componentSchema(Camera).exposureMode).toBe('f32');
219
+ expect(componentSchema(Camera).compensationEv).toBe('f32');
220
+ expect(componentSchema(Camera).rangeEv).toBe('array<f32, 2>');
221
+ expect(componentSchema(Camera).rates).toBe('array<f32, 2>');
222
+ expect(componentSchema(Camera).temperature).toBe('f32');
223
+ expect(componentSchema(Camera).tint).toBe('f32');
224
+ expect(componentSchema(Camera).colorLut).toBe('shared<TextureAsset>');
225
+ expect(componentSchema(Camera).colorLutStrength).toBe('f32');
218
226
  });
219
227
 
220
228
  it('DirectionalLight has 14 fields: 3 light + castShadow bool + closed shadow quality fields', () => {
@@ -806,12 +814,16 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
806
814
  state: { type: 'unique<SceneInstanceState>' },
807
815
  });
808
816
 
809
- function localId(n: number): LocalEntityId {
810
- return n as LocalEntityId;
811
- }
812
-
813
- function buildScene(nodes: readonly SceneEntity[]): SceneAsset {
814
- return { kind: 'scene', entities: nodes };
817
+ function buildScene(nodes: readonly (SceneEntity & { readonly key: string })[]): SceneAsset {
818
+ return {
819
+ kind: 'scene',
820
+ entities: Object.fromEntries(
821
+ nodes.map(({ key, components, instance }) => [
822
+ key,
823
+ { components, ...(instance === undefined ? {} : { instance }) },
824
+ ]),
825
+ ),
826
+ };
815
827
  }
816
828
 
817
829
  function registerSceneAsset(
@@ -847,7 +859,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
847
859
 
848
860
  const nodes: SceneEntity[] = [
849
861
  {
850
- localId: localId(0),
862
+ key: 'entity-0',
851
863
  components: { Transform: { pos: [1.5, 0, 0] } },
852
864
  },
853
865
  ];
@@ -878,7 +890,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
878
890
  // asserted in the next test).
879
891
  const nodes: SceneEntity[] = [
880
892
  {
881
- localId: localId(0),
893
+ key: 'entity-0',
882
894
  components: { Transform: {} },
883
895
  },
884
896
  ];
@@ -905,7 +917,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
905
917
  // defaults. AC-12 mandates this path is SILENT (no error code).
906
918
  const nodes: SceneEntity[] = [
907
919
  {
908
- localId: localId(0),
920
+ key: 'entity-0',
909
921
  components: { Transform: {} },
910
922
  },
911
923
  ];
@@ -935,7 +947,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
935
947
  // - read layer: null is what the AI user observes after world.get
936
948
  const nodes: SceneEntity[] = [
937
949
  {
938
- localId: localId(0),
950
+ key: 'entity-0',
939
951
  components: { TargetSlot: {} },
940
952
  },
941
953
  ];
@@ -970,7 +982,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
970
982
 
971
983
  const nodes: SceneEntity[] = [
972
984
  {
973
- localId: localId(0),
985
+ key: 'entity-0',
974
986
  components: { Transform: { pos: [1.5, 0, 0] } },
975
987
  },
976
988
  ];
@@ -996,7 +1008,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
996
1008
  const world = new World();
997
1009
  world.components.register(Transform).unwrap();
998
1010
 
999
- const nodes: SceneEntity[] = [{ localId: localId(0), components: { Transform: {} } }];
1011
+ const nodes: SceneEntity[] = [{ key: 'entity-0', components: { Transform: {} } }];
1000
1012
  const handle = registerSceneAsset(world, buildScene(nodes), [Transform]);
1001
1013
  const r = SceneOwner.worldInstantiateScene(world, handle);
1002
1014
  expect(r.ok).toBe(true);
@@ -1398,14 +1410,14 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1398
1410
  expect(pod.tonemap).toBe(TONEMAP_NONE);
1399
1411
  });
1400
1412
 
1401
- it('perspective + orthographic 20-field counts (20 Camera columns)', () => {
1413
+ it('perspective + orthographic 28-field counts (28 Camera columns)', () => {
1402
1414
  const p = perspective({ fov: 60, aspect: 4 / 3 });
1403
1415
  const o = orthographic({ left: -1, right: 1, bottom: -1, top: 1 });
1404
- // Both return exactly 20 fields (17 f32 + historyVersion u32 + one clearColor array +
1416
+ // Both return exactly 28 fields (including exposure and color grading columns +
1405
1417
  // autoAspect bool column; feat-20260709 M3 collapsed the 4-scalar
1406
1418
  // clear-color quartet into one inline array<f32,4>).
1407
- expect(Object.keys(p).length).toBe(20);
1408
- expect(Object.keys(o).length).toBe(20);
1419
+ expect(Object.keys(p).length).toBe(28);
1420
+ expect(Object.keys(o).length).toBe(28);
1409
1421
  });
1410
1422
  });
1411
1423
 
@@ -1414,7 +1426,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1414
1426
  // ────────────────────────────────────────────────────────────────────────────
1415
1427
 
1416
1428
  describe('Camera.fields reflection (w14 AC-07 SSOT)', () => {
1417
- it('Camera.fields has exactly 21 keys matching the Camera column set', () => {
1429
+ it('Camera.fields has exactly 28 keys matching the Camera column set', () => {
1418
1430
  const keys = Object.keys(Camera.fields).sort();
1419
1431
  expect(keys).toEqual([
1420
1432
  'antialias',
@@ -1426,15 +1438,23 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1426
1438
  'bloomThreshold',
1427
1439
  'bottom',
1428
1440
  'clearColor',
1441
+ 'colorLut',
1442
+ 'colorLutStrength',
1443
+ 'compensationEv',
1429
1444
  'exposure',
1445
+ 'exposureMode',
1430
1446
  'far',
1431
1447
  'fov',
1432
1448
  'historyVersion',
1433
1449
  'left',
1434
1450
  'near',
1435
1451
  'projection',
1452
+ 'rangeEv',
1453
+ 'rates',
1436
1454
  'right',
1437
1455
  'target',
1456
+ 'temperature',
1457
+ 'tint',
1438
1458
  'tonemap',
1439
1459
  'top',
1440
1460
  'whitePoint',
@@ -1448,11 +1468,15 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1448
1468
  ? 'bool'
1449
1469
  : key === 'clearColor'
1450
1470
  ? 'array<f32, 4>'
1451
- : key === 'historyVersion'
1452
- ? 'u32'
1453
- : key === 'target'
1454
- ? 'shared<RenderTarget>'
1455
- : 'f32';
1471
+ : key === 'rangeEv' || key === 'rates'
1472
+ ? 'array<f32, 2>'
1473
+ : key === 'colorLut'
1474
+ ? 'shared<TextureAsset>'
1475
+ : key === 'historyVersion'
1476
+ ? 'u32'
1477
+ : key === 'target'
1478
+ ? 'shared<RenderTarget>'
1479
+ : 'f32';
1456
1480
  expect(Camera.fields[key].type).toBe(expected);
1457
1481
  }
1458
1482
  });
@@ -1487,6 +1511,14 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1487
1511
  expect(Array.from(d.clearColor.default as Float32Array)).toEqual([0, 0, 0, 0]);
1488
1512
  // aspect-sync opt-out default (feat-20260617 / M3).
1489
1513
  expect(d.autoAspect.default).toBe(true);
1514
+ expect(d.exposureMode.default).toBe(0);
1515
+ expect(d.compensationEv.default).toBe(0);
1516
+ expect(Array.from(d.rangeEv.default as Float32Array)).toEqual([-8, 8]);
1517
+ expect(Array.from(d.rates.default as Float32Array)).toEqual([3, 1]);
1518
+ expect(d.temperature.default).toBe(6504);
1519
+ expect(d.tint.default).toBe(0);
1520
+ expect(d.colorLut.default).toBe(0);
1521
+ expect(d.colorLutStrength.default).toBe(0);
1490
1522
  // Perspective quartet defaults intentionally absent (OOS-5).
1491
1523
  expect(d.fov.default).toBeUndefined();
1492
1524
  expect(d.aspect.default).toBeUndefined();
@@ -1496,7 +1528,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1496
1528
  });
1497
1529
 
1498
1530
  describe('componentDefinition(Camera).defaults — frozen token defaults map (AC-07 + feat-20260528-fxaa-post-processing + feat-20260531-bloom + feat-20260608-clear-color)', () => {
1499
- it('componentDefinition(Camera).defaults equals { projection: 0, left: -1, right: 1, bottom: -1, top: 1, tonemap: 0, exposure: 1.0, whitePoint: 4.0, antialias: 0, bloom: 0, bloomThreshold: 1.0, bloomIntensity: 1.0, bloomBlurRadius: 4.0, clearColor: [0,0,0,0], autoAspect: true }', () => {
1531
+ it('componentDefinition(Camera).defaults includes exposure and color grading defaults', () => {
1500
1532
  expect(componentDefinition(Camera).defaults).toEqual({
1501
1533
  projection: 0,
1502
1534
  left: -1,
@@ -1514,6 +1546,14 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1514
1546
  bloomBlurRadius: 4.0,
1515
1547
  clearColor: new Float32Array([0, 0, 0, 0]),
1516
1548
  autoAspect: true,
1549
+ exposureMode: 0,
1550
+ compensationEv: 0,
1551
+ rangeEv: new Float32Array([-8, 8]),
1552
+ rates: new Float32Array([3, 1]),
1553
+ temperature: 6504,
1554
+ tint: 0,
1555
+ colorLut: 0,
1556
+ colorLutStrength: 0,
1517
1557
  });
1518
1558
  });
1519
1559
 
@@ -1722,7 +1762,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1722
1762
  expect(pod.exposure).toBeCloseTo(1.0, 6);
1723
1763
  });
1724
1764
 
1725
- it('perspective return value has all 20 Camera fields', () => {
1765
+ it('perspective return value has all 28 Camera fields', () => {
1726
1766
  const pod = perspective({ fov: 60, aspect: 4 / 3 });
1727
1767
  const keys = Object.keys(pod).sort();
1728
1768
  expect(keys).toEqual([
@@ -1735,14 +1775,22 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1735
1775
  'bloomThreshold',
1736
1776
  'bottom',
1737
1777
  'clearColor',
1778
+ 'colorLut',
1779
+ 'colorLutStrength',
1780
+ 'compensationEv',
1738
1781
  'exposure',
1782
+ 'exposureMode',
1739
1783
  'far',
1740
1784
  'fov',
1741
1785
  'historyVersion',
1742
1786
  'left',
1743
1787
  'near',
1744
1788
  'projection',
1789
+ 'rangeEv',
1790
+ 'rates',
1745
1791
  'right',
1792
+ 'temperature',
1793
+ 'tint',
1746
1794
  'tonemap',
1747
1795
  'top',
1748
1796
  'whitePoint',
@@ -62,8 +62,10 @@ if (typeof window !== 'undefined') {
62
62
 
63
63
  const browserReady = typeof navigator !== 'undefined' && navigator.gpu !== undefined;
64
64
 
65
- const CANVAS_W = 256;
66
- const CANVAS_H = 256;
65
+ // This gate checks world ownership and non-black presentation, not detail.
66
+ // Keep all composite frames while reducing software rasterization in CI.
67
+ const CANVAS_W = import.meta.env.FORGEAX_BROWSER_CI_LIGHTWEIGHT === '1' ? 128 : 256;
68
+ const CANVAS_H = CANVAS_W;
67
69
  const FRAMES = 8;
68
70
 
69
71
  // Build a bright, uniform equirect (all channels = 0.8 linear) so the projected
@@ -168,6 +168,7 @@ describe('w2 -- PBR dual-state dawn smoke (AC-01 / AC-06)', () => {
168
168
  const { renderer, assets } = host.value;
169
169
  expect(renderer.inspect().state).toBe('alive');
170
170
  expect(assets).toBeInstanceOf(AssetRegistry);
171
+ const collectionN = renderer.instances.create({ transforms: transformsN }).unwrap();
171
172
 
172
173
  // Material creation (PBR standard). D-18: a shared-ref handle is per-World;
173
174
  // each frame's World allocs the same payload to get a handle it can resolve.
@@ -207,7 +208,7 @@ describe('w2 -- PBR dual-state dawn smoke (AC-01 / AC-06)', () => {
207
208
  },
208
209
  { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
209
210
  { component: MeshRenderer, data: { materials: [matHandleN] } },
210
- { component: Instances, data: { transforms: transformsN } },
211
+ { component: Instances, data: { collectionId: collectionN.collectionId } },
211
212
  );
212
213
  worldN.spawn(
213
214
  {
@@ -270,6 +271,7 @@ describe('w2 -- PBR dual-state dawn smoke (AC-01 / AC-06)', () => {
270
271
  singleTransform[5] = 1;
271
272
  singleTransform[10] = 1;
272
273
  singleTransform[15] = 1;
274
+ const collection1 = renderer.instances.create({ transforms: singleTransform }).unwrap();
273
275
 
274
276
  const world1 = new World();
275
277
  const matHandle1 = world1.allocSharedRef<'MaterialAsset', MaterialAsset>(
@@ -287,7 +289,7 @@ describe('w2 -- PBR dual-state dawn smoke (AC-01 / AC-06)', () => {
287
289
  },
288
290
  { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
289
291
  { component: MeshRenderer, data: { materials: [matHandle1] } },
290
- { component: Instances, data: { transforms: singleTransform } },
292
+ { component: Instances, data: { collectionId: collection1.collectionId } },
291
293
  );
292
294
  world1.spawn(
293
295
  {
@@ -465,6 +467,7 @@ describe('w2 -- PBR dual-state dawn smoke (AC-01 / AC-06)', () => {
465
467
  } as MaterialAsset;
466
468
 
467
469
  const transformsN2 = buildTranslationGrid();
470
+ const collectionN2 = renderer2.instances.create({ transforms: transformsN2 }).unwrap();
468
471
 
469
472
  // Frame A: N instances
470
473
  const worldN2 = new World();
@@ -483,7 +486,7 @@ describe('w2 -- PBR dual-state dawn smoke (AC-01 / AC-06)', () => {
483
486
  },
484
487
  { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
485
488
  { component: MeshRenderer, data: { materials: [matHandleN2] } },
486
- { component: Instances, data: { transforms: transformsN2 } },
489
+ { component: Instances, data: { collectionId: collectionN2.collectionId } },
487
490
  );
488
491
  worldN2.spawn(
489
492
  {
@@ -545,6 +548,7 @@ describe('w2 -- PBR dual-state dawn smoke (AC-01 / AC-06)', () => {
545
548
  singleTransform2[5] = 1;
546
549
  singleTransform2[10] = 1;
547
550
  singleTransform2[15] = 1;
551
+ const collection12 = renderer2.instances.create({ transforms: singleTransform2 }).unwrap();
548
552
 
549
553
  const world12 = new World();
550
554
  const matHandle12 = world12.allocSharedRef<'MaterialAsset', MaterialAsset>(
@@ -562,7 +566,7 @@ describe('w2 -- PBR dual-state dawn smoke (AC-01 / AC-06)', () => {
562
566
  },
563
567
  { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
564
568
  { component: MeshRenderer, data: { materials: [matHandle12] } },
565
- { component: Instances, data: { transforms: singleTransform2 } },
569
+ { component: Instances, data: { collectionId: collection12.collectionId } },
566
570
  );
567
571
  world12.spawn(
568
572
  {
@@ -284,6 +284,9 @@ describe('w10 -- shadow instanced dawn smoke (AC-05 behavioral)', () => {
284
284
  const instanceTransforms = FALSIFY_COLLAPSED
285
285
  ? buildIdentityTransforms()
286
286
  : buildTranslationGrid();
287
+ const instanceCollection = renderer.instances
288
+ .create({ transforms: instanceTransforms })
289
+ .unwrap();
287
290
  world.spawn(
288
291
  {
289
292
  component: Transform,
@@ -295,7 +298,7 @@ describe('w10 -- shadow instanced dawn smoke (AC-05 behavioral)', () => {
295
298
  },
296
299
  { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
297
300
  { component: MeshRenderer, data: { materials: [matHandle] } },
298
- { component: Instances, data: { transforms: instanceTransforms } },
301
+ { component: Instances, data: { collectionId: instanceCollection.collectionId } },
299
302
  );
300
303
 
301
304
  // Render 300 frames for temporal stability (PCF or PCSS shadow, light accumulation)
@@ -1,10 +1,28 @@
1
- // World-authored Instances render through storage and uniform bindings on Dawn.
2
- // The restricted RHI advertises no storage buffers while retaining the real
3
- // WebGPU device, shader compiler, validation and pixel readback. This proves
4
- // the uniform lane and its 128-instance chunks, not WebGL2 backend parity.
1
+ // instances-uniform-fallback.dawn.test.ts -- feat-20260604-instances-per-instance-transform-shader-group3-bin
2
+ // M1 / w6.
3
+ //
4
+ // AC-07: storage-buffer path + uniform-fallback path both compile and render
5
+ // correctly for <=128 instances.
6
+ //
7
+ // Primary path: dawn-node renders <=128 instances through the normal storage
8
+ // buffer path (caps.storageBuffer===true on dawn) and asserts frames are
9
+ // non-clear (proves the storage variant renders).
10
+ //
11
+ // Degraded path (research R-C partial): dawn-node always has
12
+ // caps.storageBuffer===true, so we cannot force the uniform-fallback variant
13
+ // at the GPU level. Instead, we verify that:
14
+ // (a) The uniform-fallback #else form `var<uniform> instances : array<InstanceData, 128>`
15
+ // is present in common.wgsl (source-level structural check).
16
+ // (b) The variant count stays at 2 (verified by w1 unit test).
17
+ // (c) A non-trivial Instances render (<=128) works on the storage path.
18
+ //
19
+ // This is the best-effort degraded path per research R-C: "if dawn cannot
20
+ // construct caps.storageBuffer===false, fall back to a vitest unit that
21
+ // asserts the #else uniform array<..,128> form compiles, with reason
22
+ // recorded."
5
23
 
6
24
  import type { MaterialAsset } from '@forgeax/engine-types';
7
- import { beforeAll, describe, expect, it } from 'vitest';
25
+ import { describe, expect, it } from 'vitest';
8
26
  import { drawPublished } from '../draw-published';
9
27
 
10
28
  // Source-level structural check: common.wgsl must carry both the storage
@@ -27,16 +45,7 @@ const STORAGE_PATTERN =
27
45
  const UNIFORM_PATTERN =
28
46
  /@group\(3\)\s+@binding\(0\)\s+var<uniform>\s+instances\s*:\s*array<InstanceData,\s*128>/;
29
47
 
30
- describe('World Instances storage and uniform submission', () => {
31
- let ENGINE_MANIFEST_URL_W6: string;
32
- beforeAll(async () => {
33
- // Shader production is fixture setup; keep the render assertion's 30s
34
- // deadline independent of a cold compiler/manifest build.
35
- const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
36
- const manifest = await buildEngineShaderManifest();
37
- ENGINE_MANIFEST_URL_W6 = `data:application/json,${encodeURIComponent(JSON.stringify(manifest))}`;
38
- }, 180_000);
39
-
48
+ describe('w6 -- AC-07 storage+uniform variant test (degraded best-effort)', () => {
40
49
  it('(a) common.wgsl declares uniform-fallback array<InstanceData,128> under #else', async () => {
41
50
  const fsId = 'node:fs';
42
51
  const pathId = 'node:path';
@@ -79,41 +88,39 @@ describe('World Instances storage and uniform submission', () => {
79
88
  expect(commonSrc).toMatch(/localFromInstance\s*:\s*mat4x4<f32>/);
80
89
  });
81
90
 
82
- it.each([
83
- { storage: true, grid: 8 },
84
- { storage: false, grid: 8 },
85
- { storage: false, grid: 12 },
86
- ])('renders $grid x $grid instances with storage=$storage', async ({ storage, grid }) => {
91
+ // (b) deg-path rationale: dawn-node always has caps.storageBuffer===true, so
92
+ // the uniform-fallback path cannot be exercised at the GPU level in this
93
+ // dawn test. The uniform-fallback variant is structurally verified via:
94
+ // - w1 source-level variant=2 unit test (AC-11)
95
+ // - w6 (a) source-level uniform array<..,128> form check above
96
+ // - naga_oil compiles both variants at build-time (vite-plugin-shader)
97
+ // Restore as a real `it()` if a dawn-side uniform-fallback path becomes
98
+ // reachable (feat-20260608-ci-time-cut converted from `expect(true)`).
99
+ it.todo('(b) deg-path rationale recorded: dawn-node always storageBuffer=true');
100
+
101
+ it('(c) storage path renders Instances (dawn smoke integration)', async () => {
87
102
  const dawnAvailable = typeof globalThis.navigator?.gpu?.requestAdapter === 'function';
88
103
  if (!dawnAvailable) {
89
104
  throw new Error('dawn-node navigator.gpu not injected; vitest.setup-webgpu.ts regressed');
90
105
  }
91
106
 
107
+ // Render <=128 instances on the storage path and assert non-clear output.
108
+ // This proves the storage-buffer variant of @group(3) instances works
109
+ // end-to-end on dawn.
110
+
92
111
  const { World } = await import('@forgeax/engine-ecs');
93
- const { rhi } = await import('@forgeax/engine-rhi-webgpu');
94
- const restrictedRhi: typeof rhi = {
95
- ...rhi,
96
- async requestAdapter(...args) {
97
- const adapter = await rhi.requestAdapter(...args);
98
- if (!adapter.ok) return adapter;
99
- const requestDevice = adapter.value.requestDevice.bind(adapter.value);
100
- adapter.value.requestDevice = async (options) => {
101
- const result = await requestDevice(options);
102
- if (result.ok && !storage)
103
- Object.defineProperty(result.value, 'caps', {
104
- value: { ...result.value.caps, storageBuffer: false, compute: false },
105
- });
106
- return result;
107
- };
108
- return adapter;
109
- },
110
- };
111
112
  const { AssetRegistry, HANDLE_CUBE } = await import('@forgeax/engine-assets-runtime');
112
113
  const { Camera, MeshRenderer } = await import('@forgeax/engine-render');
113
114
  const { Instances } = await import('@forgeax/engine-render');
114
115
  const { constructRuntimeRendererHost } = await import('../../renderer-host');
115
116
  const { MeshFilter } = await import('@forgeax/engine-render');
116
117
  const { Transform } = await import('@forgeax/engine-scene');
118
+ const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
119
+ const ENGINE_MANIFEST_W6 = await buildEngineShaderManifest();
120
+ const ENGINE_MANIFEST_URL_W6 = `data:application/json,${encodeURIComponent(
121
+ JSON.stringify(ENGINE_MANIFEST_W6),
122
+ )}`;
123
+
117
124
  const W6_WIDTH = 256;
118
125
  const W6_HEIGHT = 256;
119
126
 
@@ -125,20 +132,15 @@ describe('World Instances storage and uniform submission', () => {
125
132
  const originalRequestDevice = rawAdapter.requestDevice.bind(rawAdapter);
126
133
  rawAdapter.requestDevice = async (desc) => {
127
134
  const dev = await originalRequestDevice(desc);
128
- if (sharedDevice === undefined) {
129
- sharedDevice = dev;
130
- dev.pushErrorScope('validation');
131
- }
135
+ if (sharedDevice === undefined) sharedDevice = dev;
132
136
  return dev;
133
137
  };
134
138
  return rawAdapter;
135
139
  };
136
140
 
137
141
  let renderTarget: GPUTexture | undefined;
138
- let targetFormat: GPUTextureFormat = 'rgba8unorm';
139
142
  const ensureTarget = (device: GPUDevice, format: GPUTextureFormat): GPUTexture => {
140
143
  if (renderTarget !== undefined) return renderTarget;
141
- targetFormat = format;
142
144
  renderTarget = device.createTexture({
143
145
  size: { width: W6_WIDTH, height: W6_HEIGHT, depthOrArrayLayers: 1 },
144
146
  format,
@@ -175,18 +177,15 @@ describe('World Instances storage and uniform submission', () => {
175
177
  try {
176
178
  host = await constructRuntimeRendererHost(
177
179
  mockCanvas,
178
- { rhi: restrictedRhi },
180
+ {},
179
181
  { shaderManifestUrl: ENGINE_MANIFEST_URL_W6 },
180
182
  );
181
183
  } finally {
182
184
  globalThis.navigator.gpu.requestAdapter = origReq;
183
185
  }
186
+ expect(host.ok).toBe(true);
184
187
  if (!host.ok) throw host.error;
185
188
  const { renderer, assets } = host.value;
186
- const errors: string[] = [];
187
- renderer.subscribe((event) => {
188
- if (event.kind === 'error') errors.push(JSON.stringify(event.error));
189
- });
190
189
  expect(renderer.inspect().state).toBe('alive');
191
190
  expect(assets).toBeInstanceOf(AssetRegistry);
192
191
 
@@ -202,8 +201,9 @@ describe('World Instances storage and uniform submission', () => {
202
201
  values: { baseColor: [0.9, 0.2, 0.2, 1], metallic: 0, roughness: 0.5 },
203
202
  } as MaterialAsset;
204
203
 
205
- const GRID = grid;
206
- const COUNT = GRID * GRID;
204
+ // Build ~60 instances (well under 128 uniform cap), 2D grid
205
+ const GRID = 8;
206
+ const COUNT = GRID * GRID; // 64
207
207
  const SP = 3.0;
208
208
  const transforms = new Float32Array(COUNT * 16);
209
209
  let idx = 0;
@@ -220,6 +220,7 @@ describe('World Instances storage and uniform submission', () => {
220
220
  idx++;
221
221
  }
222
222
  }
223
+ const instanceCollection = renderer.instances.create({ transforms }).unwrap();
223
224
 
224
225
  const world = new World();
225
226
  const matHandle = world.allocSharedRef<'MaterialAsset', MaterialAsset>(
@@ -237,7 +238,7 @@ describe('World Instances storage and uniform submission', () => {
237
238
  },
238
239
  { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
239
240
  { component: MeshRenderer, data: { materials: [matHandle] } },
240
- { component: Instances, data: { transforms } },
241
+ { component: Instances, data: { collectionId: instanceCollection.collectionId } },
241
242
  );
242
243
  world.spawn(
243
244
  {
@@ -295,45 +296,18 @@ describe('World Instances storage and uniform submission', () => {
295
296
  readbackBuf.unmap();
296
297
  readbackBuf.destroy();
297
298
 
298
- expect(errors).toEqual([]);
299
- expect((await device.popErrorScope())?.message).toBeUndefined();
300
-
301
- // Count actual red material pixels; comparing against guessed clear bytes
302
- // could pass an empty frame because the target applies sRGB conversion.
303
- let redPixels = 0;
304
- for (let y = 0; y < W6_HEIGHT; y++) {
305
- for (let x = 0; x < W6_WIDTH; x++) {
306
- const offset = y * bytesPerRow + x * 4;
307
- const r = bytes[offset + (targetFormat.startsWith('bgra') ? 2 : 0)] ?? 0;
308
- const g = bytes[offset + 1] ?? 0;
309
- const b = bytes[offset + (targetFormat.startsWith('bgra') ? 0 : 2)] ?? 0;
310
- if (r > g + 30 && r > b + 30) redPixels++;
311
- }
312
- }
313
- expect(redPixels).toBeGreaterThan(COUNT * 2);
314
- // The last row crosses the 128-instance boundary for the 12x12 case.
315
- // Verify its last matrix reaches a distinct screen location, not a
316
- // duplicate of the first chunk or merely some non-clear geometry.
317
- const edge = ((GRID - 1) / 2) * SP * 0.3;
318
- const ndcEdge = edge / (25 * Math.tan(Math.PI / 8));
319
- const lastX = Math.round(((1 + ndcEdge) * W6_WIDTH) / 2);
320
- const lastY = Math.round(((1 - ndcEdge) * W6_HEIGHT) / 2);
321
- let lastInstancePixels = 0;
322
- for (let y = lastY - 3; y <= lastY + 3; y++) {
323
- for (let x = lastX - 3; x <= lastX + 3; x++) {
324
- const offset = y * bytesPerRow + x * 4;
325
- const r = bytes[offset + (targetFormat.startsWith('bgra') ? 2 : 0)] ?? 0;
326
- const g = bytes[offset + 1] ?? 0;
327
- const b = bytes[offset + (targetFormat.startsWith('bgra') ? 0 : 2)] ?? 0;
328
- if (r > g + 30 && r > b + 30) lastInstancePixels++;
329
- }
330
- }
331
- expect(lastInstancePixels).toBeGreaterThan(0);
332
- if (!storage) {
333
- const residency = renderer.inspect().instanceCollections[0];
334
- expect(residency?.lane).toBe(COUNT > 128 ? 'chunked-uniform' : 'direct-uniform');
335
- expect(residency?.uploadedBytes).toBe(0);
336
- }
337
- await renderer.dispose();
299
+ // Sample multiple positions — the 8x8 grid should fill a substantial
300
+ // portion of the frame. Center should NOT be clear-color.
301
+ const cx = W6_WIDTH >> 1;
302
+ const cy = W6_HEIGHT >> 1;
303
+ const readPixel = (px: number, py: number): [number, number, number] => {
304
+ const off = py * bytesPerRow + px * 4;
305
+ return [bytes[off + 2] ?? 0, bytes[off + 1] ?? 0, bytes[off + 0] ?? 0];
306
+ };
307
+ const [cr, cg, cb] = readPixel(cx, cy);
308
+ // Clear color is ~(13, 13, 20) in sRGB bytes. Center should be
309
+ // significantly different (red cubes).
310
+ const distFromClear = Math.sqrt((cr - 13) ** 2 + (cg - 13) ** 2 + (cb - 20) ** 2);
311
+ expect(distFromClear).toBeGreaterThan(30);
338
312
  });
339
313
  });