@vgai/engine 0.4.1 → 0.5.0-canary.20260719.1

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 +97 -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 +21 -0
  35. package/src/manifest/schema.ts +123 -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
@@ -30,6 +30,7 @@
30
30
  import type * as PIXI from 'pixi.js';
31
31
  import type * as THREE from 'three';
32
32
  import type { AdapterSurface as AdapterSurfaceLeaf } from '../adapter/adapter-surface';
33
+ import { createInputManagerAdapter } from '../adapter/first-party-systems';
33
34
  import type { MountedWorld } from '../adapter/game-adapter';
34
35
  import { formatAudioGateMessage, formatLoopGateMessage } from '../adapter/loop-gate-report';
35
36
  import type { SystemAdapters } from '../adapter/system-adapter';
@@ -46,7 +47,7 @@ import { PHASE_ORDER, SystemPhase, type SystemPhaseName } from '../core/types';
46
47
  import { createPerformanceProfiler, type PerformanceProfiler } from '../dev/performance-profiler';
47
48
  import type { ComponentManager } from '../ecs/component-manager';
48
49
  import type { GameComponent } from '../ecs/game-component';
49
- import type { InputManager } from '../input/input-manager';
50
+ import { InputManager } from '../input/input-manager';
50
51
  import type { CollisionSystem } from '../physics/collision-system';
51
52
  import type { PhysicsRegistry } from '../physics/physics-registry';
52
53
  import type { AudioContext as GameAudio } from '../setup/setup-audio';
