@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
@@ -16,7 +16,7 @@ import { AssetRegistry } from '@forgeax/engine-assets-runtime';
16
16
  import { type Component, defineComponent, type EntityHandle, World } from '@forgeax/engine-ecs';
17
17
  import { AssetGuid } from '@forgeax/engine-pack/guid';
18
18
  import { SceneInstance } from '@forgeax/engine-render';
19
- import type { LocalEntityId, MountOverride, SceneAsset } from '@forgeax/engine-types';
19
+ import type { SceneAsset } from '@forgeax/engine-types';
20
20
  import { describe, expect, it } from 'vitest';
21
21
  import { rootsToSceneAsset, serializeSceneAssetToPack } from '../collect-scene-asset';
22
22
  import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
@@ -26,10 +26,6 @@ import { registerSceneComponents } from './helpers/register-scene-components';
26
26
  // Helpers
27
27
  // ═══════════════════════════════════════════════════════════════════════════════
28
28
 
29
- function localId(n: number): LocalEntityId {
30
- return n as LocalEntityId;
31
- }
32
-
33
29
  function mkReg(): AssetRegistry {
34
30
  return new AssetRegistry(makeMockShaderRegistry());
35
31
  }
@@ -59,10 +55,14 @@ function findMountedMember(w: World, rootA: EntityHandle): EntityHandle {
59
55
  }
60
56
  throw new Error('no mounted member found');
61
57
  }
