@vgai/engine 0.4.1 → 0.5.0-canary.20260719.0

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 (103) hide show
  1. package/README.md +48 -15
  2. package/package.json +11 -25
  3. package/schemas/engine-capabilities.json +10 -10
  4. package/schemas/entity2d.schema.json +468 -0
  5. package/schemas/mat.schema.json +2 -33
  6. package/schemas/prefab.schema.json +16 -172
  7. package/schemas/scn2d.schema.json +42 -23
  8. package/schemas/vgai-game.schema.json +34 -0
  9. package/schemas/vscn.schema.json +16 -172
  10. package/src/adapter/authoring.ts +152 -2
  11. package/src/adapter/colyseus-networking-adapter.ts +35 -1
  12. package/src/adapter/first-party-systems.ts +7 -1
  13. package/src/adapter/game-adapter.ts +13 -0
  14. package/src/adapter/index.ts +25 -0
  15. package/src/adapter/rapier-physics-adapter.ts +55 -2
  16. package/src/adapter/system-adapter.ts +249 -2
  17. package/src/adapter/vgai-scene-game-adapter.ts +149 -25
  18. package/src/ai/navigation.ts +28 -0
  19. package/src/animation/clip-map.ts +1 -8
  20. package/src/animation/theatre-director.ts +50 -0
  21. package/src/animation/xstate-animation-binding.ts +6 -0
  22. package/src/audio/audio-introspection.ts +290 -0
  23. package/src/audio/tone-context.ts +46 -0
  24. package/src/dev/chrome-trace.ts +153 -0
  25. package/src/dev/performance-profiler.ts +93 -6
  26. package/src/dev/render-debug-adapter.ts +199 -0
  27. package/src/dev/render-memory.ts +243 -0
  28. package/src/dev/webgl-frame-capture.ts +424 -0
  29. package/src/ecs/component-manager.ts +43 -10
  30. package/src/ecs/game-component.ts +39 -10
  31. package/src/input/input-manager.ts +24 -19
  32. package/src/input/input-types.ts +1 -1
  33. package/src/loader.ts +7 -0
  34. package/src/manifest/load.ts +14 -0
  35. package/src/manifest/schema.ts +65 -0
  36. package/src/react/game-state.tsx +1 -1
  37. package/src/render/render-batch-system.ts +26 -12
  38. package/src/render/spark-renderer-lifecycle.ts +64 -0
  39. package/src/runtime/create-runtime.ts +33 -5
  40. package/src/runtime/debug-bridge.ts +5 -5
  41. package/src/runtime/game.ts +102 -20
  42. package/src/runtime/mount-manifest.ts +1 -1
  43. package/src/runtime/render-control.ts +121 -0
  44. package/src/runtime/types.ts +1 -1
  45. package/src/scene/asset-loaders.ts +77 -3
  46. package/src/scene/instance-mesh.ts +25 -0
  47. package/src/scene/material-factory.ts +4 -15
  48. package/src/scene/mesh-shadow.ts +18 -0
  49. package/src/scene/particles-factory.ts +59 -0
  50. package/src/scene/scene-loader.ts +41 -16
  51. package/src/scene/schema/instances.ts +1 -2
  52. package/src/scene/schema/material.ts +83 -94
  53. package/src/scene/schema/mesh.ts +76 -90
  54. package/src/scene/schema/scene-file.ts +1 -2
  55. package/src/scene/user-data.ts +30 -14
  56. package/src/setup/setup-renderer.ts +6 -1
  57. package/src/world2d/asset-paths2d.ts +44 -0
  58. package/src/world2d/collision-2d.ts +7 -14
  59. package/src/world2d/entity2d-asset.ts +22 -0
  60. package/src/world2d/index.ts +27 -2
  61. package/src/world2d/physics2d-transform.ts +173 -0
  62. package/src/world2d/physics2d-units.ts +10 -0
  63. package/src/world2d/pixi-game-adapter.ts +148 -36
  64. package/src/world2d/scene2d-identity.ts +49 -0
  65. package/src/world2d/scene2d-loader.ts +243 -119
  66. package/src/world2d/schema/entity2d.ts +51 -33
  67. package/src/world2d/schema/physics2d.ts +14 -3
  68. package/src/world2d/schema/sprite.ts +32 -4
  69. package/src/world2d/schema/tilemap.ts +26 -9
  70. package/src/world2d/transform-writer-2d.ts +29 -11
  71. package/src/world2d/types.ts +21 -8
  72. package/src/world3d-react/behavior.tsx +138 -0
  73. package/src/world3d-react/engine-bridge.ts +48 -0
  74. package/src/world3d-react/index.ts +44 -0
  75. package/src/world3d-react/r3f-adapter.tsx +303 -0
  76. package/src/world3d-react/world-context.ts +294 -0
  77. package/vendor/realism-effects/LICENSE.md +21 -0
  78. package/vendor/realism-effects/UPSTREAM.md +19 -0
  79. package/vendor/realism-effects/dist/index.cjs +3447 -0
  80. package/vendor/realism-effects/dist/index.d.ts +59 -0
  81. package/vendor/realism-effects/dist/index.js +3434 -0
  82. package/vendor/realism-effects/package.json +23 -0
  83. package/src/character/cloth-sim.ts +0 -533
  84. package/src/character/spring-chain.ts +0 -307
  85. package/src/humanoid/body.ts +0 -663
  86. package/src/humanoid/clips.ts +0 -149
  87. package/src/humanoid/compose.ts +0 -209
  88. package/src/humanoid/generate.ts +0 -189
  89. package/src/humanoid/index.ts +0 -36
  90. package/src/humanoid/schema.ts +0 -108
  91. package/src/humanoid/skeleton.ts +0 -345
  92. package/src/react/humanoid-bake.document.tsx +0 -337
  93. package/src/scene/geometries/index.ts +0 -7
  94. package/src/scene/geometries/terrain.ts +0 -42
  95. package/src/scene/geometry-registry.ts +0 -42
  96. package/src/scene/instance-registry.ts +0 -84
  97. package/src/scene/instancers/grid.ts +0 -38
  98. package/src/scene/instancers/index.ts +0 -7
  99. package/src/scene/material-registry.ts +0 -73
  100. package/src/scene/materials/index.ts +0 -7
  101. package/src/scene/materials/water.ts +0 -56
  102. package/src/world2d/components-2d.ts +0 -86
  103. package/tools/humanoid-bake.tool.ts +0 -274
