@vgai/engine 0.2.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/LICENSE +202 -0
- package/README.md +35 -0
- package/package.json +55 -0
- package/src/adapter/authoring.ts +402 -0
- package/src/adapter/colyseus-networking-adapter.ts +72 -0
- package/src/adapter/first-party-systems.ts +103 -0
- package/src/adapter/game-adapter.ts +151 -0
- package/src/adapter/host-context.ts +77 -0
- package/src/adapter/index.ts +85 -0
- package/src/adapter/ingest/game-contract.ts +59 -0
- package/src/adapter/ingest/overlay-applier.ts +207 -0
- package/src/adapter/ingest/overlay-apply.ts +124 -0
- package/src/adapter/ingest/overlay-file.ts +126 -0
- package/src/adapter/ingest/overlay-report.ts +176 -0
- package/src/adapter/ingest/scene-capture.ts +307 -0
- package/src/adapter/ingest/upstream-pin.ts +52 -0
- package/src/adapter/loop-gate-report.ts +54 -0
- package/src/adapter/rapier-physics-adapter.ts +56 -0
- package/src/adapter/system-adapter.ts +154 -0
- package/src/adapter/transform.ts +18 -0
- package/src/adapter/vgai-scene-game-adapter.ts +886 -0
- package/src/adapter/world-kind.ts +34 -0
- package/src/ai/navigation.ts +164 -0
- package/src/animation/anim-graph-types.ts +56 -0
- package/src/animation/anim-graph.ts +406 -0
- package/src/animation/anim-system.ts +28 -0
- package/src/animation/blend-node.ts +119 -0
- package/src/animation/property-track.ts +178 -0
- package/src/animation/schema.ts +204 -0
- package/src/assets.ts +80 -0
- package/src/audio/ambient.ts +300 -0
- package/src/audio/impacts.ts +212 -0
- package/src/audio/index.ts +7 -0
- package/src/audio/movement.ts +140 -0
- package/src/audio/musical.ts +200 -0
- package/src/audio/ui-sounds.ts +171 -0
- package/src/audio/vehicle.ts +235 -0
- package/src/audio/weapons.ts +152 -0
- package/src/core/game-loop.ts +127 -0
- package/src/core/system-runner.ts +298 -0
- package/src/core/types.ts +58 -0
- package/src/dev/console-bridge.ts +83 -0
- package/src/dev/debug-draw.ts +80 -0
- package/src/dev/logger.ts +119 -0
- package/src/ecs/component-manager.ts +748 -0
- package/src/ecs/game-component.ts +147 -0
- package/src/ecs/hmr-swap-report.ts +65 -0
- package/src/input/input-manager.ts +439 -0
- package/src/input/input-types.ts +19 -0
- package/src/input/schema.ts +129 -0
- package/src/loader.ts +70 -0
- package/src/manifest/index.ts +24 -0
- package/src/manifest/load-file.ts +16 -0
- package/src/manifest/load.ts +378 -0
- package/src/manifest/schema.ts +375 -0
- package/src/physics/collision-system.ts +76 -0
- package/src/physics/physics-registry.ts +83 -0
- package/src/physics/transform-writer.ts +41 -0
- package/src/physics/trigger-dispatch.ts +97 -0
- package/src/react/game-state.tsx +172 -0
- package/src/render/auto-batcher.ts +169 -0
- package/src/render/render-batch-system.ts +268 -0
- package/src/render/render-features.ts +146 -0
- package/src/render/render-settings.ts +72 -0
- package/src/runtime/create-runtime.ts +1152 -0
- package/src/runtime/frame-selector-cache.ts +81 -0
- package/src/runtime/game.ts +1003 -0
- package/src/runtime/input-router.ts +213 -0
- package/src/runtime/mount-game.ts +269 -0
- package/src/runtime/mount-manifest.ts +361 -0
- package/src/runtime/scene-ui-bridge.ts +86 -0
- package/src/runtime/scene-ui-data.ts +119 -0
- package/src/runtime/state-bridge.ts +79 -0
- package/src/runtime/types.ts +196 -0
- package/src/scene/asset-loaders.ts +195 -0
- package/src/scene/asset-paths.ts +123 -0
- package/src/scene/asset-registry.ts +67 -0
- package/src/scene/collider-dimensions.ts +125 -0
- package/src/scene/component-registry.ts +40 -0
- package/src/scene/defaults.ts +164 -0
- package/src/scene/geometries/index.ts +7 -0
- package/src/scene/geometries/terrain.ts +42 -0
- package/src/scene/geometry-registry.ts +42 -0
- package/src/scene/instance-registry.ts +84 -0
- package/src/scene/instancers/grid.ts +38 -0
- package/src/scene/instancers/index.ts +7 -0
- package/src/scene/light-camera-factory.ts +97 -0
- package/src/scene/material-factory.ts +211 -0
- package/src/scene/material-registry.ts +73 -0
- package/src/scene/materials/index.ts +7 -0
- package/src/scene/materials/water.ts +56 -0
- package/src/scene/parse.ts +71 -0
- package/src/scene/particles-factory.ts +383 -0
- package/src/scene/scene-apply.ts +356 -0
- package/src/scene/scene-diff-schema.ts +115 -0
- package/src/scene/scene-diff-types.ts +29 -0
- package/src/scene/scene-loader.ts +1533 -0
- package/src/scene/scene-query.ts +63 -0
- package/src/scene/scene-types.ts +34 -0
- package/src/scene/scene-version.ts +40 -0
- package/src/scene/schema/animation.ts +95 -0
- package/src/scene/schema/audio.ts +25 -0
- package/src/scene/schema/camera.ts +21 -0
- package/src/scene/schema/collider.ts +69 -0
- package/src/scene/schema/entity-ref.ts +78 -0
- package/src/scene/schema/entity.ts +169 -0
- package/src/scene/schema/environment.ts +384 -0
- package/src/scene/schema/index.ts +95 -0
- package/src/scene/schema/instances.ts +35 -0
- package/src/scene/schema/joint.ts +26 -0
- package/src/scene/schema/light.ts +38 -0
- package/src/scene/schema/material.ts +113 -0
- package/src/scene/schema/mesh.ts +108 -0
- package/src/scene/schema/particles.ts +398 -0
- package/src/scene/schema/physics.ts +49 -0
- package/src/scene/schema/scene-file.ts +299 -0
- package/src/scene/schema/shadow.ts +24 -0
- package/src/scene/schema/spline.ts +21 -0
- package/src/scene/schema/tuples.ts +21 -0
- package/src/scene/schema/ui.ts +602 -0
- package/src/scene/user-data.ts +203 -0
- package/src/setup/setup-audio.ts +60 -0
- package/src/setup/setup-particles.ts +23 -0
- package/src/setup/setup-physics.ts +67 -0
- package/src/setup/setup-renderer.ts +529 -0
- package/src/types-n8ao.d.ts +37 -0
- package/src/types-realism-effects.d.ts +61 -0
- package/src/world2d/authoring-2d.ts +208 -0
- package/src/world2d/capture-to-scene2d.ts +52 -0
- package/src/world2d/collision-2d.ts +106 -0
- package/src/world2d/components-2d.ts +86 -0
- package/src/world2d/index.ts +66 -0
- package/src/world2d/ingest-iframe-2d.ts +255 -0
- package/src/world2d/ingest2d.ts +131 -0
- package/src/world2d/physics2d-registry.ts +49 -0
- package/src/world2d/pixi-game-adapter.ts +325 -0
- package/src/world2d/pixi-surface.ts +78 -0
- package/src/world2d/scene-capture-2d.ts +117 -0
- package/src/world2d/scene2d-loader.ts +308 -0
- package/src/world2d/schema/entity2d.ts +145 -0
- package/src/world2d/schema/physics2d.ts +53 -0
- package/src/world2d/schema/sprite.ts +71 -0
- package/src/world2d/schema/tilemap.ts +22 -0
- package/src/world2d/schema/tuples2d.ts +25 -0
- package/src/world2d/system-adapters-2d.ts +49 -0
- package/src/world2d/transform-writer-2d.ts +24 -0
- package/src/world2d/types.ts +55 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine-PUBLISHED `GameProvider`/`useGame`/`useGameState`/`useWorldObservation`
|
|
3
|
+
* (D-Z5, `docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md` §D-Z5) — the canonical,
|
|
4
|
+
* opt-in react entry for the T7.4 state bridge (`docs/REACT-STATE-BRIDGE.md`
|
|
5
|
+
* §3). This is a SEPARATE, react-value-importing module under
|
|
6
|
+
* `packages/engine/src/react/` — colocated per REACT-STATE-BRIDGE §3's own
|
|
7
|
+
* "colocate the react-facing hooks with the HUD seam, not the react-free
|
|
8
|
+
* core" rule, just now living IN the engine package rather than only in the
|
|
9
|
+
* project template. It is imported by NOTHING in the engine core import
|
|
10
|
+
* graph (`packages/engine/src/` outside this `react/` directory) — that
|
|
11
|
+
* invariant is proved by `packages/engine/test/react-core-import-ban.test.ts`
|
|
12
|
+
* (AC-F1).
|
|
13
|
+
*
|
|
14
|
+
* This module is the SPEC others port faithfully: it was originally authored
|
|
15
|
+
* as `packages/editor/template/src/ui/game-state.tsx` (T7.4); that file is
|
|
16
|
+
* now a thin re-export of this one (D-Z5 step 3) so every NEW scaffolded
|
|
17
|
+
* project gets ONE canonical context, while existing projects with their own
|
|
18
|
+
* full copy (their own `createContext`) keep working untouched — adoption of
|
|
19
|
+
* THIS module is an option, never a forced migration (D-Z5).
|
|
20
|
+
*
|
|
21
|
+
* Context-identity landmine (unchanged from the template file's own history,
|
|
22
|
+
* still the reason `loadProjectGameProvider`/the virtual-module plugin exist
|
|
23
|
+
* in `packages/editor/src/adapter-resolver.ts` /
|
|
24
|
+
* `packages/editor/vite-plugin-react-world-provider.ts`): `<GameProvider>`
|
|
25
|
+
* and `useGame()` must resolve against the SAME `createContext()` call. A
|
|
26
|
+
* project that ships its OWN `src/ui/game-state.tsx` (its own
|
|
27
|
+
* `createContext`) must be wrapped by ITS OWN `GameProvider` — wrapping it
|
|
28
|
+
* with THIS module's `GameProvider` instead would split context and
|
|
29
|
+
* `useGame()` would throw "no Game in context" even though a `<GameProvider>`
|
|
30
|
+
* genuinely wraps the tree (proved by
|
|
31
|
+
* `packages/editor/test/game-state-context-split.test.tsx`, AC-F1). This is
|
|
32
|
+
* exactly why the engine-published fallback (`resolveIngestReactAdapter`'s
|
|
33
|
+
* D-Y3 upgrade, `default-react`'s optional fallback) only ever fires when the
|
|
34
|
+
* project's OWN `src/ui/game-state.tsx` is ABSENT — an unmodified foreign
|
|
35
|
+
* entry that imports no vgai hooks has no context to split.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { createContext, type ReactNode, useContext, useRef, useSyncExternalStore } from 'react';
|
|
39
|
+
import type { WorldStateObserver } from '../adapter';
|
|
40
|
+
import { createFrameSelectorCache, type Equals, shallow } from '../runtime/frame-selector-cache';
|
|
41
|
+
import type { Game } from '../runtime/game';
|
|
42
|
+
|
|
43
|
+
/** Re-exported for convenience — the opt-in equality for selectors that
|
|
44
|
+
* return a fresh object/array/tuple every call (default is `Object.is`). */
|
|
45
|
+
export { shallow };
|
|
46
|
+
|
|
47
|
+
const GameContext = createContext<Game | null>(null);
|
|
48
|
+
|
|
49
|
+
/** Provide the `Game` to `useGameState`/`useGame` for everything mounted
|
|
50
|
+
* beneath it. Compose it around whatever renders your tree (`mountUI`, a
|
|
51
|
+
* react world's `createRoot(...).render(...)`, etc.) — see the module doc
|
|
52
|
+
* comment above for the context-identity rule this hook family depends on. */
|
|
53
|
+
export function GameProvider({ game, children }: { game: Game; children: ReactNode }) {
|
|
54
|
+
return <GameContext.Provider value={game}>{children}</GameContext.Provider>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read the `Game` provided by the nearest `GameProvider`. Throws
|
|
58
|
+
* descriptively when called outside one — matching the repo's loud-failure
|
|
59
|
+
* habit rather than silently returning `null`. */
|
|
60
|
+
export function useGame(): Game {
|
|
61
|
+
const game = useContext(GameContext);
|
|
62
|
+
if (!game) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
'useGame: no Game in context — wrap this component in <GameProvider game={ctx.game!}>.',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return game;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Subscribe to a selected slice of game state (`docs/REACT-STATE-BRIDGE.md`
|
|
72
|
+
* §3). Re-renders only when the selected value changes: the selector re-runs
|
|
73
|
+
* at most once per completed frame (`game.state.frameVersion`), and
|
|
74
|
+
* `equals` (default `Object.is`; pass {@link shallow} for tuples/objects)
|
|
75
|
+
* gates whether the fresh result actually counts as a change.
|
|
76
|
+
*
|
|
77
|
+
* `selector` reads the live graph (e.g.
|
|
78
|
+
* `g => g.queryByComponent(Hp)[0]?.hp`) at post-frame quiescence — no
|
|
79
|
+
* copies. A selector that returns a fresh object every call must pass
|
|
80
|
+
* `shallow` (or an equivalent `equals`), or it will re-render every frame.
|
|
81
|
+
*
|
|
82
|
+
* Note: `equals` is captured once, when this hook first mounts (in the
|
|
83
|
+
* per-instance cache created below) — pass a stable function (module-level
|
|
84
|
+
* `shallow`/`Object.is`, or a value that doesn't change across renders),
|
|
85
|
+
* not a fresh inline arrow every render.
|
|
86
|
+
*/
|
|
87
|
+
export function useGameState<T>(selector: (game: Game) => T, equals: Equals<T> = Object.is): T {
|
|
88
|
+
const game = useGame();
|
|
89
|
+
const cacheRef = useRef<ReturnType<typeof createFrameSelectorCache<T>> | null>(null);
|
|
90
|
+
if (cacheRef.current === null) {
|
|
91
|
+
cacheRef.current = createFrameSelectorCache<T>(equals);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return useSyncExternalStore(
|
|
95
|
+
(onStoreChange) => game.state.subscribe(onStoreChange),
|
|
96
|
+
() => cacheRef.current!.get(game.state.frameVersion, () => selector(game)),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pure, react-free lookup for the `useWorldObservation` hook below — factored
|
|
102
|
+
* out so the hook body stays a thin `useSyncExternalStore` wrapper. Throws
|
|
103
|
+
* descriptively (loud, not `undefined`-forever) when `worldId` doesn't name a
|
|
104
|
+
* registered world, or that world's mount has no `observe`
|
|
105
|
+
* (`WorldStateObserver`, `@engine/adapter`) — matching the `Game.registerWorld`
|
|
106
|
+
* "no state bridge" report (`runtime/game.ts`) this is the react-side half of.
|
|
107
|
+
*/
|
|
108
|
+
export function requireWorldObserver(game: Game, worldId: string): WorldStateObserver {
|
|
109
|
+
const world = game.world(worldId);
|
|
110
|
+
if (!world) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`useWorldObservation: no world registered with id "${worldId}" — check game.worlds for the ` +
|
|
113
|
+
'ids actually registered.',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const observer = world.mounted.observe;
|
|
117
|
+
if (!observer) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`useWorldObservation: world "${worldId}" (kind: ${world.kind}, adapter: "${world.adapter.id}") ` +
|
|
120
|
+
'has no state bridge — its mount has no `observe` (WorldStateObserver). This world cannot ' +
|
|
121
|
+
'be observed from React (docs/REACT-STATE-BRIDGE.md §4).',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return observer;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Subscribe to a selected slice of a FOREIGN (non-first-party) world's
|
|
129
|
+
* observed state (`docs/REACT-STATE-BRIDGE.md` §4) — the ingested-world
|
|
130
|
+
* counterpart of `useGameState` above; both are sugar over the same
|
|
131
|
+
* subscribe/snapshot shape, so a react HUD reads native and ingested sources
|
|
132
|
+
* through one mental model.
|
|
133
|
+
*
|
|
134
|
+
* `selector` runs over that world's `snapshot()` — NOT the `Game` — since a
|
|
135
|
+
* foreign world hosts no `GameComponent`s to query. Re-renders only when the
|
|
136
|
+
* selected value changes (`equals`, default `Object.is`; pass {@link shallow}
|
|
137
|
+
* for tuples/objects returned fresh every call).
|
|
138
|
+
*
|
|
139
|
+
* Cadence note: unlike `useGameState`, this does NOT key its cache off
|
|
140
|
+
* `game.state.frameVersion` — a foreign/self-driven world may notify on its
|
|
141
|
+
* OWN rAF cadence, unrelated to our frame timing (§4's "consumers must not
|
|
142
|
+
* assume our frame timing"). Instead this keeps a local version counter,
|
|
143
|
+
* bumped once per `onChange` notification from the observer itself.
|
|
144
|
+
*
|
|
145
|
+
* Throws (loud, not a silently-undefined subscription) if `worldId` isn't
|
|
146
|
+
* registered, or its mount has no `observe` — see `requireWorldObserver`.
|
|
147
|
+
*/
|
|
148
|
+
export function useWorldObservation<T>(
|
|
149
|
+
worldId: string,
|
|
150
|
+
selector: (snapshot: unknown) => T,
|
|
151
|
+
equals: Equals<T> = Object.is,
|
|
152
|
+
): T {
|
|
153
|
+
const game = useGame();
|
|
154
|
+
const observer = requireWorldObserver(game, worldId);
|
|
155
|
+
const cacheRef = useRef<ReturnType<typeof createFrameSelectorCache<T>> | null>(null);
|
|
156
|
+
if (cacheRef.current === null) {
|
|
157
|
+
cacheRef.current = createFrameSelectorCache<T>(equals);
|
|
158
|
+
}
|
|
159
|
+
// Local notification-version counter — see the cadence note above. A
|
|
160
|
+
// plain mutable ref (not React state): bumping it must not itself trigger
|
|
161
|
+
// a render, `useSyncExternalStore`'s `onStoreChange` is what does that.
|
|
162
|
+
const versionRef = useRef(0);
|
|
163
|
+
|
|
164
|
+
return useSyncExternalStore(
|
|
165
|
+
(onStoreChange) =>
|
|
166
|
+
observer.subscribe(() => {
|
|
167
|
+
versionRef.current++;
|
|
168
|
+
onStoreChange();
|
|
169
|
+
}),
|
|
170
|
+
() => cacheRef.current!.get(versionRef.current, () => selector(observer.snapshot())),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transparent auto-batcher (Strategy 1 — see docs/research/THREEJS-NATIVE-COMPILE.md §5
|
|
5
|
+
* and FAST-RENDER-BY-DEFAULT.md).
|
|
6
|
+
*
|
|
7
|
+
* The framework keeps each entity's Object3D as the logical handle (transform,
|
|
8
|
+
* gameplay, physics, picking) and renders eligible entities through a shared
|
|
9
|
+
* InstancedMesh under the hood — N logical objects, 1 draw call — WITHOUT the
|
|
10
|
+
* user changing any code. Identity is never merged, so selection/raycast survive
|
|
11
|
+
* (we keep an instanceId → entity map). The whole thing flips on/off live.
|
|
12
|
+
*
|
|
13
|
+
* Eligibility: an entity is a THREE.Mesh sharing geometry+material with others.
|
|
14
|
+
* Entities marked `userData.__noBatch` (the per-entity opt-out) always render
|
|
15
|
+
* individually. Custom/unique materials simply form their own (size-1) groups and
|
|
16
|
+
* cost a draw each — they degrade, they don't break.
|
|
17
|
+
*
|
|
18
|
+
* Cost model (measured in docs/research/bench/): per-frame cost is paid only for
|
|
19
|
+
* entities that MOVED (dirty-tracked via markMoved). A static scene re-syncs
|
|
20
|
+
* nothing — write-once at build, then 1 draw/frame.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface Batch {
|
|
24
|
+
key: string;
|
|
25
|
+
instanced: THREE.InstancedMesh;
|
|
26
|
+
members: THREE.Mesh[]; // localIndex → entity mesh (for picking + sync)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SIG = (m: THREE.Mesh): string => {
|
|
30
|
+
const mat = Array.isArray(m.material) ? m.material[0]! : m.material;
|
|
31
|
+
return `${(m.geometry as THREE.BufferGeometry).uuid}|${mat.uuid}`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export class AutoBatcher {
|
|
35
|
+
/** Root the batcher owns under the scene (instanced meshes when on, entities when off). */
|
|
36
|
+
readonly root = new THREE.Group();
|
|
37
|
+
|
|
38
|
+
private entities: THREE.Mesh[] = [];
|
|
39
|
+
private batches = new Map<string, Batch>();
|
|
40
|
+
private enabled: boolean;
|
|
41
|
+
/** Indices of entities whose transform changed since last sync. */
|
|
42
|
+
private dirty = new Set<number>();
|
|
43
|
+
private touchedBatches = new Set<Batch>();
|
|
44
|
+
// Per-mesh bookkeeping is kept in WeakMaps, NOT on userData (the typed userData
|
|
45
|
+
// registry is reserved for documented engine keys — see user-data.ts).
|
|
46
|
+
private indexOf = new WeakMap<THREE.Mesh, number>();
|
|
47
|
+
private optOut = new WeakSet<THREE.Mesh>();
|
|
48
|
+
private batchOf = new WeakMap<THREE.Mesh, { key: string; localIndex: number }>();
|
|
49
|
+
private membersOf = new WeakMap<THREE.InstancedMesh, THREE.Mesh[]>();
|
|
50
|
+
|
|
51
|
+
constructor(parent: THREE.Object3D, enabled = true) {
|
|
52
|
+
this.enabled = enabled;
|
|
53
|
+
this.root.name = 'AutoBatcher';
|
|
54
|
+
parent.add(this.root);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Register an entity (a Mesh) as the logical handle. Call build() after adding. */
|
|
58
|
+
add(entity: THREE.Mesh, optOut = false): void {
|
|
59
|
+
this.indexOf.set(entity, this.entities.length);
|
|
60
|
+
if (optOut) this.optOut.add(entity);
|
|
61
|
+
this.entities.push(entity);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Mark an entity moved this frame (dirty-tracking input). */
|
|
65
|
+
markMoved(entity: THREE.Mesh): void {
|
|
66
|
+
const i = this.indexOf.get(entity);
|
|
67
|
+
if (i !== undefined) this.dirty.add(i);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** (Re)build the render representation for the current enabled state. */
|
|
71
|
+
build(): void {
|
|
72
|
+
this.clearRoot();
|
|
73
|
+
this.batches.clear();
|
|
74
|
+
if (!this.enabled) {
|
|
75
|
+
for (const e of this.entities) this.root.add(e);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const m = new THREE.Matrix4();
|
|
79
|
+
// group eligible entities; opt-outs render individually
|
|
80
|
+
const groups = new Map<string, THREE.Mesh[]>();
|
|
81
|
+
for (const e of this.entities) {
|
|
82
|
+
if (this.optOut.has(e)) {
|
|
83
|
+
this.root.add(e);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const key = SIG(e);
|
|
87
|
+
const arr = groups.get(key) ?? [];
|
|
88
|
+
arr.push(e);
|
|
89
|
+
groups.set(key, arr);
|
|
90
|
+
}
|
|
91
|
+
for (const [key, members] of groups) {
|
|
92
|
+
const proto = members[0]!;
|
|
93
|
+
const mat = Array.isArray(proto.material) ? proto.material[0]! : proto.material;
|
|
94
|
+
const inst = new THREE.InstancedMesh(proto.geometry, mat, members.length);
|
|
95
|
+
inst.frustumCulled = false;
|
|
96
|
+
for (let i = 0; i < members.length; i++) {
|
|
97
|
+
const e = members[i]!;
|
|
98
|
+
e.updateMatrix();
|
|
99
|
+
m.copy(e.matrix);
|
|
100
|
+
inst.setMatrixAt(i, m);
|
|
101
|
+
this.batchOf.set(e, { key, localIndex: i });
|
|
102
|
+
}
|
|
103
|
+
inst.instanceMatrix.needsUpdate = true;
|
|
104
|
+
this.membersOf.set(inst, members); // localIndex → entity (picking)
|
|
105
|
+
this.root.add(inst);
|
|
106
|
+
this.batches.set(key, { key, instanced: inst, members });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Apply the dirty set to the instance buffers. Cheap when few moved. */
|
|
111
|
+
sync(): void {
|
|
112
|
+
if (!this.enabled) return;
|
|
113
|
+
const m = new THREE.Matrix4();
|
|
114
|
+
this.touchedBatches.clear();
|
|
115
|
+
for (const i of this.dirty) {
|
|
116
|
+
const e = this.entities[i]!;
|
|
117
|
+
const info = this.batchOf.get(e);
|
|
118
|
+
if (!info) continue; // opt-out or not batched
|
|
119
|
+
const batch = this.batches.get(info.key);
|
|
120
|
+
if (!batch) continue;
|
|
121
|
+
e.updateMatrix();
|
|
122
|
+
m.copy(e.matrix);
|
|
123
|
+
batch.instanced.setMatrixAt(info.localIndex, m);
|
|
124
|
+
this.touchedBatches.add(batch);
|
|
125
|
+
}
|
|
126
|
+
for (const b of this.touchedBatches) b.instanced.instanceMatrix.needsUpdate = true;
|
|
127
|
+
this.dirty.clear();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Live toggle: rebuilds the representation (one-time cost, not per-frame). */
|
|
131
|
+
setEnabled(on: boolean): void {
|
|
132
|
+
if (on === this.enabled) return;
|
|
133
|
+
this.enabled = on;
|
|
134
|
+
this.build();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
isEnabled(): boolean {
|
|
138
|
+
return this.enabled;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Map a raycast hit on a batch back to the logical entity (editor/gameplay picking). */
|
|
142
|
+
resolvePick(intersection: THREE.Intersection): THREE.Mesh | null {
|
|
143
|
+
const obj = intersection.object;
|
|
144
|
+
if ((obj as THREE.InstancedMesh).isInstancedMesh && intersection.instanceId != null) {
|
|
145
|
+
const members = this.membersOf.get(obj as THREE.InstancedMesh);
|
|
146
|
+
return members?.[intersection.instanceId] ?? null;
|
|
147
|
+
}
|
|
148
|
+
return obj instanceof THREE.Mesh ? obj : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Per-frame draw-call count this batcher contributes (for stats). */
|
|
152
|
+
get drawCallCount(): number {
|
|
153
|
+
return this.enabled ? this.batches.size + this.optOutCount : this.entities.length;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private get optOutCount(): number {
|
|
157
|
+
let n = 0;
|
|
158
|
+
for (const e of this.entities) if (this.optOut.has(e)) n++;
|
|
159
|
+
return n;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private clearRoot(): void {
|
|
163
|
+
for (let i = this.root.children.length - 1; i >= 0; i--) {
|
|
164
|
+
const c = this.root.children[i]!;
|
|
165
|
+
this.root.remove(c);
|
|
166
|
+
if ((c as THREE.InstancedMesh).isInstancedMesh) (c as THREE.InstancedMesh).dispose();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { getUserData } from '../scene/user-data';
|
|
3
|
+
import type { ResolvedRenderSettings } from './render-settings';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Runtime STATIC render-batching (the framework-level transparent perf delivery).
|
|
7
|
+
*
|
|
8
|
+
* Scans a loaded scene graph for STATIC meshes sharing geometry+material and draws
|
|
9
|
+
* each group through ONE InstancedMesh. The originals are DETACHED from the render
|
|
10
|
+
* tree (so the renderer never walks them — zero per-frame cost), but retained here
|
|
11
|
+
* as the logical handles: re-attached on teardown, and resolvable via raycast().
|
|
12
|
+
* N logical objects → 1 draw call, ~0 CPU/frame. This is the classic "static
|
|
13
|
+
* batching" technique (cf. Unity/PlayCanvas static batch groups).
|
|
14
|
+
*
|
|
15
|
+
* Strategy 1 (keep identity, don't merge): see docs/research/THREEJS-NATIVE-COMPILE.md §5,
|
|
16
|
+
* FAST-RENDER-BY-DEFAULT.md, RENDER-SETTINGS-UX.md. Moving entities (boids-style,
|
|
17
|
+
* all-frame churn) are a separate concern — see render/auto-batcher.ts (dynamic).
|
|
18
|
+
*
|
|
19
|
+
* Eligibility (anything failing stays an ordinary draw — degrade, don't break):
|
|
20
|
+
* - plain THREE.Mesh (not Instanced/Skinned/Batched)
|
|
21
|
+
* - single, opaque material (transparency needs per-object sorting)
|
|
22
|
+
* - group of ≥2 sharing (geometry, material, shadow flags)
|
|
23
|
+
* - not explicitly opted out (`entity.render.autoBatch === false`)
|
|
24
|
+
* - STATIC members (no physics/components/animation) → detached; MOVING members →
|
|
25
|
+
* kept in-graph hidden + dirty-synced each frame. A bucket may split into both.
|
|
26
|
+
*
|
|
27
|
+
* Scope / limitations (v1):
|
|
28
|
+
* - Runs once per scene load (build() is called after loadScene). Entities spawned
|
|
29
|
+
* AFTER build do not join a batch; call build() again to re-scan. Removing a batched
|
|
30
|
+
* source leaves a stale instance until rebuild. (Static scenery is load-time stable.)
|
|
31
|
+
* - Installed for scene-file loads (scenePath/sceneData), not the imperative setup()
|
|
32
|
+
* path — setup() games manage their own scene/spawning.
|
|
33
|
+
* - The batch culls as ONE unit (no per-instance frustum culling yet — Tier-2).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
interface Group {
|
|
37
|
+
instanced: THREE.InstancedMesh;
|
|
38
|
+
sources: THREE.Mesh[]; // instanceId → source (raycast mapping + restore on teardown)
|
|
39
|
+
dynamic: boolean; // true = sources kept in the graph & re-synced each frame (movers)
|
|
40
|
+
cache?: Float32Array; // dynamic only: last-synced matrixWorld elements (16/source)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Structural signature — groups meshes that are VALUE-identical, not object-identical.
|
|
44
|
+
// The engine scene-loader instantiates a fresh geometry+material per entity (no dedup),
|
|
45
|
+
// so keying on .uuid would never batch a real scene. Keying on geometry type+parameters
|
|
46
|
+
// and material type+key-props groups identical authored primitives (e.g. 10k boxes of
|
|
47
|
+
// the same size+color → one batch). Non-primitive geometry (GLTF, no `.parameters`)
|
|
48
|
+
// falls back to uuid → it simply won't group, which is the safe default.
|
|
49
|
+
const geoKey = (g: THREE.BufferGeometry): string => {
|
|
50
|
+
const params = (g as unknown as { parameters?: object }).parameters;
|
|
51
|
+
return params ? `${g.type}:${JSON.stringify(params)}` : `uuid:${g.uuid}`;
|
|
52
|
+
};
|
|
53
|
+
const matKey = (m: THREE.Material): string => {
|
|
54
|
+
const s = m as THREE.MeshStandardMaterial;
|
|
55
|
+
const col = s.color?.getHexString?.() ?? '';
|
|
56
|
+
const map = s.map?.uuid ?? '';
|
|
57
|
+
return `${m.type}:${col}:${s.roughness ?? ''}:${s.metalness ?? ''}:${map}:${m.side}:${m.transparent}:${m.vertexColors}`;
|
|
58
|
+
};
|
|
59
|
+
const SIG = (m: THREE.Mesh): string =>
|
|
60
|
+
// include shadow flags: meshes with different cast/receive must not share a batch
|
|
61
|
+
// (the InstancedMesh carries one flag for the whole group).
|
|
62
|
+
`${geoKey(m.geometry as THREE.BufferGeometry)}|${matKey(m.material as THREE.Material)}|${m.castShadow ? 1 : 0}${m.receiveShadow ? 1 : 0}`;
|
|
63
|
+
|
|
64
|
+
interface EntityDef {
|
|
65
|
+
render?: { autoBatch?: boolean };
|
|
66
|
+
physics?: unknown;
|
|
67
|
+
components?: Record<string, unknown>;
|
|
68
|
+
animation?: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Nearest self/ancestor entity descriptor, if any. */
|
|
72
|
+
function nearestEntity(obj: THREE.Object3D): EntityDef | undefined {
|
|
73
|
+
let cur: THREE.Object3D | null = obj;
|
|
74
|
+
while (cur) {
|
|
75
|
+
const entity = getUserData(cur, 'entity') as unknown as EntityDef | undefined;
|
|
76
|
+
if (entity) return entity;
|
|
77
|
+
cur = cur.parent;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Explicit per-entity opt-out (`render.autoBatch === false`). */
|
|
83
|
+
function entityOptOut(obj: THREE.Object3D): boolean {
|
|
84
|
+
return nearestEntity(obj)?.render?.autoBatch === false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Static-batching eligibility: only entities that won't move are safe to freeze
|
|
89
|
+
* and instance. An entity with physics, behavior components, or its own animation
|
|
90
|
+
* is a likely mover → left as an ordinary draw. Plain scenery (no def, or a def
|
|
91
|
+
* with none of those) is eligible. This is the safe, transparent default; a future
|
|
92
|
+
* dynamic-batch path (markMoved-driven) can include movers.
|
|
93
|
+
*/
|
|
94
|
+
function isLikelyStatic(obj: THREE.Object3D): boolean {
|
|
95
|
+
const e = nearestEntity(obj);
|
|
96
|
+
if (!e) return true; // un-tagged scenery
|
|
97
|
+
if (e.physics) return false;
|
|
98
|
+
if (e.animation) return false;
|
|
99
|
+
if (e.components && Object.keys(e.components).length > 0) return false;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class RenderBatchSystem {
|
|
104
|
+
private root = new THREE.Group();
|
|
105
|
+
private groups: Group[] = [];
|
|
106
|
+
private dynamicGroups: Group[] = []; // subset needing per-frame sync
|
|
107
|
+
// Original parent of each detached (static) source, to re-attach on teardown.
|
|
108
|
+
private parentOf = new WeakMap<THREE.Mesh, THREE.Object3D>();
|
|
109
|
+
// instanceId mapping for raycast: which group + slot an InstancedMesh belongs to.
|
|
110
|
+
private membersOf = new WeakMap<THREE.InstancedMesh, THREE.Mesh[]>();
|
|
111
|
+
|
|
112
|
+
constructor(
|
|
113
|
+
private scene: THREE.Scene,
|
|
114
|
+
private settings: ResolvedRenderSettings,
|
|
115
|
+
) {
|
|
116
|
+
this.root.name = 'RenderBatchSystem';
|
|
117
|
+
this.root.matrixAutoUpdate = false; // instances carry world matrices directly
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Scan the scene and build instanced groups. Idempotent (rebuilds). */
|
|
121
|
+
build(): void {
|
|
122
|
+
this.teardown();
|
|
123
|
+
this.scene.add(this.root);
|
|
124
|
+
this.scene.updateMatrixWorld(true);
|
|
125
|
+
|
|
126
|
+
// collect eligible candidates, grouped by (geometry, material)
|
|
127
|
+
const buckets = new Map<string, THREE.Mesh[]>();
|
|
128
|
+
this.scene.traverse((obj) => {
|
|
129
|
+
const m = obj as THREE.Mesh;
|
|
130
|
+
if (!m.isMesh) return;
|
|
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);
|
|
140
|
+
const arr = buckets.get(key) ?? [];
|
|
141
|
+
arr.push(m);
|
|
142
|
+
buckets.set(key, arr);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Each signature bucket is partitioned into STATIC (detached, zero-cost) and
|
|
146
|
+
// DYNAMIC (kept in-place, dirty-synced) members, each ≥2 forming its own batch.
|
|
147
|
+
for (const bucket of buckets.values()) {
|
|
148
|
+
const staticMembers: THREE.Mesh[] = [];
|
|
149
|
+
const dynamicMembers: THREE.Mesh[] = [];
|
|
150
|
+
for (const m of bucket) (isLikelyStatic(m) ? staticMembers : dynamicMembers).push(m);
|
|
151
|
+
if (staticMembers.length >= 2) this.makeGroup(staticMembers, false);
|
|
152
|
+
if (dynamicMembers.length >= 2) this.makeGroup(dynamicMembers, true);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private makeGroup(sources: THREE.Mesh[], dynamic: boolean): void {
|
|
157
|
+
const frustum = this.settings['frustumCulling'] !== false;
|
|
158
|
+
const tmp = new THREE.Matrix4();
|
|
159
|
+
const proto = sources[0]!;
|
|
160
|
+
const inst = new THREE.InstancedMesh(
|
|
161
|
+
proto.geometry,
|
|
162
|
+
proto.material as THREE.Material,
|
|
163
|
+
sources.length,
|
|
164
|
+
);
|
|
165
|
+
inst.frustumCulled = frustum && !dynamic; // moving batches can leave the padded sphere
|
|
166
|
+
// Inherit shadow flags from the prototype so batched objects still cast/receive
|
|
167
|
+
// shadows (otherwise detaching the sources would silently drop them from shadows).
|
|
168
|
+
inst.castShadow = proto.castShadow;
|
|
169
|
+
inst.receiveShadow = proto.receiveShadow;
|
|
170
|
+
const cache = dynamic ? new Float32Array(sources.length * 16) : undefined;
|
|
171
|
+
for (let i = 0; i < sources.length; i++) {
|
|
172
|
+
const s = sources[i]!;
|
|
173
|
+
tmp.copy(s.matrixWorld);
|
|
174
|
+
inst.setMatrixAt(i, tmp);
|
|
175
|
+
if (dynamic) {
|
|
176
|
+
// keep the source in the graph (physics/animation still updates its matrixWorld),
|
|
177
|
+
// just hide its own draw; we copy its world matrix into the instance each frame.
|
|
178
|
+
cache!.set(tmp.elements, i * 16);
|
|
179
|
+
s.visible = false;
|
|
180
|
+
} else if (s.parent) {
|
|
181
|
+
// static: detach entirely so the renderer never walks it (zero per-frame cost)
|
|
182
|
+
this.parentOf.set(s, s.parent);
|
|
183
|
+
s.parent.remove(s);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
inst.instanceMatrix.needsUpdate = true;
|
|
187
|
+
if (frustum && !dynamic) inst.computeBoundingSphere();
|
|
188
|
+
this.membersOf.set(inst, sources);
|
|
189
|
+
this.root.add(inst);
|
|
190
|
+
const group: Group = cache
|
|
191
|
+
? { instanced: inst, sources, dynamic, cache }
|
|
192
|
+
: { instanced: inst, sources, dynamic };
|
|
193
|
+
this.groups.push(group);
|
|
194
|
+
if (dynamic) this.dynamicGroups.push(group);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Per-frame sync for DYNAMIC groups only (static are detached & immutable → free).
|
|
199
|
+
* Called in the preRender phase. Refreshes world matrices once (the engine's movers
|
|
200
|
+
* set matrixWorldNeedsUpdate via physics/animation), then re-writes only the instances
|
|
201
|
+
* whose source moved (element-wise cache compare). No-op when there are no movers.
|
|
202
|
+
*/
|
|
203
|
+
update(): void {
|
|
204
|
+
if (this.dynamicGroups.length === 0) return;
|
|
205
|
+
this.scene.updateMatrixWorld(false); // refresh moved subtrees (force=false → cheap)
|
|
206
|
+
for (const g of this.dynamicGroups) {
|
|
207
|
+
const cache = g.cache!;
|
|
208
|
+
let dirty = false;
|
|
209
|
+
for (let i = 0; i < g.sources.length; i++) {
|
|
210
|
+
const el = g.sources[i]!.matrixWorld.elements;
|
|
211
|
+
const off = i * 16;
|
|
212
|
+
let changed = false;
|
|
213
|
+
for (let k = 0; k < 16; k++) {
|
|
214
|
+
if (el[k] !== cache[off + k]) {
|
|
215
|
+
changed = true;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!changed) continue;
|
|
220
|
+
g.instanced.setMatrixAt(i, g.sources[i]!.matrixWorld);
|
|
221
|
+
cache.set(el, off);
|
|
222
|
+
dirty = true;
|
|
223
|
+
}
|
|
224
|
+
if (dirty) g.instanced.instanceMatrix.needsUpdate = true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Raycast against the batched instances, mapping a hit back to the logical source
|
|
230
|
+
* Object3D (so editor/gameplay picking still resolves a detached entity).
|
|
231
|
+
*/
|
|
232
|
+
raycast(raycaster: THREE.Raycaster): THREE.Mesh | null {
|
|
233
|
+
const hits = raycaster.intersectObject(this.root, true);
|
|
234
|
+
for (const h of hits) {
|
|
235
|
+
const members = this.membersOf.get(h.object as THREE.InstancedMesh);
|
|
236
|
+
if (members && h.instanceId != null) return members[h.instanceId] ?? null;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Number of draw calls the batcher contributes (1 per group). */
|
|
242
|
+
get drawCalls(): number {
|
|
243
|
+
return this.groups.length;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** How many source meshes were collapsed into instances. */
|
|
247
|
+
get batchedCount(): number {
|
|
248
|
+
return this.groups.reduce((n, g) => n + g.sources.length, 0);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
teardown(): void {
|
|
252
|
+
for (const g of this.groups) {
|
|
253
|
+
for (const s of g.sources) {
|
|
254
|
+
if (g.dynamic)
|
|
255
|
+
s.visible = true; // dynamic sources stayed in-graph, hidden
|
|
256
|
+
else {
|
|
257
|
+
const parent = this.parentOf.get(s); // static sources were detached — re-attach
|
|
258
|
+
if (parent) parent.add(s);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
g.instanced.dispose();
|
|
262
|
+
this.root.remove(g.instanced);
|
|
263
|
+
}
|
|
264
|
+
this.groups = [];
|
|
265
|
+
this.dynamicGroups = [];
|
|
266
|
+
if (this.root.parent) this.root.parent.remove(this.root);
|
|
267
|
+
}
|
|
268
|
+
}
|