@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,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RapierPhysicsAdapter — the first-party `PhysicsAdapter` over the existing
|
|
3
|
+
* `PhysicsRegistry` (Object3D ↔ Rapier body/collider). This is the generalized,
|
|
4
|
+
* behind-the-interface form of the old `setEcsSyncTransform` teleport: it lets
|
|
5
|
+
* the editor freeze a body, apply a gizmo edit, and resume — without the editor
|
|
6
|
+
* knowing Rapier exists.
|
|
7
|
+
*
|
|
8
|
+
* The seam is keyed by NODE ID (P-4), so THIS is where the three-specific
|
|
9
|
+
* `id → Object3D` step happens: once, at the implementer's own boundary,
|
|
10
|
+
* through the `resolve` function its constructor is handed. Nothing above this
|
|
11
|
+
* line — not the editor, not the seam — needs to know an `Object3D` exists.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type RAPIER from '@dimforge/rapier3d-compat';
|
|
15
|
+
import type { PhysicsAdapter } from '@volter/editor-project/adapter/system-adapter';
|
|
16
|
+
import type { Transform, TransformOwner } from '@volter/editor-project/adapter/transform';
|
|
17
|
+
import type * as THREE from 'three';
|
|
18
|
+
import type { PhysicsRegistry } from '../physics/physics-registry';
|
|
19
|
+
import type { PhysicsContext } from '../setup/setup-physics';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Exactly what {@link createRapierBodyEditing} touches on a Rapier rigid body,
|
|
23
|
+
* declared STRUCTURALLY rather than as `RAPIER.RigidBody`.
|
|
24
|
+
*
|
|
25
|
+
* Not a wrapper and not an abstraction — it names five of Rapier's own methods
|
|
26
|
+
* with Rapier's own semantics, and every caller keeps passing Rapier's own
|
|
27
|
+
* objects. It exists because this repo legitimately resolves TWO copies of
|
|
28
|
+
* `@dimforge/rapier3d-compat`: the engine's own (0.14) and the one
|
|
29
|
+
* `@react-three/rapier` pins for R3F worlds (0.19). Their `RigidBody` classes
|
|
30
|
+
* are unrelated NOMINAL types, so a shared helper that named either one could
|
|
31
|
+
* not serve both callers.
|
|
32
|
+
*
|
|
33
|
+
* FLIP CONDITION: `@react-three/rapier` pins its rapier EXACTLY (`0.19.2` as
|
|
34
|
+
* of its 2.2.0 — check `npm view @react-three/rapier dependencies`), so an
|
|
35
|
+
* override or engine bump to any OTHER rapier version forks the wasm instance
|
|
36
|
+
* that library initializes against and is refused. This structural split
|
|
37
|
+
* collapses only when the engine's own `@dimforge/rapier3d-compat` and
|
|
38
|
+
* `@react-three/rapier`'s exact pin agree on one version — at that point the
|
|
39
|
+
* two `RigidBody` types are one nominal type again and this interface can
|
|
40
|
+
* name it directly.
|
|
41
|
+
*/
|
|
42
|
+
export interface RapierEditableBody {
|
|
43
|
+
isFixed(): boolean;
|
|
44
|
+
bodyType(): number;
|
|
45
|
+
setBodyType(type: number, wakeUp: boolean): void;
|
|
46
|
+
setTranslation(translation: { x: number; y: number; z: number }, wakeUp: boolean): void;
|
|
47
|
+
setRotation(rotation: { x: number; y: number; z: number; w: number }, wakeUp: boolean): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The four REQUIRED `PhysicsAdapter` members, over nothing but "which Rapier
|
|
52
|
+
* body drives this node" — the `freeze → apply → unfreeze` protocol itself,
|
|
53
|
+
* with no opinion about where the body came from.
|
|
54
|
+
*
|
|
55
|
+
* Split out of {@link createRapierPhysicsAdapter} (it is that function's own
|
|
56
|
+
* `ownerOf`/`freeze`/`commit`/`unfreeze`, unchanged) because the FIRST-PARTY
|
|
57
|
+
* mount is no longer the only Rapier in this engine: an R3F world does its
|
|
58
|
+
* physics with `@react-three/rapier` inside the fiber tree, which owns its own
|
|
59
|
+
* bodies and its own `Object3D` mapping, and reaches these same four verbs
|
|
60
|
+
* through `world3d-react/rapier-physics-bridge.tsx`. The optional members
|
|
61
|
+
* (`debugDraw`, `setDebugDrawEnabled`, `contactPoints`) are NOT here: each is
|
|
62
|
+
* answered differently by each Rapier owner, and a caller that cannot answer
|
|
63
|
+
* one honestly omits it.
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* What asking this Rapier owner about a node id actually yields.
|
|
67
|
+
*
|
|
68
|
+
* THE TWO NEGATIVE ANSWERS ARE DIFFERENT FACTS, and collapsing them into one
|
|
69
|
+
* `undefined` is what let `ownerOf` answer `'editor'` — "the editor drives this
|
|
70
|
+
* node's transform" — for an id this world has never heard of. `'no-body'` is a
|
|
71
|
+
* real measurement about a real node; `'unresolved'` is this implementer saying
|
|
72
|
+
* it cannot answer at all.
|
|
73
|
+
*/
|
|
74
|
+
export type RapierBodyLookup =
|
|
75
|
+
| { readonly kind: 'body'; readonly body: RapierEditableBody }
|
|
76
|
+
/** The node exists here and simply has no physics body. */
|
|
77
|
+
| { readonly kind: 'no-body' }
|
|
78
|
+
/** This world does not know this node id. */
|
|
79
|
+
| { readonly kind: 'unresolved' };
|
|
80
|
+
|
|
81
|
+
/** The refusal sentence for a protocol verb aimed at a node this world has
|
|
82
|
+
* never heard of — named once so every verb says the same thing. */
|
|
83
|
+
function unresolvedNodeRefusal(operation: string, nodeId: string): string {
|
|
84
|
+
return (
|
|
85
|
+
`[RapierPhysicsAdapter] ${operation}: this world does not know node id "${nodeId}", so ` +
|
|
86
|
+
'there is no body to drive. Refusing rather than reporting a completed write — gate on ' +
|
|
87
|
+
"ownerOf(id) !== 'unresolved' before driving physics from a game-scoped adapter."
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createRapierBodyEditing(
|
|
92
|
+
lookup: (nodeId: string) => RapierBodyLookup,
|
|
93
|
+
): Pick<PhysicsAdapter, 'ownerOf' | 'freeze' | 'commit' | 'unfreeze'> {
|
|
94
|
+
/** Saved body types while frozen, so unfreeze can restore them. */
|
|
95
|
+
const frozen = new Map<RapierEditableBody, number>();
|
|
96
|
+
|
|
97
|
+
/** The body for a verb that must actually reach one, or `null` when this node
|
|
98
|
+
* genuinely has no physics. THROWS for an unknown id: the three verbs below
|
|
99
|
+
* return `void`, so a silent return is indistinguishable from a completed
|
|
100
|
+
* write, and `commit` in particular is a WRITE the caller believes landed. */
|
|
101
|
+
const bodyForWrite = (operation: string, nodeId: string): RapierEditableBody | null => {
|
|
102
|
+
const answer = lookup(nodeId);
|
|
103
|
+
if (answer.kind === 'unresolved') throw new Error(unresolvedNodeRefusal(operation, nodeId));
|
|
104
|
+
return answer.kind === 'body' ? answer.body : null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
ownerOf(nodeId: string): TransformOwner {
|
|
109
|
+
const answer = lookup(nodeId);
|
|
110
|
+
if (answer.kind === 'unresolved') return 'unresolved';
|
|
111
|
+
// A node with no body, and a FIXED body (which never moves on its own),
|
|
112
|
+
// are both transforms the editor drives.
|
|
113
|
+
if (answer.kind === 'no-body') return 'editor';
|
|
114
|
+
return answer.body.isFixed() ? 'editor' : 'physics';
|
|
115
|
+
},
|
|
116
|
+
freeze(nodeId: string): void {
|
|
117
|
+
const body = bodyForWrite('freeze', nodeId);
|
|
118
|
+
if (!body || frozen.has(body)) return;
|
|
119
|
+
frozen.set(body, body.bodyType());
|
|
120
|
+
// Kinematic-position: the body stops simulating but tracks the pose we set.
|
|
121
|
+
body.setBodyType(2 /* KinematicPositionBased */, true);
|
|
122
|
+
},
|
|
123
|
+
commit(nodeId: string, t: Transform): void {
|
|
124
|
+
const body = bodyForWrite('commit', nodeId);
|
|
125
|
+
if (!body) return;
|
|
126
|
+
body.setTranslation({ x: t.position[0], y: t.position[1], z: t.position[2] }, true);
|
|
127
|
+
body.setRotation(
|
|
128
|
+
{ x: t.rotation[0], y: t.rotation[1], z: t.rotation[2], w: t.rotation[3] },
|
|
129
|
+
true,
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
unfreeze(nodeId: string): void {
|
|
133
|
+
const body = bodyForWrite('unfreeze', nodeId);
|
|
134
|
+
if (!body) return;
|
|
135
|
+
const prev = frozen.get(body);
|
|
136
|
+
if (prev !== undefined) {
|
|
137
|
+
body.setBodyType(prev, true);
|
|
138
|
+
frozen.delete(body);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createRapierPhysicsAdapter(
|
|
145
|
+
registry: PhysicsRegistry,
|
|
146
|
+
physics: PhysicsContext | null | undefined,
|
|
147
|
+
/**
|
|
148
|
+
* The world's own `id → Object3D` map. Injected rather than derived here so
|
|
149
|
+
* this file never hard-codes an identity convention (e.g. reading the
|
|
150
|
+
* first-party `userData.entityId` key off foreign objects, which is the
|
|
151
|
+
* ingest-stamping leak the retirement playbook deliberately parked).
|
|
152
|
+
*/
|
|
153
|
+
resolve: (nodeId: string) => THREE.Object3D | null,
|
|
154
|
+
): PhysicsAdapter {
|
|
155
|
+
/**
|
|
156
|
+
* Reused backing store for `contactPoints` (grown ×2 on demand, never
|
|
157
|
+
* shrunk) — the seam contract says the returned view is only valid until
|
|
158
|
+
* the next call, exactly so this never allocates per frame once warm.
|
|
159
|
+
*/
|
|
160
|
+
let contactBuf = new Float32Array(64 * 3);
|
|
161
|
+
let contactCount = 0;
|
|
162
|
+
|
|
163
|
+
const pushContact = (x: number, y: number, z: number): void => {
|
|
164
|
+
if ((contactCount + 1) * 3 > contactBuf.length) {
|
|
165
|
+
const grown = new Float32Array(contactBuf.length * 2);
|
|
166
|
+
grown.set(contactBuf);
|
|
167
|
+
contactBuf = grown;
|
|
168
|
+
}
|
|
169
|
+
contactBuf[contactCount * 3] = x;
|
|
170
|
+
contactBuf[contactCount * 3 + 1] = y;
|
|
171
|
+
contactBuf[contactCount * 3 + 2] = z;
|
|
172
|
+
contactCount++;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const appendManifoldContacts = (manifold: RAPIER.TempContactManifold): void => {
|
|
176
|
+
const count = manifold.numSolverContacts();
|
|
177
|
+
for (let i = 0; i < count; i++) {
|
|
178
|
+
// Solver contacts are WORLD-space (unlike localContactPoint1/2).
|
|
179
|
+
const p = manifold.solverContactPoint(i);
|
|
180
|
+
if (p) pushContact(p.x, p.y, p.z);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const collectContactPoints = (): Float32Array => {
|
|
185
|
+
if (!physics) return contactBuf.subarray(0, 0);
|
|
186
|
+
const world = physics.rapierWorld;
|
|
187
|
+
contactCount = 0;
|
|
188
|
+
for (const [, refs] of registry.entries()) {
|
|
189
|
+
world.contactPairsWith(refs.collider, (other) => {
|
|
190
|
+
// Each touching pair is enumerated from BOTH sides — process it only
|
|
191
|
+
// from the lower-handle side (also skips self).
|
|
192
|
+
if (other.handle <= refs.collider.handle) return;
|
|
193
|
+
world.contactPair(refs.collider, other, appendManifoldContacts);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return contactBuf.subarray(0, contactCount * 3);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** The one place this adapter crosses from node id into Three's vocabulary —
|
|
200
|
+
* and the one place that can tell an UNKNOWN node from one with no body. */
|
|
201
|
+
const lookup = (nodeId: string): RapierBodyLookup => {
|
|
202
|
+
const object = resolve(nodeId);
|
|
203
|
+
if (!object) return { kind: 'unresolved' };
|
|
204
|
+
const body = registry.get(object)?.body;
|
|
205
|
+
return body ? { kind: 'body', body } : { kind: 'no-body' };
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const adapter: PhysicsAdapter = { ...createRapierBodyEditing(lookup) };
|
|
209
|
+
// The debug-draw and contact capabilities exist only where a physics CONTEXT
|
|
210
|
+
// does. They used to be attached unconditionally and then no-op internally:
|
|
211
|
+
// `setDebugDrawEnabled(true)` returned as if it had enabled something, and
|
|
212
|
+
// `contactPoints()` returned an empty view — "we looked and there are no
|
|
213
|
+
// contacts" — for a world with no simulation at all. Presence mirrors the
|
|
214
|
+
// capability, so `physics-debug.ts`'s existing `?.()` calls now see absence.
|
|
215
|
+
if (physics) {
|
|
216
|
+
adapter.debugDraw = () => physics.debugMesh;
|
|
217
|
+
adapter.setDebugDrawEnabled = (enabled: boolean): void => {
|
|
218
|
+
// Same two fields the in-game KeyP toggle flips — the adapter's render
|
|
219
|
+
// system reads `debugEnabled` and feeds `debugRender()` into the mesh.
|
|
220
|
+
physics.debugEnabled = enabled;
|
|
221
|
+
physics.debugMesh.visible = enabled;
|
|
222
|
+
};
|
|
223
|
+
adapter.contactPoints = collectContactPoints;
|
|
224
|
+
}
|
|
225
|
+
return adapter;
|
|
226
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format-neutral live reflection-probe mark.
|
|
3
|
+
*
|
|
4
|
+
* A project-owned scene component writes this mark onto the Object3D it
|
|
5
|
+
* returns. The renderer capability owns capture and material integration; the
|
|
6
|
+
* editor only reads the mark to draw ordinary probe gizmos and expose the
|
|
7
|
+
* capture command/status. Authored truth remains the component's JSX props —
|
|
8
|
+
* this is a live adapter seam, not another document format.
|
|
9
|
+
*/
|
|
10
|
+
import type * as THREE from 'three';
|
|
11
|
+
import { deleteUserData, getUserData, setUserData } from '../ecs/user-data';
|
|
12
|
+
|
|
13
|
+
export type ReflectionProbeShape = 'box' | 'sphere';
|
|
14
|
+
export type ReflectionProbeCaptureMode = 'on-change' | 'manual' | 'realtime';
|
|
15
|
+
export type ReflectionProbeCaptureStatus = 'idle' | 'queued' | 'capturing' | 'ready' | 'error';
|
|
16
|
+
|
|
17
|
+
export interface ReflectionProbeConfig {
|
|
18
|
+
readonly shape: ReflectionProbeShape;
|
|
19
|
+
/** Full local-space box dimensions. */
|
|
20
|
+
readonly size: readonly [number, number, number];
|
|
21
|
+
/** Local-space sphere radius when `shape === 'sphere'`. */
|
|
22
|
+
readonly radius: number;
|
|
23
|
+
/** Local-space distance over which this probe fades at its boundary. */
|
|
24
|
+
readonly blendDistance: number;
|
|
25
|
+
/** Larger values win before equal-priority probes blend. */
|
|
26
|
+
readonly priority: number;
|
|
27
|
+
readonly intensity: number;
|
|
28
|
+
readonly parallaxProjection: boolean;
|
|
29
|
+
/** Full local-space projection-box dimensions. */
|
|
30
|
+
readonly parallaxSize: readonly [number, number, number];
|
|
31
|
+
readonly parallaxOffset: readonly [number, number, number];
|
|
32
|
+
readonly captureOffset: readonly [number, number, number];
|
|
33
|
+
readonly captureMode: ReflectionProbeCaptureMode;
|
|
34
|
+
readonly resolution: number;
|
|
35
|
+
readonly near: number;
|
|
36
|
+
readonly far: number;
|
|
37
|
+
readonly cullMask: number;
|
|
38
|
+
readonly captureShadows: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ReflectionProbeSnapshot {
|
|
42
|
+
readonly status: ReflectionProbeCaptureStatus;
|
|
43
|
+
/** Monotonic browser time from `performance.now()`, or null before capture. */
|
|
44
|
+
readonly lastCapturedAt: number | null;
|
|
45
|
+
readonly message?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ReflectionProbeMark {
|
|
49
|
+
readonly config: ReflectionProbeConfig;
|
|
50
|
+
readonly revision: number;
|
|
51
|
+
recapture(): void;
|
|
52
|
+
getSnapshot(): ReflectionProbeSnapshot;
|
|
53
|
+
subscribe(listener: () => void): () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function reflectionProbeOf(
|
|
57
|
+
object: THREE.Object3D | null | undefined,
|
|
58
|
+
): ReflectionProbeMark | null {
|
|
59
|
+
const candidate = getUserData(object, 'reflectionProbe');
|
|
60
|
+
if (!candidate || typeof candidate !== 'object') return null;
|
|
61
|
+
const mark = candidate as Partial<ReflectionProbeMark>;
|
|
62
|
+
return mark.config &&
|
|
63
|
+
typeof mark.recapture === 'function' &&
|
|
64
|
+
typeof mark.getSnapshot === 'function'
|
|
65
|
+
? (candidate as ReflectionProbeMark)
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function setReflectionProbeMark(object: THREE.Object3D, mark: ReflectionProbeMark): void {
|
|
70
|
+
setUserData(object, 'reflectionProbe', mark);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function clearReflectionProbeMark(object: THREE.Object3D): void {
|
|
74
|
+
deleteUserData(object, 'reflectionProbe');
|
|
75
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `applyWorldRendererConfig` — the three.js half of the renderer-config seam.
|
|
3
|
+
*
|
|
4
|
+
* The SHAPE is the project contract's (`@volter/editor-project/adapter/renderer-config`,
|
|
5
|
+
* whose header states the rule and the three load-bearing properties); this
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
WorldOutputColorSpace,
|
|
14
|
+
WorldRendererConfig,
|
|
15
|
+
WorldShadowMapType,
|
|
16
|
+
WorldToneMapping,
|
|
17
|
+
} from '@volter/editor-project/adapter/renderer-config';
|
|
18
|
+
import type * as THREE from 'three';
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
WorldOutputColorSpace,
|
|
22
|
+
WorldRendererConfig,
|
|
23
|
+
WorldShadowMapType,
|
|
24
|
+
WorldToneMapping,
|
|
25
|
+
} from '@volter/editor-project/adapter/renderer-config';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Apply `config` to `renderer`, returning the restore function that puts back what was there.
|
|
29
|
+
*
|
|
30
|
+
* `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.
|
|
32
|
+
*/
|
|
33
|
+
export function applyWorldRendererConfig(
|
|
34
|
+
three: typeof THREE,
|
|
35
|
+
renderer: THREE.WebGLRenderer,
|
|
36
|
+
config: WorldRendererConfig,
|
|
37
|
+
): () => void {
|
|
38
|
+
// A host that mounts a world WITHOUT rasterizing it hands the adapter a duck-typed renderer —
|
|
39
|
+
// the editor's design session (`createDesignTimeRenderer`: four members, deliberately never
|
|
40
|
+
// widened) and jsdom test harnesses both do. Such a surface has no colour pipeline to configure:
|
|
41
|
+
// the frame the user sees is drawn by a DIFFERENT renderer (the editor's own), so applying the
|
|
42
|
+
// world's config there is meaningless — and calling `getClearColor` on it is a TypeError that
|
|
43
|
+
// unmounts the whole world at edit time (measured: every Godot port's edit viewport blanked with
|
|
44
|
+
// '"world" failed to mount — renderer.getClearColor is not a function'). Detect the real
|
|
45
|
+
// `WebGLRenderer` surface by the one method this function must call, and no-op otherwise.
|
|
46
|
+
if (typeof renderer.getClearColor !== 'function') {
|
|
47
|
+
return () => {};
|
|
48
|
+
}
|
|
49
|
+
const toneMappings: Record<WorldToneMapping, THREE.ToneMapping> = {
|
|
50
|
+
none: three.NoToneMapping,
|
|
51
|
+
linear: three.LinearToneMapping,
|
|
52
|
+
reinhard: three.ReinhardToneMapping,
|
|
53
|
+
cineon: three.CineonToneMapping,
|
|
54
|
+
aces: three.ACESFilmicToneMapping,
|
|
55
|
+
agx: three.AgXToneMapping,
|
|
56
|
+
neutral: three.NeutralToneMapping,
|
|
57
|
+
};
|
|
58
|
+
const colorSpaces: Record<WorldOutputColorSpace, THREE.ColorSpace> = {
|
|
59
|
+
srgb: three.SRGBColorSpace,
|
|
60
|
+
'srgb-linear': three.LinearSRGBColorSpace,
|
|
61
|
+
};
|
|
62
|
+
const shadowMapTypes: Record<WorldShadowMapType, THREE.ShadowMapType> = {
|
|
63
|
+
basic: three.BasicShadowMap,
|
|
64
|
+
pcf: three.PCFShadowMap,
|
|
65
|
+
'pcf-soft': three.PCFSoftShadowMap,
|
|
66
|
+
vsm: three.VSMShadowMap,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const restores: (() => void)[] = [];
|
|
70
|
+
|
|
71
|
+
if (config.toneMapping !== undefined) {
|
|
72
|
+
const previous = renderer.toneMapping;
|
|
73
|
+
renderer.toneMapping = toneMappings[config.toneMapping];
|
|
74
|
+
restores.push(() => {
|
|
75
|
+
renderer.toneMapping = previous;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (config.toneMappingExposure !== undefined) {
|
|
79
|
+
const previous = renderer.toneMappingExposure;
|
|
80
|
+
renderer.toneMappingExposure = config.toneMappingExposure;
|
|
81
|
+
restores.push(() => {
|
|
82
|
+
renderer.toneMappingExposure = previous;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (config.outputColorSpace !== undefined) {
|
|
86
|
+
const previous = renderer.outputColorSpace;
|
|
87
|
+
renderer.outputColorSpace = colorSpaces[config.outputColorSpace];
|
|
88
|
+
restores.push(() => {
|
|
89
|
+
renderer.outputColorSpace = previous;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (config.shadowMapType !== undefined && renderer.shadowMap !== undefined) {
|
|
93
|
+
const previous = renderer.shadowMap.type;
|
|
94
|
+
renderer.shadowMap.type = shadowMapTypes[config.shadowMapType];
|
|
95
|
+
renderer.shadowMap.needsUpdate = true;
|
|
96
|
+
restores.push(() => {
|
|
97
|
+
renderer.shadowMap.type = previous;
|
|
98
|
+
renderer.shadowMap.needsUpdate = true;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (config.clearColor !== undefined) {
|
|
102
|
+
const previousColor = new three.Color();
|
|
103
|
+
renderer.getClearColor(previousColor);
|
|
104
|
+
// Alpha is READ BACK and re-passed, never assumed: see `clearColor`'s doc above.
|
|
105
|
+
const alpha = renderer.getClearAlpha();
|
|
106
|
+
renderer.setClearColor(new three.Color(config.clearColor), alpha);
|
|
107
|
+
restores.push(() => {
|
|
108
|
+
renderer.setClearColor(previousColor, alpha);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
// Reverse order, so a field written twice (it cannot be, today) unwinds correctly.
|
|
114
|
+
for (let i = restores.length - 1; i >= 0; i--) restores[i]?.();
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format-neutral live trigger-volume mark.
|
|
3
|
+
*
|
|
4
|
+
* A project writes plain `userData.triggerVolume = { radius }` on the node that
|
|
5
|
+
* IS the volume — in R3F that is an ordinary `userData` prop on the group, with
|
|
6
|
+
* no engine import and no question asked about whether the game is being
|
|
7
|
+
* authored. The mark exists so the editor can DRAW what such a node is: a
|
|
8
|
+
* radius around a point decides where a hazard fires or a quest arrives, and
|
|
9
|
+
* without a gizmo the hierarchy lists the node while the viewport shows nothing
|
|
10
|
+
* there. This is a live adapter seam, never another document format — the
|
|
11
|
+
* game's own hit test owns the semantics, and the editor reads the radius only
|
|
12
|
+
* to draw it.
|
|
13
|
+
*/
|
|
14
|
+
import type * as THREE from 'three';
|
|
15
|
+
import { getUserData } from '../ecs/user-data';
|
|
16
|
+
|
|
17
|
+
export interface TriggerVolumeMark {
|
|
18
|
+
/** Local-space radius of the volume, in world units. */
|
|
19
|
+
readonly radius: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function triggerVolumeOf(
|
|
23
|
+
object: THREE.Object3D | null | undefined,
|
|
24
|
+
): TriggerVolumeMark | null {
|
|
25
|
+
const candidate = getUserData(object, 'triggerVolume');
|
|
26
|
+
if (!candidate || typeof candidate !== 'object') return null;
|
|
27
|
+
const { radius } = candidate as Partial<TriggerVolumeMark>;
|
|
28
|
+
return typeof radius === 'number' && Number.isFinite(radius) && radius > 0 ? { radius } : null;
|
|
29
|
+
}
|