@@ -14,18 +14,17 @@ import {
14
14
  assetCacheSizes,
15
15
  clearAssetCaches as clearSharedAssetCaches,
16
16
  loadGLTF,
17
+ loadSplat,
17
18
  resolveGltfNode,
18
19
  } from './asset-loaders';
19
20
  import { applyImportCorrection, getAssetMeta, loadRegistry } from './asset-registry';
20
21
  import { computeColliderWorldDimensions, computeColliderWorldOffset } from './collider-dimensions';
21
22
  import { applyComponents, type ComponentRegistry } from './component-registry';
22
23
  import { DEFAULTS } from './defaults';
23
- import './geometries';
24
- import './instancers';
25
- import { buildInstancedMesh, buildInstancedMeshFromTuples } from './instance-registry';
24
+ import { buildInstancedMeshFromTuples } from './instance-mesh';
26
25
  import { createCamera, createLight } from './light-camera-factory';
27
26
  import { createGeometry, createMaterial } from './material-factory';
28
- import './materials';
27
+ import { setMeshShadowEnabled } from './mesh-shadow';
29
28
  import { parseInstancesFile, parseMaterialFile, parsePrefabFile, parseSceneFile } from './parse';
30
29
  import { createParticleSystemFromData } from './particles-factory';
31
30
  import {
@@ -78,9 +77,8 @@ export function clearAssetCaches(): void {
78
77
  /**
79
78
  * F7 — per-frame LOD tick. THREE.LOD only swaps its active level when
80
79
  * `.update(camera)` runs, so every `THREE.LOD` in the scene graph (built by
81
- * the `mesh.lod` spawn branch above) needs this called once a frame. Mirrors
82
- * `tickCustomMaterials`'s traverse-and-tick shape; wired into the runtime's
83
- * `preRender` system in `vgai-scene-game-adapter.ts`.
80
+ * the `mesh.lod` spawn branch above) needs this called once a frame. Wired
81
+ * into the runtime's `preRender` system in `vgai-scene-game-adapter.ts`.
84
82
  */
85
83
  export function updateSceneLODs(scene: THREE.Scene, camera: THREE.Camera): void {
86
84
  scene.traverse((o) => {
@@ -96,12 +94,14 @@ export function updateSceneLODs(scene: THREE.Scene, camera: THREE.Camera): void
96
94
  export function __assetCacheSizesForTest(): {
97
95
  textures: number;
98
96
  gltf: number;
97
+ splat: number;
99
98
  env: number;
100
99
  } {
101
100
  const sizes = assetCacheSizes();
102
101
  return {
103
102
  textures: sizes.textures,
104
103
  gltf: sizes.gltf,
104
+ splat: sizes.splat,
105
105
  env: envTextureCache.size,
106
106
  };
107
107
  }
@@ -222,6 +222,13 @@ export interface SceneInstance {
222
222
  * `instantiateScene` return site, so this is populated identically for both.
223
223
  */
224
224
  entities: ReadonlyMap<string, THREE.Object3D>;
225
+ /** Number of native Gaussian-splat Object3Ds owned by this scene. The render
226
+ * host uses this to install exactly one SparkRenderer for the scene. */
227
+ splatCount: number;
228
+ }
229
+
230
+ interface DisposableSplat extends THREE.Object3D {
231
+ dispose(): void;
225
232
  }
226
233
 
227
234
  /**
@@ -405,6 +412,7 @@ async function instantiateScene(
405
412
  ): Promise<SceneInstance> {
406
413
  const tickFns: ((dt: number) => void)[] = [];
407
414
  const mixers: THREE.AnimationMixer[] = [];
415
+ const splats: DisposableSplat[] = [];
408
416
 
409
417
  // Ensure asset registry is loaded before spawning entities
410
418
  await loadRegistry();
@@ -462,7 +470,7 @@ async function instantiateScene(
462
470
  const idMap = new Map<string, THREE.Object3D>();
463
471
 
464
472
  for (const entityDef of data.entities) {
465
- const obj = await spawnEntity(entityDef, ctx, tickFns, mixers, idMap);
473
+ const obj = await spawnEntity(entityDef, ctx, tickFns, mixers, splats, idMap);
466
474
  ctx.scene.add(obj);
467
475
  }
468
476
 
@@ -516,6 +524,7 @@ async function instantiateScene(
516
524
  cameras,
517
525
  environment: data.environment,
518
526
  entities: idMap,
527
+ splatCount: splats.length,
519
528
  update(dt: number) {
520
529
  for (const fn of tickFns) fn(dt);
521
530
  },
@@ -523,6 +532,8 @@ async function instantiateScene(
523
532
  for (const mixer of mixers) mixer.stopAllAction();
524
533
  tickFns.length = 0;
525
534
  mixers.length = 0;
535
+ for (const splat of splats) splat.dispose();
536
+ splats.length = 0;
526
537
  },
527
538
  };
528
539
  }
@@ -532,6 +543,7 @@ async function spawnEntity(
532
543
  ctx: SceneLoadContext,
533
544
  tickFns: ((dt: number) => void)[],
534
545
  mixers: THREE.AnimationMixer[],
546
+ splats: DisposableSplat[],
535
547
  idMap?: Map<string, THREE.Object3D>,
536
548
  ancestorCtx: AncestorPhysicsContext = ROOT_ANCESTOR_CONTEXT,
537
549
  audioTracker?: AudioTracker,
@@ -578,7 +590,7 @@ async function spawnEntity(
578
590
  if (def.prefab) {
579
591
  const prefabDef = await loadPrefabFile(def.prefab, ctx);
580
592
  const merged = mergePrefabInstance(prefabDef.root, def);
581
- return spawnEntity(merged, ctx, tickFns, mixers, idMap, ancestorCtx, audioTracker);
593
+ return spawnEntity(merged, ctx, tickFns, mixers, splats, idMap, ancestorCtx, audioTracker);
582
594
  }
583
595
 
584
596
  // Transform
@@ -664,6 +676,11 @@ async function spawnEntity(
664
676
  // Apply import correction from asset registry
665
677
  applyImportCorrection(content, getAssetMeta(def.mesh.src)?.importCorrection);
666
678
 
679
+ // A glTF entity is a hierarchy, not one Mesh. Propagate the same
680
+ // authored shadow contract primitive entities already honor to every
681
+ // Mesh/SkinnedMesh descendant before wrapping the content.
682
+ setMeshShadowEnabled(content, def.shadow?.enabled);
683
+
667
684
  // Wrap in a group so entity transform and import correction are separated.
668
685
  // `content` carries import correction; wrapper carries entity transform.
669
686
  const wrapper = new THREE.Group();
@@ -700,6 +717,16 @@ async function spawnEntity(
700
717
  }
701
718
  }
702
719
  }
720
+ } else if (def.mesh.type === 'splat' && def.mesh.src) {
721
+ const wrapper = new THREE.Group();
722
+ // A headless game still needs the authored entity, transform, components,
723
+ // and hierarchy for bots/CI; only the visual payload is GPU-bound.
724
+ if (ctx.renderer) {
725
+ const content = await loadSplat(def.mesh.src);
726
+ splats.push(content);
727
+ wrapper.add(content);
728
+ }
729
+ object3d = wrapper;
703
730
  } else {
704
731
  const geometry = createGeometry(def.mesh);
705
732
  const resolvedMat = await resolveMaterial(def, ctx);
@@ -710,13 +737,6 @@ async function spawnEntity(
710
737
  // .instances.json and build one InstancedMesh from its tuples.
711
738
  const tuples = await loadInstancesFile(def.mesh.instances, ctx);
712
739
  mesh = buildInstancedMeshFromTuples(geometry, material, tuples);
713
- } else if (def.mesh.instancer) {
714
- mesh = buildInstancedMesh(
715
- geometry,
716
- material,
717
- def.mesh.instancer,
718
- def.mesh.instancerParams ?? {},
719
- );
720
740
  } else {
721
741
  mesh = new THREE.Mesh(geometry, material);
722
742
  }
@@ -930,6 +950,7 @@ async function spawnEntity(
930
950
  ctx,
931
951
  tickFns,
932
952
  mixers,
953
+ splats,
933
954
  idMap,
934
955
  childAncestorCtx,
935
956
  audioTracker,
@@ -1391,12 +1412,14 @@ export async function spawnPrefab(
1391
1412
  };
1392
1413
  const tickFns: ((dt: number) => void)[] = [];
1393
1414
  const mixers: THREE.AnimationMixer[] = [];
1415
+ const splats: DisposableSplat[] = [];
1394
1416
  const audioTracker: AudioTracker = { audios: [], disposed: { value: false } };
1395
1417
  const object3D = await spawnEntity(
1396
1418
  instance,
1397
1419
  ctx,
1398
1420
  tickFns,
1399
1421
  mixers,
1422
+ splats,
1400
1423
  undefined,
1401
1424
  ROOT_ANCESTOR_CONTEXT,
1402
1425
  audioTracker,
@@ -1494,8 +1517,10 @@ export async function spawnPrefab(
1494
1517
 
1495
1518
  ctx.scene.remove(object3D);
1496
1519
  for (const mixer of mixers) mixer.stopAllAction();
1520
+ for (const splat of splats) splat.dispose();
1497
1521
  tickFns.length = 0;
1498
1522
  mixers.length = 0;
1523
+ splats.length = 0;
1499
1524
  },
1500
1525
  };
1501
1526
  }
@@ -3,8 +3,7 @@ import { z } from 'zod';
3
3
  /**
4
4
  * `.instances.json` — a declarative, authored-data instance-transform asset
5
5
  * (F3, docs/VSCN-STRUCTURAL-GAPS-DESIGN.md). Referenced by `mesh.instances`
6
- * as the data counterpart to `mesh.instancer` (a code-registered generator):
7
- * a bare array of 10-number tuples, one per instance, each
6
+ * as a bare array of 10-number tuples, one per instance, each
8
7
  * `[posX,posY,posZ, quatX,quatY,quatZ,quatW, scaleX,scaleY,scaleZ]`. The
9
8
  * scene loader fetches+parses this file and renders it as one
10
9
  * `THREE.InstancedMesh` (one draw call) — bulk transform data is referenced
@@ -1,105 +1,94 @@
1
1
  import { z } from 'zod';
2
2
 
3
- const SceneMaterialObjectSchema = z.object({
4
- type: z
5
- .enum(['standard', 'physical', 'basic', 'toon', 'custom'])
6
- .describe('Material shading model. "custom" resolves `def` against the material registry'),
7
- def: z
8
- .string()
9
- .optional()
10
- .describe('Registered custom-material name (required when type is "custom")'),
11
- uniforms: z
12
- .record(z.string(), z.union([z.number(), z.string(), z.boolean(), z.array(z.number())]))
13
- .optional()
14
- .describe('Authored uniform values for a custom material, keyed by uniform name'),
15
- color: z.string().optional().describe('Base color as CSS hex string (e.g. "#ff0000")'),
16
- metalness: z.number().optional().describe('Metalness factor 0–1 (standard/physical only)'),
17
- roughness: z.number().optional().describe('Roughness factor 0–1 (standard/physical only)'),
18
- map: z.string().optional().describe('Path to albedo/diffuse texture image'),
19
- normalMap: z.string().optional().describe('Path to normal map texture image'),
20
- emissive: z.string().optional().describe('Emissive color as CSS hex string'),
21
- emissiveIntensity: z.number().optional().describe('Emissive light intensity multiplier'),
22
- emissiveMap: z.string().optional().describe('Path to emissive map texture image'),
23
- aoMap: z.string().optional().describe('Path to ambient occlusion map texture image'),
24
- lightMap: z
25
- .string()
26
- .optional()
27
- .describe(
28
- "Path to a baked lightmap texture (uses the mesh's second UV set). Applies the pre-baked GI/shadow result on top of the material.",
29
- ),
30
- lightMapIntensity: z.number().optional().describe('Lightmap intensity multiplier (default 1).'),
31
- roughnessMap: z.string().optional().describe('Path to roughness map texture image'),
32
- metalnessMap: z.string().optional().describe('Path to metalness map texture image'),
33
- opacity: z.number().optional().describe('Opacity 0–1. Set transparent: true to enable'),
34
- transparent: z.boolean().optional().describe('Enable alpha blending for this material'),
35
- side: z.enum(['front', 'back', 'double']).optional().describe('Which face sides to render'),
36
- flatShading: z.boolean().optional().describe('Use flat (faceted) shading instead of smooth'),
3
+ const SceneMaterialObjectSchema = z
4
+ .object({
5
+ type: z.enum(['standard', 'physical', 'basic', 'toon']).describe('Material shading model'),
6
+ color: z.string().optional().describe('Base color as CSS hex string (e.g. "#ff0000")'),
7
+ metalness: z.number().optional().describe('Metalness factor 0–1 (standard/physical only)'),
8
+ roughness: z.number().optional().describe('Roughness factor 0–1 (standard/physical only)'),
9
+ map: z.string().optional().describe('Path to albedo/diffuse texture image'),
10
+ normalMap: z.string().optional().describe('Path to normal map texture image'),
11
+ emissive: z.string().optional().describe('Emissive color as CSS hex string'),
12
+ emissiveIntensity: z.number().optional().describe('Emissive light intensity multiplier'),
13
+ emissiveMap: z.string().optional().describe('Path to emissive map texture image'),
14
+ aoMap: z.string().optional().describe('Path to ambient occlusion map texture image'),
15
+ lightMap: z
16
+ .string()
17
+ .optional()
18
+ .describe(
19
+ "Path to a baked lightmap texture (uses the mesh's second UV set). Applies the pre-baked GI/shadow result on top of the material.",
20
+ ),
21
+ lightMapIntensity: z.number().optional().describe('Lightmap intensity multiplier (default 1).'),
22
+ roughnessMap: z.string().optional().describe('Path to roughness map texture image'),
23
+ metalnessMap: z.string().optional().describe('Path to metalness map texture image'),
24
+ opacity: z.number().optional().describe('Opacity 0–1. Set transparent: true to enable'),
25
+ transparent: z.boolean().optional().describe('Enable alpha blending for this material'),
26
+ side: z.enum(['front', 'back', 'double']).optional().describe('Which face sides to render'),
27
+ flatShading: z.boolean().optional().describe('Use flat (faceted) shading instead of smooth'),
37
28
 
38
- // Physical material feature groups (physical only, all optional)
39
- clearcoat: z
40
- .object({
41
- clearcoat: z.number().describe('Clearcoat layer intensity 0–1'),
42
- clearcoatRoughness: z.number().optional().describe('Clearcoat roughness 0–1'),
43
- clearcoatMap: z.string().optional().describe('Path to clearcoat intensity map'),
44
- clearcoatRoughnessMap: z.string().optional().describe('Path to clearcoat roughness map'),
45
- })
46
- .optional()
47
- .describe('Clearcoat layer (physical only)'),
29
+ // Physical material feature groups (physical only, all optional)
30
+ clearcoat: z
31
+ .object({
32
+ clearcoat: z.number().describe('Clearcoat layer intensity 0–1'),
33
+ clearcoatRoughness: z.number().optional().describe('Clearcoat roughness 0–1'),
34
+ clearcoatMap: z.string().optional().describe('Path to clearcoat intensity map'),
35
+ clearcoatRoughnessMap: z.string().optional().describe('Path to clearcoat roughness map'),
36
+ })
37
+ .optional()
38
+ .describe('Clearcoat layer (physical only)'),
48
39
 
49
- transmission: z
50
- .object({
51
- transmission: z.number().describe('Transmission intensity 0–1 (glass, liquid)'),
52
- ior: z.number().optional().describe('Index of refraction (default 1.5)'),
53
- thickness: z.number().optional().describe('Volume thickness for refraction'),
54
- attenuationColor: z.string().optional().describe('Color absorbed over distance'),
55
- attenuationDistance: z
56
- .number()
57
- .optional()
58
- .describe('Distance at which attenuation color takes full effect'),
59
- transmissionMap: z.string().optional().describe('Path to transmission map'),
60
- })
61
- .optional()
62
- .describe('Light transmission (physical only)'),
40
+ transmission: z
41
+ .object({
42
+ transmission: z.number().describe('Transmission intensity 0–1 (glass, liquid)'),
43
+ ior: z.number().optional().describe('Index of refraction (default 1.5)'),
44
+ thickness: z.number().optional().describe('Volume thickness for refraction'),
45
+ attenuationColor: z.string().optional().describe('Color absorbed over distance'),
46
+ attenuationDistance: z
47
+ .number()
48
+ .optional()
49
+ .describe('Distance at which attenuation color takes full effect'),
50
+ transmissionMap: z.string().optional().describe('Path to transmission map'),
51
+ })
52
+ .optional()
53
+ .describe('Light transmission (physical only)'),
63
54
 
64
- sheen: z
65
- .object({
66
- sheen: z.number().describe('Sheen intensity 0–1 (fabric, velvet)'),
67
- sheenColor: z.string().optional().describe('Sheen tint color'),
68
- sheenRoughness: z.number().optional().describe('Sheen roughness 0–1'),
69
- sheenColorMap: z.string().optional().describe('Path to sheen color map'),
70
- sheenRoughnessMap: z.string().optional().describe('Path to sheen roughness map'),
71
- })
72
- .optional()
73
- .describe('Sheen layer (physical only)'),
55
+ sheen: z
56
+ .object({
57
+ sheen: z.number().describe('Sheen intensity 0–1 (fabric, velvet)'),
58
+ sheenColor: z.string().optional().describe('Sheen tint color'),
59
+ sheenRoughness: z.number().optional().describe('Sheen roughness 0–1'),
60
+ sheenColorMap: z.string().optional().describe('Path to sheen color map'),
61
+ sheenRoughnessMap: z.string().optional().describe('Path to sheen roughness map'),
62
+ })
63
+ .optional()
64
+ .describe('Sheen layer (physical only)'),
74
65
 
75
- iridescence: z
76
- .object({
77
- iridescence: z.number().describe('Iridescence intensity 0–1 (soap bubbles, oil slick)'),
78
- iridescenceIOR: z.number().optional().describe('Thin-film IOR (default 1.3)'),
79
- iridescenceThicknessRange: z
80
- .tuple([z.number(), z.number()])
81
- .optional()
82
- .describe('Min/max thin-film thickness in nm'),
83
- iridescenceMap: z.string().optional().describe('Path to iridescence intensity map'),
84
- iridescenceThicknessMap: z.string().optional().describe('Path to iridescence thickness map'),
85
- })
86
- .optional()
87
- .describe('Iridescence thin-film (physical only)'),
66
+ iridescence: z
67
+ .object({
68
+ iridescence: z.number().describe('Iridescence intensity 0–1 (soap bubbles, oil slick)'),
69
+ iridescenceIOR: z.number().optional().describe('Thin-film IOR (default 1.3)'),
70
+ iridescenceThicknessRange: z
71
+ .tuple([z.number(), z.number()])
72
+ .optional()
73
+ .describe('Min/max thin-film thickness in nm'),
74
+ iridescenceMap: z.string().optional().describe('Path to iridescence intensity map'),
75
+ iridescenceThicknessMap: z
76
+ .string()
77
+ .optional()
78
+ .describe('Path to iridescence thickness map'),
79
+ })
80
+ .optional()
81
+ .describe('Iridescence thin-film (physical only)'),
88
82
 
89
- displacementMap: z.string().optional().describe('Path to displacement/height map'),
90
- displacementScale: z.number().optional().describe('Displacement height multiplier'),
91
- displacementBias: z.number().optional().describe('Displacement offset'),
92
- });
83
+ displacementMap: z.string().optional().describe('Path to displacement/height map'),
84
+ displacementScale: z.number().optional().describe('Displacement height multiplier'),
85
+ displacementBias: z.number().optional().describe('Displacement offset'),
86
+ })
87
+ .strict();
93
88
 
94
- export const SceneMaterialSchema = SceneMaterialObjectSchema.superRefine((material, ctx) => {
95
- if (material.type === 'custom' && !material.def?.trim()) {
96
- ctx.addIssue({
97
- code: 'custom',
98
- path: ['def'],
99
- message: 'Custom material requires a non-empty registered definition name',
100
- });
101
- }
102
- }).describe('Material appearance properties');
89
+ export const SceneMaterialSchema = SceneMaterialObjectSchema.describe(
90
+ 'Material appearance properties',
91
+ );
103
92
 
104
93
  /** Partial schema for prefab instance overrides (type not required). */
105
94
  export const SceneMaterialOverrideSchema = SceneMaterialObjectSchema.partial();
@@ -1,104 +1,90 @@
1
1
  import { z } from 'zod';
2
2
 
3
- const SceneMeshBase = z.object({
4
- type: z
5
- .enum(['box', 'sphere', 'plane', 'cylinder', 'capsule', 'gltf'])
6
- .describe('Mesh primitive type, or "gltf" for an external 3D model'),
7
- args: z
8
- .array(z.number())
9
- .optional()
10
- .describe(
11
- 'Geometry constructor args — box: [w,h,d], sphere: [radius], cylinder: [rTop,rBot,h], capsule: [radius,length], plane: [w,h]',
12
- ),
13
- src: z.string().optional().describe('Path to GLTF/GLB model file. Required when type is "gltf"'),
14
- node: z
15
- .string()
16
- .optional()
17
- .describe(
18
- 'Name (or path) of a single node inside the referenced glTF (resolved via ' +
19
- "getObjectByName) to instantiate as this entity's geometry, instead of the whole " +
20
- 'file. The geometry stays SHARED from the cached glTF — nothing is copied out. ' +
21
- 'Only meaningful with type: "gltf". CONSTRAINT: a skinned/animated node cannot be ' +
22
- 'lifted out of its armature — if the named node is a SkinnedMesh (or contains one), ' +
23
- 'resolving it throws (degrade loudly, not silently).',
24
- ),
25
- instances: z
26
- .string()
27
- .optional()
28
- .describe(
29
- 'Path to an instance-transform asset (.instances.json: an array of ' +
30
- '[posX,posY,posZ, quatX,quatY,quatZ,quatW, scaleX,scaleY,scaleZ] tuples). Renders one ' +
31
- 'THREE.InstancedMesh (one draw call). Bulk transform data is referenced as an asset, ' +
32
- 'never inlined — keeps vscn thin.',
33
- ),
34
- instancer: z
35
- .string()
36
- .optional()
37
- .describe(
38
- 'Registered instancer name. When set, the mesh renders as a single THREE.InstancedMesh ' +
39
- '(one draw call) with code-generated per-instance transforms instead of one THREE.Mesh',
40
- ),
41
- instancerParams: z
42
- .record(z.string(), z.union([z.number(), z.string(), z.boolean()]))
43
- .optional()
44
- .describe('Params for the instancer (e.g. count, spacing), keyed by name'),
45
- generator: z
46
- .string()
47
- .optional()
48
- .describe(
49
- 'Registered geometry-generator name (e.g. "Terrain"). When set, the mesh geometry is ' +
50
- 'produced procedurally in code instead of from `type`/`args`. Pairs with a "trimesh" ' +
51
- 'collider for matching physics.',
52
- ),
53
- generatorParams: z
54
- .record(z.string(), z.union([z.number(), z.string(), z.boolean()]))
55
- .optional()
56
- .describe('Params for the geometry generator (e.g. width, segments, amplitude)'),
57
- lod: z
58
- .array(
59
- z.object({
60
- distance: z
61
- .number()
62
- .describe(
63
- 'Camera distance in world units at/beyond which this level shows (THREE.LOD ' +
64
- 'threshold). Use 0 for the closest/highest-detail level.',
65
- ),
66
- node: z
67
- .string()
68
- .describe(
69
- 'Name of a node inside the referenced glTF (mesh.src) to show at this distance — ' +
70
- 'resolved via getObjectByName, geometry shared (F4-style). A skinned node throws ' +
71
- '(atomic-subtree rule).',
72
- ),
73
- }),
74
- )
75
- .optional()
76
- .describe(
77
- 'Per-entity Level-of-Detail: distance-keyed glTF sub-nodes swapped by camera distance ' +
78
- '(THREE.LOD). gltf-only. Gated by the environment.rendering.lod master toggle — when ' +
79
- 'off, only the closest level renders. Cannot combine with node/instances/instancer.',
80
- ),
81
- });
3
+ const SceneMeshBase = z
4
+ .object({
5
+ type: z
6
+ .enum(['box', 'sphere', 'plane', 'cylinder', 'capsule', 'gltf', 'splat'])
7
+ .describe(
8
+ 'Renderable type: a built-in mesh primitive, "gltf" for an external 3D model, or "splat" for a native Gaussian-splat asset',
9
+ ),
10
+ args: z
11
+ .array(z.number())
12
+ .optional()
13
+ .describe(
14
+ 'Geometry constructor args — box: [w,h,d], sphere: [radius], cylinder: [rTop,rBot,h], capsule: [radius,length], plane: [w,h,widthSegments,heightSegments]',
15
+ ),
16
+ src: z
17
+ .string()
18
+ .optional()
19
+ .describe(
20
+ 'Path to the external render asset. Required for "gltf" (GLTF/GLB) and "splat" (SPZ)',
21
+ ),
22
+ node: z
23
+ .string()
24
+ .optional()
25
+ .describe(
26
+ 'Name (or path) of a single node inside the referenced glTF (resolved via ' +
27
+ "getObjectByName) to instantiate as this entity's geometry, instead of the whole " +
28
+ 'file. The geometry stays SHARED from the cached glTF — nothing is copied out. ' +
29
+ 'Only meaningful with type: "gltf". CONSTRAINT: a skinned/animated node cannot be ' +
30
+ 'lifted out of its armature — if the named node is a SkinnedMesh (or contains one), ' +
31
+ 'resolving it throws (degrade loudly, not silently).',
32
+ ),
33
+ instances: z
34
+ .string()
35
+ .optional()
36
+ .describe(
37
+ 'Path to an instance-transform asset (.instances.json: an array of ' +
38
+ '[posX,posY,posZ, quatX,quatY,quatZ,quatW, scaleX,scaleY,scaleZ] tuples). Renders one ' +
39
+ 'THREE.InstancedMesh (one draw call). Bulk transform data is referenced as an asset, ' +
40
+ 'never inlined — keeps vscn thin.',
41
+ ),
42
+ lod: z
43
+ .array(
44
+ z.object({
45
+ distance: z
46
+ .number()
47
+ .describe(
48
+ 'Camera distance in world units at/beyond which this level shows (THREE.LOD ' +
49
+ 'threshold). Use 0 for the closest/highest-detail level.',
50
+ ),
51
+ node: z
52
+ .string()
53
+ .describe(
54
+ 'Name of a node inside the referenced glTF (mesh.src) to show at this distance — ' +
55
+ 'resolved via getObjectByName, geometry shared (F4-style). A skinned node throws ' +
56
+ '(atomic-subtree rule).',
57
+ ),
58
+ }),
59
+ )
60
+ .optional()
61
+ .describe(
62
+ 'Per-entity Level-of-Detail: distance-keyed glTF sub-nodes swapped by camera distance ' +
63
+ '(THREE.LOD). gltf-only. Gated by the environment.rendering.lod master toggle — when ' +
64
+ 'off, only the closest level renders. Cannot combine with node/instances.',
65
+ ),
66
+ })
67
+ .strict();
82
68
 
83
- export const SceneMeshSchema = SceneMeshBase.refine((m) => m.type !== 'gltf' || m.src != null, {
84
- message: "GLTF mesh requires 'src' field",
85
- })
69
+ export const SceneMeshSchema = SceneMeshBase.refine(
70
+ (m) => (m.type !== 'gltf' && m.type !== 'splat') || m.src != null,
71
+ {
72
+ message: "External render asset requires 'src' field",
73
+ },
74
+ )
86
75
  .refine((m) => m.node == null || m.type === 'gltf', {
87
76
  message:
88
77
  "Mesh 'node' is only meaningful with type: 'gltf' (names a sub-node of the referenced glTF)",
89
78
  })
90
- .refine((m) => !(m.instances && m.instancer), {
91
- message:
92
- "Mesh cannot set both 'instances' (authored) and 'instancer' (code-registered) — " +
93
- 'pick one canonical instancing source per mesh',
94
- })
95
79
  .refine((m) => m.lod == null || m.type === 'gltf', {
96
80
  message: "Mesh 'lod' is only meaningful with type: 'gltf'",
97
81
  })
98
- .refine((m) => !(m.lod && (m.node || m.instances || m.instancer)), {
82
+ .refine((m) => m.instances == null || m.type !== 'splat', {
83
+ message: "Mesh 'instances' is not supported with type: 'splat'",
84
+ })
85
+ .refine((m) => !(m.lod && (m.node || m.instances)), {
99
86
  message:
100
- "Mesh 'lod' cannot combine with 'node'/'instances'/'instancer' — LOD is the multi-level " +
101
- 'form of a node ref',
87
+ "Mesh 'lod' cannot combine with 'node'/'instances' — LOD is the multi-level form of a node ref",
102
88
  })
103
89
  .describe('Mesh geometry definition');
104
90
 
@@ -105,8 +105,7 @@ function validateMergedEntity(merged: SceneEntity, instance: SceneEntity): Scene
105
105
  * is now included — an instance's per-entity render overrides are honored
106
106
  * and merged the same way as `mesh`/`material`.)
107
107
  *
108
- * NOTE (T1.10 audit): `mesh.instancerParams`/`mesh.generatorParams` and
109
- * `animation.parameters`/`animation.clipAliases` are keyed dict maps nested
108
+ * NOTE (T1.10 audit): `animation.parameters`/`animation.clipAliases` are keyed dict maps nested
110
109
  * one level down, and `particles` nests discriminated-union shapes/behaviors
111
110
  * arrays. These have the same "shallow replace" defect as `material`/
112
111
  * `physics` did, but no single well-defined field-merge semantics (dict keys