@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
@@ -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++;
@@ -378,7 +378,9 @@ type __MergedKeep =
378
378
  written.push({ offset, data });
379
379
  return { ok: true };
380
380
  },
381
+ onSubmittedWorkDone: async () => undefined,
381
382
  },
383
+ destroyBuffer: () => ({ ok: true, value: undefined }),
382
384
  } as unknown as Parameters<typeof createSkinPaletteAllocator>[0],
383
385
  buffers,
384
386
  written,
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
9
9
  import { World } from '@forgeax/engine-ecs';
10
+ import { packInterleavedVertexAttributes } from '@forgeax/engine-geometry';
10
11
  import type { Renderer } from '@forgeax/engine-render';
11
12
  import { Camera, DirectionalLight, MeshFilter, MeshRenderer } from '@forgeax/engine-render';
12
13
  import { Transform } from '@forgeax/engine-scene';
@@ -67,7 +68,7 @@ function staticMaterial(world: World): Handle<'MaterialAsset', 'shared'> {
67
68
  });
68
69
  }
69
70
 
70
- function skinnedMaterial(world: World): Handle<'MaterialAsset', 'shared'> {
71
+ function skinnedMaterial(world: World, masked: boolean): Handle<'MaterialAsset', 'shared'> {
71
72
  return world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
72
73
  kind: 'material',
73
74
  passes: [
@@ -86,7 +87,8 @@ function skinnedMaterial(world: World): Handle<'MaterialAsset', 'shared'> {
86
87
  },
87
88
  ],
88
89
  values: {
89
- baseColor: [0.15, 0.45, 0.9, 1],
90
+ baseColor: [0.15, 0.45, 0.9, masked ? 0 : 1],
91
+ alphaCutoff: masked ? 0.5 : 0,
90
92
  metallic: 0.1,
91
93
  roughness: 0.55,
92
94
  emissive: [0, 0, 0],
@@ -120,22 +122,22 @@ function floorMaterial(world: World): Handle<'MaterialAsset', 'shared'> {
120
122
  function skinnedMesh(world: World): Handle<'MeshAsset', 'shared'> {
121
123
  // Canonical interleaved order: position, normal, uv, tangent, skinIndex,
122
124
  // skinWeight (72 bytes / vertex). All vertices use joint zero.
123
- const vertices = new Float32Array([
124
- -0.75, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0.75, 0, 0, 0, 0, 1, 1, 0, 1, 0,
125
- 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1.2, 0, 0, 0, 1, 0.5, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0,
126
- ]);
125
+ const attributes = {
126
+ position: new Float32Array([-0.75, 0, 0, 0.75, 0, 0, 0, 1.2, 0]),
127
+ normal: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]),
128
+ uv: new Float32Array([0, 0, 1, 0, 0.5, 1]),
129
+ tangent: new Float32Array([1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1]),
130
+ skinIndex: new Uint16Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
131
+ skinWeight: new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]),
132
+ };
133
+ const packed = packInterleavedVertexAttributes(attributes, 3);
134
+ if (!packed.ok) throw packed.error;
135
+ const vertices = packed.value.vertices;
127
136
  return world.allocSharedRef<'MeshAsset', MeshAsset>('MeshAsset', {
128
137
  kind: 'mesh',
129
138
  vertices,
130
139
  indices: new Uint16Array([0, 1, 2]),
131
- attributes: {
132
- position: new Float32Array([-0.75, 0, 0, 0.75, 0, 0, 0, 1.2, 0]),
133
- normal: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]),
134
- uv: new Float32Array([0, 0, 1, 0, 0.5, 1]),
135
- tangent: new Float32Array([1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1]),
136
- skinIndex: new Uint16Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
137
- skinWeight: new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]),
138
- },
140
+ attributes,
139
141
  aabb: new Float32Array([-0.75, 0, 0, 0.75, 1.2, 0]),
140
142
  materialSlots: [{ slotName: 'Default' }],
141
143
  submeshes: [
@@ -150,7 +152,7 @@ function skinnedMesh(world: World): Handle<'MeshAsset', 'shared'> {
150
152
  });
151
153
  }
152
154
 
