@scenography/engine 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +718 -257
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +497 -54
- package/dist/index.d.mts +497 -54
- package/dist/index.mjs +710 -256
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,219 +1,373 @@
|
|
|
1
|
-
import {
|
|
2
|
-
//#region src/
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return next;
|
|
12
|
-
};
|
|
13
|
-
//#endregion
|
|
14
|
-
//#region src/controls/headBob.ts
|
|
15
|
-
const createHeadBobState = () => ({
|
|
16
|
-
active: false,
|
|
17
|
-
timer: 0
|
|
18
|
-
});
|
|
19
|
-
/**
|
|
20
|
-
* Advance the head-bob timer by `elapsed` seconds, snapping to half-wavelength
|
|
21
|
-
* steps so the bob always settles back to neutral when movement stops.
|
|
22
|
-
*/
|
|
23
|
-
const updateHeadBob = (state, elapsed) => {
|
|
24
|
-
if (!state.active) return;
|
|
25
|
-
const wavelength = Math.PI;
|
|
26
|
-
const nextStepTime = (1 + Math.floor((state.timer + 1e-6) * 10 / wavelength)) * wavelength / 10;
|
|
27
|
-
state.timer = Math.min(state.timer + elapsed, nextStepTime);
|
|
28
|
-
if (state.timer === nextStepTime) state.active = false;
|
|
29
|
-
};
|
|
30
|
-
/** Vertical offset to add to the camera, in world units. */
|
|
31
|
-
const headBobOffset = (state) => Math.sin(state.timer * 10) * 1.5;
|
|
32
|
-
//#endregion
|
|
33
|
-
//#region src/controls/keyboard.ts
|
|
34
|
-
const createKeyboardState = () => ({});
|
|
35
|
-
const onKeyDown = (state, event) => {
|
|
36
|
-
state[event.code] = true;
|
|
37
|
-
};
|
|
38
|
-
const onKeyUp = (state, event) => {
|
|
39
|
-
state[event.code] = false;
|
|
40
|
-
};
|
|
41
|
-
/**
|
|
42
|
-
* Compute the per-frame translation from WASD input, oriented by the current
|
|
43
|
-
* yaw (`phi`). Holding Shift acts as a speed booster.
|
|
44
|
-
*/
|
|
45
|
-
const movementFromKeyboard = (state, phi, delta) => {
|
|
46
|
-
const has = (code) => state[code] ? 1 : 0;
|
|
47
|
-
const forwardVelocity = has("KeyW") - has("KeyS");
|
|
48
|
-
const strafeVelocity = has("KeyA") - has("KeyD");
|
|
49
|
-
const factor = state["ShiftLeft"] || state["ShiftRight"] ? 30 : 10;
|
|
50
|
-
const yaw = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), phi);
|
|
51
|
-
const forward = new Vector3(0, 0, -10).applyQuaternion(yaw).multiplyScalar(forwardVelocity * delta * factor);
|
|
52
|
-
const left = new Vector3(-10, 0, 0).applyQuaternion(yaw).multiplyScalar(strafeVelocity * delta * factor);
|
|
53
|
-
return forward.add(left);
|
|
54
|
-
};
|
|
55
|
-
//#endregion
|
|
56
|
-
//#region src/controls/mouse.ts
|
|
57
|
-
const PHI_SPEED = 8;
|
|
58
|
-
const THETA_SPEED = 5;
|
|
59
|
-
const createMouseState = () => ({
|
|
60
|
-
dragging: false,
|
|
61
|
-
pos: new Vector2(),
|
|
62
|
-
prevPos: new Vector2(),
|
|
63
|
-
delta: new Vector2()
|
|
64
|
-
});
|
|
65
|
-
const clamp = (x, a, b) => Math.min(Math.max(x, a), b);
|
|
66
|
-
const onMouseDown = (state, event) => {
|
|
67
|
-
state.dragging = event.button === 0;
|
|
68
|
-
};
|
|
69
|
-
const onMouseUp = (state) => {
|
|
70
|
-
state.dragging = false;
|
|
71
|
-
};
|
|
72
|
-
const onMouseMove = (state, event) => {
|
|
73
|
-
state.pos.set(event.pageX - window.innerWidth / 2, event.pageY - window.innerHeight / 2);
|
|
74
|
-
state.delta.subVectors(state.pos, state.prevPos);
|
|
75
|
-
};
|
|
76
|
-
const commitMouseFrame = (state) => {
|
|
77
|
-
state.prevPos.copy(state.pos);
|
|
78
|
-
state.delta.set(0, 0);
|
|
1
|
+
import { BoxGeometry, Clock, EquirectangularReflectionMapping, Group, HalfFloatType, Mesh, MeshBasicMaterial, NoColorSpace, PCFShadowMap, PerspectiveCamera, Quaternion, Raycaster, RepeatWrapping, SRGBColorSpace, Scene, StereoCamera, TextureLoader, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from "three";
|
|
2
|
+
//#region src/environment/EnvironmentController.ts
|
|
3
|
+
/** Load an equirectangular HDRI, picking the loader by extension. */
|
|
4
|
+
const loadHDRI = (src, onLoad) => {
|
|
5
|
+
if (src.toLowerCase().endsWith(".exr")) import("three/examples/jsm/loaders/EXRLoader.js").then(({ EXRLoader }) => {
|
|
6
|
+
new EXRLoader().load(src, onLoad);
|
|
7
|
+
});
|
|
8
|
+
else import("three/examples/jsm/loaders/RGBELoader.js").then(({ RGBELoader }) => {
|
|
9
|
+
new RGBELoader().load(src, onLoad);
|
|
10
|
+
});
|
|
79
11
|
};
|
|
80
12
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
13
|
+
* Owns the scene's HDRI sky / image-based lighting and keeps it in sync with the
|
|
14
|
+
* `Environment` definition. Loads the equirectangular map on demand (its loader
|
|
15
|
+
* ships in its own chunk) and assigns it to `scene.background` and/or
|
|
16
|
+
* `scene.environment`. Reconciles by a JSON hash so hot edits only reload when
|
|
17
|
+
* the definition actually changes.
|
|
84
18
|
*/
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
19
|
+
var EnvironmentController = class {
|
|
20
|
+
scene;
|
|
21
|
+
hash = null;
|
|
22
|
+
texture = null;
|
|
23
|
+
constructor(scene) {
|
|
24
|
+
this.scene = scene;
|
|
25
|
+
}
|
|
26
|
+
reconcile(env) {
|
|
27
|
+
const hash = env ? JSON.stringify(env) : null;
|
|
28
|
+
if (hash === this.hash) return;
|
|
29
|
+
this.hash = hash;
|
|
30
|
+
this.clear();
|
|
31
|
+
if (!env) return;
|
|
32
|
+
loadHDRI(env.src, (tex) => {
|
|
33
|
+
if (this.hash !== hash) {
|
|
34
|
+
tex.dispose();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
tex.mapping = EquirectangularReflectionMapping;
|
|
38
|
+
this.texture = tex;
|
|
39
|
+
if (env.background ?? true) {
|
|
40
|
+
this.scene.background = tex;
|
|
41
|
+
this.scene.backgroundIntensity = env.intensity ?? 1;
|
|
42
|
+
}
|
|
43
|
+
if (env.lighting ?? true) this.scene.environment = tex;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Set the visible sky background brightness (0 = black). Live, for behavior. */
|
|
47
|
+
setBackgroundIntensity(value) {
|
|
48
|
+
this.scene.backgroundIntensity = value;
|
|
49
|
+
}
|
|
50
|
+
clear() {
|
|
51
|
+
this.scene.background = null;
|
|
52
|
+
this.scene.environment = null;
|
|
53
|
+
this.texture?.dispose();
|
|
54
|
+
this.texture = null;
|
|
55
|
+
}
|
|
56
|
+
dispose() {
|
|
57
|
+
this.clear();
|
|
58
|
+
this.hash = null;
|
|
59
|
+
}
|
|
93
60
|
};
|
|
94
61
|
//#endregion
|
|
95
|
-
//#region src/
|
|
62
|
+
//#region src/interaction/InteractionController.ts
|
|
63
|
+
const HIGHLIGHT_COLOR = "#ef4444";
|
|
64
|
+
const HIGHLIGHT_THICKNESS = 1.5;
|
|
65
|
+
const HIGHLIGHT_MARGIN = 1.5;
|
|
66
|
+
const CLICK_SLOP = 4;
|
|
96
67
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* `attach()`, then `update(delta)` once per frame.
|
|
68
|
+
* Pointer picking for artworks: hovering a piece's click target (its label, or
|
|
69
|
+
* the piece itself when it has none) draws a pulsing red frame and shows a
|
|
70
|
+
* pointer cursor; clicking it emits `artwork:select { id }`. Targets are marked
|
|
71
|
+
* in the scene graph (`userData.pickTarget`/`pickInfo`) by the artwork builder,
|
|
72
|
+
* so this just raycasts and walks up to find them. An optional per-id radius
|
|
73
|
+
* gates interaction to when the camera is reasonably close.
|
|
104
74
|
*/
|
|
105
|
-
var
|
|
106
|
-
bounds;
|
|
75
|
+
var InteractionController = class {
|
|
107
76
|
camera;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
77
|
+
canvas;
|
|
78
|
+
scene;
|
|
79
|
+
emit;
|
|
80
|
+
canInteract;
|
|
81
|
+
raycaster = new Raycaster();
|
|
82
|
+
pointer = new Vector2();
|
|
83
|
+
over = false;
|
|
84
|
+
downPos = null;
|
|
85
|
+
radii = /* @__PURE__ */ new Map();
|
|
86
|
+
hovered = null;
|
|
87
|
+
time = 0;
|
|
88
|
+
highlight = new Group();
|
|
89
|
+
highlightMat = null;
|
|
118
90
|
handlers = [];
|
|
119
|
-
constructor(camera,
|
|
91
|
+
constructor(camera, canvas, scene, emit, canInteract) {
|
|
120
92
|
this.camera = camera;
|
|
121
|
-
this.
|
|
122
|
-
this.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
this.
|
|
93
|
+
this.canvas = canvas;
|
|
94
|
+
this.scene = scene;
|
|
95
|
+
this.emit = emit;
|
|
96
|
+
this.canInteract = canInteract;
|
|
97
|
+
this.highlight.visible = false;
|
|
98
|
+
this.scene.add(this.highlight);
|
|
99
|
+
}
|
|
100
|
+
/** Per-artwork max interaction distance (XZ). Absent id = no distance gate. */
|
|
101
|
+
setRadii(radii) {
|
|
102
|
+
this.radii = radii;
|
|
126
103
|
}
|
|
127
104
|
attach() {
|
|
128
|
-
const bind = (
|
|
129
|
-
|
|
105
|
+
const bind = (type, listener) => {
|
|
106
|
+
this.canvas.addEventListener(type, listener);
|
|
130
107
|
this.handlers.push([
|
|
131
|
-
|
|
108
|
+
this.canvas,
|
|
132
109
|
type,
|
|
133
110
|
listener
|
|
134
111
|
]);
|
|
135
112
|
};
|
|
136
|
-
bind(
|
|
137
|
-
bind(
|
|
138
|
-
bind(
|
|
139
|
-
bind(
|
|
140
|
-
|
|
113
|
+
bind("pointermove", (e) => this.onMove(e));
|
|
114
|
+
bind("pointerdown", (e) => this.onDown(e));
|
|
115
|
+
bind("pointerup", (e) => this.onUp(e));
|
|
116
|
+
bind("pointerleave", () => {
|
|
117
|
+
this.over = false;
|
|
118
|
+
});
|
|
141
119
|
}
|
|
142
120
|
detach() {
|
|
143
121
|
for (const [target, type, listener] of this.handlers) target.removeEventListener(type, listener);
|
|
144
122
|
this.handlers.length = 0;
|
|
123
|
+
this.scene.remove(this.highlight);
|
|
124
|
+
this.clearHighlight();
|
|
145
125
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
this.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
126
|
+
setPointer(e) {
|
|
127
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
128
|
+
this.pointer.set((e.clientX - rect.left) / rect.width * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
|
|
129
|
+
this.over = true;
|
|
130
|
+
}
|
|
131
|
+
onMove(e) {
|
|
132
|
+
this.setPointer(e);
|
|
133
|
+
}
|
|
134
|
+
onDown(e) {
|
|
135
|
+
this.downPos = {
|
|
136
|
+
x: e.clientX,
|
|
137
|
+
y: e.clientY
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
onUp(e) {
|
|
141
|
+
const down = this.downPos;
|
|
142
|
+
this.downPos = null;
|
|
143
|
+
if (!down || !this.canInteract()) return;
|
|
144
|
+
if (Math.hypot(e.clientX - down.x, e.clientY - down.y) > CLICK_SLOP) return;
|
|
145
|
+
this.setPointer(e);
|
|
146
|
+
const pick = this.raycast();
|
|
147
|
+
if (pick) this.emit("artwork:select", { id: pick.id });
|
|
148
|
+
}
|
|
149
|
+
/** Resolve the nearest pickable artwork under the pointer, honoring the gate. */
|
|
150
|
+
raycast() {
|
|
151
|
+
this.raycaster.setFromCamera(this.pointer, this.camera);
|
|
152
|
+
const hits = this.raycaster.intersectObjects(this.scene.children, true);
|
|
153
|
+
for (const hit of hits) {
|
|
154
|
+
let node = hit.object;
|
|
155
|
+
while (node && node.userData.pickTarget === void 0) node = node.parent;
|
|
156
|
+
if (!node) continue;
|
|
157
|
+
const id = node.userData.pickTarget;
|
|
158
|
+
const radius = this.radii.get(id);
|
|
159
|
+
if (radius !== void 0) {
|
|
160
|
+
const p = node.getWorldPosition(new Vector3());
|
|
161
|
+
if (Math.hypot(this.camera.position.x - p.x, this.camera.position.z - p.z) > radius) continue;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
id,
|
|
165
|
+
holder: node,
|
|
166
|
+
info: node.userData.pickInfo
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
/** Recompute hover state and animate the highlight. Call once per frame. */
|
|
172
|
+
update(deltaMs) {
|
|
173
|
+
this.time += deltaMs;
|
|
174
|
+
const pick = this.over && this.canInteract() ? this.raycast() : null;
|
|
175
|
+
if ((pick?.id ?? null) !== this.hovered) {
|
|
176
|
+
this.hovered = pick?.id ?? null;
|
|
177
|
+
this.canvas.style.cursor = pick ? "pointer" : "";
|
|
178
|
+
if (pick) this.buildHighlight(pick);
|
|
179
|
+
else this.highlight.visible = false;
|
|
180
|
+
}
|
|
181
|
+
if (this.highlight.visible && this.highlightMat) this.highlightMat.opacity = .45 + .45 * (.5 + .5 * Math.sin(this.time / 320));
|
|
182
|
+
}
|
|
183
|
+
clearHighlight() {
|
|
184
|
+
for (const child of [...this.highlight.children]) {
|
|
185
|
+
this.highlight.remove(child);
|
|
186
|
+
child.geometry?.dispose();
|
|
187
|
+
}
|
|
188
|
+
this.highlightMat?.dispose();
|
|
189
|
+
this.highlightMat = null;
|
|
190
|
+
}
|
|
191
|
+
/** Position a fresh pulsing red frame over the picked target's front face. */
|
|
192
|
+
buildHighlight(pick) {
|
|
193
|
+
this.clearHighlight();
|
|
194
|
+
const mat = new MeshBasicMaterial({
|
|
195
|
+
color: HIGHLIGHT_COLOR,
|
|
196
|
+
transparent: true,
|
|
197
|
+
depthTest: false
|
|
198
|
+
});
|
|
199
|
+
this.highlightMat = mat;
|
|
200
|
+
const w = pick.info.width + 2 * HIGHLIGHT_MARGIN;
|
|
201
|
+
const h = pick.info.height + 2 * HIGHLIGHT_MARGIN;
|
|
202
|
+
const t = HIGHLIGHT_THICKNESS;
|
|
203
|
+
const bars = [
|
|
204
|
+
[
|
|
205
|
+
w + 2 * t,
|
|
206
|
+
t,
|
|
207
|
+
0,
|
|
208
|
+
h / 2 + t / 2
|
|
209
|
+
],
|
|
210
|
+
[
|
|
211
|
+
w + 2 * t,
|
|
212
|
+
t,
|
|
213
|
+
0,
|
|
214
|
+
-(h / 2 + t / 2)
|
|
215
|
+
],
|
|
216
|
+
[
|
|
217
|
+
t,
|
|
218
|
+
h,
|
|
219
|
+
-(w / 2 + t / 2),
|
|
220
|
+
0
|
|
221
|
+
],
|
|
222
|
+
[
|
|
223
|
+
t,
|
|
224
|
+
h,
|
|
225
|
+
w / 2 + t / 2,
|
|
226
|
+
0
|
|
227
|
+
]
|
|
228
|
+
];
|
|
229
|
+
for (const [bw, bh, x, y] of bars) {
|
|
230
|
+
const bar = new Mesh(new BoxGeometry(bw, bh, .5), mat);
|
|
231
|
+
bar.position.set(x, y, 0);
|
|
232
|
+
bar.renderOrder = 999;
|
|
233
|
+
this.highlight.add(bar);
|
|
234
|
+
}
|
|
235
|
+
const pos = pick.holder.getWorldPosition(new Vector3());
|
|
236
|
+
const quat = pick.holder.getWorldQuaternion(new Quaternion());
|
|
237
|
+
const normal = new Vector3(0, 0, 1).applyQuaternion(quat);
|
|
238
|
+
pos.addScaledVector(normal, pick.info.frontOffset);
|
|
239
|
+
this.highlight.position.copy(pos);
|
|
240
|
+
this.highlight.quaternion.copy(quat);
|
|
241
|
+
this.highlight.visible = true;
|
|
159
242
|
}
|
|
160
243
|
};
|
|
161
244
|
//#endregion
|
|
162
|
-
//#region src/
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
245
|
+
//#region src/input/InputBus.ts
|
|
246
|
+
/**
|
|
247
|
+
* The shared analog-input channel: the current movement and look intent, each
|
|
248
|
+
* axis in `-1..1` and held until a producer changes it. Producers (on-screen
|
|
249
|
+
* joysticks in the overlay, a gamepad…) write to it; whatever camera system is
|
|
250
|
+
* active reads it and interprets it its own way — free-roam walks with it, a
|
|
251
|
+
* fixed camera might only use the look axes to glance around. This is what makes
|
|
252
|
+
* the joysticks reusable across camera types. Pure state — no three.js.
|
|
253
|
+
*/
|
|
254
|
+
var InputBus = class {
|
|
255
|
+
/** Forward (`+1`) / backward (`-1`). */
|
|
256
|
+
moveForward = 0;
|
|
257
|
+
/** Strafe right (`+1`) / left (`-1`). */
|
|
258
|
+
moveStrafe = 0;
|
|
259
|
+
/** Look right (`+1`) / left (`-1`). */
|
|
260
|
+
lookX = 0;
|
|
261
|
+
/** Look up (`+1`) / down (`-1`). */
|
|
262
|
+
lookY = 0;
|
|
263
|
+
/** Set the movement intent (forward, strafe). */
|
|
264
|
+
setMove(forward, strafe = 0) {
|
|
265
|
+
this.moveForward = forward;
|
|
266
|
+
this.moveStrafe = strafe;
|
|
267
|
+
}
|
|
268
|
+
/** Set the look intent (turn, pitch). */
|
|
269
|
+
setLook(x, y = 0) {
|
|
270
|
+
this.lookX = x;
|
|
271
|
+
this.lookY = y;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
176
274
|
//#endregion
|
|
177
|
-
//#region src/
|
|
178
|
-
const loader = new TextureLoader();
|
|
179
|
-
const FRAME_MARGIN = 4;
|
|
180
|
-
const SURFACE_OFFSET = 1;
|
|
275
|
+
//#region src/plugin/CapabilityRegistry.ts
|
|
181
276
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
277
|
+
* Shared runtime contracts between plugins, keyed by name. A plugin (or the
|
|
278
|
+
* kernel) *provides* a capability — e.g. the floor provides `terrain` — and
|
|
279
|
+
* other plugins *consume* it if present, without depending on the provider's
|
|
280
|
+
* package. Each name keeps a monotonic version counter so consumers can hash
|
|
281
|
+
* their dependencies cheaply and rebuild when a provider changes.
|
|
185
282
|
*/
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
283
|
+
var CapabilityRegistry = class {
|
|
284
|
+
values = /* @__PURE__ */ new Map();
|
|
285
|
+
versions = /* @__PURE__ */ new Map();
|
|
286
|
+
/** Last change-token seen per name, to suppress redundant version bumps. */
|
|
287
|
+
tokens = /* @__PURE__ */ new Map();
|
|
288
|
+
/**
|
|
289
|
+
* Publish (or replace) the capability under `name`. Pass a `token` (a cheap
|
|
290
|
+
* hash of the inputs) to dedupe: re-providing with an unchanged token is a
|
|
291
|
+
* no-op, so consumers that depend on this capability don't rebuild when the
|
|
292
|
+
* provider recomputes an identical value.
|
|
293
|
+
*/
|
|
294
|
+
provide(name, value, token) {
|
|
295
|
+
if (token !== void 0 && this.values.has(name) && this.tokens.get(name) === token) return;
|
|
296
|
+
this.values.set(name, value);
|
|
297
|
+
if (token !== void 0) this.tokens.set(name, token);
|
|
298
|
+
else this.tokens.delete(name);
|
|
299
|
+
this.bump(name);
|
|
300
|
+
}
|
|
301
|
+
/** Remove the capability; consumers see `undefined` and a version bump. */
|
|
302
|
+
revoke(name) {
|
|
303
|
+
if (this.values.delete(name)) {
|
|
304
|
+
this.tokens.delete(name);
|
|
305
|
+
this.bump(name);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
get(name) {
|
|
309
|
+
return this.values.get(name);
|
|
310
|
+
}
|
|
311
|
+
/** Monotonic change counter for `name` (`0` if never provided). */
|
|
312
|
+
version(name) {
|
|
313
|
+
return this.versions.get(name) ?? 0;
|
|
314
|
+
}
|
|
315
|
+
bump(name) {
|
|
316
|
+
this.versions.set(name, (this.versions.get(name) ?? 0) + 1);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/plugin/systemPlugin.ts
|
|
321
|
+
/** Narrow a mixed plugin to a system plugin (has `setup`, not `build`). */
|
|
322
|
+
const isSystemPlugin = (plugin) => typeof plugin === "object" && plugin !== null && "setup" in plugin;
|
|
323
|
+
//#endregion
|
|
324
|
+
//#region src/plugin/roamControl.ts
|
|
325
|
+
/** Capability name under which a roaming camera system exposes pose control. */
|
|
326
|
+
const ROAM_CONTROL = "roamControl";
|
|
327
|
+
//#endregion
|
|
328
|
+
//#region src/plugin/terrain.ts
|
|
329
|
+
/** Capability name under which the floor publishes its terrain. */
|
|
330
|
+
const TERRAIN = "terrain";
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region src/plugin/colliders.ts
|
|
333
|
+
/** Capability name under which walkable-space obstacles are published. */
|
|
334
|
+
const COLLIDERS = "colliders";
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/plugin/edges.ts
|
|
337
|
+
/** Capability name under which named wall edges are published for hanging. */
|
|
338
|
+
const EDGES = "edges";
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/plugin/eyeHeight.ts
|
|
341
|
+
/**
|
|
342
|
+
* Capability name under which the visitor's eye height (world units) is
|
|
343
|
+
* published — the default vertical placement for wall-hung artworks. The kernel
|
|
344
|
+
* provides it from the camera start; a camera plugin will own it later.
|
|
345
|
+
*/
|
|
346
|
+
const EYE_HEIGHT = "eyeHeight";
|
|
209
347
|
//#endregion
|
|
210
348
|
//#region src/SceneManager.ts
|
|
211
349
|
const disposeObject = (object) => {
|
|
212
350
|
object.traverse((child) => {
|
|
351
|
+
const video = child.userData?.video;
|
|
352
|
+
if (video) {
|
|
353
|
+
video.pause();
|
|
354
|
+
video.removeAttribute("src");
|
|
355
|
+
video.load();
|
|
356
|
+
}
|
|
213
357
|
const mesh = child;
|
|
214
358
|
mesh.geometry?.dispose?.();
|
|
359
|
+
const dispose = (m) => {
|
|
360
|
+
const material = m;
|
|
361
|
+
for (const slot of [
|
|
362
|
+
"map",
|
|
363
|
+
"normalMap",
|
|
364
|
+
"roughnessMap",
|
|
365
|
+
"aoMap",
|
|
366
|
+
"metalnessMap"
|
|
367
|
+
]) (material?.[slot])?.dispose?.();
|
|
368
|
+
material?.dispose?.();
|
|
369
|
+
};
|
|
215
370
|
const material = mesh.material;
|
|
216
|
-
const dispose = (m) => m?.dispose?.();
|
|
217
371
|
if (Array.isArray(material)) material.forEach(dispose);
|
|
218
372
|
else dispose(material);
|
|
219
373
|
});
|
|
@@ -227,82 +381,109 @@ const disposeObject = (object) => {
|
|
|
227
381
|
*/
|
|
228
382
|
var SceneManager = class {
|
|
229
383
|
root;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
384
|
+
plugins;
|
|
385
|
+
capabilities;
|
|
386
|
+
/** Mounted plugin elements, per kind, keyed by element id ("" for singletons). */
|
|
387
|
+
pluginElements = /* @__PURE__ */ new Map();
|
|
388
|
+
constructor(root, plugins = [], capabilities = new CapabilityRegistry()) {
|
|
233
389
|
this.root = root;
|
|
390
|
+
this.plugins = plugins;
|
|
391
|
+
this.capabilities = capabilities;
|
|
234
392
|
}
|
|
235
393
|
reconcile(scene) {
|
|
236
|
-
const
|
|
237
|
-
this.
|
|
238
|
-
this.
|
|
394
|
+
const eye = scene.camera.start.position[1];
|
|
395
|
+
this.capabilities.provide(EYE_HEIGHT, eye, String(eye));
|
|
396
|
+
this.reconcilePlugins(scene);
|
|
239
397
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Reconcile every registered plugin's elements by content: an element
|
|
400
|
+
* rebuilds when its definition changes or when a capability it depends on
|
|
401
|
+
* was re-provided (the capability versions are part of the hash).
|
|
402
|
+
*/
|
|
403
|
+
reconcilePlugins(scene) {
|
|
404
|
+
for (const plugin of this.plugins) {
|
|
405
|
+
const { kind, cardinality } = plugin.data;
|
|
406
|
+
let mounted = this.pluginElements.get(kind);
|
|
407
|
+
if (!mounted) {
|
|
408
|
+
mounted = /* @__PURE__ */ new Map();
|
|
409
|
+
this.pluginElements.set(kind, mounted);
|
|
410
|
+
}
|
|
411
|
+
const raw = scene[kind];
|
|
412
|
+
const items = cardinality === "many" ? Array.isArray(raw) ? raw.map((item, i) => [String(item.id ?? i), item]) : [] : raw !== void 0 ? [["", raw]] : [];
|
|
413
|
+
const deps = (plugin.dependsOn ?? []).map((name) => this.capabilities.version(name));
|
|
414
|
+
const seen = /* @__PURE__ */ new Set();
|
|
415
|
+
for (const [key, value] of items) {
|
|
416
|
+
seen.add(key);
|
|
417
|
+
const hash = JSON.stringify({
|
|
418
|
+
value,
|
|
419
|
+
deps
|
|
420
|
+
});
|
|
421
|
+
const existing = mounted.get(key);
|
|
422
|
+
if (existing?.hash === hash) continue;
|
|
423
|
+
if (existing) this.removePluginElement(mounted, key, existing);
|
|
424
|
+
const built = plugin.build(value, { capabilities: this.capabilities });
|
|
425
|
+
if (built.object) this.root.add(built.object);
|
|
426
|
+
mounted.set(key, {
|
|
427
|
+
...built,
|
|
428
|
+
hash
|
|
429
|
+
});
|
|
254
430
|
}
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
this.walls.set(wall.id, {
|
|
258
|
-
object,
|
|
259
|
-
hash
|
|
260
|
-
});
|
|
431
|
+
for (const [key, entry] of mounted) if (!seen.has(key)) this.removePluginElement(mounted, key, entry);
|
|
432
|
+
plugin.provide?.(items.map(([, value]) => value), { capabilities: this.capabilities });
|
|
261
433
|
}
|
|
262
434
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
for (const [id, entry] of this.artworks) if (!next.has(id)) {
|
|
435
|
+
removePluginElement(mounted, key, entry) {
|
|
436
|
+
if (entry.object) {
|
|
266
437
|
this.root.remove(entry.object);
|
|
267
438
|
disposeObject(entry.object);
|
|
268
|
-
this.artworks.delete(id);
|
|
269
|
-
}
|
|
270
|
-
for (const artwork of artworks) {
|
|
271
|
-
const wall = wallById.get(artwork.wall);
|
|
272
|
-
if (!wall) continue;
|
|
273
|
-
const hash = JSON.stringify({
|
|
274
|
-
a: artwork,
|
|
275
|
-
w: wall
|
|
276
|
-
});
|
|
277
|
-
const existing = this.artworks.get(artwork.id);
|
|
278
|
-
if (existing?.hash === hash) continue;
|
|
279
|
-
if (existing) {
|
|
280
|
-
this.root.remove(existing.object);
|
|
281
|
-
disposeObject(existing.object);
|
|
282
|
-
}
|
|
283
|
-
const object = buildArtwork(artwork, wall);
|
|
284
|
-
this.root.add(object);
|
|
285
|
-
this.artworks.set(artwork.id, {
|
|
286
|
-
object,
|
|
287
|
-
hash
|
|
288
|
-
});
|
|
289
439
|
}
|
|
440
|
+
entry.dispose?.();
|
|
441
|
+
mounted.delete(key);
|
|
442
|
+
}
|
|
443
|
+
/** Advance time-based plugin elements (e.g. water ripple). Call once per frame. */
|
|
444
|
+
updateElements(deltaMs) {
|
|
445
|
+
for (const mounted of this.pluginElements.values()) for (const entry of mounted.values()) entry.update?.(deltaMs);
|
|
290
446
|
}
|
|
291
|
-
/**
|
|
447
|
+
/** Run every element's auxiliary pre-render pass (e.g. water reflection). */
|
|
448
|
+
beforeRender(renderer, scene, camera) {
|
|
449
|
+
for (const mounted of this.pluginElements.values()) for (const entry of mounted.values()) entry.beforeRender?.(renderer, scene, camera);
|
|
450
|
+
}
|
|
451
|
+
/** Look up the live three.js object for any plugin element `id`. */
|
|
292
452
|
get(id) {
|
|
293
|
-
|
|
453
|
+
for (const mounted of this.pluginElements.values()) {
|
|
454
|
+
const object = mounted.get(id)?.object;
|
|
455
|
+
if (object) return object;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/** Live objects of one plugin kind, keyed by `id` — for that plugin's controller. */
|
|
459
|
+
elementsOf(kind) {
|
|
460
|
+
const out = /* @__PURE__ */ new Map();
|
|
461
|
+
const mounted = this.pluginElements.get(kind);
|
|
462
|
+
if (mounted) {
|
|
463
|
+
for (const [id, entry] of mounted) if (entry.object) out.set(id, entry.object);
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
294
466
|
}
|
|
295
467
|
dispose() {
|
|
296
|
-
for (const
|
|
297
|
-
this.
|
|
298
|
-
|
|
468
|
+
for (const [kind, mounted] of this.pluginElements) {
|
|
469
|
+
for (const [key, entry] of mounted) this.removePluginElement(mounted, key, entry);
|
|
470
|
+
this.pluginElements.delete(kind);
|
|
299
471
|
}
|
|
300
|
-
this.walls.clear();
|
|
301
|
-
this.artworks.clear();
|
|
302
472
|
}
|
|
303
473
|
};
|
|
304
474
|
//#endregion
|
|
305
475
|
//#region src/createGallery.ts
|
|
476
|
+
/** Maps a plugin kind/name onto the `ctx` slot its api fills. */
|
|
477
|
+
const CTX_SLOT = {
|
|
478
|
+
camera: "focus",
|
|
479
|
+
lights: "lights",
|
|
480
|
+
video: "videos",
|
|
481
|
+
sky: "sky",
|
|
482
|
+
media: "audio"
|
|
483
|
+
};
|
|
484
|
+
/** The system the cardboard render toggle drives (gyroscope head-look). */
|
|
485
|
+
const CARDBOARD_SYSTEM = "cardboard";
|
|
486
|
+
const DEFAULT_EYE_SEPARATION = 3;
|
|
306
487
|
/**
|
|
307
488
|
* Create and run a gallery from a scene definition. Sets up the three.js
|
|
308
489
|
* renderer, camera, lights and free-roam controls, materializes the scene, and
|
|
@@ -314,35 +495,220 @@ function createGallery(scene, options) {
|
|
|
314
495
|
const height = canvas.clientHeight || window.innerHeight;
|
|
315
496
|
const renderer = new WebGLRenderer({
|
|
316
497
|
canvas,
|
|
317
|
-
antialias:
|
|
498
|
+
antialias: false
|
|
318
499
|
});
|
|
319
500
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
320
501
|
renderer.setSize(width, height, false);
|
|
502
|
+
renderer.shadowMap.type = PCFShadowMap;
|
|
503
|
+
const render = {
|
|
504
|
+
antialias: options.antialias ?? 4,
|
|
505
|
+
ssao: options.ssao ?? false,
|
|
506
|
+
bloom: options.bloom ?? 0,
|
|
507
|
+
shadows: options.shadows ?? true,
|
|
508
|
+
cardboard: options.cardboard ?? false,
|
|
509
|
+
eyeSeparation: options.eyeSeparation ?? DEFAULT_EYE_SEPARATION
|
|
510
|
+
};
|
|
511
|
+
const stereo = new StereoCamera();
|
|
512
|
+
stereo.aspect = .5;
|
|
513
|
+
stereo.eyeSep = render.eyeSeparation;
|
|
514
|
+
const stereoSize = new Vector2();
|
|
321
515
|
const threeScene = new Scene();
|
|
322
|
-
const camera = new PerspectiveCamera(
|
|
323
|
-
const { position
|
|
516
|
+
const camera = new PerspectiveCamera(scene.camera.fov ?? 55, width / height, .1, 5e3);
|
|
517
|
+
const { position } = scene.camera.start;
|
|
324
518
|
camera.position.set(position[0], position[1], position[2]);
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
519
|
+
const allPlugins = options.plugins ?? [];
|
|
520
|
+
const systemPlugins = allPlugins.filter(isSystemPlugin);
|
|
521
|
+
const elementPlugins = allPlugins.filter((p) => !isSystemPlugin(p));
|
|
522
|
+
const capabilities = new CapabilityRegistry();
|
|
523
|
+
const manager = new SceneManager(threeScene, elementPlugins, capabilities);
|
|
328
524
|
manager.reconcile(scene);
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
yaw
|
|
333
|
-
});
|
|
334
|
-
controls.attach();
|
|
525
|
+
const input = new InputBus();
|
|
526
|
+
const environment = new EnvironmentController(threeScene);
|
|
527
|
+
environment.reconcile(scene.environment);
|
|
335
528
|
const listeners = /* @__PURE__ */ new Map();
|
|
529
|
+
const emit = (event, payload) => {
|
|
530
|
+
listeners.get(event)?.forEach((handler) => handler(payload));
|
|
531
|
+
};
|
|
532
|
+
const frameHandlers = /* @__PURE__ */ new Set();
|
|
533
|
+
const apis = /* @__PURE__ */ new Map();
|
|
534
|
+
const controllers = [];
|
|
535
|
+
for (const plugin of elementPlugins) {
|
|
536
|
+
const controller = plugin.controller?.(() => manager.elementsOf(plugin.data.kind));
|
|
537
|
+
if (!controller) continue;
|
|
538
|
+
if (controller.api !== void 0) apis.set(plugin.data.kind, controller.api);
|
|
539
|
+
controllers.push(controller);
|
|
540
|
+
}
|
|
541
|
+
const systems = systemPlugins.map((plugin) => {
|
|
542
|
+
const instance = plugin.setup({
|
|
543
|
+
scene,
|
|
544
|
+
camera,
|
|
545
|
+
canvas,
|
|
546
|
+
scene3d: threeScene,
|
|
547
|
+
renderer,
|
|
548
|
+
input,
|
|
549
|
+
capabilities,
|
|
550
|
+
emit,
|
|
551
|
+
resolve: (id) => manager.get(id)
|
|
552
|
+
});
|
|
553
|
+
if (instance.api !== void 0) apis.set(plugin.name, instance.api);
|
|
554
|
+
return instance;
|
|
555
|
+
});
|
|
556
|
+
const cameraApi = apis.get(CTX_SLOT.camera) ?? {
|
|
557
|
+
focusArtwork: () => {},
|
|
558
|
+
centerArtwork: () => {},
|
|
559
|
+
release: () => {},
|
|
560
|
+
isAnimating: () => false
|
|
561
|
+
};
|
|
562
|
+
const headTracking = apis.get(CARDBOARD_SYSTEM);
|
|
563
|
+
if (options.cardboard) headTracking?.setEnabled(true);
|
|
564
|
+
const lightsApi = apis.get(CTX_SLOT.lights) ?? {
|
|
565
|
+
set: () => {},
|
|
566
|
+
dimAll: () => {},
|
|
567
|
+
restore: () => {}
|
|
568
|
+
};
|
|
569
|
+
const videoApi = apis.get(CTX_SLOT.video) ?? {
|
|
570
|
+
play: () => {},
|
|
571
|
+
pause: () => {},
|
|
572
|
+
setVisible: () => {}
|
|
573
|
+
};
|
|
574
|
+
const skyApi = apis.get(CTX_SLOT.sky) ?? {
|
|
575
|
+
setTime: () => {},
|
|
576
|
+
getTime: () => 12,
|
|
577
|
+
setAzimuth: () => {},
|
|
578
|
+
getAzimuth: () => 0
|
|
579
|
+
};
|
|
580
|
+
const mediaApi = apis.get(CTX_SLOT.media) ?? {
|
|
581
|
+
play: () => {},
|
|
582
|
+
pause: () => {},
|
|
583
|
+
stop: () => {},
|
|
584
|
+
setVolume: () => {},
|
|
585
|
+
playing: () => false,
|
|
586
|
+
setMuted: () => {},
|
|
587
|
+
muted: () => false
|
|
588
|
+
};
|
|
589
|
+
const radiiOf = (s) => {
|
|
590
|
+
const gated = s.artworks ?? [];
|
|
591
|
+
return new Map(gated.filter((a) => a.infoRadius !== void 0).map((a) => [a.id, a.infoRadius]));
|
|
592
|
+
};
|
|
593
|
+
const interaction = new InteractionController(camera, canvas, threeScene, emit, () => !cameraApi.isAnimating());
|
|
594
|
+
interaction.setRadii(radiiOf(scene));
|
|
595
|
+
interaction.attach();
|
|
336
596
|
const clock = new Clock();
|
|
337
597
|
let rafId = null;
|
|
598
|
+
const BLOOM = {
|
|
599
|
+
radius: .4,
|
|
600
|
+
threshold: .85
|
|
601
|
+
};
|
|
602
|
+
let postClasses = null;
|
|
603
|
+
let composer = null;
|
|
604
|
+
let ssaoPass = null;
|
|
605
|
+
let bloomPass = null;
|
|
606
|
+
let composerLoading = false;
|
|
607
|
+
const pixelRatio = Math.min(window.devicePixelRatio, 2);
|
|
608
|
+
const needsComposer = () => render.antialias > 0 || render.ssao || render.bloom > 0;
|
|
609
|
+
const buildComposer = () => {
|
|
610
|
+
if (!postClasses) return;
|
|
611
|
+
composer?.dispose();
|
|
612
|
+
const { EffectComposer, RenderPass, SSAOPass, UnrealBloomPass, OutputPass } = postClasses;
|
|
613
|
+
const c = new EffectComposer(renderer, new WebGLRenderTarget(1, 1, {
|
|
614
|
+
samples: render.antialias,
|
|
615
|
+
type: HalfFloatType
|
|
616
|
+
}));
|
|
617
|
+
c.setPixelRatio(pixelRatio);
|
|
618
|
+
c.setSize(width, height);
|
|
619
|
+
c.addPass(new RenderPass(threeScene, camera));
|
|
620
|
+
const ssao = new SSAOPass(threeScene, camera, width, height);
|
|
621
|
+
ssao.kernelRadius = 16;
|
|
622
|
+
ssao.enabled = render.ssao;
|
|
623
|
+
c.addPass(ssao);
|
|
624
|
+
const bloom = new UnrealBloomPass(new Vector2(width, height), render.bloom, BLOOM.radius, BLOOM.threshold);
|
|
625
|
+
bloom.enabled = render.bloom > 0;
|
|
626
|
+
c.addPass(bloom);
|
|
627
|
+
c.addPass(new OutputPass());
|
|
628
|
+
composer = c;
|
|
629
|
+
ssaoPass = ssao;
|
|
630
|
+
bloomPass = bloom;
|
|
631
|
+
};
|
|
632
|
+
const ensureComposer = () => {
|
|
633
|
+
if (!needsComposer()) return;
|
|
634
|
+
if (postClasses) {
|
|
635
|
+
if (!composer) buildComposer();
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (composerLoading) return;
|
|
639
|
+
composerLoading = true;
|
|
640
|
+
Promise.all([
|
|
641
|
+
import("three/examples/jsm/postprocessing/EffectComposer.js"),
|
|
642
|
+
import("three/examples/jsm/postprocessing/RenderPass.js"),
|
|
643
|
+
import("three/examples/jsm/postprocessing/SSAOPass.js"),
|
|
644
|
+
import("three/examples/jsm/postprocessing/UnrealBloomPass.js"),
|
|
645
|
+
import("three/examples/jsm/postprocessing/OutputPass.js")
|
|
646
|
+
]).then(([{ EffectComposer }, { RenderPass }, { SSAOPass }, { UnrealBloomPass }, { OutputPass }]) => {
|
|
647
|
+
postClasses = {
|
|
648
|
+
EffectComposer,
|
|
649
|
+
RenderPass,
|
|
650
|
+
SSAOPass,
|
|
651
|
+
UnrealBloomPass,
|
|
652
|
+
OutputPass
|
|
653
|
+
};
|
|
654
|
+
composerLoading = false;
|
|
655
|
+
buildComposer();
|
|
656
|
+
});
|
|
657
|
+
};
|
|
658
|
+
const refreshShadows = () => {
|
|
659
|
+
renderer.shadowMap.enabled = render.shadows;
|
|
660
|
+
threeScene.traverse((obj) => {
|
|
661
|
+
const mat = obj.material;
|
|
662
|
+
if (!mat) return;
|
|
663
|
+
for (const m of Array.isArray(mat) ? mat : [mat]) m.needsUpdate = true;
|
|
664
|
+
});
|
|
665
|
+
};
|
|
666
|
+
refreshShadows();
|
|
667
|
+
ensureComposer();
|
|
668
|
+
const renderStereo = () => {
|
|
669
|
+
camera.updateWorldMatrix(true, false);
|
|
670
|
+
stereo.update(camera);
|
|
671
|
+
renderer.getSize(stereoSize);
|
|
672
|
+
const w = stereoSize.x;
|
|
673
|
+
const h = stereoSize.y;
|
|
674
|
+
if (renderer.autoClear) renderer.clear();
|
|
675
|
+
renderer.setScissorTest(true);
|
|
676
|
+
renderer.setScissor(0, 0, w / 2, h);
|
|
677
|
+
renderer.setViewport(0, 0, w / 2, h);
|
|
678
|
+
renderer.render(threeScene, stereo.cameraL);
|
|
679
|
+
renderer.setScissor(w / 2, 0, w / 2, h);
|
|
680
|
+
renderer.setViewport(w / 2, 0, w / 2, h);
|
|
681
|
+
renderer.render(threeScene, stereo.cameraR);
|
|
682
|
+
renderer.setScissorTest(false);
|
|
683
|
+
renderer.setViewport(0, 0, w, h);
|
|
684
|
+
};
|
|
338
685
|
const renderFrame = () => {
|
|
339
686
|
rafId = requestAnimationFrame(renderFrame);
|
|
340
|
-
|
|
341
|
-
|
|
687
|
+
const delta = clock.getDelta();
|
|
688
|
+
const deltaMs = delta * 1e3;
|
|
689
|
+
for (const system of systems) system.update?.(deltaMs);
|
|
690
|
+
interaction.update(deltaMs);
|
|
691
|
+
manager.updateElements(deltaMs);
|
|
692
|
+
for (const controller of controllers) controller.update?.(deltaMs);
|
|
693
|
+
for (const handler of frameHandlers) handler(deltaMs);
|
|
694
|
+
manager.beforeRender(renderer, threeScene, camera);
|
|
695
|
+
if (render.cardboard) renderStereo();
|
|
696
|
+
else if (composer && needsComposer()) composer.render(delta);
|
|
697
|
+
else renderer.render(threeScene, camera);
|
|
342
698
|
};
|
|
343
699
|
return {
|
|
344
700
|
ctx: {
|
|
345
701
|
entity: (id) => manager.get(id),
|
|
702
|
+
lights: lightsApi,
|
|
703
|
+
media: mediaApi,
|
|
704
|
+
controls: {
|
|
705
|
+
setMoveInput: (forward, strafe) => input.setMove(forward, strafe),
|
|
706
|
+
setLookInput: (x, y) => input.setLook(x, y)
|
|
707
|
+
},
|
|
708
|
+
environment: { setBackgroundIntensity: (v) => environment.setBackgroundIntensity(v) },
|
|
709
|
+
sky: skyApi,
|
|
710
|
+
camera: cameraApi,
|
|
711
|
+
video: videoApi,
|
|
346
712
|
three: {
|
|
347
713
|
scene: threeScene,
|
|
348
714
|
camera,
|
|
@@ -351,7 +717,14 @@ function createGallery(scene, options) {
|
|
|
351
717
|
},
|
|
352
718
|
setScene(next) {
|
|
353
719
|
manager.reconcile(next);
|
|
354
|
-
|
|
720
|
+
interaction.setRadii(radiiOf(next));
|
|
721
|
+
environment.reconcile(next.environment);
|
|
722
|
+
for (const system of systems) system.setScene?.(next);
|
|
723
|
+
const fov = next.camera.fov ?? 55;
|
|
724
|
+
if (camera.fov !== fov) {
|
|
725
|
+
camera.fov = fov;
|
|
726
|
+
camera.updateProjectionMatrix();
|
|
727
|
+
}
|
|
355
728
|
},
|
|
356
729
|
start() {
|
|
357
730
|
if (rafId === null) {
|
|
@@ -367,10 +740,15 @@ function createGallery(scene, options) {
|
|
|
367
740
|
},
|
|
368
741
|
dispose() {
|
|
369
742
|
this.stop();
|
|
370
|
-
|
|
743
|
+
for (const system of systems) system.dispose?.();
|
|
744
|
+
for (const controller of controllers) controller.dispose?.();
|
|
745
|
+
interaction.detach();
|
|
746
|
+
environment.dispose();
|
|
371
747
|
manager.dispose();
|
|
748
|
+
composer?.dispose();
|
|
372
749
|
renderer.dispose();
|
|
373
750
|
listeners.clear();
|
|
751
|
+
frameHandlers.clear();
|
|
374
752
|
},
|
|
375
753
|
on(event, handler) {
|
|
376
754
|
const set = listeners.get(event) ?? /* @__PURE__ */ new Set();
|
|
@@ -379,16 +757,92 @@ function createGallery(scene, options) {
|
|
|
379
757
|
return () => set.delete(handler);
|
|
380
758
|
},
|
|
381
759
|
emit(event, payload) {
|
|
382
|
-
|
|
760
|
+
emit(event, payload);
|
|
761
|
+
},
|
|
762
|
+
onFrame(handler) {
|
|
763
|
+
frameHandlers.add(handler);
|
|
764
|
+
return () => frameHandlers.delete(handler);
|
|
383
765
|
},
|
|
384
766
|
resize(w, h) {
|
|
385
767
|
camera.aspect = w / h;
|
|
386
768
|
camera.updateProjectionMatrix();
|
|
387
769
|
renderer.setSize(w, h, false);
|
|
770
|
+
composer?.setSize(w, h);
|
|
771
|
+
ssaoPass?.setSize(w, h);
|
|
772
|
+
bloomPass?.setSize(w, h);
|
|
773
|
+
},
|
|
774
|
+
setRender(next) {
|
|
775
|
+
if (next.shadows !== void 0 && next.shadows !== render.shadows) {
|
|
776
|
+
render.shadows = next.shadows;
|
|
777
|
+
refreshShadows();
|
|
778
|
+
}
|
|
779
|
+
if (next.ssao !== void 0) render.ssao = next.ssao;
|
|
780
|
+
if (next.bloom !== void 0) render.bloom = next.bloom;
|
|
781
|
+
const aaChanged = next.antialias !== void 0 && next.antialias !== render.antialias;
|
|
782
|
+
if (next.antialias !== void 0) render.antialias = next.antialias;
|
|
783
|
+
if (next.cardboard !== void 0 && next.cardboard !== render.cardboard) {
|
|
784
|
+
render.cardboard = next.cardboard;
|
|
785
|
+
headTracking?.setEnabled(next.cardboard);
|
|
786
|
+
}
|
|
787
|
+
if (next.eyeSeparation !== void 0) {
|
|
788
|
+
render.eyeSeparation = next.eyeSeparation;
|
|
789
|
+
stereo.eyeSep = next.eyeSeparation;
|
|
790
|
+
}
|
|
791
|
+
if (ssaoPass) ssaoPass.enabled = render.ssao;
|
|
792
|
+
if (bloomPass) {
|
|
793
|
+
bloomPass.strength = render.bloom;
|
|
794
|
+
bloomPass.enabled = render.bloom > 0;
|
|
795
|
+
}
|
|
796
|
+
if (aaChanged && composer) buildComposer();
|
|
797
|
+
ensureComposer();
|
|
388
798
|
}
|
|
389
799
|
};
|
|
390
800
|
}
|
|
391
801
|
//#endregion
|
|
392
|
-
|
|
802
|
+
//#region src/builders/texture.ts
|
|
803
|
+
const textureLoader = new TextureLoader();
|
|
804
|
+
/** Configure a loaded texture: tiling + color space (sRGB only for color maps). */
|
|
805
|
+
const configure = (tex, repeat, srgb) => {
|
|
806
|
+
tex.wrapS = RepeatWrapping;
|
|
807
|
+
tex.wrapT = RepeatWrapping;
|
|
808
|
+
tex.repeat.set(repeat[0], repeat[1]);
|
|
809
|
+
tex.colorSpace = srgb ? SRGBColorSpace : NoColorSpace;
|
|
810
|
+
tex.needsUpdate = true;
|
|
811
|
+
};
|
|
812
|
+
/**
|
|
813
|
+
* Load one map and assign it to `material[slot]` once ready. `.exr` maps go
|
|
814
|
+
* through EXRLoader, imported on demand so it only ships in the bundle when a
|
|
815
|
+
* scene actually uses an EXR map; everything else uses the image loader.
|
|
816
|
+
*/
|
|
817
|
+
const loadMap = (material, slot, src, repeat, srgb) => {
|
|
818
|
+
const assign = (tex) => {
|
|
819
|
+
configure(tex, repeat, srgb);
|
|
820
|
+
material[slot] = tex;
|
|
821
|
+
material.needsUpdate = true;
|
|
822
|
+
};
|
|
823
|
+
if (src.toLowerCase().endsWith(".exr")) import("three/examples/jsm/loaders/EXRLoader.js").then(({ EXRLoader }) => {
|
|
824
|
+
new EXRLoader().load(src, assign);
|
|
825
|
+
});
|
|
826
|
+
else textureLoader.load(src, assign);
|
|
827
|
+
};
|
|
828
|
+
/**
|
|
829
|
+
* Apply a `SurfaceTexture` to a standard material. Returns whether a second UV
|
|
830
|
+
* set (`uv1`) is needed — three samples `aoMap` from it, so the geometry must
|
|
831
|
+
* provide one.
|
|
832
|
+
*/
|
|
833
|
+
const applyTexture = (material, texture) => {
|
|
834
|
+
const repeat = texture.repeat ?? [1, 1];
|
|
835
|
+
loadMap(material, "map", texture.src, repeat, true);
|
|
836
|
+
if (texture.normal) {
|
|
837
|
+
loadMap(material, "normalMap", texture.normal, repeat, false);
|
|
838
|
+
const s = texture.normalScale ?? 1;
|
|
839
|
+
material.normalScale = new Vector2(s, s);
|
|
840
|
+
}
|
|
841
|
+
if (texture.roughness) loadMap(material, "roughnessMap", texture.roughness, repeat, false);
|
|
842
|
+
if (texture.ao) loadMap(material, "aoMap", texture.ao, repeat, false);
|
|
843
|
+
return texture.ao !== void 0;
|
|
844
|
+
};
|
|
845
|
+
//#endregion
|
|
846
|
+
export { COLLIDERS, CapabilityRegistry, EDGES, EYE_HEIGHT, InputBus, InteractionController, ROAM_CONTROL, SceneManager, TERRAIN, applyTexture, createGallery, isSystemPlugin };
|
|
393
847
|
|
|
394
848
|
//# sourceMappingURL=index.mjs.map
|