@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 +2 -2
- package/src/adapter/first-party-navigation-system.ts +3 -5
- package/src/adapter/rapier-physics-adapter.ts +1 -1
- package/src/adapter/renderer-config.ts +3 -4
- package/src/assets.ts +13 -2
- package/src/ecs/object-marks.ts +1 -1
- package/src/ecs/user-data.ts +0 -9
- package/src/render/render-features.ts +1 -3
- package/src/render/structural-signature.ts +17 -41
- package/src/render/viewport-shading.ts +6 -2
- package/src/render/render-batch-system.ts +0 -260
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.
|
|
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.
|
|
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
|
|
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.
|
|
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 `
|
|
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.
|
|
8
|
-
*
|
|
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
|
|
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
|
|
14
|
+
import type * as THREE from 'three';
|
|
15
15
|
import { gltfLoader, resolveUrl } from './loader';
|
|
16
16
|
|
|
17
|
-
|
|
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>>();
|
package/src/ecs/object-marks.ts
CHANGED
|
@@ -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
|
-
// (
|
|
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
|
|
package/src/ecs/user-data.ts
CHANGED
|
@@ -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`
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
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
|
-
* ──
|
|
53
|
-
* {@link
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
|
171
|
+
* every member it collapses.
|
|
196
172
|
*
|
|
197
|
-
* Everything
|
|
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
|
-
}
|