153
- function skeleton(world: World): Handle<'SkeletonAsset', 'shared'> {
155
+ function skeleton(world: World, missingBounds: boolean): Handle<'SkeletonAsset', 'shared'> {
154
156
  const inverseBindMatrices = new Float32Array(16);
155
157
  inverseBindMatrices[0] = 1;
156
158
  inverseBindMatrices[5] = 1;
@@ -160,6 +162,7 @@ function skeleton(world: World): Handle<'SkeletonAsset', 'shared'> {
160
162
  kind: 'skeleton',
161
163
  inverseBindMatrices,
162
164
  jointCount: 1,
165
+ ...(missingBounds ? {} : { bounds: new Float32Array([-1, -1, -1, 1, 1, 1]) }),
163
166
  });
164
167
  }
165
168
 
@@ -167,12 +170,17 @@ function identityTransform(pos: readonly [number, number, number] = [0, 0, 0]) {
167
170
  return { pos, quat: [0, 0, 0, 1] as const, scale: [1, 1, 1] as const };
168
171
  }
169
172
 
170
- function spawnMixedScene(world: World, castShadow: boolean): void {
173
+ function spawnMixedScene(
174
+ world: World,
175
+ castShadow: boolean,
176
+ maskedSkin: boolean,
177
+ omitSkin: boolean,
178
+ ): void {
171
179
  const staticMat = staticMaterial(world);
172
- const skinMat = skinnedMaterial(world);
180
+ const skinMat = skinnedMaterial(world, maskedSkin);
173
181
  const floorMat = floorMaterial(world);
174
182
  const skinMesh = skinnedMesh(world);
175
- const skinSkeleton = skeleton(world);
183
+ const skinSkeleton = skeleton(world, maskedSkin);
176
184
 
177
185
  world.spawn(
178
186
  { component: Transform, data: { ...identityTransform([0, 5, 5]), scale: [10, 0.1, 10] } },
@@ -219,18 +227,19 @@ function spawnMixedScene(world: World, castShadow: boolean): void {
219
227
  // Entity order is intentional: static A -> skinned B -> static C.
220
228
  spawnStaticCaster(-3);
221
229
  const joint = world.spawn({ component: Transform, data: identityTransform([0, 7, 5]) }).unwrap();
222
- world.spawn(
223
- { component: Transform, data: identityTransform() },
224
- { component: MeshFilter, data: { assetHandle: skinMesh } },
225
- { component: MeshRenderer, data: { materials: [skinMat] } },
226
- {
227
- component: Skin,
228
- data: {
229
- skeleton: skinSkeleton,
230
- joints: new Uint32Array([joint as unknown as number]),
230
+ if (!omitSkin)
231
+ world.spawn(
232
+ { component: Transform, data: identityTransform() },
233
+ { component: MeshFilter, data: { assetHandle: skinMesh } },
234
+ { component: MeshRenderer, data: { materials: [skinMat] } },
235
+ {
236
+ component: Skin,
237
+ data: {
238
+ skeleton: skinSkeleton,
239
+ joints: new Uint32Array([joint as unknown as number]),
240
+ },
231
241
  },
232
- },
233
- );
242
+ );
234
243
  spawnStaticCaster(3);
235
244
  }
236
245
 
@@ -253,7 +262,18 @@ async function readPixels(device: GPUDevice, texture: GPUTexture): Promise<Uint8
253
262
  return pixels;
254
263
  }
255
264
 
256
- async function renderMixed(castShadow: boolean): Promise<MixedCapture> {
265
+ /**
266
+ * Render both comparison worlds through one host/device. The previous
267
+ * fixture constructed and tore down a complete renderer twice; that paid the
268
+ * native shader prewarm cost twice while adding no GPU coverage. Keeping the
269
+ * renderer alive across the two worlds preserves the shadow/no-shadow pixel
270
+ * comparison and makes the Dawn test's startup cost proportional to one
271
+ * renderer initialization.
272
+ */
273
+ async function renderMixedPair(maskedSkin = false): Promise<{
274
+ readonly shadow: MixedCapture;
275
+ readonly baseline: MixedCapture;
276
+ }> {
257
277
  let device: GPUDevice | undefined;
258
278
  let target: GPUTexture | undefined;
259
279
  const originalRequestAdapter = globalThis.navigator.gpu.requestAdapter.bind(
@@ -313,22 +333,35 @@ async function renderMixed(castShadow: boolean): Promise<MixedCapture> {
313
333
  errors.push(event.error.code);
314
334
  }
315
335
  });
316
- const world = new World();
317
- spawnMixedScene(world, castShadow);
318
- // The first frame warms the static and skinned PSOs; the second frame is
319
- // the asserted mixed command stream after both cache entries exist.
320
- for (let frame = 0; frame < 2; frame += 1) {
321
- const receipt = drawPublished(renderer, world);
322
- expect(receipt.ok, `mixed ${castShadow ? 'shadow' : 'baseline'} draw`).toBe(true);
323
- if (!receipt.ok) throw receipt.error;
324
- const completed = await receipt.value.completed;
325
- expect(completed.ok, 'Queue::submit completion').toBe(true);
326
- if (!completed.ok) throw completed.error;
327
- }
328
- if (device === undefined || target === undefined)
329
- throw new Error('Dawn target not initialized');
330
- await device.queue.onSubmittedWorkDone();
331
- return { pixels: await readPixels(device, target), errors };
336
+ const renderWorld = async (castShadow: boolean, omitSkin = false): Promise<MixedCapture> => {
337
+ const world = new World();
338
+ spawnMixedScene(world, castShadow, maskedSkin, omitSkin);
339
+ const attached = renderer?.attach(world);
340
+ if (attached === undefined || !attached.ok) {
341
+ throw attached?.error ?? new Error('renderer attachment missing');
342
+ }
343
+ // The first frame warms the static and skinned PSOs; the second frame is
344
+ // the asserted mixed command stream after both cache entries exist.
345
+ for (let frame = 0; frame < 2; frame += 1) {
346
+ const receipt = drawPublished(renderer, world);
347
+ expect(receipt.ok, `mixed ${castShadow ? 'shadow' : 'baseline'} draw`).toBe(true);
348
+ if (!receipt.ok) throw receipt.error;
349
+ const completed = await receipt.value.completed;
350
+ expect(completed.ok, 'Queue::submit completion').toBe(true);
351
+ if (!completed.ok) throw completed.error;
352
+ }
353
+ if (device === undefined || target === undefined)
354
+ throw new Error('Dawn target not initialized');
355
+ await device.queue.onSubmittedWorkDone();
356
+ const pixels = await readPixels(device, target);
357
+ attached.value.dispose();
358
+ return { pixels, errors: [...errors] };
359
+ };
360
+ const shadow = await renderWorld(true);
361
+ // Remove only the clipped skin for the mask control; retain neighboring
362
+ // rigid shadows so their PCF contributions cancel exactly.
363
+ const baseline = maskedSkin ? await renderWorld(true, true) : await renderWorld(false);
364
+ return { shadow, baseline };
332
365
  } finally {
333
366
  globalThis.navigator.gpu.requestAdapter = originalRequestAdapter;
334
367
  unsubscribe?.();
@@ -356,12 +389,24 @@ function regionDiff(a: Uint8Array, b: Uint8Array, x0: number, x1: number): numbe
356
389
  }
357
390
 
358
391
  describe('mixed static/skinned shadow pipeline (Dawn)', () => {
392
+ it('keeps alpha clipping on CPU skin residuals without conservative bounds', async () => {
393
+ const { shadow, baseline } = await renderMixedPair(true);
394
+ expect(shadow.errors).toEqual([]);
395
+ expect(baseline.errors).toEqual([]);
396
+ // A fully clipped skin must match an absent skin in both color and depth.
397
+ // The opaque test below independently proves all three caster regions.
398
+ const changed = shadow.pixels.reduce(
399
+ (count, value, index) => count + Number(value !== baseline.pixels[index]),
400
+ 0,
401
+ );
402
+ expect(changed).toBe(0);
403
+ }, 120000);
404
+
359
405
  it('submits static → skinned → static casters with zero validation errors', async () => {
360
406
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') {
361
407
  throw new Error('dawn-node navigator.gpu not injected');
362
408
  }
363
- const shadow = await renderMixed(true);
364
- const baseline = await renderMixed(false);
409
+ const { shadow, baseline } = await renderMixedPair();
365
410
  expect(shadow.errors).toEqual([]);
366
411
  expect(baseline.errors).toEqual([]);
367
412
  expect(shadow.pixels.length).toBeGreaterThan(0);
@@ -420,13 +420,17 @@ type __MergedKeep =
420
420
  expect(layout?.label).toBe('pbr-pl');
421
421
  });
422
422
 
423
- it('(b) PBR material BGL entry count === 24 (user region 0..14 + Skylight 15..21 + transmission 22..23)', () => {
423
+ it('(b) PBR material BGL entry count === 25 (user region 0..14 + Skylight 15..21 + transmission 22..23 + scene row 46)', () => {
424
424
  const device = makeMockDevice();
425
425
  // biome-ignore lint/suspicious/noExplicitAny: structural mock
426
426
  buildPbrPipelineLayouts(device as any, STORAGE_CAPS);
427
427
  const materialBgl = device.capturedBgls.find((b) => b.label === 'pbr-material-skylight-bgl');
428
428
  expect(materialBgl).toBeDefined();
429
- expect(materialBgl?.entries).toHaveLength(24);
429
+ expect(materialBgl?.entries).toHaveLength(25);
430
+ expect(materialBgl?.entries[24]).toMatchObject({
431
+ binding: 46,
432
+ buffer: { type: 'read-only-storage', hasDynamicOffset: false },
433
+ });
430
434
  });
431
435
 
432
436
  it('(c) binding indices 0..23 in order; 15..21 resource types in D-5 round-4 order; 22..23 transmission', () => {
@@ -24,7 +24,7 @@ describe('sparse tag scene persistence', () => {
24
24
  .unwrap();
25
25
  const registry = {} as AssetRegistry;
26
26
  const collected = rootsToSceneAsset(registry, source, [entity]).unwrap();
27
- const components = collected.entities[0]?.components as
27
+ const components = Object.values(collected.entities)[0]?.components as
28
28
  | Record<string, Record<string, unknown>>
29
29
  | undefined;
30
30
 
@@ -126,16 +126,24 @@ describe('sprite-lit BGL byte-identical to sprite (AC-07, w4/w5 close)', () => {
126
126
  expect(base.length).toBe(15);
127
127
  });
128
128
 
129
- it('pbr-material-merged stays 24 entries (sprite/sprite-lit do not change material BGL shape)', () => {
129
+ it('pbr-material-merged stays 25 entries (sprite/sprite-lit do not change material BGL shape)', () => {
130
130
  const merged = buildBindGroupLayoutDescriptor(makeSpec('forgeax::sprite-lit'), {
131
131
  kind: 'pbr-material-merged',
132
132
  });
133
- expect(merged.entries.length).toBe(24);
133
+ expect(merged.entries.length).toBe(25);
134
134
  // mergeSkylightIntoMaterialBgl + transmission injection must stay
135
135
  // append-only against the 7-entry PBR base.
136
136
  const base = buildPbrMaterialUserRegionEntries();
137
137
  const afterSky = mergeSkylightIntoMaterialBgl(base);
138
- const expected = [...afterSky, ...appendInjection(afterSky, 'transmission')];
138
+ const expected = [
139
+ ...afterSky,
140
+ ...appendInjection(afterSky, 'transmission'),
141
+ {
142
+ binding: 46,
143
+ visibility: 0x1 | 0x2,
144
+ buffer: { type: 'read-only-storage', hasDynamicOffset: false },
145
+ },
146
+ ];
139
147
  expect(merged.entries).toEqual(expected);
140
148
  });
141
149
  });
@@ -38,15 +38,11 @@ import { MeshRenderer, SceneInstance } from '@forgeax/engine-render';
38
38
  // undefined during instantiate. ChildOf + SceneInstance are used directly
39
39
  // below as values; Transform / MeshRenderer pin the side-effect import.
40
40
  import { ChildOf, Transform } from '@forgeax/engine-scene';
41
- import type { LocalEntityId, MaterialAsset, SceneAsset, SceneEntity } from '@forgeax/engine-types';
41
+ import type { MaterialAsset, SceneAsset } from '@forgeax/engine-types';
42
42
  import { describe, expect, it } from 'vitest';
43
43
  import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
44
44
  import { registerRuntimeComponents } from './helpers/register-runtime-components';
45
45
 
46
- function lid(n: number): LocalEntityId {
47
- return n as LocalEntityId;
48
- }
49
-
50
46
  // Shape this fixture mirrors the `apps/game-capability-lab/assets/scene.pack.json`
51
47
  // SceneAsset payload after the template's `instantiateScenePack` helper has
52
48
  // rewritten ref-int → GUID strings + lifted MeshRenderer.material → materials[].
@@ -61,9 +57,8 @@ function buildSceneAssetWithGuidRef(): SceneAsset {
61
57
  void Transform;
62
58
  void MeshRenderer;
63
59
 
64
- const nodes: SceneEntity[] = [
65
- {
66
- localId: lid(0),
60
+ const nodes = {
61
+ 'entity-0': {
67
62
  components: {
68
63
  Transform: { pos: [0, 0, 0] },
69
64
  // GUID string (post template ref-int → GUID rewrite). The registry's
@@ -72,14 +67,13 @@ function buildSceneAssetWithGuidRef(): SceneAsset {
72
67
  MeshRenderer: { materials: [MAT_GUID] },
73
68
  },
74
69
  },
75
- {
76
- localId: lid(1),
70
+ 'entity-1': {
77
71
  components: {
78
72
  Transform: { pos: [1, 0, 0] },
79
- ChildOf: { parent: 0 as unknown as EntityHandle },
73
+ ChildOf: { parent: 'entity-0' },
80
74
  },
81
75
  },
82
- ];
76
+ };
83
77
  return { kind: 'scene', entities: nodes };
84
78
  }
85
79
 
@@ -272,13 +266,19 @@ describe('game-capability-lab API contract (regression for `world.sceneInstances
272
266
  assets:
273
267
  | {
274
268
  kind: string;
275
- payload?: { entities?: SceneEntity[]; nodes?: SceneEntity[] };
269
+ payload?: {
270
+ entities?: Record<string, { components: Record<string, Record<string, unknown>> }>;
271
+ nodes?: Record<string, { components: Record<string, Record<string, unknown>> }>;
272
+ };
276
273
  }[]
277
274
  | Record<
278
275
  string,
279
276
  {
280
277
  kind: string;
281
- payload?: { entities?: SceneEntity[]; nodes?: SceneEntity[] };
278
+ payload?: {
279
+ entities?: Record<string, { components: Record<string, Record<string, unknown>> }>;
280
+ nodes?: Record<string, { components: Record<string, Record<string, unknown>> }>;
281
+ };
282
282
  }
283
283
  >;
284
284
  };
@@ -291,7 +291,7 @@ describe('game-capability-lab API contract (regression for `world.sceneInstances
291
291
  // no longer restates it (Derive, Don't Duplicate — the loader synthesises
292
292
  // Asset.kind from the envelope on load).
293
293
  if (asset.kind !== 'scene') continue;
294
- for (const node of asset.payload?.entities ?? asset.payload?.nodes ?? []) {
294
+ for (const node of Object.values(asset.payload?.entities ?? asset.payload?.nodes ?? {})) {
295
295
  for (const [compName, data] of Object.entries(node.components)) {
296
296
  if (STRIP_COMPONENTS.has(compName)) continue;
297
297
  scanned++;
@@ -305,9 +305,7 @@ describe('game-capability-lab API contract (regression for `world.sceneInstances
305
305
  for (const rawField of Object.keys(data ?? {})) {
306
306
  const field = rename[rawField] ?? rawField;
307
307
  if (!(field in schema)) {
308
- offenders.push(
309
- `${compName}.${rawField} (localId ${node.localId as unknown as number})`,
310
- );
308
+ offenders.push(`${compName}.${rawField} (scene entity key)`);
311
309
  }
312
310
  }
313
311
  }
@@ -330,7 +328,9 @@ describe('game-capability-lab API contract (regression for `world.sceneInstances
330
328
  type TemplatePackAsset = {
331
329
  guid?: string;
332
330
  kind: string;
333
- payload?: { entities?: { components: { MeshRenderer?: { materials?: number[] } } }[] };
331
+ payload?: {
332
+ entities?: Record<string, { components: { MeshRenderer?: { materials?: number[] } } }>;
333
+ };
334
334
  refs?: string[];
335
335
  };
336
336
  const pack = JSON.parse(readFileSync(packPath, 'utf-8')) as {
@@ -346,7 +346,7 @@ describe('game-capability-lab API contract (regression for `world.sceneInstances
346
346
  if (scene === undefined) return;
347
347
 
348
348
  const referencedMaterialGuids = new Set(
349
- (scene.payload?.entities ?? []).flatMap((entity) =>
349
+ Object.values(scene.payload?.entities ?? {}).flatMap((entity) =>
350
350
  (entity.components.MeshRenderer?.materials ?? []).map((index) => scene.refs?.[index]),
351
351
  ),
352
352
  );
@@ -369,7 +369,7 @@ describe('game-capability-lab API contract (regression for `world.sceneInstances
369
369
  const reg = new AssetRegistry(makeMockShaderRegistry());
370
370
  const sceneGuid = AssetGuid.parse(SCENE_GUID);
371
371
  if (!sceneGuid.ok) throw new Error('parse');
372
- const payload: SceneAsset = { kind: 'scene', entities: [] };
372
+ const payload: SceneAsset = { kind: 'scene', entities: {} };
373
373
  const cataloged = reg.catalog<SceneAsset>(sceneGuid.value, payload);
374
374
  expect(cataloged.ok).toBe(true);
375
375
  if (!cataloged.ok) return;
@@ -407,10 +407,10 @@ type __MergedKeep =
407
407
  __mockTag: 'gpu-device',
408
408
  lost,
409
409
  features: new Set(),
410
- // Model a native WebGPU device: the test below asserts the preferred
411
- // swap-chain format selected by the storage-buffer path. An empty limits
412
- // object describes a downlevel device and correctly selects the rgba
413
- // fallback instead.
410
+ // Model a native WebGPU device: this test exercises the preferred
411
+ // swap-chain format selected by the storage-buffer path. An empty
412
+ // limits object describes a downlevel device and correctly selects the
413
+ // rgba fallback instead.
414
414
  limits: {
415
415
  maxStorageBuffersPerShaderStage: 8,
416
416
  },
@@ -634,14 +634,17 @@ type __MergedKeep =
634
634
  // test if the format is RENDER_ATTACHMENT-renderable. NOT an HDR
635
635
  // colour attachment -- a one-shot probe destroyed immediately. Filter
636
636
  // by label so the AC-03(c) lazy-HDR contract remains testable.
637
+ // The neutral normal-map fallback uses float16 to represent 0.5 exactly;
638
+ // it is a 1x1 sampled material input, not a scene HDR attachment.
637
639
  const hdrTextureCalls = log.records.filter(
638
640
  (r) =>
639
641
  r.type === 'createTexture' &&
640
642
  r.format === 'rgba16float' &&
643
+ r.label !== 'fallback-normal-1x1' &&
641
644
  !(r.label?.startsWith('skylight-fallback-') ?? false) &&
642
645
  !(r.label?.startsWith('forgeax-caps-probe-') ?? false),
643
646
  );
644
- expect(hdrTextureCalls.length).toBe(0);
647
+ expect(hdrTextureCalls.map((record) => record.label)).toEqual([]);
645
648
  });
646
649
 
647
650
  it('row 4: pbr + unlit engine modules compile at boot (tonemap defers to unified channel)', async () => {
@@ -519,10 +519,13 @@ type __MergedKeep =
519
519
  },
520
520
  getPipelineState: () => null,
521
521
  assets: {
522
+ catalogEpoch: 0,
522
523
  get: () => ({ ok: false, error: { code: 'asset-not-registered' } }),
523
524
  getMeshGpuHandles: () => undefined,
524
525
  getTextureGpuView: () => undefined,
525
526
  },
527
+ deviceScope: { generation: 0 },
528
+ gpuStore: { meshResidencyEpoch: 0 },
526
529
  errorRegistry: {
527
530
  fire: () => undefined,
528
531
  },