@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.
- 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 +34 -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 +14 -0
- package/src/manifest/schema.ts +65 -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
|
@@ -40,7 +40,7 @@ function isTextEntryFocused(): boolean {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Thrown by `InputManager.setVirtualAction`/`tapVirtualAction` (Task 1.4,
|
|
43
|
-
*
|
|
43
|
+
* E2E-TESTING-BUILD-PLAN.md / SYNTHETIC-PLAYER-SPEC.md §3.2) for an
|
|
44
44
|
* action name that was never `registerAction`/`loadMap`-ed. Carries a
|
|
45
45
|
* machine-readable `code` and `data.registered` (every declared action name)
|
|
46
46
|
* rather than requiring a caller to parse the message.
|
|
@@ -360,7 +360,7 @@ export class InputManager {
|
|
|
360
360
|
private static isStickActive(v: Vector2): boolean {
|
|
361
361
|
return Math.hypot(v.x, v.y) > 0.3;
|
|
362
362
|
}
|
|
363
|
-
// Task 1.4 (
|
|
363
|
+
// Task 1.4 (E2E-TESTING-BUILD-PLAN.md) — action-level virtual input
|
|
364
364
|
// for a synthetic player, promoted from hollowstone's `VirtualInput`. Held
|
|
365
365
|
// digitals OR into isPressed alongside binding contributions (like
|
|
366
366
|
// touchButtonsDown above); tap queues, then is promoted to "active" for
|
|
@@ -468,7 +468,7 @@ export class InputManager {
|
|
|
468
468
|
* Window focus deliberately does not participate: a synthetic player
|
|
469
469
|
* drives the shared editor tab precisely while the human's focus is
|
|
470
470
|
* elsewhere (their terminal, another window) — the first live in-editor
|
|
471
|
-
*
|
|
471
|
+
* E2E run froze mid-suite the moment the owner started typing to their
|
|
472
472
|
* agent, which is the bug this gate split fixes. Real-device input keeps
|
|
473
473
|
* the stricter `inputActive` (enabled AND focused) gate above. */
|
|
474
474
|
private get machineInputActive(): boolean {
|
|
@@ -614,13 +614,15 @@ export class InputManager {
|
|
|
614
614
|
}
|
|
615
615
|
|
|
616
616
|
constructor() {
|
|
617
|
-
window
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
617
|
+
if (typeof window !== 'undefined') {
|
|
618
|
+
window.addEventListener('keydown', this.onKeyDown);
|
|
619
|
+
window.addEventListener('keyup', this.onKeyUp);
|
|
620
|
+
window.addEventListener('mousedown', this.onMouseDown);
|
|
621
|
+
window.addEventListener('mouseup', this.onMouseUp);
|
|
622
|
+
window.addEventListener('mousemove', this.onMouseMove);
|
|
623
|
+
window.addEventListener('blur', this.onWindowBlur);
|
|
624
|
+
window.addEventListener('focus', this.onWindowFocus);
|
|
625
|
+
}
|
|
624
626
|
// `document` isn't touched by any pre-F2 code path (the headless vitest
|
|
625
627
|
// env stubs `window` only) — guard the same way `isTextEntryFocused` does
|
|
626
628
|
// so tests that don't need pointer-lock coverage need no `document` stub.
|
|
@@ -830,7 +832,8 @@ export class InputManager {
|
|
|
830
832
|
return;
|
|
831
833
|
}
|
|
832
834
|
|
|
833
|
-
this.gamepads =
|
|
835
|
+
this.gamepads =
|
|
836
|
+
typeof navigator !== 'undefined' && navigator.getGamepads ? [...navigator.getGamepads()] : [];
|
|
834
837
|
|
|
835
838
|
// Gamepad button edge detection: diff this frame's pressed set against the
|
|
836
839
|
// previous frame's so isJustPressed/isJustReleased work for gamepads too.
|
|
@@ -2004,7 +2007,7 @@ export class InputManager {
|
|
|
2004
2007
|
}
|
|
2005
2008
|
|
|
2006
2009
|
// ==========================================================================
|
|
2007
|
-
// Task 1.4 (
|
|
2010
|
+
// Task 1.4 (E2E-TESTING-BUILD-PLAN.md / SYNTHETIC-PLAYER-SPEC.md §3.2)
|
|
2008
2011
|
// — action-level virtual input for a synthetic player. Promoted from
|
|
2009
2012
|
// hollowstone's proven `VirtualInput` (`bot/virtual-input.ts`): a bot drives
|
|
2010
2013
|
// NAMED ACTIONS (not raw devices), and its contributions join action
|
|
@@ -2274,13 +2277,15 @@ export class InputManager {
|
|
|
2274
2277
|
dispose() {
|
|
2275
2278
|
if (this.disposed) return;
|
|
2276
2279
|
this.disposed = true;
|
|
2277
|
-
window
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2280
|
+
if (typeof window !== 'undefined') {
|
|
2281
|
+
window.removeEventListener('keydown', this.onKeyDown);
|
|
2282
|
+
window.removeEventListener('keyup', this.onKeyUp);
|
|
2283
|
+
window.removeEventListener('mousedown', this.onMouseDown);
|
|
2284
|
+
window.removeEventListener('mouseup', this.onMouseUp);
|
|
2285
|
+
window.removeEventListener('mousemove', this.onMouseMove);
|
|
2286
|
+
window.removeEventListener('blur', this.onWindowBlur);
|
|
2287
|
+
window.removeEventListener('focus', this.onWindowFocus);
|
|
2288
|
+
}
|
|
2284
2289
|
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
|
|
2285
2290
|
document.removeEventListener('pointerlockchange', this.onPointerLockChange);
|
|
2286
2291
|
}
|
package/src/input/input-types.ts
CHANGED
|
@@ -91,7 +91,7 @@ export type ActionValueOf<T extends ActionValueType> = T extends 'digital'
|
|
|
91
91
|
*/
|
|
92
92
|
export interface ActionValueSource {
|
|
93
93
|
/** The binding kind that produced the current value. `'virtual'` (Task 1.4,
|
|
94
|
-
*
|
|
94
|
+
* E2E-TESTING-BUILD-PLAN.md) is not a real `InputBinding` — it marks a
|
|
95
95
|
* value written by `InputManager.setVirtualAction` (the synthetic-player
|
|
96
96
|
* seat), not a device binding. */
|
|
97
97
|
bindingType: InputBinding['type'] | 'virtual';
|
package/src/loader.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import * as THREE from 'three';
|
|
14
|
+
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
|
|
14
15
|
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
|
|
15
16
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
16
17
|
|
|
@@ -51,6 +52,12 @@ dracoLoader.setDecoderPath('/jsm/libs/draco/gltf/');
|
|
|
51
52
|
/** Pre-configured GLTFLoader using the shared manager, with DRACO decoding wired in. */
|
|
52
53
|
export const gltfLoader = new GLTFLoader(loadingManager);
|
|
53
54
|
gltfLoader.setDRACOLoader(dracoLoader);
|
|
55
|
+
// EXT_meshopt_compression decoding (three's own bundled decoder — inline
|
|
56
|
+
// wasm, no fetch). Without this, a meshopt-compressed GLB — including the
|
|
57
|
+
// output of the editor's Asset Budget "Compress · meshopt" action (W4a M3) —
|
|
58
|
+
// throws at load time in every runtime and the editor viewport. Symmetric
|
|
59
|
+
// with the DRACO wiring above (CB2): the ONE shared loader owns codec setup.
|
|
60
|
+
gltfLoader.setMeshoptDecoder(MeshoptDecoder);
|
|
54
61
|
|
|
55
62
|
/**
|
|
56
63
|
* Set the URL prefix prepended to relative asset paths.
|
package/src/manifest/load.ts
CHANGED
|
@@ -119,6 +119,19 @@ export interface ResolvedGameManifest {
|
|
|
119
119
|
readonly determinism:
|
|
120
120
|
| { readonly seededRandom: boolean; readonly defaultSeed?: number | undefined }
|
|
121
121
|
| undefined;
|
|
122
|
+
/** W3d (F11 perf gates) — read by `vgai perf --assert-budget`
|
|
123
|
+
* (packages/vgai-cli/src/perf.ts, assertBudget): each set metric is
|
|
124
|
+
* compared against the seeded headless perf run's measured value and any
|
|
125
|
+
* exceedance fails the gate with a per-metric table + nonzero exit. */
|
|
126
|
+
readonly budget:
|
|
127
|
+
| {
|
|
128
|
+
readonly frameCpuMsP95?: number | undefined;
|
|
129
|
+
readonly physicsMsP95?: number | undefined;
|
|
130
|
+
readonly maxEntities?: number | undefined;
|
|
131
|
+
readonly maxDrawCalls?: number | undefined;
|
|
132
|
+
readonly maxTriangles?: number | undefined;
|
|
133
|
+
}
|
|
134
|
+
| undefined;
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
// ---------------------------------------------------------------------------
|
|
@@ -427,5 +440,6 @@ export function loadGameManifest(raw: unknown): ResolvedGameManifest {
|
|
|
427
440
|
authoring: manifest.authoring,
|
|
428
441
|
debug: manifest.debug,
|
|
429
442
|
determinism: manifest.determinism,
|
|
443
|
+
budget: manifest.budget,
|
|
430
444
|
};
|
|
431
445
|
}
|
package/src/manifest/schema.ts
CHANGED
|
@@ -475,6 +475,71 @@ export const GameManifestSchema = z
|
|
|
475
475
|
'whether ctx.random is boot-seeded from a reproducible seed and whether the ' +
|
|
476
476
|
'gameplay-rng-ban scan / dev-mode Math.random phase trap are active for this project.',
|
|
477
477
|
),
|
|
478
|
+
budget: z
|
|
479
|
+
.object({
|
|
480
|
+
frameCpuMsP95: z
|
|
481
|
+
.number()
|
|
482
|
+
.positive()
|
|
483
|
+
.optional()
|
|
484
|
+
.describe(
|
|
485
|
+
'Maximum allowed p95 of per-frame CPU time (ms) across the measured frames of a ' +
|
|
486
|
+
'`vgai perf` seeded headless run. CPU-side only: headless runs rasterize under ' +
|
|
487
|
+
'SwiftShader, so this is main-thread frame cost, NOT real GPU frame time. ' +
|
|
488
|
+
'Machine-dependent — prefer the baseline-diff tolerance band for regression ' +
|
|
489
|
+
'gating and use this as a coarse absolute ceiling.',
|
|
490
|
+
),
|
|
491
|
+
physicsMsP95: z
|
|
492
|
+
.number()
|
|
493
|
+
.positive()
|
|
494
|
+
.optional()
|
|
495
|
+
.describe(
|
|
496
|
+
"Maximum allowed p95 of the profiler's `physics` phase time (ms) per fixed-step " +
|
|
497
|
+
'frame in a `vgai perf` run. Same CPU-timing caveats as frameCpuMsP95.',
|
|
498
|
+
),
|
|
499
|
+
maxEntities: z
|
|
500
|
+
.number()
|
|
501
|
+
.int()
|
|
502
|
+
.nonnegative()
|
|
503
|
+
.optional()
|
|
504
|
+
.describe(
|
|
505
|
+
'Maximum allowed entity-tagged scene-graph nodes (nodes carrying ' +
|
|
506
|
+
"userData.entityId) summed across the game's threejs/pixijs worlds at the end " +
|
|
507
|
+
'of a `vgai perf` run. Exact and deterministic under a seed — no tolerance band.',
|
|
508
|
+
),
|
|
509
|
+
maxDrawCalls: z
|
|
510
|
+
.number()
|
|
511
|
+
.int()
|
|
512
|
+
.positive()
|
|
513
|
+
.optional()
|
|
514
|
+
.describe(
|
|
515
|
+
'Maximum allowed renderer draw calls in any measured frame of a `vgai perf` run ' +
|
|
516
|
+
"(the profiler's per-frame render.drawCalls, from renderer.info). Exact and " +
|
|
517
|
+
'deterministic under a seed.',
|
|
518
|
+
),
|
|
519
|
+
maxTriangles: z
|
|
520
|
+
.number()
|
|
521
|
+
.int()
|
|
522
|
+
.positive()
|
|
523
|
+
.optional()
|
|
524
|
+
.describe(
|
|
525
|
+
'Maximum allowed rendered triangles in any measured frame of a `vgai perf` run ' +
|
|
526
|
+
"(the profiler's per-frame render.triangles, from renderer.info). Exact and " +
|
|
527
|
+
'deterministic under a seed.',
|
|
528
|
+
),
|
|
529
|
+
})
|
|
530
|
+
.refine((budget) => Object.values(budget).some((value) => value !== undefined), {
|
|
531
|
+
message:
|
|
532
|
+
'Game manifest: `budget` must set at least one metric (frameCpuMsP95, physicsMsP95, ' +
|
|
533
|
+
'maxEntities, maxDrawCalls, maxTriangles) — an empty budget block gates nothing ' +
|
|
534
|
+
'(T4.1 no-dead-fields policy).',
|
|
535
|
+
})
|
|
536
|
+
.optional()
|
|
537
|
+
.describe(
|
|
538
|
+
'Performance budget (W3d, F11 perf regression gates) asserted by ' +
|
|
539
|
+
'`vgai perf --assert-budget`: a seeded, fixed-step headless run samples the engine ' +
|
|
540
|
+
'profiler and fails (nonzero exit) when any metric here is exceeded. Only ' +
|
|
541
|
+
'headlessly-measurable metrics are budgetable; real GPU frame time is not (SwiftShader).',
|
|
542
|
+
),
|
|
478
543
|
})
|
|
479
544
|
.strict()
|
|
480
545
|
.describe('Game manifest (vgai.game.json) — the complete v2 project configuration');
|
package/src/react/game-state.tsx
CHANGED
|
@@ -223,7 +223,7 @@ export function useDebugProvider(
|
|
|
223
223
|
*
|
|
224
224
|
* Generic over the declared `args` Zod tuple, same as
|
|
225
225
|
* `DebugCtxSurface.registerCommand` ({@link DebugCommandArgs}, dry-run
|
|
226
|
-
* finding — `docs/
|
|
226
|
+
* finding — `docs/E2E-TESTING-BUILD-PLAN.md` Wave 6 ledger): declaring
|
|
227
227
|
* `args: z.tuple([z.number(), z.string()])` types `fn`'s parameters as
|
|
228
228
|
* `(n: number, s: string) => ...` with no `unknown[]` cast; omitting `args`
|
|
229
229
|
* keeps `fn` typed `(...args: unknown[]) => ...` as before.
|
|
@@ -84,6 +84,29 @@ function entityOptOut(obj: THREE.Object3D): boolean {
|
|
|
84
84
|
return nearestEntity(obj)?.render?.autoBatch === false;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** Match Three.js renderer visibility: a hidden ancestor hides the whole subtree. */
|
|
88
|
+
function isEffectivelyVisible(obj: THREE.Object3D): boolean {
|
|
89
|
+
let cur: THREE.Object3D | null = obj;
|
|
90
|
+
while (cur) {
|
|
91
|
+
if (!cur.visible) return false;
|
|
92
|
+
cur = cur.parent;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isBatchCandidate(object: THREE.Object3D, batchRoot: THREE.Group): object is THREE.Mesh {
|
|
98
|
+
const mesh = object as THREE.Mesh;
|
|
99
|
+
if (!mesh.isMesh) return false;
|
|
100
|
+
if ((mesh as THREE.InstancedMesh).isInstancedMesh) return false;
|
|
101
|
+
if ((mesh as THREE.SkinnedMesh).isSkinnedMesh) return false;
|
|
102
|
+
if ((mesh as unknown as THREE.BatchedMesh).isBatchedMesh) return false;
|
|
103
|
+
if (!isEffectivelyVisible(mesh)) return false;
|
|
104
|
+
if (!mesh.geometry || !mesh.material || Array.isArray(mesh.material)) return false;
|
|
105
|
+
if ((mesh.material as THREE.Material).transparent) return false;
|
|
106
|
+
if (batchRoot === mesh.parent) return false;
|
|
107
|
+
return !entityOptOut(mesh);
|
|
108
|
+
}
|
|
109
|
+
|
|
87
110
|
/**
|
|
88
111
|
* Static-batching eligibility: only entities that won't move are safe to freeze
|
|
89
112
|
* and instance. An entity with physics, behavior components, or its own animation
|
|
@@ -126,19 +149,10 @@ export class RenderBatchSystem {
|
|
|
126
149
|
// collect eligible candidates, grouped by (geometry, material)
|
|
127
150
|
const buckets = new Map<string, THREE.Mesh[]>();
|
|
128
151
|
this.scene.traverse((obj) => {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if ((m as THREE.InstancedMesh).isInstancedMesh) return;
|
|
132
|
-
if ((m as THREE.SkinnedMesh).isSkinnedMesh) return;
|
|
133
|
-
if ((m as unknown as THREE.BatchedMesh).isBatchedMesh) return;
|
|
134
|
-
if (!m.visible) return;
|
|
135
|
-
if (!m.geometry || !m.material || Array.isArray(m.material)) return;
|
|
136
|
-
if ((m.material as THREE.Material).transparent) return;
|
|
137
|
-
if (this.root === m.parent) return; // our own instances
|
|
138
|
-
if (entityOptOut(m)) return;
|
|
139
|
-
const key = SIG(m);
|
|
152
|
+
if (!isBatchCandidate(obj, this.root)) return;
|
|
153
|
+
const key = SIG(obj);
|
|
140
154
|
const arr = buckets.get(key) ?? [];
|
|
141
|
-
arr.push(
|
|
155
|
+
arr.push(obj);
|
|
142
156
|
buckets.set(key, arr);
|
|
143
157
|
});
|
|
144
158
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { SparkRenderer } from '@sparkjsdev/spark';
|
|
2
|
+
import type * as THREE from 'three';
|
|
3
|
+
import { hasUserData } from '../scene/user-data';
|
|
4
|
+
|
|
5
|
+
const IDLE_POLL_MS = 16;
|
|
6
|
+
const MAX_IDLE_WAIT_MS = 3_000;
|
|
7
|
+
export const SPARK_DISCOVERY_INTERVAL_MS = 500;
|
|
8
|
+
|
|
9
|
+
/** Gate fallback discovery so ordinary non-splat scenes are never traversed every frame. */
|
|
10
|
+
export function shouldDiscoverGaussianSplat(lastCheckAt: number, now: number): boolean {
|
|
11
|
+
return now - lastCheckAt >= SPARK_DISCOVERY_INTERVAL_MS;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** True when a native scene currently contains at least one Gaussian payload. */
|
|
15
|
+
export function sceneHasGaussianSplat(scene: THREE.Object3D): boolean {
|
|
16
|
+
let found = false;
|
|
17
|
+
scene.traverse((object) => {
|
|
18
|
+
if (!found && hasUserData(object, 'gaussianSplat')) found = true;
|
|
19
|
+
});
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stop and dispose Spark after its asynchronous depth-sort worker is idle.
|
|
25
|
+
*
|
|
26
|
+
* SparkRenderer.dispose() rejects any worker calls still in flight. Spark's
|
|
27
|
+
* automatic update path intentionally does not await those calls, so disposing
|
|
28
|
+
* synchronously during an editor stop otherwise produces an unhandled
|
|
29
|
+
* `Worker terminate` rejection. Disable future work immediately, then release
|
|
30
|
+
* the renderer once the current sort has naturally settled.
|
|
31
|
+
*/
|
|
32
|
+
export function disposeSparkRendererWhenIdle(renderer: SparkRenderer): void {
|
|
33
|
+
renderer.autoUpdate = false;
|
|
34
|
+
renderer.sortDirty = false;
|
|
35
|
+
|
|
36
|
+
if (renderer.updateTimeoutId !== -1) {
|
|
37
|
+
clearTimeout(renderer.updateTimeoutId);
|
|
38
|
+
renderer.updateTimeoutId = -1;
|
|
39
|
+
}
|
|
40
|
+
if (renderer.sortTimeoutId !== -1) {
|
|
41
|
+
clearTimeout(renderer.sortTimeoutId);
|
|
42
|
+
renderer.sortTimeoutId = -1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let remainingIdleWaitMs = MAX_IDLE_WAIT_MS;
|
|
46
|
+
const disposeWhenIdle = (): void => {
|
|
47
|
+
if (renderer.sorting) {
|
|
48
|
+
if (remainingIdleWaitMs > 0) {
|
|
49
|
+
remainingIdleWaitMs -= IDLE_POLL_MS;
|
|
50
|
+
setTimeout(disposeWhenIdle, IDLE_POLL_MS);
|
|
51
|
+
} else {
|
|
52
|
+
// A stuck upstream worker is safer to abandon than to turn a routine
|
|
53
|
+
// editor stop into an unhandled rejection. Page teardown will reclaim
|
|
54
|
+
// the worker; normal sorts complete in a few milliseconds.
|
|
55
|
+
// biome-ignore lint/suspicious/noConsole: This rare upstream worker leak must degrade loudly.
|
|
56
|
+
console.warn('SparkRenderer did not become idle; skipped worker termination.');
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
renderer.dispose();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
disposeWhenIdle();
|
|
64
|
+
}
|
|
@@ -88,6 +88,12 @@ export function registerThreeWorld(
|
|
|
88
88
|
// world directly at attach time, unaffected by this call.
|
|
89
89
|
if (isFirstPartyMounted(mounted)) {
|
|
90
90
|
mounted.ctx.components.adoptWorld(world);
|
|
91
|
+
} else if (mounted.components) {
|
|
92
|
+
// Same one-time backfill for a non-first-party mount exposing the
|
|
93
|
+
// optional `MountedWorldBase.components` capability (e.g.
|
|
94
|
+
// `@engine/world3d-react` — its `<Behavior>` attaches happened during
|
|
95
|
+
// `mount()`, before this `WorldInstance` existed).
|
|
96
|
+
mounted.components.adoptWorld(world);
|
|
91
97
|
}
|
|
92
98
|
game.registerWorld(world);
|
|
93
99
|
return world;
|
|
@@ -407,7 +413,10 @@ export interface GameSession {
|
|
|
407
413
|
pause(): void;
|
|
408
414
|
resume(): void;
|
|
409
415
|
step(): void;
|
|
410
|
-
|
|
416
|
+
/** Resize every world's render buffer. `pixelRatio` (W2c device preview)
|
|
417
|
+
* optionally re-pins the threejs renderers' DPR in the same pass; omitted,
|
|
418
|
+
* the mount-time ratio is left untouched (legacy behavior). */
|
|
419
|
+
resize(width: number, height: number, pixelRatio?: number): void;
|
|
411
420
|
readonly scene: THREE.Scene;
|
|
412
421
|
readonly camera: THREE.PerspectiveCamera;
|
|
413
422
|
/** The mounted game (interface surface). Cast to `VgaiMountedGame` for first-party extras. */
|
|
@@ -616,14 +625,15 @@ async function mountOneThreeWorld(
|
|
|
616
625
|
* did (never self-healing, by design — see above). */
|
|
617
626
|
async function mountOnePixiWorld(
|
|
618
627
|
spec: PixiWorldMountSpec,
|
|
619
|
-
ctx: Omit<OneWorldContext, '
|
|
628
|
+
ctx: Omit<OneWorldContext, 'loopHandle' | 'assets'>,
|
|
620
629
|
): Promise<OneWorldResult> {
|
|
621
|
-
const { game, canvas, w, h, dpr, isBottom } = ctx;
|
|
630
|
+
const { game, canvas, w, h, dpr, headless, isBottom } = ctx;
|
|
622
631
|
const pixiHost: World2DHost = {
|
|
623
632
|
canvas,
|
|
624
633
|
width: w,
|
|
625
634
|
height: h,
|
|
626
635
|
game,
|
|
636
|
+
headless,
|
|
627
637
|
dpr,
|
|
628
638
|
transparent: !isBottom,
|
|
629
639
|
preserveDrawingBuffer: true,
|
|
@@ -846,6 +856,7 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
|
|
|
846
856
|
stacked.forEach((entry, i) => {
|
|
847
857
|
const isReact = kindById.get(entry.id) === 'react';
|
|
848
858
|
const surface = document.createElement(isReact ? 'div' : 'canvas') as HTMLElement;
|
|
859
|
+
surface.dataset['vgaiWorldSurface'] = 'true';
|
|
849
860
|
if (!isReact) {
|
|
850
861
|
const canvas = surface as unknown as HTMLCanvasElement;
|
|
851
862
|
canvas.width = w;
|
|
@@ -938,8 +949,20 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
|
|
|
938
949
|
entry.mounted.dispose();
|
|
939
950
|
entry.renderer?.dispose();
|
|
940
951
|
entry.renderer?.forceContextLoss();
|
|
941
|
-
|
|
952
|
+
// Remove from the element's CURRENT parent, not the mount-time
|
|
953
|
+
// `container`: the editor's Game panel re-parents the live surfaces
|
|
954
|
+
// when its mount element swaps (fill <-> device preset, W2c), and
|
|
955
|
+
// `container.removeChild` would throw NotFoundError after such a move.
|
|
956
|
+
// `container` remains the fallback for hosts whose element stand-ins
|
|
957
|
+
// never wire `parentNode` (headless unit fixtures); an element already
|
|
958
|
+
// detached by such a host is a no-op via the catch.
|
|
959
|
+
try {
|
|
960
|
+
(entry.element.parentNode ?? container).removeChild(entry.element);
|
|
961
|
+
} catch {
|
|
962
|
+
/* already detached — nothing to remove */
|
|
963
|
+
}
|
|
942
964
|
}
|
|
965
|
+
game.dispose();
|
|
943
966
|
retractDevGlobals();
|
|
944
967
|
}
|
|
945
968
|
|
|
@@ -960,10 +983,15 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
|
|
|
960
983
|
step() {
|
|
961
984
|
game.play.step();
|
|
962
985
|
},
|
|
963
|
-
resize(rw: number, rh: number) {
|
|
986
|
+
resize(rw: number, rh: number, pixelRatio?: number) {
|
|
964
987
|
const safeWidth = Math.max(1, rw);
|
|
965
988
|
const safeHeight = Math.max(1, rh);
|
|
966
989
|
for (const entry of mountedEntries) {
|
|
990
|
+
// W2c device preview: re-pin DPR before setSize so the drawing
|
|
991
|
+
// buffer (and the first-party composer, whose `mounted.resize` reads
|
|
992
|
+
// `getDrawingBufferSize`) picks it up in this same pass. Pixi/react
|
|
993
|
+
// worlds have no `entry.renderer` and keep their own resolution.
|
|
994
|
+
if (pixelRatio !== undefined) entry.renderer?.setPixelRatio(pixelRatio);
|
|
967
995
|
entry.renderer?.setSize(safeWidth, safeHeight, false);
|
|
968
996
|
entry.mounted.resize?.(safeWidth, safeHeight);
|
|
969
997
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The standalone in-page debug bridge (`docs/SYNTHETIC-PLAYER-SPEC.md` §3.4,
|
|
3
|
-
* D17/D18; Task 2.1 of `docs/
|
|
3
|
+
* D17/D18; Task 2.1 of `docs/E2E-TESTING-BUILD-PLAN.md`). Query-gated
|
|
4
4
|
* `window.__vgai`, the same production-protection-lives-in-the-installer
|
|
5
5
|
* pattern `render-control.ts`'s `installRenderControlHarness` established for
|
|
6
6
|
* `?vgai-render=1` (production protection lives HERE, not just at whatever
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* ever gets a handle when the gate below actually passes).
|
|
10
10
|
*
|
|
11
11
|
* D17: this is door (a) of the seam's three doors — the SAME `DebugAdapter`
|
|
12
|
-
* names/JSON the editor relay (door b) and `@vgai/
|
|
12
|
+
* names/JSON the editor relay (door b) and `@vgai/e2e` (door c, which
|
|
13
13
|
* drives door (a) itself over Playwright) all read. D18: the bridge installs
|
|
14
14
|
* only when the page opts in (`?vgai-debug=1`) AND is either a dev build
|
|
15
15
|
* (`import.meta.env.DEV`) or the project's manifest explicitly opts a
|
|
@@ -152,7 +152,7 @@ export interface VgaiDebugHandle {
|
|
|
152
152
|
* this is the honest human-input-path proof) → `input.clearVirtualActions()`
|
|
153
153
|
* into ONE async bridge call, as a TOP-LEVEL method (not under `input`)
|
|
154
154
|
* because it needs the sim clock, not just the input target. Was 15+
|
|
155
|
-
* transport round trips over the editor relay (`@vgai/
|
|
155
|
+
* transport round trips over the editor relay (`@vgai/e2e`'s old
|
|
156
156
|
* `GameInput.hold`: set → a 150ms-interval `waitSimTime` snapshot poll loop
|
|
157
157
|
* → clear); now one `page.evaluate`/relay call that runs the wait
|
|
158
158
|
* in-process on the page.
|
|
@@ -224,13 +224,13 @@ export interface MaybeInstallDebugBridgeOptions {
|
|
|
224
224
|
const PAGE_ERROR_CAP = 100;
|
|
225
225
|
|
|
226
226
|
/** `holdFor`'s in-process poll interval — this loop never leaves the page
|
|
227
|
-
* (no transport round trip per poll, unlike `@vgai/
|
|
227
|
+
* (no transport round trip per poll, unlike `@vgai/e2e`'s old
|
|
228
228
|
* `waitSimTime`), so it can afford to be tighter than that loop's 150ms. */
|
|
229
229
|
const HOLD_FOR_POLL_MS = 50;
|
|
230
230
|
|
|
231
231
|
/** Consecutive `HOLD_FOR_POLL_MS` polls with the tick unchanged before
|
|
232
232
|
* `holdFor` gives up waiting and treats the game as stopped — mirrors
|
|
233
|
-
* `@vgai/
|
|
233
|
+
* `@vgai/e2e`'s `WAIT_FOR_STALL_POLL_LIMIT` reasoning (`wait-for.ts`:
|
|
234
234
|
* a genuinely frozen sim clock must never poll forever), scaled to this
|
|
235
235
|
* faster in-process interval so the wall-clock grace period (~5s) lands in
|
|
236
236
|
* the same neighborhood. */
|