@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.
- package/README.md +48 -15
- package/package.json +11 -25
- package/schemas/engine-capabilities.json +10 -10
- package/schemas/entity2d.schema.json +468 -0
- package/schemas/mat.schema.json +2 -33
- package/schemas/prefab.schema.json +16 -172
- package/schemas/scn2d.schema.json +42 -23
- package/schemas/vgai-game.schema.json +97 -0
- package/schemas/vscn.schema.json +16 -172
- package/src/adapter/authoring.ts +152 -2
- package/src/adapter/colyseus-networking-adapter.ts +35 -1
- package/src/adapter/first-party-systems.ts +7 -1
- package/src/adapter/game-adapter.ts +13 -0
- package/src/adapter/index.ts +25 -0
- package/src/adapter/rapier-physics-adapter.ts +55 -2
- package/src/adapter/system-adapter.ts +249 -2
- package/src/adapter/vgai-scene-game-adapter.ts +149 -25
- package/src/ai/navigation.ts +28 -0
- package/src/animation/clip-map.ts +1 -8
- package/src/animation/theatre-director.ts +50 -0
- package/src/animation/xstate-animation-binding.ts +6 -0
- package/src/audio/audio-introspection.ts +290 -0
- package/src/audio/tone-context.ts +46 -0
- package/src/dev/chrome-trace.ts +153 -0
- package/src/dev/performance-profiler.ts +93 -6
- package/src/dev/render-debug-adapter.ts +199 -0
- package/src/dev/render-memory.ts +243 -0
- package/src/dev/webgl-frame-capture.ts +424 -0
- package/src/ecs/component-manager.ts +43 -10
- package/src/ecs/game-component.ts +39 -10
- package/src/input/input-manager.ts +24 -19
- package/src/input/input-types.ts +1 -1
- package/src/loader.ts +7 -0
- package/src/manifest/load.ts +21 -0
- package/src/manifest/schema.ts +123 -0
- package/src/react/game-state.tsx +1 -1
- package/src/render/render-batch-system.ts +26 -12
- package/src/render/spark-renderer-lifecycle.ts +64 -0
- package/src/runtime/create-runtime.ts +33 -5
- package/src/runtime/debug-bridge.ts +5 -5
- package/src/runtime/game.ts +102 -20
- package/src/runtime/mount-manifest.ts +1 -1
- package/src/runtime/render-control.ts +121 -0
- package/src/runtime/types.ts +1 -1
- package/src/scene/asset-loaders.ts +77 -3
- package/src/scene/instance-mesh.ts +25 -0
- package/src/scene/material-factory.ts +4 -15
- package/src/scene/mesh-shadow.ts +18 -0
- package/src/scene/particles-factory.ts +59 -0
- package/src/scene/scene-loader.ts +41 -16
- package/src/scene/schema/instances.ts +1 -2
- package/src/scene/schema/material.ts +83 -94
- package/src/scene/schema/mesh.ts +76 -90
- package/src/scene/schema/scene-file.ts +1 -2
- package/src/scene/user-data.ts +30 -14
- package/src/setup/setup-renderer.ts +6 -1
- package/src/world2d/asset-paths2d.ts +44 -0
- package/src/world2d/collision-2d.ts +7 -14
- package/src/world2d/entity2d-asset.ts +22 -0
- package/src/world2d/index.ts +27 -2
- package/src/world2d/physics2d-transform.ts +173 -0
- package/src/world2d/physics2d-units.ts +10 -0
- package/src/world2d/pixi-game-adapter.ts +148 -36
- package/src/world2d/scene2d-identity.ts +49 -0
- package/src/world2d/scene2d-loader.ts +243 -119
- package/src/world2d/schema/entity2d.ts +51 -33
- package/src/world2d/schema/physics2d.ts +14 -3
- package/src/world2d/schema/sprite.ts +32 -4
- package/src/world2d/schema/tilemap.ts +26 -9
- package/src/world2d/transform-writer-2d.ts +29 -11
- package/src/world2d/types.ts +21 -8
- package/src/world3d-react/behavior.tsx +138 -0
- package/src/world3d-react/engine-bridge.ts +48 -0
- package/src/world3d-react/index.ts +44 -0
- package/src/world3d-react/r3f-adapter.tsx +303 -0
- package/src/world3d-react/world-context.ts +294 -0
- package/vendor/realism-effects/LICENSE.md +21 -0
- package/vendor/realism-effects/UPSTREAM.md +19 -0
- package/vendor/realism-effects/dist/index.cjs +3447 -0
- package/vendor/realism-effects/dist/index.d.ts +59 -0
- package/vendor/realism-effects/dist/index.js +3434 -0
- package/vendor/realism-effects/package.json +23 -0
- package/src/character/cloth-sim.ts +0 -533
- package/src/character/spring-chain.ts +0 -307
- package/src/humanoid/body.ts +0 -663
- package/src/humanoid/clips.ts +0 -149
- package/src/humanoid/compose.ts +0 -209
- package/src/humanoid/generate.ts +0 -189
- package/src/humanoid/index.ts +0 -36
- package/src/humanoid/schema.ts +0 -108
- package/src/humanoid/skeleton.ts +0 -345
- package/src/react/humanoid-bake.document.tsx +0 -337
- package/src/scene/geometries/index.ts +0 -7
- package/src/scene/geometries/terrain.ts +0 -42
- package/src/scene/geometry-registry.ts +0 -42
- package/src/scene/instance-registry.ts +0 -84
- package/src/scene/instancers/grid.ts +0 -38
- package/src/scene/instancers/index.ts +0 -7
- package/src/scene/material-registry.ts +0 -73
- package/src/scene/materials/index.ts +0 -7
- package/src/scene/materials/water.ts +0 -56
- package/src/world2d/components-2d.ts +0 -86
- package/tools/humanoid-bake.tool.ts +0 -274
package/src/runtime/game.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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,
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
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
|
|
708
|
-
// input-target
|
|
709
|
-
// the SAME
|
|
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
|
|
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>([
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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/
|
|
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;
|
package/src/runtime/types.ts
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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
|
*/
|