@volter/threejs-runtime 0.5.66 → 0.5.67

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/threejs-runtime",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.66",
5
+ "version": "0.5.67",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -43,7 +43,7 @@
43
43
  "@dimforge/rapier3d-compat": "^0.14.0",
44
44
  "@recast-navigation/three": "^0.43.0",
45
45
  "@sparkjsdev/spark": "^2.1.0",
46
- "@volter/editor-project": "0.5.66",
46
+ "@volter/editor-project": "0.5.67",
47
47
  "n8ao": "^1.10.1",
48
48
  "postprocessing": "^6.38.0",
49
49
  "quarks.core": "0.16.0",
@@ -5,11 +5,9 @@
5
5
  * external game can supply its own equivalent.
6
6
  *
7
7
  * Its AUDIO sibling (`createAudioSystemAdapter`, `releaseAudioMeters`) is the
8
- * game runtime's — `@volter/game-runtime/adapter/first-party-audio-system` — for
8
+ * game runtime's — `@volter/editor-game/runtime/adapter/first-party-audio-system` — for
9
9
  * the same reason this one is three.js's: each lives with the subsystem it
10
- * wraps. (The former `AnimationAdapter` over the `AnimGraph` map was removed
11
- * by E5; `createInputManagerAdapter`/`createVgaiAssetAdapter` went with
12
- * `SystemAdapters.input`/`.assets`, registered and never read.)
10
+ * wraps.
13
11
  */
14
12
 