@@ -476,8 +477,7 @@ export interface Game {
476
477
  /** Delegates to the default world's first-party `ComponentManager`.
477
478
  * Throws when the default world is not a first-party mount. */
478
479
  readonly components: ComponentManager;
479
- /** Delegates to the default world's first-party `InputManager`. Throws
480
- * when the default world is not a first-party mount. */
480
+ /** The one game-owned `InputManager`, shared by every first-party root. */
481
481
  readonly input: InputManager;
482
482
  /** Delegates to the default world's first-party audio context. Throws
483
483
  * when the default world is not a first-party mount. */
@@ -499,11 +499,14 @@ export interface Game {
499
499
  * game-level half; `ComponentManager.queryByComponent` (`ecs/
500
500
  * component-manager.ts`) is the per-world half this aggregates.
501
501
  *
502
- * Iterates `roots` in declaration order, skipping any world whose mount
503
- * is not first-party (an opaque/foreign mount has no `ComponentManager`
504
- * to query), applying `opts.worldId`/`opts.kind` as world-level filters,
505
- * and concatenating each remaining world's `queryByComponent(cls)`
506
- * result. No filter (`opts` omitted) spans **every** world — that is the
502
+ * Iterates `roots` in declaration order, querying each world's
503
+ * `ComponentManager` — the first-party mount's `ctx.components`, or the
504
+ * optional `MountedWorldBase.components` capability for a non-first-party
505
+ * adapter that runs a real manager (e.g. `@engine/world3d-react`'s R3F
506
+ * mount — its `<Behavior>` components are aggregated here exactly like
507
+ * scene-authored ones). A mount with neither is skipped (an opaque/foreign
508
+ * world hosts no `GameComponent`s). `opts.worldId`/`opts.kind` apply as
509
+ * world-level filters; the per-world results are concatenated. No filter (`opts` omitted) spans **every** world — that is the
507
510
  * whole point of this API existing (D7's "cross-world flow is ordinary
508
511
  * component access", e.g. a pixi minimap component querying the 3D
509
512
  * world's tank components).
@@ -632,6 +635,20 @@ export interface GameInternal extends Game {
632
635
  * normal play; `runTicks` has no special-cased "force focus" behavior.
633
636
  */
634
637
  runTicks(n: number, opts?: RunTicksOptions): void;
638
+ /**
639
+ * ADAPTER-MOUNT surface, not game-facing: an adapter's `mount()` calls
640
+ * this (via `host.game` — the classic `VgaiSceneGameAdapter` and the R3F
641
+ * `createR3FWorldContext` both do) to load the game-owned input map ONCE,
642
+ * even when several roots ask for the same path; competing paths throw
643
+ * (input is game-owned, so roots cannot load competing maps). GAME code
644
+ * never calls this — a project's map loads automatically at mount from
645
+ * the conventional `/inputmaps/default.inputmap.json` (or the adapter's
646
+ * configured `inputMapPath`); runtime additions go through
647
+ * `ctx.input.registerAction` instead.
648
+ */
649
+ loadInputMap(path: string): Promise<void>;
650
+ /** Release game-owned resources after every mounted root has disposed. */
651
+ dispose(): void;
635
652
  }
636
653
 
637
654
  function describeMismatch(handle: 'components' | 'input' | 'audio', world: WorldInstance): string {
@@ -666,6 +683,7 @@ export function createGame(opts: {
666
683
  const roots: WorldInstance[] = [];
667
684
  const profiler = createPerformanceProfiler();
668
685
  const systems = createSystemRunner(profiler.systemObserver, 'game');
686
+ const input = new InputManager();
669
687
  const stateBridge = createStateBridge();
670
688
  // D15 (T-D15.1) — the game-scoped seeded-random surface every world's
671
689
  // `ctx.random` aliases (see `vgai-scene-game-adapter.ts`'s `ctx.random =
@@ -704,13 +722,14 @@ export function createGame(opts: {
704
722
  const debugRegistry = createDebugRegistry({
705
723
  getTick: () => tick,
706
724
  getSimT: () => simT,
707
- // D15/T-D15.5 — "the manifest's first/default world" for the per-world
708
- // input-target resolution (`resolveInputWorldId`, `debug-registry.ts`):
709
- // the SAME "first threejs world, else first world" rule
725
+ // D15/T-D15.5 — "the manifest's first/default world" for the debug
726
+ // registry's world-addressed input-target compatibility surface. Every
727
+ // first-party root now registers the SAME game-owned InputManager, but the
728
+ // stable default id still keeps explicit/implicit debug routing coherent.
729
+ // Use the SAME "first threejs world, else first world" rule
710
730
  // `requireDefaultWorld` (declared just below — safe: this closure is
711
731
  // only ever CALLED later, once at least one world has mounted) already
712
- // defines for `Game.defaultWorld`/`Game.input` — so the debug bridge and
713
- // the editor relay resolve the exact same world `Game.input` would.
732
+ // defines for `Game.defaultWorld`.
714
733
  getDefaultWorldId: () => (roots.length > 0 ? requireDefaultWorld().id : null),
715
734
  // Issue #175 — the built-in `time` provider's `loopLiveness` field reads
716
735
  // the REAL loop, not any UI-level play-state store: `opts.loop` is the
@@ -719,6 +738,18 @@ export function createGame(opts: {
719
738
  // the loop is actually ticking.
720
739
  getLoopLiveness: () => opts.loop.liveness,
721
740
  });
741
+ const inputAdapter = createInputManagerAdapter(input);
742
+ let inputFrameActive = false;
743
+ systems.add(
744
+ 'input',
745
+ () => {
746
+ if (inputFrameActive) input.poll(debugRegistry.getGameTick());
747
+ },
748
+ { name: 'input.poll' },
749
+ );
750
+ input.setDebugEmit((event, detail) => debugRegistry.forWorld('(game)').emit(event, detail));
751
+ let inputMapPath: string | null = null;
752
+ let inputMapLoad: Promise<void> | null = null;
722
753
 
723
754
  function requireDefaultWorld(): WorldInstance {
724
755
  if (roots.length === 0) {
@@ -749,8 +780,11 @@ export function createGame(opts: {
749
780
  // root may still publish this SAME adapter (the first-party Three/Pixi
750
781
  // mounts do); the reference-equality branch below treats that as the
751
782
  // intentional shared registration it is.
752
- const result: SystemAdapters = { debug: debugRegistry.adapter };
753
- const ownerWorldId = new Map<string, string>([['debug', '(game)']]);
783
+ const result: SystemAdapters = { debug: debugRegistry.adapter, input: inputAdapter };
784
+ const ownerWorldId = new Map<string, string>([
785
+ ['debug', '(game)'],
786
+ ['input', '(game)'],
787
+ ]);
754
788
  for (const world of roots) {
755
789
  const adapters = world.mounted.systems;
756
790
  if (!adapters) continue;
@@ -870,6 +904,24 @@ export function createGame(opts: {
870
904
  // a world pushed during this very frame. This also drops the two
871
905
  // per-phase `for...of` iterator allocations.
872
906
  const n = roots.length;
907
+ inputFrameActive = false;
908
+ for (let i = 0; i < n; i++) {
909
+ const world = roots[i]!;
910
+ if (onlyFrozen) {
911
+ if (paused && world.pausable && !world.mounted.drivesOwnLoop) {
912
+ inputFrameActive = true;
913
+ break;
914
+ }
915
+ } else if (
916
+ ignorePause ||
917
+ (world.mounted.drivesOwnLoop
918
+ ? !paused || !world.pausable || !world.mounted.setPaused
919
+ : !paused || !world.pausable)
920
+ ) {
921
+ inputFrameActive = true;
922
+ break;
923
+ }
924
+ }
873
925
 
874
926
  // §7.1-11 fix (probe5): `stateBridge.bump()` must fire iff at least one
875
927
  // world actually advanced this call — not unconditionally. `advanced`
@@ -901,6 +953,12 @@ export function createGame(opts: {
901
953
  // needed.
902
954
  for (const phase of PHASE_ORDER) {
903
955
  profiler.beginPhase();
956
+ // Game-owned systems follow the same one-run-per-phase contract as a
957
+ // normal frame whenever Step advances at least one frozen world.
958
+ // In particular, the game-owned InputManager must poll before those
959
+ // worlds read actions; its matching endFrame remains at the shared
960
+ // frame tail below.
961
+ if (inputFrameActive) systems.runPhase(phase, dt);
904
962
  for (let i = 0; i < n; i++) {
905
963
  const world = roots[i]!;
906
964
  if (world.mounted.drivesOwnLoop) continue;
@@ -1017,6 +1075,7 @@ export function createGame(opts: {
1017
1075
  // once per completed `runFrame`/`step()` call, never per phase/world —
1018
1076
  // and, per §7.1-11's fix, only when `advanced` (see above) is true: a
1019
1077
  // fully-gated paused game produces no notifications at all.
1078
+ if (inputFrameActive) input.endFrame();
1020
1079
  if (advanced) {
1021
1080
  stateBridge.bump();
1022
1081
  tick++;
@@ -1046,9 +1105,7 @@ export function createGame(opts: {
1046
1105
  get components() {
1047
1106
  return requireFirstPartyCtx('components').components;
1048
1107
  },
1049
- get input() {
1050
- return requireFirstPartyCtx('input').input;
1051
- },
1108
+ input,
1052
1109
  get audio() {
1053
1110
  return requireFirstPartyCtx('audio').audio;
1054
1111
  },
@@ -1113,13 +1170,23 @@ export function createGame(opts: {
1113
1170
  }
1114
1171
  const result: T[] = [];
1115
1172
  for (const world of roots) {
1116
- if (!isFirstPartyMounted(world.mounted)) continue;
1173
+ // A world's manager comes from its first-party ctx OR from the
1174
+ // optional `MountedWorldBase.components` capability (a non-first-
1175
+ // party adapter running the engine's REAL ComponentManager — e.g.
1176
+ // `@engine/world3d-react`'s R3F mount, whose `<Behavior>` components
1177
+ // used to be invisible here: the query compiled, ran, and returned
1178
+ // `[]` forever). A mount with NEITHER hosts no GameComponents, so
1179
+ // its silent empty contribution is a real, not a mistaken, result.
1180
+ const manager = isFirstPartyMounted(world.mounted)
1181
+ ? world.mounted.ctx.components
1182
+ : world.mounted.components;
1183
+ if (!manager) continue;
1117
1184
  if (opts?.worldId !== undefined && world.id !== opts.worldId) continue;
1118
1185
  if (opts?.kind !== undefined && world.kind !== opts.kind) continue;
1119
1186
  // Checklist item 8b: a plain for-loop push instead of
1120
1187
  // `result.push(...arr)` — spread-as-arguments can hit engine/runtime
1121
1188
  // argument-count limits once a world's instance count gets large.
1122
- const instances = world.mounted.ctx.components.queryByComponent(cls);
1189
+ const instances = manager.queryByComponent(cls);
1123
1190
  for (const inst of instances) result.push(inst);
1124
1191
  }
1125
1192
  return result;
@@ -1228,6 +1295,21 @@ export function createGame(opts: {
1228
1295
  runFrameImpl(fixedDt, { skipRenderPhases });
1229
1296
  }
1230
1297
  },
1298
+ loadInputMap(path: string): Promise<void> {
1299
+ if (inputMapPath && inputMapPath !== path) {
1300
+ throw new Error(
1301
+ `Game.loadInputMap: input map is already "${inputMapPath}"; root requested "${path}". ` +
1302
+ 'Input is game-owned, so roots cannot load competing maps.',
1303
+ );
1304
+ }
1305
+ inputMapPath = path;
1306
+ inputMapLoad ??= input.loadMap(path);
1307
+ return inputMapLoad;
1308
+ },
1309
+ dispose(): void {
1310
+ input.dispose();
1311
+ debugRegistry.strip();
1312
+ },
1231
1313
  };
1232
1314
 
1233
1315
  // Filed AFTER the shell exists (the WeakMap keys on the Game object
@@ -148,7 +148,7 @@ export interface MountManifestOptions {
148
148
  * never a real host. See `WorldsRuntimeConfig.headless`. */
149
149
  readonly headless?: boolean | undefined;
150
150
  /**
151
- * Task 2.1 (`docs/ACCEPTANCE-DRIVER-BUILD-PLAN.md`,
151
+ * Task 2.1 (`docs/E2E-TESTING-BUILD-PLAN.md`,
152
152
  * `docs/SYNTHETIC-PLAYER-SPEC.md` §3.4): overrides for the `?vgai-debug=1`
153
153
  * bridge `mountManifestWorlds` installs at the tail of every mount — the
154
154
  * engine-owned install point so every standalone project gets it with zero
@@ -213,6 +213,67 @@ export interface VgaiRenderHarness {
213
213
  * than a silently wrong hardcoded `1/60`.
214
214
  */
215
215
  simulateFixedDt(): number;
216
+ /**
217
+ * W3d (F11 perf regression gates) — the `vgai perf` sampling seam. Runs
218
+ * exactly `steps` FULL fixed-step gameplay frames (the same
219
+ * `game.runFrame(fixedDt)` path {@link simulateSubsteps} drives — every
220
+ * phase in `PHASE_ORDER`, including `render`, so `renderer.info`-backed
221
+ * draw/triangle counters populate per frame) with the game's OWN
222
+ * `PerformanceProfiler` (`dev/performance-profiler.ts`) enabled, sampling
223
+ * it after every frame, then restores the profiler's prior enabled state.
224
+ * Returns per-frame CPU/phase timings + render counters, plus a
225
+ * structural node/entity count of every threejs/pixijs world's live scene
226
+ * graph. NOT a parallel instrumentation layer: every number here comes
227
+ * from the existing profiler (timings, render counters) or the live world
228
+ * roots themselves (counts).
229
+ *
230
+ * Honesty notes, recorded here because this seam is what `vgai perf`
231
+ * reports: `gpuMs` is whatever the profiler's render reporter measured —
232
+ * `null` under headless SwiftShader (no usable GPU timer), never a
233
+ * fabricated 0; CPU timings include the profiler's own (small) phase
234
+ * bookkeeping, inherent to profiling; warmup belongs OUTSIDE this call
235
+ * (drive {@link simulateSubsteps} first — profiler disabled, zero
236
+ * overhead).
237
+ */
238
+ perfSample(steps: number): PerfSampleReport;
239
+ }
240
+
241
+ /** One measured fixed-step frame from {@link VgaiRenderHarness.perfSample}. */
242
+ export interface PerfFrameSample {
243
+ /** CPU time (ms) for the whole `runFrame` pass (profiler `cpuMs`). */
244
+ readonly cpuMs: number;
245
+ /** Per-phase CPU ms (profiler phase timings), keyed by phase name. */
246
+ readonly phases: Readonly<Record<string, number>>;
247
+ /** Renderer counters reported during this frame's `render` phase
248
+ * (`profiler.reportRender` — renderer.info). All zeros/null for a world
249
+ * whose adapter never reports render stats. */
250
+ readonly render: {
251
+ readonly gpuMs: number | null;
252
+ readonly drawCalls: number;
253
+ readonly triangles: number;
254
+ readonly geometries: number;
255
+ readonly textures: number;
256
+ };
257
+ }
258
+
259
+ /** Per-world structural counts from {@link VgaiRenderHarness.perfSample}. */
260
+ export interface PerfWorldCount {
261
+ readonly id: string;
262
+ readonly kind: string;
263
+ /** Total scene-graph descendants of the world root (exclusive of the root
264
+ * itself). Exact and deterministic under a seed. */
265
+ readonly nodes: number;
266
+ /** Descendants carrying `userData.entityId` (the loader/editor entity
267
+ * tag). 0 for a hand-built world that tags nothing — an honest count of
268
+ * tagged nodes, not a guess at "entities". */
269
+ readonly entities: number;
270
+ }
271
+
272
+ export interface PerfSampleReport {
273
+ readonly frames: readonly PerfFrameSample[];
274
+ /** threejs/pixijs worlds only — a react/DOM world has no scene-graph node
275
+ * count; it is omitted rather than fabricated. */
276
+ readonly worlds: readonly PerfWorldCount[];
216
277
  }
217
278
 
218
279
  export interface RenderControlHarnessOptions {
@@ -381,6 +442,44 @@ function computeWorldExclusionReasons(game: Game): string[] {
381
442
  return reasons;
382
443
  }
383
444
 
445
+ /** Structural scene-graph walk shared by threejs (`Object3D`) and pixijs
446
+ * (`Container`) roots — both expose a `children` array, and threejs nodes
447
+ * additionally carry `userData` (where the loader/editor entity tag lives).
448
+ * Deliberately duck-typed so this file keeps its type-only three/pixi rule. */
449
+ function countWorldGraph(root: { readonly children?: readonly unknown[] }): {
450
+ nodes: number;
451
+ entities: number;
452
+ } {
453
+ let nodes = 0;
454
+ let entities = 0;
455
+ const stack: unknown[] = [...(root.children ?? [])];
456
+ while (stack.length > 0) {
457
+ const node = stack.pop() as {
458
+ readonly children?: readonly unknown[];
459
+ readonly userData?: Record<string, unknown>;
460
+ };
461
+ nodes++;
462
+ if (node.userData?.['entityId'] !== undefined) entities++;
463
+ if (node.children) stack.push(...node.children);
464
+ }
465
+ return { nodes, entities };
466
+ }
467
+
468
+ /** See {@link PerfSampleReport.worlds} — threejs/pixijs worlds only; a
469
+ * react/DOM world has no scene-graph node count and is omitted, never
470
+ * fabricated. */
471
+ function countWorlds(game: Game): PerfWorldCount[] {
472
+ const counts: PerfWorldCount[] = [];
473
+ for (const world of game.roots) {
474
+ if (world.kind === 'threejs') {
475
+ counts.push({ id: world.id, kind: world.kind, ...countWorldGraph(world.threeRoot()) });
476
+ } else if (world.kind === 'pixijs') {
477
+ counts.push({ id: world.id, kind: world.kind, ...countWorldGraph(world.pixiRoot()) });
478
+ }
479
+ }
480
+ return counts;
481
+ }
482
+
384
483
  function nextAnimationFrame(): Promise<void> {
385
484
  return new Promise((resolve) => {
386
485
  requestAnimationFrame(() => resolve());
@@ -487,6 +586,28 @@ export function installRenderControlHarness(
487
586
  simulateFixedDt(): number {
488
587
  return simulateFixedDt;
489
588
  },
589
+ perfSample(steps: number): PerfSampleReport {
590
+ const profiler = game.profiler;
591
+ const wasEnabled = profiler.enabled;
592
+ profiler.enabled = true;
593
+ profiler.clear();
594
+ const frames: PerfFrameSample[] = [];
595
+ try {
596
+ for (let i = 0; i < steps; i++) {
597
+ game.runFrame(simulateFixedDt);
598
+ const snapshot = profiler.getSnapshot();
599
+ const latest = snapshot.frames.at(-1);
600
+ frames.push({
601
+ cpuMs: latest?.cpuMs ?? 0,
602
+ phases: Object.fromEntries((latest?.phases ?? []).map((p) => [p.name, p.ms])),
603
+ render: { ...snapshot.render },
604
+ });
605
+ }
606
+ } finally {
607
+ profiler.enabled = wasEnabled;
608
+ }
609
+ return { frames, worlds: countWorlds(game) };
610
+ },
490
611
  };
491
612
 
492
613
  target['__vgaiRender'] = harness;
@@ -36,7 +36,7 @@ export interface DebugRoomHandle {
36
36
  /**
37
37
  * Infers a `registerCommand`/`registerReactCommand`/`useDebugCommand`
38
38
  * handler's parameter tuple from its declared Zod `args` tuple (dry-run
39
- * finding, `docs/ACCEPTANCE-DRIVER-BUILD-PLAN.md` Wave 6 ledger —
39
+ * finding, `docs/E2E-TESTING-BUILD-PLAN.md` Wave 6 ledger —
40
40
  * "`registerCommand` fn typing forces `unknown[]` casts"): a real
41
41
  * `z.ZodTuple` infers its element types (`z.tuple([z.number(), z.string()])`
42
42
  * → `(n: number, s: string) => ...`), while omitting `args` (the generic's
@@ -22,9 +22,10 @@
22
22
  * teardown via {@link clearAssetCaches}.
23
23
  */
24
24
 
25
+ import type { SplatMesh } from '@sparkjsdev/spark';
25
26
  import * as THREE from 'three';
26
27
  import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
27
- import { gltfLoader, textureLoader } from '../loader';
28
+ import { gltfLoader, resolveUrl, textureLoader } from '../loader';
28
29
  import { SceneParseError } from './parse';
29
30
  import { setUserData } from './user-data';
30
31
 
@@ -33,6 +34,7 @@ const gltfCache = new Map<
33
34
  string,
34
35
  Promise<{ scene: THREE.Group; animations: THREE.AnimationClip[] }>
35
36
  >();
37
+ const splatBytesCache = new Map<string, Promise<Uint8Array>>();
36
38
 
37
39
  /** Load (and cache) a texture via the shared, prefix-aware TextureLoader. */
38
40
  export function loadTexture(url: string): THREE.Texture {
@@ -83,6 +85,65 @@ export function loadGLTF(
83
85
  });
84
86
  }
85
87
 
88
+ /**
89
+ * Load one native Gaussian-splat asset as Spark's own Object3D.
90
+ *
91
+ * The URL cache stores immutable source bytes, not a live SplatMesh: every scene
92
+ * entity needs its own transformable/disposable Object3D, while repeated uses of
93
+ * the same SPZ must not download the multi-megabyte source more than once.
94
+ * Spark stays a lazy chunk so games without splats do not pay its bundle cost.
95
+ */
96
+ export async function loadSplat(url: string, providedBytes?: Uint8Array): Promise<SplatMesh> {
97
+ let bytes = splatBytesCache.get(url);
98
+ if (!bytes && providedBytes) {
99
+ bytes = Promise.resolve(providedBytes);
100
+ splatBytesCache.set(url, bytes);
101
+ }
102
+ if (!bytes) {
103
+ bytes = fetch(resolveUrl(url)).then(async (response) => {
104
+ if (!response.ok) {
105
+ throw new Error(
106
+ `Failed to load Gaussian splat: ${url} (${response.status} ${response.statusText})`,
107
+ );
108
+ }
109
+ return new Uint8Array(await response.arrayBuffer());
110
+ });
111
+ splatBytesCache.set(url, bytes);
112
+ }
113
+
114
+ try {
115
+ const [{ SplatMesh }, fileBytes] = await Promise.all([import('@sparkjsdev/spark'), bytes]);
116
+ const mesh = new SplatMesh({ fileBytes, fileName: url, raycastable: true });
117
+ await mesh.initialized;
118
+ setUserData(mesh, 'gaussianSplat', { src: url, numSplats: mesh.numSplats });
119
+ const bounds = mesh.getBoundingBox();
120
+ if (!bounds.isEmpty()) {
121
+ // Three's generic Box3.setFromObject only understands geometry-bearing
122
+ // Object3Ds. Spark intentionally renders without THREE.BufferGeometry,
123
+ // so give editor/runtime framing a hidden, non-pickable bounds proxy.
124
+ // It is infrastructure, never authored hierarchy or rendered content.
125
+ // Include a framing margin. This proxy is consumed by generic editor
126
+ // focus/selection bounds, while inspectors continue to report Spark's
127
+ // exact Gaussian bounds directly.
128
+ const size = bounds.getSize(new THREE.Vector3()).multiplyScalar(1.5);
129
+ const center = bounds.getCenter(new THREE.Vector3());
130
+ const proxy = new THREE.Mesh(new THREE.BoxGeometry(size.x, size.y, size.z));
131
+ proxy.name = '__vgai_splat_bounds';
132
+ proxy.position.copy(center);
133
+ proxy.visible = false;
134
+ proxy.raycast = () => {};
135
+ setUserData(proxy, 'engineInternal', true);
136
+ mesh.add(proxy);
137
+ }
138
+ return mesh;
139
+ } catch (error) {
140
+ // A failed fetch must be retryable after the source is repaired or the
141
+ // network recovers; successful source bytes remain cached across instances.
142
+ splatBytesCache.delete(url);
143
+ throw error;
144
+ }
145
+ }
146
+
86
147
  /**
87
148
  * Resolve a single named node inside an already-loaded glTF scene graph (F4,
88
149
  * docs/VSCN-STRUCTURAL-GAPS-DESIGN.md `mesh.node`). Pure and headlessly
@@ -157,6 +218,19 @@ export function resolveGltfNode(
157
218
  export function clearAssetCaches(): void {
158
219
  textureCache.clear();
159
220
  gltfCache.clear();
221
+ splatBytesCache.clear();
222
+ }
223
+
224
+ /**
225
+ * Drop the cached entry for ONE asset URL after its bytes changed on disk
226
+ * (editor asset-optimization write-back — W4a). Existing clones keep their
227
+ * shared geometry alive (the cache entry only owns the lookup, not the
228
+ * buffers), and the next `loadGLTF`/`loadTexture` for this URL re-fetches the
229
+ * rewritten file instead of serving stale bytes.
230
+ */
231
+ export function invalidateCachedAsset(url: string): void {
232
+ textureCache.delete(url);
233
+ gltfCache.delete(url);
160
234
  }
161
235
 
162
236
  /**
@@ -164,6 +238,6 @@ export function clearAssetCaches(): void {
164
238
  * test to assert caches stay bounded (one entry per distinct asset URL) across
165
239
  * repeated loads of the same scene, rather than growing per load.
166
240
  */
167
- export function assetCacheSizes(): { textures: number; gltf: number } {
168
- return { textures: textureCache.size, gltf: gltfCache.size };
241
+ export function assetCacheSizes(): { textures: number; gltf: number; splat: number } {
242
+ return { textures: textureCache.size, gltf: gltfCache.size, splat: splatBytesCache.size };
169
243
  }
@@ -0,0 +1,25 @@
1
+ import * as THREE from 'three';
2
+ import type { InstancesFile } from './schema/instances';
3
+
4
+ /** Build one native InstancedMesh from a parsed `.instances.json` tuple array. */
5
+ export function buildInstancedMeshFromTuples(
6
+ geometry: THREE.BufferGeometry,
7
+ material: THREE.Material,
8
+ tuples: InstancesFile,
9
+ ): THREE.InstancedMesh {
10
+ const mesh = new THREE.InstancedMesh(geometry, material, tuples.length);
11
+ const position = new THREE.Vector3();
12
+ const quaternion = new THREE.Quaternion();
13
+ const scale = new THREE.Vector3();
14
+ const matrix = new THREE.Matrix4();
15
+ for (let index = 0; index < tuples.length; index += 1) {
16
+ const tuple = tuples[index]!;
17
+ position.set(tuple[0], tuple[1], tuple[2]);
18
+ quaternion.set(tuple[3], tuple[4], tuple[5], tuple[6]);
19
+ scale.set(tuple[7], tuple[8], tuple[9]);
20
+ matrix.compose(position, quaternion, scale);
21
+ mesh.setMatrixAt(index, matrix);
22
+ }
23
+ mesh.instanceMatrix.needsUpdate = true;
24
+ return mesh;
25
+ }
@@ -1,8 +1,6 @@
1
1
  import * as THREE from 'three';
2
2
  import { loadTexture } from './asset-loaders';
3
3
  import { DEFAULTS } from './defaults';
4
- import { buildGeneratedGeometry } from './geometry-registry';
5
- import { buildCustomMaterial } from './material-registry';
6
4
  import type { SceneMaterial, SceneMesh } from './scene-types';
7
5
 
8
6
  /**
@@ -13,15 +11,9 @@ import type { SceneMaterial, SceneMesh } from './scene-types';
13
11
  * copy-pasted in two places and had to be hand-synced — any new material feature
14
12
  * added to one and not the other silently broke that guarantee.
15
13
  *
16
- * Callers must have imported the side-effect registration modules
17
- * (`./geometries`, `./materials`, plus any custom defs) so `buildGeneratedGeometry`
18
- * and `buildCustomMaterial` resolve their registries.
19
14
  */
20
15
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: straightforward type-based switch with fallback args
21
16
  export function createGeometry(mesh: SceneMesh): THREE.BufferGeometry {
22
- if (mesh.generator) {
23
- return buildGeneratedGeometry(mesh.generator, mesh.generatorParams ?? {});
24
- }
25
17
  const args = mesh.args ?? [];
26
18
  switch (mesh.type) {
27
19
  case 'box':
@@ -29,7 +21,10 @@ export function createGeometry(mesh: SceneMesh): THREE.BufferGeometry {
29
21
  case 'sphere':
30
22
  return new THREE.SphereGeometry(args[0] ?? 0.5, args[1] ?? 32, args[2] ?? 16);
31
23
  case 'plane':
32
- return new THREE.PlaneGeometry(args[0] ?? 1, args[1] ?? 1);
24
+ // args[2]/args[3] (width/height segments) matter for vertex-displacing
25
+ // materials (e.g. the Water shader) — they used to be silently dropped,
26
+ // which collapsed the water demo to a 2-triangle quad.
27
+ return new THREE.PlaneGeometry(args[0] ?? 1, args[1] ?? 1, args[2] ?? 1, args[3] ?? 1);
33
28
  case 'cylinder':
34
29
  return new THREE.CylinderGeometry(
35
30
  args[0] ?? 0.5,
@@ -187,12 +182,6 @@ export function createMaterial(mat?: SceneMaterial): THREE.Material {
187
182
  : {}),
188
183
  ...(mat.displacementBias !== undefined ? { displacementBias: mat.displacementBias } : {}),
189
184
  });
190
- case 'custom': {
191
- if (!mat.def) {
192
- throw new Error('Custom material requires a "def" (registered material name)');
193
- }
194
- return buildCustomMaterial(mat.def, mat.uniforms ?? {});
195
- }
196
185
  default:
197
186
  return new THREE.MeshStandardMaterial({
198
187
  color: mat.color ?? dMat.color,
@@ -0,0 +1,18 @@
1
+ import * as THREE from 'three';
2
+
3
+ /**
4
+ * Apply the authored mesh-shadow contract to every renderable in a model.
5
+ *
6
+ * Primitive scene entities contain one Mesh, while glTF entities usually
7
+ * contain a Group with many Mesh/SkinnedMesh descendants. Keeping this walk
8
+ * shared prevents editor preview and runtime Play from disagreeing by source
9
+ * format.
10
+ */
11
+ export function setMeshShadowEnabled(root: THREE.Object3D, enabled: boolean | undefined): void {
12
+ if (enabled === undefined) return;
13
+ root.traverse((object) => {
14
+ if (!(object instanceof THREE.Mesh)) return;
15
+ object.castShadow = enabled;
16
+ object.receiveShadow = enabled;
17
+ });
18
+ }
@@ -328,6 +328,65 @@ export function createParticleSystemFromData(data: SceneParticles): ParticleSyst
328
328
  return { emitter: ps.emitter, system: ps };
329
329
  }
330
330
 
331
+ /**
332
+ * Patch a RUNNING ParticleSystem in place from SceneParticles JSON — the
333
+ * editor's live-tune path (W1b). Unlike `createParticleSystemFromData`, this
334
+ * does NOT recreate the system: the particle pool, per-particle ages, and the
335
+ * emission clock all survive, so a curve-key drag reshapes the live emitter
336
+ * with no restart stutter.
337
+ *
338
+ * Covers the live-tunable subset: lifecycle scalars (duration/looping/
339
+ * prewarm), emitter shape, start-value generators, emission rates + bursts,
340
+ * and the behavior stack (behavior instances are rebuilt from JSON — quarks
341
+ * behaviors read their generators per-update, so existing particles pick the
342
+ * new curves up on the next frame).
343
+ *
344
+ * NOT covered (these change the render batch and must go through the normal
345
+ * teardown/rebuild path): material, renderMode, tiling/soft-particle render
346
+ * settings, worldSpace.
347
+ */
348
+ export function applyParticlesDataLive(system: ParticleSystem, data: SceneParticles): void {
349
+ if (data.duration != null) system.duration = data.duration;
350
+ if (data.looping != null) system.looping = data.looping;
351
+ if (data.prewarm != null) system.prewarm = data.prewarm;
352
+ if (data.shape) system.emitterShape = createEmitterShape(data.shape);
353
+ if (data.startLife) system.startLife = valueGen(data.startLife) as any;
354
+ if (data.startSpeed) system.startSpeed = valueGen(data.startSpeed) as any;
355
+ if (data.startSize) system.startSize = valueGen(data.startSize) as any;
356
+ if (data.startRotation) system.startRotation = valueGen(data.startRotation) as any;
357
+ if (data.startColor) system.startColor = colorGen(data.startColor) as any;
358
+ if (data.startTileIndex) system.startTileIndex = valueGen(data.startTileIndex) as any;
359
+ if (data.emissionOverTime) system.emissionOverTime = valueGen(data.emissionOverTime) as any;
360
+ if (data.emissionOverDistance)
361
+ system.emissionOverDistance = valueGen(data.emissionOverDistance) as any;
362
+ system.emissionBursts = (data.emissionBursts ?? []).map((b) => ({
363
+ time: b.time,
364
+ count: valueGen(b.count) as any,
365
+ cycle: b.cycle ?? 1,
366
+ interval: b.interval ?? 0,
367
+ probability: b.probability ?? 1,
368
+ }));
369
+ system.behaviors = (data.behaviors ?? []).map(createBehavior);
370
+ }
371
+
372
+ /**
373
+ * Scrub a ParticleSystem to an absolute time by deterministic re-simulation:
374
+ * restart, then advance in fixed steps to `time`, then pause. Used by the
375
+ * editor's emitter-transport time scrub. `update` is TS-private on
376
+ * ParticleSystem but is exactly what BatchedRenderer.update calls per system.
377
+ */
378
+ export function scrubParticleSystemTo(system: ParticleSystem, time: number, step = 1 / 60): void {
379
+ system.restart();
380
+ const target = Math.max(0, time);
381
+ let t = 0;
382
+ while (t < target) {
383
+ const dt = Math.min(step, target - t);
384
+ (system as any).update(dt);
385
+ t += dt;
386
+ }
387
+ system.pause();
388
+ }
389
+
331
390
  /**
332
391
  * Register a particle system with a BatchedRenderer.
333
392
  */