62
- function firstMountOverride(scene: SceneAsset, comp: string): MountOverride | undefined {
63
- for (const m of scene.mounts ?? []) {
64
- for (const ov of m.overrides ?? []) {
65
- if (ov.comp === comp) return ov;
58
+ function firstInstanceOverride(
59
+ scene: SceneAsset,
60
+ comp: string,
61
+ ): { readonly value: unknown } | undefined {
62
+ for (const entity of Object.values(scene.entities)) {
63
+ for (const instanceOverride of entity.instance?.overrides ?? []) {
64
+ const value = instanceOverride.components[comp];
65
+ if (value !== undefined) return { value };
66
66
  }
67
67
  }
68
68
  return undefined;
@@ -83,12 +83,7 @@ describe('m3-t1: serialize refs index with schema derivation', () => {
83
83
 
84
84
  const sceneAsset: SceneAsset = {
85
85
  kind: 'scene',
86
- entities: [
87
- {
88
- localId: localId(0),
89
- components: { Test_SPackSharedScalar: { assetRef: guid1 } },
90
- },
91
- ],
86
+ entities: { 'entity-0': { components: { Test_SPackSharedScalar: { assetRef: guid1 } } } },
92
87
  };
93
88
 
94
89
  const packResult = serializeSceneAssetToPack(
@@ -116,7 +111,7 @@ describe('m3-t1: serialize refs index with schema derivation', () => {
116
111
  expect(refs).toContain(guid1);
117
112
 
118
113
  const payload = asset.payload as Record<string, unknown>;
119
- const entities = payload.entities as Array<Record<string, unknown>>;
114
+ const entities = Object.values(payload.entities as Record<string, Record<string, unknown>>);
120
115
  expect(entities).toHaveLength(1);
121
116
 
122
117
  const ent0 = entities[0];
@@ -138,12 +133,9 @@ describe('m3-t1: serialize refs index with schema derivation', () => {
138
133
 
139
134
  const sceneAsset: SceneAsset = {
140
135
  kind: 'scene',
141
- entities: [
142
- {
143
- localId: localId(0),
144
- components: { Test_SPackSharedArray: { sources: [guidA, guidB] } },
145
- },
146
- ],
136
+ entities: {
137
+ 'entity-0': { components: { Test_SPackSharedArray: { sources: [guidA, guidB] } } },
138
+ },
147
139
  };
148
140
 
149
141
  const packResult = serializeSceneAssetToPack(
@@ -163,7 +155,7 @@ describe('m3-t1: serialize refs index with schema derivation', () => {
163
155
  expect(refs).toContain(guidB);
164
156
 
165
157
  const payload = asset.payload as Record<string, unknown>;
166
- const entities = payload.entities as Array<Record<string, unknown>>;
158
+ const entities = Object.values(payload.entities as Record<string, Record<string, unknown>>);
167
159
  const ent0 = entities[0];
168
160
  if (!ent0) throw new Error('entity 0 missing');
169
161
  const comps = ent0.components as Record<string, Record<string, unknown>>;
@@ -190,12 +182,7 @@ describe('m3-t1: serialize refs index with schema derivation', () => {
190
182
 
191
183
  const sceneAsset: SceneAsset = {
192
184
  kind: 'scene',
193
- entities: [
194
- {
195
- localId: localId(0),
196
- components: { Test_STilesetPack: { tileset: tilesetGuid } },
197
- },
198
- ],
185
+ entities: { 'entity-0': { components: { Test_STilesetPack: { tileset: tilesetGuid } } } },
199
186
  };
200
187
 
201
188
  const packResult = serializeSceneAssetToPack(
@@ -226,12 +213,11 @@ describe('m3-t1: serialize refs index with schema derivation', () => {
226
213
  // collection (only strings are collected), so Phase 2 index lookup will fail.
227
214
  const sceneAsset: SceneAsset = {
228
215
  kind: 'scene',
229
- entities: [
230
- {
231
- localId: localId(0),
216
+ entities: {
217
+ 'entity-0': {
232
218
  components: { Test_SFailFastPack: { loneRef: 'non-collected-guid-wont-be-in-refs' } },
233
219
  },
234
- ],
220
+ },
235
221
  };
236
222
 
237
223
  // Use a completely unrelated GUID for the asset to ensure Phase 1 collects nothing.
@@ -272,15 +258,14 @@ describe('m3-t2: unregistered component silently skipped', () => {
272
258
 
273
259
  const sceneAsset: SceneAsset = {
274
260
  kind: 'scene',
275
- entities: [
276
- {
277
- localId: localId(0),
261
+ entities: {
262
+ 'entity-0': {
278
263
  components: {
279
264
  Test_SRegComp: { val: 42 },
280
265
  Unreg_TestSkip: { mysteryField: 'should-survive' },
281
266
  },
282
267
  },
283
- ],
268
+ },
284
269
  };
285
270
 
286
271
  const packResult = serializeSceneAssetToPack(
@@ -296,7 +281,7 @@ describe('m3-t2: unregistered component silently skipped', () => {
296
281
  const asset = assets[0];
297
282
  if (!asset) throw new Error('asset missing');
298
283
  const payload = asset.payload as Record<string, unknown>;
299
- const entities = payload.entities as Array<Record<string, unknown>>;
284
+ const entities = Object.values(payload.entities as Record<string, Record<string, unknown>>);
300
285
  expect(entities).toHaveLength(1);
301
286
 
302
287
  const ent0 = entities[0];
@@ -325,15 +310,14 @@ describe('m3-t2: unregistered component silently skipped', () => {
325
310
 
326
311
  const sceneAsset: SceneAsset = {
327
312
  kind: 'scene',
328
- entities: [
329
- {
330
- localId: localId(0),
313
+ entities: {
314
+ 'entity-0': {
331
315
  components: {
332
316
  Test_SRegComp2: { count: 99 },
333
317
  Unreg_TestSkip2: { ghostField: 12345 },
334
318
  },
335
319
  },
336
- ],
320
+ },
337
321
  };
338
322
 
339
323
  const packResult = serializeSceneAssetToPack(
@@ -349,7 +333,7 @@ describe('m3-t2: unregistered component silently skipped', () => {
349
333
  const asset = assets[0];
350
334
  if (!asset) throw new Error('asset missing');
351
335
  const payload = asset.payload as Record<string, unknown>;
352
- const entities = payload.entities as Array<Record<string, unknown>>;
336
+ const entities = Object.values(payload.entities as Record<string, Record<string, unknown>>);
353
337
  expect(entities).toHaveLength(1);
354
338
 
355
339
  const ent0 = entities[0];
@@ -409,13 +393,15 @@ function mintCatalogued(reg: AssetRegistry, w: World, guid: string, tag: number)
409
393
  function mountOne(reg: AssetRegistry, w: World): EntityHandle {
410
394
  const child: SceneAsset = {
411
395
  kind: 'scene',
412
- entities: [{ localId: localId(0), components: { Transform: { pos: [1, 0, 0] } } }],
396
+ entities: { 'entity-0': { components: { Transform: { pos: [1, 0, 0] } } } },
413
397
  };
414
398
  catScene(reg, W18_CHILD, child);
415
399
  const parent: SceneAsset = {
416
400
  kind: 'scene',
417
- entities: [{ localId: localId(0), components: { Transform: { pos: [0, 0, 0] } } }],
418
- mounts: [{ localId: localId(1), source: W18_CHILD, memberFirst: localId(2), memberCount: 1 }],
401
+ entities: {
402
+ 'entity-0': { components: { Transform: { pos: [0, 0, 0] } } },
403
+ child: { components: {}, instance: { source: W18_CHILD } },
404
+ },
419
405
  };
420
406
  catScene(reg, W18_PARENT, parent);
421
407
  const inst = reg.instantiate(rs(w, parent), w);
@@ -443,7 +429,7 @@ describe('w18 — override-value shared handle→GUID two-state NULL-sentinel (A
443
429
  const collect = rootsToSceneAsset(reg, w, [root]);
444
430
  expect(collect.ok).toBe(true);
445
431
  if (!collect.ok) return;
446
- const ov = firstMountOverride(collect.value, 'AnimationPlayer');
432
+ const ov = firstInstanceOverride(collect.value, 'AnimationPlayer');
447
433
  expect(ov).toBeDefined();
448
434
  if (!ov) return;
449
435
  const clips = (ov.value as Record<string, unknown>).clips as unknown[];
@@ -473,7 +459,7 @@ describe('w18 — override-value shared handle→GUID two-state NULL-sentinel (A
473
459
  const collect = rootsToSceneAsset(reg, w, [root]);
474
460
  expect(collect.ok).toBe(true);
475
461
  if (!collect.ok) return;
476
- const ov = firstMountOverride(collect.value, 'AnimationPlayer');
462
+ const ov = firstInstanceOverride(collect.value, 'AnimationPlayer');
477
463
  expect(ov).toBeDefined();
478
464
  if (!ov) return;
479
465
  const clips = (ov.value as Record<string, unknown>).clips as unknown[];
@@ -499,7 +485,7 @@ describe('w18 — override-value shared handle→GUID two-state NULL-sentinel (A
499
485
  const collect = rootsToSceneAsset(reg, w, [root]);
500
486
  expect(collect.ok).toBe(true);
501
487
  if (!collect.ok) return;
502
- const ov = firstMountOverride(collect.value, 'W18_ScalarShared');
488
+ const ov = firstInstanceOverride(collect.value, 'W18_ScalarShared');
503
489
  expect(ov).toBeDefined();
504
490
  if (!ov) return;
505
491
  const val = ov.value as Record<string, unknown>;
@@ -522,7 +508,7 @@ describe('w18 — override-value shared handle→GUID two-state NULL-sentinel (A
522
508
  const collect = rootsToSceneAsset(reg, w, [root]);
523
509
  expect(collect.ok).toBe(true);
524
510
  if (!collect.ok) return;
525
- const ov = firstMountOverride(collect.value, 'W18_ScalarShared');
511
+ const ov = firstInstanceOverride(collect.value, 'W18_ScalarShared');
526
512
  expect(ov).toBeDefined();
527
513
  if (!ov) return;
528
514
  const val = ov.value as Record<string, unknown>;
@@ -45,7 +45,9 @@ describe('shadow_caster shared Surface schema', () => {
45
45
 
46
46
  it('(b) shadow_caster has no material sidecar', () => {
47
47
  const sourcePath = join(repoRoot, 'packages', 'shader', 'src', 'shadow_caster.wgsl');
48
- expect(readFileSync(sourcePath, 'utf8')).toContain('shadow_caster.wgsl');
48
+ const source = readFileSync(sourcePath, 'utf8');
49
+ expect(source).toContain('#define_import_path forgeax::default-shadow-caster');
50
+ expect(source).toContain('#import forgeax_material::surface_v1::{SurfaceInput, SurfaceData}');
49
51
  expect(existsSync(`${sourcePath}.meta.json`)).toBe(false);
50
52
  });
51
53
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
16
16
  import { World } from '@forgeax/engine-ecs';
17
- import type { Renderer } from '@forgeax/engine-render';
17
+ import type { InstanceCollectionId, Renderer } from '@forgeax/engine-render';
18
18
  import {
19
19
  Camera,
20
20
  DirectionalLight,
@@ -39,11 +39,9 @@ const WIDTH = LIGHTWEIGHT_DAWN ? 128 : 256;
39
39
  const HEIGHT = LIGHTWEIGHT_DAWN ? 128 : 256;
40
40
  const BPR = Math.ceil((WIDTH * 4) / 256) * 256;
41
41
 
42
- // Each falsifier performs four fresh Dawn readbacks at the active target size
43
- // (256x256 by default, 128x128 in the lightweight CI lane). Windows' Dawn
44
- // prebuild can exceed Vitest's 90-second bound for that sequence while still
45
- // completing well inside the nightly platform job's 45-minute budget.
46
- const SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS = 300_000;
42
+ // Four fresh readbacks, or the 2048-square map's full 300 frames, retain a
43
+ // bounded software-GPU budget. The lightweight map comparison keeps 60 seconds.
44
+ const SHADOW_FIELDS_FULL_PIXEL_TIMEOUT_MS = 300_000;
47
45
 
48
46
  const ENGINE_MANIFEST = await (async () => {
49
47
  const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
@@ -191,6 +189,7 @@ function spawnScene(
191
189
  normalBias: number,
192
190
  pcf: number,
193
191
  mapSize?: number,
192
+ instanceCollectionId?: InstanceCollectionId,
194
193
  ) {
195
194
  const cm = casterMat(world);
196
195
  const fm = floorMat(world);
@@ -234,6 +233,10 @@ function spawnScene(
234
233
  intensity: 1,
235
234
  castShadow,
236
235
  mapSize: mapSize ?? 1024,
236
+ // One cascade covers this field-comparison scene. Multi-cascade rebuilds
237
+ // are exercised by shadow-csm-runtime-vary.dawn.test.ts; repeating four
238
+ // 2048-square depth maps here hid the field assertions behind timeouts.
239
+ cascadeCount: 1,
237
240
  depthBias,
238
241
  normalBias,
239
242
  shadowDistance: 50,
@@ -253,7 +256,9 @@ function spawnScene(
253
256
  },
254
257
  { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
255
258
  { component: MeshRenderer, data: { materials: [cm] } },
256
- { component: Instances, data: { transforms: buildTranslationGrid() } },
259
+ ...(instanceCollectionId === undefined
260
+ ? []
261
+ : [{ component: Instances, data: { collectionId: instanceCollectionId } }]),
257
262
  );
258
263
  }
259
264
 
@@ -360,7 +365,10 @@ async function renderConfig(
360
365
  const dev = sd;
361
366
 
362
367
  const w = new World();
363
- spawnScene(w, castShadow, depthBias, normalBias, pcf, mapSize);
368
+ const instanceCollection = renderer.instances
369
+ .create({ transforms: buildTranslationGrid() })
370
+ .unwrap();
371
+ spawnScene(w, castShadow, depthBias, normalBias, pcf, mapSize, instanceCollection.collectionId);
364
372
  let de = 0;
365
373
  for (let i = 0; i < SHADOW_FIELDS_RENDER_FRAMES; i++) {
366
374
  const r = drawPublished(renderer, w);
@@ -485,7 +493,10 @@ async function renderConfigWithSpy(
485
493
  const dev = sd;
486
494
 
487
495
  const w = new World();
488
- spawnScene(w, castShadow, depthBias, normalBias, pcf, mapSize);
496
+ const instanceCollection = renderer.instances
497
+ .create({ transforms: buildTranslationGrid() })
498
+ .unwrap();
499
+ spawnScene(w, castShadow, depthBias, normalBias, pcf, mapSize, instanceCollection.collectionId);
489
500
  let de = 0;
490
501
  for (let i = 0; i < 10; i++) {
491
502
  const r = drawPublished(renderer, w);
@@ -623,13 +634,17 @@ describe('M6 shadow fields observability', () => {
623
634
  }, 60000);
624
635
 
625
636
  // --- Gate F: mapSize 256 vs 2048 (independent shadow proof) ---
626
- it('pixel A/B: mapSize 256 vs 2048', async () => {
627
- if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
628
- const pxA = await renderConfig(true, 0.005, 0.05, 3, 256);
629
- const pxB = await renderConfig(true, 0.005, 0.05, 3, 2048);
630
- const d = diff(pxA, pxB);
631
- expect(d.diff).toBeGreaterThan(0);
632
- }, 60000);
637
+ it(
638
+ 'pixel A/B: mapSize 256 vs 2048',
639
+ async () => {
640
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
641
+ const pxA = await renderConfig(true, 0.005, 0.05, 3, 256);
642
+ const pxB = await renderConfig(true, 0.005, 0.05, 3, 2048);
643
+ const d = diff(pxA, pxB);
644
+ expect(d.diff).toBeGreaterThan(0);
645
+ },
646
+ LIGHTWEIGHT_DAWN ? 60_000 : SHADOW_FIELDS_FULL_PIXEL_TIMEOUT_MS,
647
+ );
633
648
 
634
649
  // --- Gate E: equal-control — same config twice => 0 diff ---
635
650
  it('equal-control: same config twice = 0 byte diff', async () => {
@@ -678,7 +693,7 @@ describe('M6 shadow fields observability', () => {
678
693
  expect(varied.diff).toBeGreaterThan(0);
679
694
  expect(held.diff).toBe(0);
680
695
  },
681
- SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS,
696
+ SHADOW_FIELDS_FULL_PIXEL_TIMEOUT_MS,
682
697
  );
683
698
 
684
699
  it(
@@ -696,7 +711,7 @@ describe('M6 shadow fields observability', () => {
696
711
  expect(varied.diff).toBeGreaterThan(0);
697
712
  expect(held.diff).toBe(0);
698
713
  },
699
- SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS,
714
+ SHADOW_FIELDS_FULL_PIXEL_TIMEOUT_MS,
700
715
  );
701
716
 
702
717
  it(
@@ -714,6 +729,6 @@ describe('M6 shadow fields observability', () => {
714
729
  expect(varied.diff).toBeGreaterThan(0);
715
730
  expect(held.diff).toBe(0);
716
731
  },
717
- SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS,
732
+ SHADOW_FIELDS_FULL_PIXEL_TIMEOUT_MS,
718
733
  );
719
734
  });
@@ -13,32 +13,25 @@ function sourceAfter(signature: string): string {
13
13
  return shadowPass.slice(start);
14
14
  }
15
15
 
16
- function expectValidatedOrderedLoop(
17
- signature: string,
18
- indexName: string,
19
- dynamicOffsetBindGroup: string,
20
- ): void {
16
+ function expectValidatedOrderedLoop(signature: string, indexName: string): void {
21
17
  const body = sourceAfter(signature);
22
18
  expect(body).toContain(
23
19
  `for (let ${indexName} = 0; ${indexName} < validatedOrdered.length; ${indexName}++)`,
24
20
  );
25
- expect(body).toContain(`${dynamicOffsetBindGroup}, [${indexName} * MESH_PER_ENTITY_STRIDE]`);
21
+ expect(body).toContain('const meshSsboBase = c.shadowMeshSsboBase ?? 0');
22
+ expect(body).toContain(`(meshSsboBase + ${indexName}) * MESH_PER_ENTITY_STRIDE`);
26
23
  }
27
24
 
28
25
  describe('shadow mesh SSBO order', () => {
29
26
  it('uses the upload order for typed directional, point, and spot shadow offsets', () => {
30
- expectValidatedOrderedLoop(
31
- 'function recordShadowCasterDraws(',
32
- 'i',
33
- 'shadowPass.setBindGroup(2, shadowMeshBindGroup',
34
- );
27
+ expectValidatedOrderedLoop('function recordShadowCasterDraws(', 'i');
35
28
  expect(shadowPass).toContain('export function encodeDirectionalShadowPass(');
36
29
  expect(shadowPass).toContain('export function encodePointShadowPass(');
37
30
  expect(shadowPass).toContain('export function encodeSpotShadowPass(');
38
31
  const spotBody = sourceAfter('export function encodeSpotShadowPass(');
39
32
  expect(spotBody).toContain('recordShadowCasterDraws(');
40
33
  expect(spotBody).toContain(
41
- "buildMatchedRenderableIndices(c.dispatch, { LightMode: ['ShadowCaster'] })",
34
+ "buildMatchedRenderableIndices(shadowDispatchEntries(c), { LightMode: ['ShadowCaster'] })",
42
35
  );
43
36
  expect(shadowPass).not.toContain('export function recordShadowPass(');
44
37
  expect(shadowPass).not.toContain('export function recordPointShadowPass(');
@@ -4,8 +4,9 @@ import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { describe, expect, it } from 'vitest';
6
6
 
7
- // Regression guard for the hot extract path: joint/instance array fields must
8
- // stay on the zero-copy column route instead of whole-row world.get calls.
7
+ // Regression guard for the hot extract path: joint and sprite array fields stay
8
+ // on column views, while large explicit instance matrices stay in the
9
+ // renderer-owned collection store instead of being copied into ECS.
9
10
 
10
11
  const extractPaths = (() => {
11
12
  const here = dirname(fileURLToPath(import.meta.url));
@@ -14,12 +15,13 @@ const extractPaths = (() => {
14
15
  })();
15
16
 
16
17
  describe('_getArrayView count (AC-03 gate)', () => {
17
- it('keeps skin and instance hot fields on column views', () => {
18
+ it('keeps skin/sprite hot fields on column views and instances renderer-owned', () => {
18
19
  const src = extractPaths.map((path) => readFileSync(path, 'utf8')).join('\n');
19
20
  expect(src).toMatch(/_getArrayView\(jointEntity,\s*GlobalTransform,\s*'world'\)/);
20
- expect(src).toContain("_getArrayView(entity, Instances, 'transforms')");
21
21
  expect(src).toContain("_getArrayView(entity, SpriteInstances, 'transforms')");
22
22
  expect(src).toContain("_getArrayView(entity, SpriteInstances, 'regions')");
23
+ expect(src).toContain('instanceCollections.snapshot(collectionId)');
24
+ expect(src).not.toContain("_getArrayView(entity, Instances, 'transforms')");
23
25
  expect(src).not.toMatch(/^\s*const\s+\w+\s*=\s*world\.get\(jointEntity, Transform\)/m);
24
26
  expect(src).not.toMatch(/^\s*const\s+\w+\s*=\s*world\.get\(entity, Instances\)/m);
25
27
  });
@@ -63,6 +63,7 @@ import { AssetRegistry } from '@forgeax/engine-assets-runtime';
63
63
  import type { EntityHandle, World as WorldType } from '@forgeax/engine-ecs';
64
64
  import { ENTITY_NULL_RAW, World } from '@forgeax/engine-ecs';
65
65
  import { SpriteAnimationInvalidError } from '@forgeax/engine-ecs/projection';
66
+ import { buildMeshAttributeMapForUvSets } from '@forgeax/engine-geometry';
66
67
  import { mat4, vec3 } from '@forgeax/engine-math';
67
68
  import type { RenderErrorCode, Renderer as RendererType } from '@forgeax/engine-render';
68
69
  import {
@@ -391,6 +392,7 @@ function registerSkinM2Mesh(world: World): Handle<'MeshAsset', 'shared'> {
391
392
  vertices: SKIN_M2_TRIANGLE_VERTICES,
392
393
  indices: new Uint16Array([0, 1, 2]),
393
394
  attributes: {
395
+ ...buildMeshAttributeMapForUvSets(1),
394
396
  position: SKIN_M2_TRIANGLE_POSITIONS,
395
397
  skinIndex: new Uint16Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
396
398
  skinWeight: new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]),
@@ -492,17 +494,40 @@ function spawnSkinM2SkinnedEntity(
492
494
 
493
495
  type SkinM2StubAllocator = {
494
496
  buffer: null;
495
- allocateSlice(jointCount: number): { jointCount: number; byteOffset: number };
497
+ allocatePersistentSlice(input: {
498
+ identity: string;
499
+ generation: number;
500
+ jointCount: number;
501
+ bounds?: Float32Array;
502
+ }): SkinM2PersistentReceipt;
503
+ observePersistentJoints(
504
+ identity: string,
505
+ ibms: readonly Float32Array[],
506
+ jointWorlds: readonly Float32Array[],
507
+ ): readonly number[];
508
+ allocateSlice(jointCount: number): { jointCount: number; byteOffset: number; buffer: null };
496
509
  writeJointPalette(
497
510
  slice: { jointCount: number; byteOffset: number },
498
511
  ibms: readonly Float32Array[],
499
512
  jointWorlds: readonly Float32Array[],
500
513
  ): void;
514
+ beginFrame(): void;
515
+ endFrame(): void;
516
+ releasePersistentSlice(identity: string): void;
517
+ writePersistentJointPalette(receipt: SkinM2PersistentReceipt): void;
501
518
  resetForFrame(): void;
502
519
  _resetCount(): number;
503
520
  _writeCount(): number;
504
521
  };
505
522
 
523
+ type SkinM2PersistentReceipt = {
524
+ identity: string;
525
+ generation: number;
526
+ jointCount: number;
527
+ byteOffset: number;
528
+ buffer: null;
529
+ };
530
+
506
531
  function makeSkinM2StubAllocator(): SkinM2StubAllocator {
507
532
  let cursor = 0;
508
533
  let resetCount = 0;
@@ -513,11 +538,34 @@ function makeSkinM2StubAllocator(): SkinM2StubAllocator {
513
538
  const offset = cursor;
514
539
  // 256-aligned bump (mirrors real allocator dyn-offset alignment).
515
540
  cursor = (cursor + jointCount * 64 + 255) & ~255;
516
- return { jointCount, byteOffset: offset };
541
+ return { jointCount, byteOffset: offset, buffer: null };
517
542
  },
518
543
  writeJointPalette() {
519
544
  writeCount++;
520
545
  },
546
+ observePersistentJoints() {
547
+ return [];
548
+ },
549
+ allocatePersistentSlice(input) {
550
+ const slice = this.allocateSlice(input.jointCount);
551
+ return {
552
+ ...slice,
553
+ identity: input.identity,
554
+ generation: input.generation,
555
+ fence: 1,
556
+ customDataStart: 0,
557
+ storageOrUniform: 'storage',
558
+ dirtyRanges: [{ startJoint: 0, jointCount: input.jointCount }],
559
+ uploadBytes: input.jointCount * 64,
560
+ ...(input.bounds === undefined ? {} : { bounds: input.bounds }),
561
+ };
562
+ },
563
+ writePersistentJointPalette() {
564
+ writeCount++;
565
+ },
566
+ beginFrame() {},
567
+ endFrame() {},
568
+ releasePersistentSlice() {},
521
569
  resetForFrame() {
522
570
  cursor = 0;
523
571
  resetCount++;
@@ -63,6 +63,7 @@ import { AssetRegistry } from '@forgeax/engine-assets-runtime';
63
63
  import type { EntityHandle, World as WorldType } from '@forgeax/engine-ecs';
64
64
  import { ENTITY_NULL_RAW, World } from '@forgeax/engine-ecs';
65
65
  import { SpriteAnimationInvalidError } from '@forgeax/engine-ecs/projection';
66
+ import { buildMeshAttributeMapForUvSets } from '@forgeax/engine-geometry';
66
67
  import { mat4, vec3 } from '@forgeax/engine-math';
67
68
  import type { RenderErrorCode, Renderer as RendererType } from '@forgeax/engine-render';
68
69
  import {
@@ -391,6 +392,7 @@ function registerSkinM2Mesh(world: World): Handle<'MeshAsset', 'shared'> {
391
392
  vertices: SKIN_M2_TRIANGLE_VERTICES,
392
393
  indices: new Uint16Array([0, 1, 2]),
393
394
  attributes: {
395
+ ...buildMeshAttributeMapForUvSets(1),
394
396
  position: SKIN_M2_TRIANGLE_POSITIONS,
395
397
  skinIndex: new Uint16Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
396
398
  skinWeight: new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]),
@@ -492,17 +494,40 @@ function spawnSkinM2SkinnedEntity(
492
494
 
493
495
  type SkinM2StubAllocator = {
494
496
  buffer: null;
495
- allocateSlice(jointCount: number): { jointCount: number; byteOffset: number };
497
+ allocatePersistentSlice(input: {
498
+ identity: string;
499
+ generation: number;
500
+ jointCount: number;
501
+ bounds?: Float32Array;
502
+ }): SkinM2PersistentReceipt;
503
+ observePersistentJoints(
504
+ identity: string,
505
+ ibms: readonly Float32Array[],
506
+ jointWorlds: readonly Float32Array[],
507
+ ): readonly number[];
508
+ allocateSlice(jointCount: number): { jointCount: number; byteOffset: number; buffer: null };
496
509
  writeJointPalette(
497
510
  slice: { jointCount: number; byteOffset: number },
498
511
  ibms: readonly Float32Array[],
499
512
  jointWorlds: readonly Float32Array[],
500
513
  ): void;
514
+ beginFrame(): void;
515
+ endFrame(): void;
516
+ releasePersistentSlice(identity: string): void;
517
+ writePersistentJointPalette(receipt: SkinM2PersistentReceipt): void;
501
518
  resetForFrame(): void;
502
519
  _resetCount(): number;
503
520
  _writeCount(): number;
504
521
  };
505
522
 
523
+ type SkinM2PersistentReceipt = {
524
+ identity: string;
525
+ generation: number;
526
+ jointCount: number;
527
+ byteOffset: number;
528
+ buffer: null;
529
+ };
530
+
506
531
  function makeSkinM2StubAllocator(): SkinM2StubAllocator {
507
532
  let cursor = 0;
508
533
  let resetCount = 0;
@@ -513,11 +538,34 @@ function makeSkinM2StubAllocator(): SkinM2StubAllocator {
513
538
  const offset = cursor;
514
539
  // 256-aligned bump (mirrors real allocator dyn-offset alignment).
515
540
  cursor = (cursor + jointCount * 64 + 255) & ~255;
516
- return { jointCount, byteOffset: offset };
541
+ return { jointCount, byteOffset: offset, buffer: null };
517
542
  },
518
543
  writeJointPalette() {
519
544
  writeCount++;
520
545
  },
546
+ observePersistentJoints() {
547
+ return [];
548
+ },
549
+ allocatePersistentSlice(input) {
550
+ const slice = this.allocateSlice(input.jointCount);
551
+ return {
552
+ ...slice,
553
+ identity: input.identity,
554
+ generation: input.generation,
555
+ fence: 1,
556
+ customDataStart: 0,
557
+ storageOrUniform: 'storage',
558
+ dirtyRanges: [{ startJoint: 0, jointCount: input.jointCount }],
559
+ uploadBytes: input.jointCount * 64,
560
+ ...(input.bounds === undefined ? {} : { bounds: input.bounds }),
561
+ };
562
+ },
563
+ writePersistentJointPalette() {
564
+ writeCount++;
565
+ },
566
+ beginFrame() {},
567
+ endFrame() {},
568
+ releasePersistentSlice() {},
521
569
  resetForFrame() {
522
570
  cursor = 0;
523
571
  resetCount++;