15
13
  import type {
@@ -40,7 +38,7 @@ export function createNavigationAdapter(
40
38
  hasNavMesh: () => nav.hasNavMesh(),
41
39
  findPath: (start: NavPoint, end: NavPoint) => nav.findPath(start, end) as NavPoint[],
42
40
  debugMesh: (scene: THREE.Scene) => nav.getDebugMesh(scene) as unknown as THREE.Object3D | null,
43
- bake: ownerCapabilities.bake ?? ((meshes, params) => nav.buildFromMeshes(meshes, params)),
41
+ bake: ownerCapabilities.bake ?? ((meshes: THREE.Mesh[], params?: NavBakeParams) => nav.buildFromMeshes(meshes, params)),
44
42
  exportData: () => nav.exportData(),
45
43
  ...(ownerCapabilities.clear ? { clear: ownerCapabilities.clear } : {}),
46
44
  crowdAgents: () => nav.getCrowdAgents(),
@@ -57,7 +57,7 @@ export interface RapierEditableBody {
57
57
  * mount is no longer the only Rapier in this engine: an R3F world does its
58
58
  * physics with `@react-three/rapier` inside the fiber tree, which owns its own
59
59
  * bodies and its own `Object3D` mapping, and reaches these same four verbs
60
- * through `world3d-react/rapier-physics-bridge.tsx`. The optional members
60
+ * through the editor's observer of that world (`editor-game/src/services/game-physics.ts`). The optional members
61
61
  * (`debugDraw`, `setDebugDrawEnabled`, `contactPoints`) are NOT here: each is
62
62
  * answered differently by each Rapier owner, and a caller that cannot answer
63
63
  * one honestly omits it.
@@ -4,9 +4,8 @@
4
4
  * The SHAPE is the project contract's (`@volter/editor-project/adapter/renderer-config`,
5
5
  * whose header states the rule and the three load-bearing properties); this
6
6
  * file is what writes it onto a live `WebGLRenderer` and hands back the
7
- * restore. `world3d-react/r3f-root-factory.tsx` is the declarer,
8
- * `runtime/create-runtime.ts` the host, the editor's world-root stage the
9
- * applier.
7
+ * restore. A mounted three root declares it (`MountedThreeRoot.rendererConfig`)
8
+ * and the editor's world-root stage applies it.
10
9
  */
11
10
 
12
11
  import type {
@@ -28,7 +27,7 @@ export type {
28
27
  * Apply `config` to `renderer`, returning the restore function that puts back what was there.
29
28
  *
30
29
  * `three` is passed in rather than imported for values so the enum constants come from the HOST's
31
- * three instance — the same identity rule `r3f-root-factory.tsx` follows for the scene and camera.
30
+ * three instance — the same identity rule `r3f-root.tsx` follows for the scene and camera.
32
31
  */
33
32
  export function applyWorldRendererConfig(
34
33
  three: typeof THREE,
package/src/assets.ts CHANGED
@@ -11,10 +11,21 @@
11
11
  * Cache lives for the app lifetime — no dispose needed.
12
12
  */
13
13
 
14
- import type { AssetCache, GLTFResult } from '@volter/editor-project/adapter/asset-cache';
14
+ import type * as THREE from 'three';
15
15
  import { gltfLoader, resolveUrl } from './loader';
16
16
 
17
- export type { AssetCache, GLTFResult } from '@volter/editor-project/adapter/asset-cache';
17
+ /** Result of loading a .glb/.gltf file. */
18
+ export interface GLTFResult {
19
+ scene: THREE.Group;
20
+ animations: THREE.AnimationClip[];
21
+ }
22
+
23
+ export interface AssetCache {
24
+ /** Load an asset by URL. Returns a cached promise if already loading/loaded. */
25
+ load<T = unknown>(url: string): Promise<T>;
26
+ /** Get a previously-loaded asset synchronously. Throws if not yet loaded. */
27
+ get<T = unknown>(url: string): T;
28
+ }
18
29
 
19
30
  export function createAssetCache(): AssetCache {
20
31
  const cache = new Map<string, Promise<unknown>>();
@@ -33,7 +33,7 @@ export type EditorHelperType =
33
33
  | 'trigger-volumes'
34
34
  | 'skeletons'
35
35
  // A package's own helper kind, shown through the editor's viewport door
36
- // (`host.viewport.setHelper`): the host lists the kinds it toggles by name,
36
+ // (the viewport door's `setViewportHelper`): the host lists the kinds it toggles by name,
37
37
  // any other follows the master Helpers toggle.
38
38
  | (string & {});
39
39
 
@@ -40,11 +40,6 @@
40
40
  * - `gaussianSplat` — native Spark splat metadata used for renderer discovery,
41
41
  * inspector facts, bounds, and deterministic disposal.
42
42
  *
43
- * Animation (load-bearing — ED5 disposal contract):
44
- * - `_animMixer` — THREE.AnimationMixer driving this subtree's clips.
45
- * - `_availableClips` — string[] of clip names discovered on the GLTF
46
- * (inspector dropdown).
47
- *
48
43
  * Disposal contract:
49
44
  * - `__sharedGeometry` — `true` when a mesh's geometry is shared/cached and MUST
50
45
  * NOT be disposed by per-object cleanup (P0.2 contract).
@@ -144,8 +139,6 @@ export interface UserDataSchema extends ObjectMarkSchema {
144
139
  _camera: THREE.Camera;
145
140
  _particleSystem: ParticleSystem;
146
141
  gaussianSplat: { src: string; numSplats: number };
147
- _animMixer: THREE.AnimationMixer;
148
- _availableClips: string[];
149
142
  __sharedGeometry: boolean;
150
143
  __shadeOrig: THREE.Material | THREE.Material[];
151
144
  __shadeUnlit: THREE.Material[];
@@ -197,8 +190,6 @@ export const UserDataKeys = {
197
190
  _camera: '_camera',
198
191
  _particleSystem: '_particleSystem',
199
192
  gaussianSplat: 'gaussianSplat',
200
- _animMixer: '_animMixer',
201
- _availableClips: '_availableClips',
202
193
  __sharedGeometry: '__sharedGeometry',
203
194
  __shadeOrig: '__shadeOrig',
204
195
  __shadeUnlit: '__shadeUnlit',
@@ -13,9 +13,7 @@ import { z } from 'zod';
13
13
  * until `schema-consumption-map.ts` names the file that reads it.
14
14
  *
15
15
  * Three entries were deleted when that enforcement first ran, having reached it
16
- * with no reader at all: `autoBatch` (its only mention was a COMMENT in
17
- * `render-batch-system.ts`, a class whose own header says nothing calls it),
18
- * `lod`, and `backend` (which offered a `webgpu` option while
16
+ * with no reader at all: `autoBatch`, `lod`, and `backend` (which offered a `webgpu` option while
19
17
  * `setup-renderer.ts` unconditionally constructs a `THREE.WebGLRenderer`). A
20
18
  * toggle that changes nothing is worse than a missing one — it tells an author
21
19
  * the engine has a dial it does not have.
@@ -3,13 +3,12 @@
3
3
  * place that answers "may this draw be collapsed at all?" and "are these two
4
4
  * meshes the same thing drawn twice?".
5
5
  *
6
- * Three consumers, which is the whole reason it is a module rather than three
7
- * private helpers: `render/render-batch-system.ts` (the engine's own
8
- * lab-driven instancer), `dev/render-census.ts`'s structural scan (what the
9
- * advisor and `render.families` measure), and the `static-batch` capability's
10
- * `<Frozen>` (what a game actually installs). A batcher and the advisor that
11
- * routes people to it MUST agree about what is batchable, or the advisor
12
- * promises a win the wrapper then declines to take.
6
+ * Two consumers, which is the whole reason it is a module rather than private
7
+ * helpers: `dev/render-census.ts`'s structural scan (what the advisor and
8
+ * `render.families` measure) and the `static-batch` capability's `<Frozen>`
9
+ * (what a game actually installs). A batcher and the advisor that routes
10
+ * people to it MUST agree about what is batchable, or the advisor promises a
11
+ * win the wrapper then declines to take.
13
12
  *
14
13
  * ── THE UUID TRAP, WHICH IS WHY THIS MODULE EXISTS ──────────────────────────
15
14
  * The obvious key for "can these two meshes be drawn as one" is object
@@ -28,9 +27,7 @@
28
27
  * OWN `THREE.MeshStandardMaterial`. Five hundred crates are five hundred
29
28
  * distinct uuids describing one identical appearance. A uuid-keyed grouper
30
29
  * reports five hundred families of one, finds nothing to do, and is indistin-
31
- * guishable from a correct batcher over an unbatchable scene. The engine's own
32
- * scene construction has the same property, which is why
33
- * `render/render-batch-system.ts` has keyed on VALUE since it was written.
30
+ * guishable from a correct batcher over an unbatchable scene.
34
31
  *
35
32
  * So the key here is the STRUCTURE: geometry `type` + its construction
36
33
  * `parameters`, material `type` + the props that decide what the draw looks
@@ -49,14 +46,10 @@
49
46
  * - a shader material ({@link materialMergeSignature} only): its appearance
50
47
  * lives in shader source and uniforms that no fixed prop list can read.
51
48
  *
52
- * ── TWO KEYS, DELIBERATELY ──────────────────────────────────────────────────
53
- * {@link staticBatchSignature} is `RenderBatchSystem`'s key, extracted here
54
- * unchanged so there is one owner of the answer rather than two that drift.
55
- * {@link materialMergeSignature} is STRICTER, and it is what the `static-batch`
56
- * capability's `<Frozen>` groups by: that path hands ONE material instance to
57
- * a merged/instanced product, so its members must be interchangeable, not
58
- * merely similar. The looser key predates it and is kept exactly as it was —
59
- * see that constant's own note.
49
+ * ── THE KEY ─────────────────────────────────────────────────────────────────
50
+ * {@link materialMergeSignature} is what `<Frozen>` groups by: that path hands
51
+ * ONE material instance to a merged/instanced product, so its members must be
52
+ * interchangeable, not merely similar.
60
53
  */
61
54
 
62
55
  import type * as THREE from 'three';
@@ -144,14 +137,10 @@ export function geometrySignature(geometry: THREE.BufferGeometry): string {
144
137
  }
145
138
 
146
139
  /**
147
- * Material identity as `RenderBatchSystem` has always computed it: type plus
148
- * the standard-material props that change the draw.
149
- *
150
- * FROZEN ON PURPOSE. This is one half of {@link staticBatchSignature}, which
151
- * is a live batcher's grouping key; widening or narrowing it silently
152
- * regroups that batcher's scenes. New discrimination goes in
153
- * {@link materialMergeSignature}, which is free to be stricter because
154
- * stricter only ever means "batches less".
140
+ * The standard-material props that change the draw: type, colour, roughness,
141
+ * metalness, colour map, side, transparency and vertex colours. The base of
142
+ * {@link materialMergeSignature}, which adds everything else that decides the
143
+ * picture.
155
144
  */
156
145
  export function materialSignature(material: THREE.Material): string {
157
146
  const standard = material as THREE.MeshStandardMaterial;
@@ -160,19 +149,6 @@ export function materialSignature(material: THREE.Material): string {
160
149
  return `${material.type}:${color}:${standard.roughness ?? ''}:${standard.metalness ?? ''}:${map}:${material.side}:${material.transparent}:${material.vertexColors}`;
161
150
  }
162
151
 
163
- /**
164
- * One mesh's batch key: geometry, material, and the shadow flags.
165
- *
166
- * The shadow flags are part of the key because a batched product carries ONE
167
- * `castShadow`/`receiveShadow` pair for every member it swallowed — mixing a
168
- * caster and a non-caster into one draw changes the picture.
169
- */
170
- export function staticBatchSignature(mesh: THREE.Mesh): string {
171
- const geometry = mesh.geometry as THREE.BufferGeometry;
172
- const material = mesh.material as THREE.Material;
173
- return `${geometrySignature(geometry)}|${materialSignature(material)}|${mesh.castShadow ? 1 : 0}${mesh.receiveShadow ? 1 : 0}`;
174
- }
175
-
176
152
  /**
177
153
  * Which vertex attributes a geometry carries, and whether it is indexed —
178
154
  * the compatibility precondition for `mergeGeometries`, which refuses a batch
@@ -192,9 +168,9 @@ export function geometryLayoutSignature(geometry: THREE.BufferGeometry): string
192
168
 
193
169
  /**
194
170
  * Material identity for a path that will SHARE one material instance between
195
- * every member it collapses — stricter than {@link materialSignature}.
171
+ * every member it collapses.
196
172
  *
197
- * Everything the looser key reads, plus the props that decide the picture
173
+ * Everything {@link materialSignature} reads, plus the props that decide the picture
198
174
  * without touching colour/roughness/metalness: opacity and the depth/blend
199
175
  * state, emissive, the remaining standard maps, and the flags that change the
200
176
  * compiled program. A shader material answers with its uuid instead (see the
@@ -4,6 +4,10 @@ import { createNeutralMatcapTexture } from './matcap-texture';
4
4
  /** Temporary developer-facing shading modes. These never become scene data. */
5
5
  export type ViewportShadingMode =
6
6
  | 'solid'
7
+ /** Authored materials, as `solid`; a stage lights it by a preview (Blender's Material Preview). */
8
+ | 'preview'
9
+ /** Authored materials, as `solid`; a stage lights it by the scene (Blender's Rendered). */
10
+ | 'rendered'
7
11
  | 'clay'
8
12
  | 'unlit'
9
13
  | 'wireframe'
@@ -65,7 +69,7 @@ export class ViewportShadingRenderer {
65
69
  draw: () => void,
66
70
  include: (mesh: THREE.Mesh) => boolean = () => true,
67
71
  ): void {
68
- if (mode === 'solid') {
72
+ if (mode === 'solid' || mode === 'preview' || mode === 'rendered') {
69
73
  draw();
70
74
  return;
71
75
  }
@@ -107,7 +111,7 @@ export class ViewportShadingRenderer {
107
111
 
108
112
  private _materialFor(
109
113
  material: THREE.Material,
110
- mode: Exclude<ViewportShadingMode, 'solid'>,
114
+ mode: Exclude<ViewportShadingMode, 'solid' | 'preview' | 'rendered'>,
111
115
  ): THREE.Material {
112
116
  if (mode === 'normals') return this._normals;
113
117
  if (mode === 'overdraw') return this._overdraw;
@@ -1,260 +0,0 @@
1
- import * as THREE from 'three';
2
- import { getUserData } from '../ecs/user-data';
3
- import type { ResolvedRenderSettings } from './render-settings';
4
- import { staticBatchSignature } from './structural-signature';
5
-
6
- /**
7
- * Runtime STATIC render-batching (the framework-level transparent perf delivery).
8
- *
9
- * Scans a loaded scene graph for STATIC meshes sharing geometry+material and draws
10
- * each group through ONE InstancedMesh. The originals are DETACHED from the render
11
- * tree (so the renderer never walks them — zero per-frame cost), but retained here
12
- * as the logical handles: re-attached on teardown, and resolvable via raycast().
13
- * N logical objects → 1 draw call, ~0 CPU/frame. This is the classic "static
14
- * batching" technique (cf. Unity/PlayCanvas static batch groups).
15
- *
16
- * Strategy 1 (keep identity, don't merge). Moving entities (boids-style,
17
- * all-frame churn) are a separate concern, and the engine does not answer it:
18
- * a game's production answer is the `static-batch` capability's `<Frozen>`
19
- * (see the scope note below).
20
- *
21
- * Eligibility (anything failing stays an ordinary draw — degrade, don't break):
22
- * - plain THREE.Mesh (not Instanced/Skinned/Batched)
23
- * - single, opaque material (transparency needs per-object sorting)
24
- * - group of ≥2 sharing (geometry, material, shadow flags)
25
- * - (there is no per-entity opt-out setting: this docblock used to name
26
- * `entity.render.autoBatch === false`, a render-feature field NO CODE HERE
27
- * OR ANYWHERE EVER READ. The field was deleted with R-2's coverage fix;
28
- * opting a subtree out is the `static-batch` capability's `<Frozen>`
29
- * declaration, per the scope note below.)
30
- * - STATIC members (no physics/components/animation) → detached; MOVING members →
31
- * kept in-graph hidden + dirty-synced each frame. A bucket may split into both.
32
- *
33
- * Scope / limitations (v1):
34
- * - NOT WIRED: nothing in the engine calls build(), and nothing constructs
35
- * this class. The only remaining reference is the hosted editor's
36
- * project-script module map (`editor/src/served-bundle-runtime-modules.ts`),
37
- * which loads the module so a project script MAY drive it; the example
38
- * that used to is gone. That is settled, not pending: a game's
39
- * production answer is the `static-batch` capability's `<Frozen>`, a
40
- * DECLARED mount-static subtree in the game's own TSX, and `dev/static-
41
- * batch-advisor.ts` is what routes an author to it by measurement. Full-
42
- * auto inference over a scene nobody declared static is what this class
43
- * would need to become, and it was rejected (issue #1503).
44
- * - build() is a one-shot scan of the scene handed to it. Entities spawned
45
- * AFTER build do not join a batch; call build() again to re-scan. Removing a batched
46
- * source leaves a stale instance until rebuild. (Static scenery is load-time stable.)
47
- * - The batch culls as ONE unit (no per-instance frustum culling yet — Tier-2).
48
- */
49
-
50
- interface Group {
51
- instanced: THREE.InstancedMesh;
52
- sources: THREE.Mesh[]; // instanceId → source (raycast mapping + restore on teardown)
53
- dynamic: boolean; // true = sources kept in the graph & re-synced each frame (movers)
54
- cache?: Float32Array; // dynamic only: last-synced matrixWorld elements (16/source)
55
- }
56
-
57
- // Structural signature — groups meshes that are VALUE-identical, not object-identical.
58
- // The engine scene-loader instantiates a fresh geometry+material per entity (no dedup),
59
- // so keying on .uuid would never batch a real scene; a TSX/R3F world writing inline
60
- // `<meshStandardMaterial>` elements has exactly the same property. That key lives in
61
- // `render/structural-signature.ts` — ONE owner, shared with the `static-batch`
62
- // capability's `<Frozen>`, so the two cannot drift into disagreeing about what
63
- // "the same draw" means. `staticBatchSignature` is this file's original key, moved.
64
- const SIG = staticBatchSignature;
65
-
66
- /** Match Three.js renderer visibility: a hidden ancestor hides the whole subtree. */
67
- function isEffectivelyVisible(obj: THREE.Object3D): boolean {
68
- let cur: THREE.Object3D | null = obj;
69
- while (cur) {
70
- if (!cur.visible) return false;
71
- cur = cur.parent;
72
- }
73
- return true;
74
- }
75
-
76
- function isBatchCandidate(object: THREE.Object3D, batchRoot: THREE.Group): object is THREE.Mesh {
77
- const mesh = object as THREE.Mesh;
78
- if (!mesh.isMesh) return false;
79
- if ((mesh as THREE.InstancedMesh).isInstancedMesh) return false;
80
- if ((mesh as THREE.SkinnedMesh).isSkinnedMesh) return false;
81
- if ((mesh as unknown as THREE.BatchedMesh).isBatchedMesh) return false;
82
- if (!isEffectivelyVisible(mesh)) return false;
83
- if (!mesh.geometry || !mesh.material || Array.isArray(mesh.material)) return false;
84
- if ((mesh.material as THREE.Material).transparent) return false;
85
- return batchRoot !== mesh.parent;
86
- }
87
-
88
- /**
89
- * Static-batching eligibility: only objects that won't move are safe to freeze
90
- * and instance. An object (or an ancestor) carrying its own animation mixer is
91
- * a likely mover → left as an ordinary draw. Plain scenery is eligible. This is
92
- * the safe, transparent default; a future dynamic-batch path (markMoved-driven)
93
- * can include movers.
94
- */
95
- function isLikelyStatic(obj: THREE.Object3D): boolean {
96
- let cur: THREE.Object3D | null = obj;
97
- while (cur) {
98
- if (getUserData(cur, '_animMixer')) return false;
99
- cur = cur.parent;
100
- }
101
- return true;
102
- }
103
-
104
- export class RenderBatchSystem {
105
- private root = new THREE.Group();
106
- private groups: Group[] = [];
107
- private dynamicGroups: Group[] = []; // subset needing per-frame sync
108
- // Original parent of each detached (static) source, to re-attach on teardown.
109
- private parentOf = new WeakMap<THREE.Mesh, THREE.Object3D>();
110
- // instanceId mapping for raycast: which group + slot an InstancedMesh belongs to.
111
- private membersOf = new WeakMap<THREE.InstancedMesh, THREE.Mesh[]>();
112
-
113
- constructor(
114
- private scene: THREE.Scene,
115
- private settings: ResolvedRenderSettings,
116
- ) {
117
- this.root.name = 'RenderBatchSystem';
118
- this.root.matrixAutoUpdate = false; // instances carry world matrices directly
119
- }
120
-
121
- /** Scan the scene and build instanced groups. Idempotent (rebuilds). */
122
- build(): void {
123
- this.teardown();
124
- this.scene.add(this.root);
125
- this.scene.updateMatrixWorld(true);
126
-
127
- // collect eligible candidates, grouped by (geometry, material)
128
- const buckets = new Map<string, THREE.Mesh[]>();
129
- this.scene.traverse((obj) => {
130
- if (!isBatchCandidate(obj, this.root)) return;
131
- const key = SIG(obj);
132
- const arr = buckets.get(key) ?? [];
133
- arr.push(obj);
134
- buckets.set(key, arr);
135
- });
136
-
137
- // Each signature bucket is partitioned into STATIC (detached, zero-cost) and
138
- // DYNAMIC (kept in-place, dirty-synced) members, each ≥2 forming its own batch.
139
- for (const bucket of buckets.values()) {
140
- const staticMembers: THREE.Mesh[] = [];
141
- const dynamicMembers: THREE.Mesh[] = [];
142
- for (const m of bucket) (isLikelyStatic(m) ? staticMembers : dynamicMembers).push(m);
143
- if (staticMembers.length >= 2) this.makeGroup(staticMembers, false);
144
- if (dynamicMembers.length >= 2) this.makeGroup(dynamicMembers, true);
145
- }
146
- }
147
-
148
- private makeGroup(sources: THREE.Mesh[], dynamic: boolean): void {
149
- const frustum = this.settings['frustumCulling'] !== false;
150
- const tmp = new THREE.Matrix4();
151
- const proto = sources[0]!;
152
- const inst = new THREE.InstancedMesh(
153
- proto.geometry,
154
- proto.material as THREE.Material,
155
- sources.length,
156
- );
157
- inst.frustumCulled = frustum && !dynamic; // moving batches can leave the padded sphere
158
- // Inherit shadow flags from the prototype so batched objects still cast/receive
159
- // shadows (otherwise detaching the sources would silently drop them from shadows).
160
- inst.castShadow = proto.castShadow;
161
- inst.receiveShadow = proto.receiveShadow;
162
- const cache = dynamic ? new Float32Array(sources.length * 16) : undefined;
163
- for (let i = 0; i < sources.length; i++) {
164
- const s = sources[i]!;
165
- tmp.copy(s.matrixWorld);
166
- inst.setMatrixAt(i, tmp);
167
- if (dynamic) {
168
- // keep the source in the graph (physics/animation still updates its matrixWorld),
169
- // just hide its own draw; we copy its world matrix into the instance each frame.
170
- cache!.set(tmp.elements, i * 16);
171
- s.visible = false;
172
- } else if (s.parent) {
173
- // static: detach entirely so the renderer never walks it (zero per-frame cost)
174
- this.parentOf.set(s, s.parent);
175
- s.parent.remove(s);
176
- }
177
- }
178
- inst.instanceMatrix.needsUpdate = true;
179
- if (frustum && !dynamic) inst.computeBoundingSphere();
180
- this.membersOf.set(inst, sources);
181
- this.root.add(inst);
182
- const group: Group = cache
183
- ? { instanced: inst, sources, dynamic, cache }
184
- : { instanced: inst, sources, dynamic };
185
- this.groups.push(group);
186
- if (dynamic) this.dynamicGroups.push(group);
187
- }
188
-
189
- /**
190
- * Per-frame sync for DYNAMIC groups only (static are detached & immutable → free).
191
- * Called in the preRender phase. Refreshes world matrices once (the engine's movers
192
- * set matrixWorldNeedsUpdate via physics/animation), then re-writes only the instances
193
- * whose source moved (element-wise cache compare). No-op when there are no movers.
194
- */
195
- update(): void {
196
- if (this.dynamicGroups.length === 0) return;
197
- this.scene.updateMatrixWorld(false); // refresh moved subtrees (force=false → cheap)
198
- for (const g of this.dynamicGroups) {
199
- const cache = g.cache!;
200
- let dirty = false;
201
- for (let i = 0; i < g.sources.length; i++) {
202
- const el = g.sources[i]!.matrixWorld.elements;
203
- const off = i * 16;
204
- let changed = false;
205
- for (let k = 0; k < 16; k++) {
206
- if (el[k] !== cache[off + k]) {
207
- changed = true;
208
- break;
209
- }
210
- }
211
- if (!changed) continue;
212
- g.instanced.setMatrixAt(i, g.sources[i]!.matrixWorld);
213
- cache.set(el, off);
214
- dirty = true;
215
- }
216
- if (dirty) g.instanced.instanceMatrix.needsUpdate = true;
217
- }
218
- }
219
-
220
- /**
221
- * Raycast against the batched instances, mapping a hit back to the logical source
222
- * Object3D (so editor/gameplay picking still resolves a detached entity).
223
- */
224
- raycast(raycaster: THREE.Raycaster): THREE.Mesh | null {
225
- const hits = raycaster.intersectObject(this.root, true);
226
- for (const h of hits) {
227
- const members = this.membersOf.get(h.object as THREE.InstancedMesh);
228
- if (members && h.instanceId != null) return members[h.instanceId] ?? null;
229
- }
230
- return null;
231
- }
232
-
233
- /** Number of draw calls the batcher contributes (1 per group). */
234
- get drawCalls(): number {
235
- return this.groups.length;
236
- }
237
-
238
- /** How many source meshes were collapsed into instances. */
239
- get batchedCount(): number {
240
- return this.groups.reduce((n, g) => n + g.sources.length, 0);
241
- }
242
-
243
- teardown(): void {
244
- for (const g of this.groups) {
245
- for (const s of g.sources) {
246
- if (g.dynamic)
247
- s.visible = true; // dynamic sources stayed in-graph, hidden
248
- else {
249
- const parent = this.parentOf.get(s); // static sources were detached — re-attach
250
- if (parent) parent.add(s);
251
- }
252
- }
253
- g.instanced.dispose();
254
- this.root.remove(g.instanced);
255
- }
256
- this.groups = [];
257
- this.dynamicGroups = [];
258
- if (this.root.parent) this.root.parent.remove(this.root);
259
- }
260
- }