@volter/threejs-runtime 0.5.65
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/NOTICE +12 -0
- package/package.json +62 -0
- package/src/adapter/body-marks.ts +69 -0
- package/src/adapter/constraint.ts +78 -0
- package/src/adapter/first-party-navigation-system.ts +48 -0
- package/src/adapter/hierarchy-marks.ts +156 -0
- package/src/adapter/ingest/scene-capture.ts +865 -0
- package/src/adapter/ingest/structural-ids.ts +139 -0
- package/src/adapter/ingest/visible-capture-window.ts +302 -0
- package/src/adapter/object3d-authoring-subject.ts +50 -0
- package/src/adapter/rapier-physics-adapter.ts +226 -0
- package/src/adapter/reflection-probe.ts +75 -0
- package/src/adapter/renderer-config.ts +116 -0
- package/src/adapter/trigger-volume.ts +29 -0
- package/src/ai/navigation.ts +627 -0
- package/src/animation/animation-clock.ts +479 -0
- package/src/animation/runtime-inspection.ts +45 -0
- package/src/animation/xstate-animation-binding.ts +655 -0
- package/src/animation/xstate-animation-meta.ts +360 -0
- package/src/asset-formats/camera.ts +18 -0
- package/src/asset-formats/collider.ts +66 -0
- package/src/asset-formats/index.ts +45 -0
- package/src/asset-formats/light.ts +50 -0
- package/src/asset-formats/material.ts +93 -0
- package/src/asset-formats/mesh.ts +78 -0
- package/src/asset-formats/particles.ts +426 -0
- package/src/asset-formats/render-env.ts +318 -0
- package/src/asset-formats/tuples.ts +21 -0
- package/src/asset-loaders.ts +242 -0
- package/src/asset-parse-error.ts +29 -0
- package/src/assets.ts +68 -0
- package/src/behavior/xstate-inspection.ts +51 -0
- package/src/defaults.ts +135 -0
- package/src/ecs/object-marks.ts +75 -0
- package/src/ecs/scene-index.ts +439 -0
- package/src/ecs/scene-query.ts +43 -0
- package/src/ecs/user-data.ts +272 -0
- package/src/loader.ts +134 -0
- package/src/physics/collider-dimensions.ts +121 -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/render/directional-shadow-fit.ts +158 -0
- package/src/render/environment-capture.ts +133 -0
- package/src/render/ibl-override-material.ts +202 -0
- package/src/render/light-camera-factory.ts +103 -0
- package/src/render/matcap-texture.ts +92 -0
- package/src/render/material-factory.ts +201 -0
- package/src/render/mesh-shadow.ts +18 -0
- package/src/render/particles-factory.ts +589 -0
- package/src/render/render-batch-system.ts +260 -0
- package/src/render/render-features.ts +128 -0
- package/src/render/render-settings.ts +71 -0
- package/src/render/soft-particle-depth.ts +360 -0
- package/src/render/spark-renderer-lifecycle.ts +64 -0
- package/src/render/structural-signature.ts +231 -0
- package/src/render/viewport-shading.ts +163 -0
- package/src/render/voxel-surface.ts +358 -0
- package/src/setup/setup-particles.ts +32 -0
- package/src/setup/setup-physics.ts +67 -0
- package/src/setup/setup-renderer.ts +524 -0
- package/src/types-n8ao.d.ts +37 -0
- package/src/types-realism-effects.d.ts +61 -0
- package/vendor/realism-effects/LICENSE.md +21 -0
- package/vendor/realism-effects/UPSTREAM.md +19 -0
- package/vendor/realism-effects/dist/index.cjs +3447 -0
- package/vendor/realism-effects/dist/index.d.ts +59 -0
- package/vendor/realism-effects/dist/index.js +3434 -0
- package/vendor/realism-effects/package.json +23 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type * as THREE from 'three';
|
|
2
|
+
import type { AnyStateMachine } from 'xstate';
|
|
3
|
+
import { deleteUserData, getUserData, setUserData } from '../ecs/user-data';
|
|
4
|
+
|
|
5
|
+
/** Minimal structural actor surface needed by the live behavior debugger. */
|
|
6
|
+
export interface InspectableXStateActor {
|
|
7
|
+
readonly getSnapshot: () => {
|
|
8
|
+
readonly value: unknown;
|
|
9
|
+
readonly context: unknown;
|
|
10
|
+
readonly machine?: AnyStateMachine;
|
|
11
|
+
};
|
|
12
|
+
readonly subscribe: (next: (snapshot: { readonly value: unknown }) => void) => {
|
|
13
|
+
unsubscribe: () => void;
|
|
14
|
+
};
|
|
15
|
+
readonly logic?: AnyStateMachine;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface XStateBehaviorInspection {
|
|
19
|
+
readonly actor: InspectableXStateActor;
|
|
20
|
+
readonly machine: AnyStateMachine;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let version = 0;
|
|
24
|
+
const listeners = new Set<() => void>();
|
|
25
|
+
|
|
26
|
+
function notify(): void {
|
|
27
|
+
version++;
|
|
28
|
+
for (const listener of listeners) listener();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function subscribeXStateBehaviorInspections(listener: () => void): () => void {
|
|
32
|
+
listeners.add(listener);
|
|
33
|
+
return () => listeners.delete(listener);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function xstateBehaviorInspectionsVersion(): number {
|
|
37
|
+
return version;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function attachXStateBehaviorInspection(
|
|
41
|
+
owner: THREE.Object3D,
|
|
42
|
+
inspection: XStateBehaviorInspection,
|
|
43
|
+
): () => void {
|
|
44
|
+
setUserData(owner, '_xstateBehavior', inspection);
|
|
45
|
+
notify();
|
|
46
|
+
return () => {
|
|
47
|
+
if (getUserData(owner, '_xstateBehavior') !== inspection) return;
|
|
48
|
+
deleteUserData(owner, '_xstateBehavior');
|
|
49
|
+
notify();
|
|
50
|
+
};
|
|
51
|
+
}
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for default values the engine's factories read.
|
|
3
|
+
*
|
|
4
|
+
* A group belongs here only while a FACTORY reads it. A default with no
|
|
5
|
+
* reader is dead documentation: it looks authoritative, nothing consults it,
|
|
6
|
+
* and it drifts from the value the code actually uses. Restating a library's
|
|
7
|
+
* own constructor values (THREE's `Object3D` transform, say) is the same
|
|
8
|
+
* mistake in a different shape — the library is already the source of truth.
|
|
9
|
+
* So when a factory goes, its group goes with it.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULTS = {
|
|
12
|
+
material: {
|
|
13
|
+
type: 'standard' as const,
|
|
14
|
+
color: '#888888',
|
|
15
|
+
metalness: 0,
|
|
16
|
+
roughness: 0.5,
|
|
17
|
+
emissive: '#000000',
|
|
18
|
+
emissiveIntensity: 0,
|
|
19
|
+
opacity: 1,
|
|
20
|
+
transparent: false,
|
|
21
|
+
side: 'front' as const,
|
|
22
|
+
flatShading: false,
|
|
23
|
+
clearcoat: { clearcoat: 1, clearcoatRoughness: 0 },
|
|
24
|
+
transmission: { transmission: 1, ior: 1.5, thickness: 0.5 },
|
|
25
|
+
sheen: { sheen: 1, sheenColor: '#ffffff', sheenRoughness: 0.5 },
|
|
26
|
+
iridescence: {
|
|
27
|
+
iridescence: 1,
|
|
28
|
+
iridescenceIOR: 1.3,
|
|
29
|
+
iridescenceThicknessRange: [100, 400] as [number, number],
|
|
30
|
+
},
|
|
31
|
+
displacementScale: 1,
|
|
32
|
+
displacementBias: 0,
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
light: {
|
|
36
|
+
color: '#ffffff',
|
|
37
|
+
intensity: 1,
|
|
38
|
+
groundColor: '#000000',
|
|
39
|
+
distance: 0,
|
|
40
|
+
decay: 2,
|
|
41
|
+
angle: Math.PI / 6,
|
|
42
|
+
penumbra: 0,
|
|
43
|
+
// three.js's own RectAreaLight constructor defaults (area lights only).
|
|
44
|
+
width: 10,
|
|
45
|
+
height: 10,
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
camera: {
|
|
49
|
+
fov: 60,
|
|
50
|
+
near: 0.1,
|
|
51
|
+
far: 1000,
|
|
52
|
+
orthoSize: 5,
|
|
53
|
+
width: 1920,
|
|
54
|
+
height: 1080,
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
collider: {
|
|
58
|
+
cuboid: { halfExtents: [0.5, 0.5, 0.5] as [number, number, number] },
|
|
59
|
+
ball: { radius: 0.5 },
|
|
60
|
+
capsule: { radius: 0.25, halfHeight: 0.5 },
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
toneMapping: {
|
|
64
|
+
// T1.13: the effective renderer default is ACES (see
|
|
65
|
+
// setup-renderer.ts's `createHostRenderer` + `applyScenePostProcessing`,
|
|
66
|
+
// which both hardcode `THREE.ACESFilmicToneMapping`/'aces' as the
|
|
67
|
+
// fallback when a scene omits `environment.toneMapping`) — NOT 'none'.
|
|
68
|
+
// This used to say 'none', silently disagreeing with the runtime and
|
|
69
|
+
// with the editor's Tone Mapping inspector dropdown.
|
|
70
|
+
mode: 'aces' as const,
|
|
71
|
+
exposure: 1.0,
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
postProcessing: {
|
|
75
|
+
bloom: { intensity: 1.0, luminanceThreshold: 0.9, luminanceSmoothing: 0.025 },
|
|
76
|
+
vignette: { darkness: 0.5, offset: 0.5 },
|
|
77
|
+
brightnessContrast: { brightness: 0, contrast: 0 },
|
|
78
|
+
hueSaturation: { hue: 0, saturation: 0 },
|
|
79
|
+
sepia: { intensity: 1.0 },
|
|
80
|
+
colorDepth: { bits: 16 },
|
|
81
|
+
chromaticAberration: { offsetX: 0.002, offsetY: 0.002, modulationOffset: 0.15 },
|
|
82
|
+
lensDistortion: { distortionX: 0, distortionY: 0, focalLengthX: 1, focalLengthY: 1, skew: 0 },
|
|
83
|
+
depthOfField: { worldFocusDistance: 10, worldFocusRange: 5, bokehScale: 3, focalLength: 0.04 },
|
|
84
|
+
tiltShift: { offset: 0, rotation: 0, focusArea: 0.4, feather: 0.3 },
|
|
85
|
+
noise: { premultiply: false },
|
|
86
|
+
scanline: { density: 1.25 },
|
|
87
|
+
dotScreen: { angle: 1.57, scale: 1.0 },
|
|
88
|
+
grid: { scale: 1.0, lineWidth: 0.0 },
|
|
89
|
+
pixelation: { granularity: 6 },
|
|
90
|
+
glitch: {
|
|
91
|
+
delayX: 1.5,
|
|
92
|
+
delayY: 3.5,
|
|
93
|
+
durationX: 0.6,
|
|
94
|
+
durationY: 1.0,
|
|
95
|
+
strengthX: 0.3,
|
|
96
|
+
strengthY: 1.0,
|
|
97
|
+
columns: 0.05,
|
|
98
|
+
ratio: 0.85,
|
|
99
|
+
},
|
|
100
|
+
shockWave: { speed: 2, maxRadius: 1, waveSize: 0.2, amplitude: 0.05 },
|
|
101
|
+
smaa: { preset: 'high' as const },
|
|
102
|
+
ssao: { radius: 0.05, intensity: 2.0, bias: 0.025, samples: 16, rings: 7, fade: 0.01 },
|
|
103
|
+
n8ao: {
|
|
104
|
+
aoSamples: 16,
|
|
105
|
+
aoRadius: 5.0,
|
|
106
|
+
intensity: 5,
|
|
107
|
+
denoiseSamples: 8,
|
|
108
|
+
denoiseRadius: 12,
|
|
109
|
+
distanceFalloff: 1.0,
|
|
110
|
+
},
|
|
111
|
+
godRays: { density: 0.96, decay: 0.93, weight: 0.4, exposure: 0.6, samples: 60 },
|
|
112
|
+
outline: {
|
|
113
|
+
edgeStrength: 3,
|
|
114
|
+
pulseSpeed: 0,
|
|
115
|
+
visibleEdgeColor: '#ffffff',
|
|
116
|
+
hiddenEdgeColor: '#22090a',
|
|
117
|
+
},
|
|
118
|
+
ssr: { intensity: 1, exponent: 1, distance: 10, thickness: 10, maxRoughness: 1 },
|
|
119
|
+
ssgi: { intensity: 1, distance: 10, thickness: 10, maxRoughness: 1 },
|
|
120
|
+
motionBlur: { intensity: 1, jitter: 1, samples: 16 },
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
navigation: {
|
|
124
|
+
cellSize: 0.3,
|
|
125
|
+
cellHeight: 0.2,
|
|
126
|
+
walkableSlopeAngle: 45,
|
|
127
|
+
walkableHeight: 2,
|
|
128
|
+
walkableClimb: 1,
|
|
129
|
+
walkableRadius: 0.5,
|
|
130
|
+
maxEdgeLen: 12,
|
|
131
|
+
maxSimplificationError: 1.3,
|
|
132
|
+
minRegionArea: 8,
|
|
133
|
+
mergeRegionArea: 20,
|
|
134
|
+
},
|
|
135
|
+
} as const;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural object marks shared by runtime producers and Three editor tools.
|
|
3
|
+
* Keep this contract native and dependency-free except for Three's types: a
|
|
4
|
+
* bounds/layer reader must not load physics, animation or editor contracts.
|
|
5
|
+
* The full user-data registry extends this schema and reuses these same keys.
|
|
6
|
+
*/
|
|
7
|
+
import type { Object3D } from 'three';
|
|
8
|
+
|
|
9
|
+
/** The kinds of editor helper objects tagged via `editorHelperType`. */
|
|
10
|
+
export type EditorHelperType =
|
|
11
|
+
| 'lights'
|
|
12
|
+
| 'cameras'
|
|
13
|
+
| 'audio'
|
|
14
|
+
| 'colliders'
|
|
15
|
+
// Joint anchor/axis/limit gizmos (W2a) — distinct from 'colliders' so the
|
|
16
|
+
// viewport's per-type visibility filter can toggle them independently.
|
|
17
|
+
| 'joints'
|
|
18
|
+
// LOD distance rings (W2b) — one ring per `mesh.lod` level, radius = the
|
|
19
|
+
// level's camera-distance threshold in world units.
|
|
20
|
+
| 'lod'
|
|
21
|
+
| 'splines'
|
|
22
|
+
// 'particles' tags the LIVE three.quarks emitter Object3D itself (so the
|
|
23
|
+
// editor's helper machinery skips disposing it); 'particle-shape' tags the
|
|
24
|
+
// editor's emitter-shape WIREFRAME (W1b) — they must stay distinct: sweeping
|
|
25
|
+
// "particles"-tagged children would detach the running emitter, and quarks
|
|
26
|
+
// self-disposes any system whose emitter leaves the scene.
|
|
27
|
+
| 'particles'
|
|
28
|
+
| 'particle-shape'
|
|
29
|
+
| 'pivot'
|
|
30
|
+
| 'navmesh'
|
|
31
|
+
| 'constraints'
|
|
32
|
+
| 'reflection-probes'
|
|
33
|
+
| 'trigger-volumes'
|
|
34
|
+
| 'skeletons'
|
|
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,
|
|
37
|
+
// any other follows the master Helpers toggle.
|
|
38
|
+
| (string & {});
|
|
39
|
+
|
|
40
|
+
export interface ObjectMarkSchema {
|
|
41
|
+
editorHelperType: EditorHelperType;
|
|
42
|
+
skeletonVisible: boolean;
|
|
43
|
+
skeletonEnabled: boolean;
|
|
44
|
+
engineInternal: boolean;
|
|
45
|
+
editorHelper: boolean;
|
|
46
|
+
vgaiComponentRoot: string;
|
|
47
|
+
vgaiBuiltInternal: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const ObjectMarkKeys = {
|
|
51
|
+
editorHelperType: 'editorHelperType',
|
|
52
|
+
skeletonVisible: 'skeletonVisible',
|
|
53
|
+
skeletonEnabled: 'skeletonEnabled',
|
|
54
|
+
engineInternal: 'engineInternal',
|
|
55
|
+
editorHelper: 'editorHelper',
|
|
56
|
+
vgaiComponentRoot: 'vgaiComponentRoot',
|
|
57
|
+
vgaiBuiltInternal: 'vgaiBuiltInternal',
|
|
58
|
+
} as const satisfies Record<keyof ObjectMarkSchema, string>;
|
|
59
|
+
|
|
60
|
+
export function getObjectMark<K extends keyof ObjectMarkSchema>(
|
|
61
|
+
object: Object3D | null | undefined,
|
|
62
|
+
key: K,
|
|
63
|
+
): ObjectMarkSchema[K] | undefined {
|
|
64
|
+
return object
|
|
65
|
+
? (object.userData[ObjectMarkKeys[key]] as ObjectMarkSchema[K] | undefined)
|
|
66
|
+
: undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function setObjectMark<K extends keyof ObjectMarkSchema>(
|
|
70
|
+
object: Object3D,
|
|
71
|
+
key: K,
|
|
72
|
+
value: ObjectMarkSchema[K],
|
|
73
|
+
): void {
|
|
74
|
+
object.userData[ObjectMarkKeys[key]] = value;
|
|
75
|
+
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createSceneIndex` — the LIVE scene index (punchlist P2, `observe`).
|
|
3
|
+
*
|
|
4
|
+
* `scene-query.ts` answers "which objects carry tag X?" by walking the whole
|
|
5
|
+
* graph on every call, and it tells nobody when the answer changes. This
|
|
6
|
+
* module is the notified half: a tag/entity-id index kept current as objects
|
|
7
|
+
* join and leave the scene, plus the change signals a game needs so it never
|
|
8
|
+
* has to poll.
|
|
9
|
+
*
|
|
10
|
+
* ## The one event primitive is `EventTarget`
|
|
11
|
+
*
|
|
12
|
+
* There is no bus, no emitter class, no `subscribe()` returning a closure.
|
|
13
|
+
* `index.events` is a plain `EventTarget` dispatching `CustomEvent`s, and
|
|
14
|
+
* **unsubscribe is `AbortSignal`** — the platform's own answer:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const ac = new AbortController();
|
|
18
|
+
* ctx.sceneIndex.events.addEventListener(
|
|
19
|
+
* 'tagadded',
|
|
20
|
+
* (e) => console.log((e as SceneIndexEvent<'tagadded'>).detail.object.name),
|
|
21
|
+
* { signal: ac.signal },
|
|
22
|
+
* );
|
|
23
|
+
* ac.abort(); // done — nothing to remember, nothing to leak
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* {@link onSceneIndexEvent} is the same call with the detail typed for you.
|
|
27
|
+
* `index.eventsFor(obj)` is the per-object target (Roblox's `inst.Changed`):
|
|
28
|
+
* the same events, already filtered to one object, so a listener that only
|
|
29
|
+
* cares about one entity does not filter by hand.
|
|
30
|
+
*
|
|
31
|
+
* ## How the index stays live: three's own parent events, not a monkeypatch
|
|
32
|
+
*
|
|
33
|
+
* three r155+ dispatches `childadded`/`childremoved` on the **parent** from
|
|
34
|
+
* `Object3D.add`/`remove` (and therefore from `attach`, `clear`,
|
|
35
|
+
* `removeFromParent`, and R3F's reconciler, which all go through those two).
|
|
36
|
+
* The index subscribes to those two events on every object it indexes; on
|
|
37
|
+
* `childadded` it walks the new subtree, indexes it, and subscribes each
|
|
38
|
+
* node, and on `childremoved` it does the inverse. `Object3D.prototype` is
|
|
39
|
+
* NOT patched — nothing in this repo patches a library prototype.
|
|
40
|
+
*
|
|
41
|
+
* Consequences worth knowing:
|
|
42
|
+
*
|
|
43
|
+
* - **The quiet-frame cost is zero.** The index registers no system and runs
|
|
44
|
+
* no per-frame reconcile; it does work only when three tells it something
|
|
45
|
+
* moved. Nothing here ticks.
|
|
46
|
+
* - **Reparenting nets out to one entry.** `parent.add(obj)` removes `obj`
|
|
47
|
+
* from its old parent first, so a move inside the indexed tree fires
|
|
48
|
+
* `childremoved` then `childadded` — unindex, then index. The object ends
|
|
49
|
+
* up indexed exactly once (and a listener sees an `entityremoved` followed
|
|
50
|
+
* by an `entityadded`, which is the truth about what happened).
|
|
51
|
+
* - **Splicing `parent.children` by hand is invisible to the index**, because
|
|
52
|
+
* it is invisible to three as well. Use `add`/`remove`.
|
|
53
|
+
*
|
|
54
|
+
* ## Tags are stored where they always were
|
|
55
|
+
*
|
|
56
|
+
* `addTag`/`removeTag` write the existing `userData['tags']` array, so
|
|
57
|
+
* `queryByTag` keeps returning the truth and nothing has to be migrated. The
|
|
58
|
+
* index is the fast, notified path over the same storage. A game that writes
|
|
59
|
+
* `userData['tags']` by hand gets a stale index — that is not prevented, it is
|
|
60
|
+
* repaired: call {@link SceneIndex.reindex} on the object.
|
|
61
|
+
*
|
|
62
|
+
* ## Attributes
|
|
63
|
+
*
|
|
64
|
+
* `getAttribute`/`setAttribute`/`deleteAttribute` are typed accessors over ONE
|
|
65
|
+
* new reserved userData key, `attributes` (registered in `ecs/user-data.ts`,
|
|
66
|
+
* `Record<string, string | number | boolean>`). They emit `attributechanged`
|
|
67
|
+
* only when the value actually changes, so a re-set of the same value is
|
|
68
|
+
* silent.
|
|
69
|
+
*
|
|
70
|
+
* ## What this deliberately does NOT do
|
|
71
|
+
*
|
|
72
|
+
* - **No property-level `Changed` on transforms.** three does not notify on
|
|
73
|
+
* `position.set(...)`; faking it means diffing every transform every frame,
|
|
74
|
+
* and a signal that is really a per-frame poll is worse than no signal —
|
|
75
|
+
* it hides its own cost. Read the transform when you need it, or drive the
|
|
76
|
+
* change from the code that made it.
|
|
77
|
+
* - **No `Touched`.** Contact/trigger events are Rapier's, dispatched through
|
|
78
|
+
* `ctx.collisions.onCollision`. A second path would be a second truth.
|
|
79
|
+
* - **No 2D index.** This module indexes a three graph only.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
import type * as THREE from 'three';
|
|
83
|
+
import { deleteUserData, getUserData, setUserData } from './user-data';
|
|
84
|
+
|
|
85
|
+
/** The value types an attribute may hold — deliberately JSON-simple, so an
|
|
86
|
+
* attribute survives serialization and network replication unchanged. */
|
|
87
|
+
export type AttributeValue = string | number | boolean;
|
|
88
|
+
|
|
89
|
+
/** The `detail` payload of every scene-index event, keyed by event type.
|
|
90
|
+
* Every detail carries `object`, so a scene-wide listener filters without a
|
|
91
|
+
* second lookup. */
|
|
92
|
+
export interface SceneIndexEventDetails {
|
|
93
|
+
/** An object (and, one event each, its descendants) joined the indexed tree. */
|
|
94
|
+
entityadded: { object: THREE.Object3D };
|
|
95
|
+
/** An object (and, one event each, its descendants) left the indexed tree. */
|
|
96
|
+
entityremoved: { object: THREE.Object3D };
|
|
97
|
+
/** A tag was added via {@link SceneIndex.addTag}. */
|
|
98
|
+
tagadded: { object: THREE.Object3D; tag: string };
|
|
99
|
+
/** A tag was removed via {@link SceneIndex.removeTag}. */
|
|
100
|
+
tagremoved: { object: THREE.Object3D; tag: string };
|
|
101
|
+
/** An attribute's value changed (`value` is `undefined` after a delete;
|
|
102
|
+
* `previous` is `undefined` when it was previously unset). */
|
|
103
|
+
attributechanged: {
|
|
104
|
+
object: THREE.Object3D;
|
|
105
|
+
name: string;
|
|
106
|
+
value: AttributeValue | undefined;
|
|
107
|
+
previous: AttributeValue | undefined;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Every event type `index.events` / `index.eventsFor(obj)` dispatches. */
|
|
112
|
+
export type SceneIndexEventType = keyof SceneIndexEventDetails;
|
|
113
|
+
|
|
114
|
+
/** The `CustomEvent` a given scene-index event type dispatches. */
|
|
115
|
+
export type SceneIndexEvent<K extends SceneIndexEventType = SceneIndexEventType> = CustomEvent<
|
|
116
|
+
SceneIndexEventDetails[K]
|
|
117
|
+
>;
|
|
118
|
+
|
|
119
|
+
/** Event-type → event, for callers that want a typed `addEventListener` map. */
|
|
120
|
+
export type SceneIndexEventMap = { [K in SceneIndexEventType]: SceneIndexEvent<K> };
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `addEventListener` with the detail typed — the ONLY convenience over the
|
|
124
|
+
* platform call, and it returns nothing on purpose: unsubscribe stays
|
|
125
|
+
* `AbortSignal` (`{ signal }`), never a returned closure.
|
|
126
|
+
*/
|
|
127
|
+
export function onSceneIndexEvent<K extends SceneIndexEventType>(
|
|
128
|
+
target: EventTarget,
|
|
129
|
+
type: K,
|
|
130
|
+
listener: (detail: SceneIndexEventDetails[K], event: SceneIndexEvent<K>) => void,
|
|
131
|
+
options?: { signal?: AbortSignal; once?: boolean },
|
|
132
|
+
): void {
|
|
133
|
+
target.addEventListener(
|
|
134
|
+
type,
|
|
135
|
+
(event) => {
|
|
136
|
+
const custom = event as SceneIndexEvent<K>;
|
|
137
|
+
listener(custom.detail, custom);
|
|
138
|
+
},
|
|
139
|
+
options,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The live scene index — see this module's header. */
|
|
144
|
+
export interface SceneIndex {
|
|
145
|
+
/** The scene-wide signal target. Listen with `addEventListener(type, fn,
|
|
146
|
+
* { signal })`, or {@link onSceneIndexEvent} for a typed `detail`. */
|
|
147
|
+
readonly events: EventTarget;
|
|
148
|
+
/** The per-object signal target (Roblox's `inst.Changed`): the same events,
|
|
149
|
+
* filtered to `obj`. Created lazily and held weakly. */
|
|
150
|
+
eventsFor(obj: THREE.Object3D): EventTarget;
|
|
151
|
+
|
|
152
|
+
/** Every indexed object carrying `tag`, in index order. Never traverses. */
|
|
153
|
+
byTag(tag: string): readonly THREE.Object3D[];
|
|
154
|
+
/** The indexed object whose `userData['entityId']` is `id` (the first, in
|
|
155
|
+
* index order, if a project has minted the id twice). Never traverses. */
|
|
156
|
+
byEntityId(id: string): THREE.Object3D | undefined;
|
|
157
|
+
/** True when `obj` is currently in the indexed tree. */
|
|
158
|
+
has(obj: THREE.Object3D): boolean;
|
|
159
|
+
|
|
160
|
+
/** Add `tag` to `obj`'s `userData['tags']` and the index; emits `tagadded`.
|
|
161
|
+
* A no-op (and silent) when the tag is already present. Legal on an object
|
|
162
|
+
* that has not joined the scene yet — the tag is stored, and the index
|
|
163
|
+
* picks it up when the object is added. */
|
|
164
|
+
addTag(obj: THREE.Object3D, tag: string): void;
|
|
165
|
+
/** Remove `tag`; emits `tagremoved`. A no-op (and silent) when absent. */
|
|
166
|
+
removeTag(obj: THREE.Object3D, tag: string): void;
|
|
167
|
+
/** True when `obj` carries `tag` (reads `userData['tags']`, so it is
|
|
168
|
+
* truthful even for an object that is not in the scene yet). */
|
|
169
|
+
hasTag(obj: THREE.Object3D, tag: string): boolean;
|
|
170
|
+
|
|
171
|
+
/** Read one attribute (`undefined` when unset). */
|
|
172
|
+
getAttribute(obj: THREE.Object3D, name: string): AttributeValue | undefined;
|
|
173
|
+
/** Write one attribute; emits `attributechanged` only if the value changed. */
|
|
174
|
+
setAttribute(obj: THREE.Object3D, name: string, value: AttributeValue): void;
|
|
175
|
+
/** Delete one attribute; emits `attributechanged` with `value: undefined`. */
|
|
176
|
+
deleteAttribute(obj: THREE.Object3D, name: string): void;
|
|
177
|
+
|
|
178
|
+
/** Re-read `obj`'s subtree from `userData` — the escape hatch for code that
|
|
179
|
+
* wrote `userData['tags']`/`userData['entityId']` by hand. Indexes anything
|
|
180
|
+
* in the subtree that is not indexed yet (emitting `entityadded`) and
|
|
181
|
+
* re-syncs the tag/id entries of everything that is. */
|
|
182
|
+
reindex(obj: THREE.Object3D): void;
|
|
183
|
+
|
|
184
|
+
/** Drop every listener this index installed and forget everything it knows.
|
|
185
|
+
* Called from the owning root's teardown; safe to call twice. */
|
|
186
|
+
dispose(): void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readTags(obj: THREE.Object3D): string[] {
|
|
190
|
+
const tags = getUserData(obj, 'tags');
|
|
191
|
+
return Array.isArray(tags) ? tags : [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function readAttributes(obj: THREE.Object3D): Record<string, AttributeValue> | undefined {
|
|
195
|
+
const attributes = getUserData(obj, 'attributes');
|
|
196
|
+
return attributes && typeof attributes === 'object' ? attributes : undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Build a live index over `root` and everything currently under it. One index
|
|
201
|
+
* per three root; the root adapter creates it and exposes it as
|
|
202
|
+
* `ctx.sceneIndex`.
|
|
203
|
+
*/
|
|
204
|
+
export function createSceneIndex(root: THREE.Object3D): SceneIndex {
|
|
205
|
+
const events = new EventTarget();
|
|
206
|
+
// Weak on purpose: the per-object target exists only while something else
|
|
207
|
+
// still references the object. The index keeps NO map from target back to
|
|
208
|
+
// object, so a removed-and-forgotten object can be collected with its target.
|
|
209
|
+
const objectTargets = new WeakMap<THREE.Object3D, EventTarget>();
|
|
210
|
+
|
|
211
|
+
const indexed = new Set<THREE.Object3D>();
|
|
212
|
+
const tagged = new Map<string, Set<THREE.Object3D>>();
|
|
213
|
+
// Arrays, not single values: a project that mints the same entityId twice
|
|
214
|
+
// must not blind `byEntityId` for the survivor when one of them is removed.
|
|
215
|
+
const byId = new Map<string, THREE.Object3D[]>();
|
|
216
|
+
// The tag/id snapshot each object was indexed under, so unindexing (and
|
|
217
|
+
// `reindex`) can undo exactly what indexing did even if `userData` has been
|
|
218
|
+
// rewritten by hand in between.
|
|
219
|
+
const indexedTags = new WeakMap<THREE.Object3D, string[]>();
|
|
220
|
+
const indexedIds = new WeakMap<THREE.Object3D, string>();
|
|
221
|
+
let disposed = false;
|
|
222
|
+
|
|
223
|
+
function emit<K extends SceneIndexEventType>(type: K, detail: SceneIndexEventDetails[K]): void {
|
|
224
|
+
// Two distinct CustomEvent instances: one Event object cannot carry two
|
|
225
|
+
// `target`s, and re-dispatching a dispatched event is a spec footgun.
|
|
226
|
+
events.dispatchEvent(new CustomEvent(type, { detail }));
|
|
227
|
+
const perObject = objectTargets.get(detail.object);
|
|
228
|
+
if (perObject) perObject.dispatchEvent(new CustomEvent(type, { detail }));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function addToTagIndex(obj: THREE.Object3D, tag: string): void {
|
|
232
|
+
let set = tagged.get(tag);
|
|
233
|
+
if (!set) {
|
|
234
|
+
set = new Set();
|
|
235
|
+
tagged.set(tag, set);
|
|
236
|
+
}
|
|
237
|
+
set.add(obj);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function removeFromTagIndex(obj: THREE.Object3D, tag: string): void {
|
|
241
|
+
const set = tagged.get(tag);
|
|
242
|
+
if (!set) return;
|
|
243
|
+
set.delete(obj);
|
|
244
|
+
if (set.size === 0) tagged.delete(tag);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function addToIdIndex(obj: THREE.Object3D, id: string): void {
|
|
248
|
+
const list = byId.get(id);
|
|
249
|
+
if (list) {
|
|
250
|
+
if (!list.includes(obj)) list.push(obj);
|
|
251
|
+
} else {
|
|
252
|
+
byId.set(id, [obj]);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function removeFromIdIndex(obj: THREE.Object3D, id: string): void {
|
|
257
|
+
const list = byId.get(id);
|
|
258
|
+
if (!list) return;
|
|
259
|
+
const at = list.indexOf(obj);
|
|
260
|
+
if (at !== -1) list.splice(at, 1);
|
|
261
|
+
if (list.length === 0) byId.delete(id);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const onChildAdded = (event: { child: THREE.Object3D }): void => {
|
|
265
|
+
indexSubtree(event.child);
|
|
266
|
+
};
|
|
267
|
+
const onChildRemoved = (event: { child: THREE.Object3D }): void => {
|
|
268
|
+
unindexSubtree(event.child);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** Point the tag entries at whatever `userData['tags']` says right now,
|
|
272
|
+
* undoing whatever the object was previously indexed under. */
|
|
273
|
+
function syncTags(obj: THREE.Object3D): void {
|
|
274
|
+
for (const tag of indexedTags.get(obj) ?? []) removeFromTagIndex(obj, tag);
|
|
275
|
+
const tags = [...new Set(readTags(obj))];
|
|
276
|
+
indexedTags.set(obj, tags);
|
|
277
|
+
for (const tag of tags) addToTagIndex(obj, tag);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Same, for the entity-id entry. */
|
|
281
|
+
function syncId(obj: THREE.Object3D): void {
|
|
282
|
+
const previous = indexedIds.get(obj);
|
|
283
|
+
if (previous !== undefined) removeFromIdIndex(obj, previous);
|
|
284
|
+
indexedIds.delete(obj);
|
|
285
|
+
const id = getUserData(obj, 'entityId');
|
|
286
|
+
if (typeof id === 'string' && id !== '') {
|
|
287
|
+
indexedIds.set(obj, id);
|
|
288
|
+
addToIdIndex(obj, id);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function indexOne(obj: THREE.Object3D): void {
|
|
293
|
+
if (indexed.has(obj)) return;
|
|
294
|
+
indexed.add(obj);
|
|
295
|
+
// three's EventDispatcher de-dupes an identical (type, listener) pair, so
|
|
296
|
+
// this is idempotent even if an object somehow arrives twice.
|
|
297
|
+
obj.addEventListener('childadded', onChildAdded);
|
|
298
|
+
obj.addEventListener('childremoved', onChildRemoved);
|
|
299
|
+
syncTags(obj);
|
|
300
|
+
syncId(obj);
|
|
301
|
+
emit('entityadded', { object: obj });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function unindexOne(obj: THREE.Object3D): void {
|
|
305
|
+
if (!indexed.has(obj)) return;
|
|
306
|
+
indexed.delete(obj);
|
|
307
|
+
obj.removeEventListener('childadded', onChildAdded);
|
|
308
|
+
obj.removeEventListener('childremoved', onChildRemoved);
|
|
309
|
+
|
|
310
|
+
for (const tag of indexedTags.get(obj) ?? []) removeFromTagIndex(obj, tag);
|
|
311
|
+
indexedTags.delete(obj);
|
|
312
|
+
const id = indexedIds.get(obj);
|
|
313
|
+
if (id !== undefined) {
|
|
314
|
+
removeFromIdIndex(obj, id);
|
|
315
|
+
indexedIds.delete(obj);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
emit('entityremoved', { object: obj });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function indexSubtree(subtreeRoot: THREE.Object3D): void {
|
|
322
|
+
if (disposed) return;
|
|
323
|
+
subtreeRoot.traverse(indexOne);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function unindexSubtree(subtreeRoot: THREE.Object3D): void {
|
|
327
|
+
if (disposed) return;
|
|
328
|
+
// `traverse` still works on a detached subtree: `remove` only broke the
|
|
329
|
+
// link to the parent, the children below are intact.
|
|
330
|
+
subtreeRoot.traverse(unindexOne);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
indexSubtree(root);
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
events,
|
|
337
|
+
|
|
338
|
+
eventsFor(obj) {
|
|
339
|
+
let target = objectTargets.get(obj);
|
|
340
|
+
if (!target) {
|
|
341
|
+
target = new EventTarget();
|
|
342
|
+
objectTargets.set(obj, target);
|
|
343
|
+
}
|
|
344
|
+
return target;
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
byTag(tag) {
|
|
348
|
+
const set = tagged.get(tag);
|
|
349
|
+
return set ? [...set] : [];
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
byEntityId(id) {
|
|
353
|
+
return byId.get(id)?.[0];
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
has(obj) {
|
|
357
|
+
return indexed.has(obj);
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
addTag(obj, tag) {
|
|
361
|
+
const tags = readTags(obj);
|
|
362
|
+
if (tags.includes(tag)) return;
|
|
363
|
+
setUserData(obj, 'tags', [...tags, tag]);
|
|
364
|
+
if (indexed.has(obj)) {
|
|
365
|
+
indexedTags.set(obj, [...(indexedTags.get(obj) ?? []), tag]);
|
|
366
|
+
addToTagIndex(obj, tag);
|
|
367
|
+
}
|
|
368
|
+
emit('tagadded', { object: obj, tag });
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
removeTag(obj, tag) {
|
|
372
|
+
const tags = readTags(obj);
|
|
373
|
+
if (!tags.includes(tag)) return;
|
|
374
|
+
setUserData(
|
|
375
|
+
obj,
|
|
376
|
+
'tags',
|
|
377
|
+
tags.filter((t) => t !== tag),
|
|
378
|
+
);
|
|
379
|
+
if (indexed.has(obj)) {
|
|
380
|
+
indexedTags.set(
|
|
381
|
+
obj,
|
|
382
|
+
(indexedTags.get(obj) ?? []).filter((t) => t !== tag),
|
|
383
|
+
);
|
|
384
|
+
removeFromTagIndex(obj, tag);
|
|
385
|
+
}
|
|
386
|
+
emit('tagremoved', { object: obj, tag });
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
hasTag(obj, tag) {
|
|
390
|
+
return readTags(obj).includes(tag);
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
getAttribute(obj, name) {
|
|
394
|
+
return readAttributes(obj)?.[name];
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
setAttribute(obj, name, value) {
|
|
398
|
+
const attributes = readAttributes(obj);
|
|
399
|
+
const previous = attributes?.[name];
|
|
400
|
+
if (previous === value) return;
|
|
401
|
+
if (attributes) attributes[name] = value;
|
|
402
|
+
else setUserData(obj, 'attributes', { [name]: value });
|
|
403
|
+
emit('attributechanged', { object: obj, name, value, previous });
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
deleteAttribute(obj, name) {
|
|
407
|
+
const attributes = readAttributes(obj);
|
|
408
|
+
if (!attributes || !(name in attributes)) return;
|
|
409
|
+
const previous = attributes[name];
|
|
410
|
+
delete attributes[name];
|
|
411
|
+
if (Object.keys(attributes).length === 0) deleteUserData(obj, 'attributes');
|
|
412
|
+
emit('attributechanged', { object: obj, name, value: undefined, previous });
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
reindex(obj) {
|
|
416
|
+
if (disposed) return;
|
|
417
|
+
obj.traverse((node) => {
|
|
418
|
+
if (indexed.has(node)) {
|
|
419
|
+
syncTags(node);
|
|
420
|
+
syncId(node);
|
|
421
|
+
} else {
|
|
422
|
+
indexOne(node);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
dispose() {
|
|
428
|
+
if (disposed) return;
|
|
429
|
+
disposed = true;
|
|
430
|
+
for (const obj of indexed) {
|
|
431
|
+
obj.removeEventListener('childadded', onChildAdded);
|
|
432
|
+
obj.removeEventListener('childremoved', onChildRemoved);
|
|
433
|
+
}
|
|
434
|
+
indexed.clear();
|
|
435
|
+
tagged.clear();
|
|
436
|
+
byId.clear();
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|