entangled-worlds-orbiters-shared 1.5.1 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "1.5.1",
3
+ "version": "2.1.0",
4
4
  "description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -10,6 +10,7 @@
10
10
  "./world": "./src/world/index.js",
11
11
  "./moons": "./src/moons/index.js",
12
12
  "./loaders": "./src/loaders/index.js",
13
+ "./render": "./src/render/index.js",
13
14
  "./core": "./src/core/index.js",
14
15
  "./config": "./src/config/index.js",
15
16
  "./utils": "./src/utils/index.js",
@@ -1,11 +1,5 @@
1
1
  import * as THREE from 'three';
2
2
 
3
- // `hemiRef`/`ambientRef` track the MOST-RECENTLY ensured scene's lights, only so the legacy
4
- // `updateDefaultLights()` (which takes no scene) keeps targeting the active scene. They are NOT a
5
- // "lights already created" cache — see ensureDefaultLights.
6
- let hemiRef = null;
7
- let ambientRef = null;
8
-
9
3
  /**
10
4
  * Ensure a scene has the default hemisphere + ambient lights. Idempotent PER SCENE.
11
5
  *
@@ -29,29 +23,39 @@ export function ensureDefaultLights(scene) {
29
23
  ambient.name = 'ew_AmbientLight';
30
24
  scene.add(ambient);
31
25
  }
32
- // updateDefaultLights() (no scene arg) targets the most-recently ensured scene's lights.
33
- hemiRef = hemi;
34
- ambientRef = ambient;
35
26
  return { hemi, ambient };
36
27
  }
37
28
 
38
- export function updateDefaultLights({ ambientIntensity, diffuseIntensity, lightColor, lightDirection } = {}) {
39
- if (typeof ambientIntensity === 'number' && ambientRef) ambientRef.intensity = Math.max(0, ambientIntensity);
40
- if (hemiRef) {
41
- if (typeof diffuseIntensity === 'number') hemiRef.intensity = Math.max(0, diffuseIntensity);
42
- if (lightColor != null) {
43
- if (lightColor instanceof THREE.Color) {
44
- hemiRef.color.copy(lightColor);
45
- } else if (typeof lightColor === 'number') {
46
- hemiRef.color.setHex(lightColor);
47
- } else if (typeof lightColor === 'string') {
48
- hemiRef.color.set(lightColor);
49
- }
50
- }
51
- if (lightDirection && typeof lightDirection.x === 'number') {
52
- hemiRef.position.set(lightDirection.x, lightDirection.y ?? 0, lightDirection.z ?? 0).normalize();
29
+ /**
30
+ * Apply a lighting patch to ONE scene's default lights.
31
+ *
32
+ * The scene is required. This used to address module-global light refs pointing at whichever scene
33
+ * was ensured last, so on a page rendering several scenes every lighting update landed on one of
34
+ * them — a shared-renderer page (N feed worlds, N orbiters) lit the wrong world.
35
+ *
36
+ * @param {import('three').Scene} scene
37
+ */
38
+ export function updateDefaultLights(scene, { ambientIntensity, diffuseIntensity, lightColor, lightDirection } = {}) {
39
+ if (!scene?.isObject3D) {
40
+ console.error('[lights] updateDefaultLights(scene, patch) requires the scene to update; ignoring patch.');
41
+ return;
42
+ }
43
+ const { hemi, ambient } = ensureDefaultLights(scene);
44
+
45
+ if (typeof ambientIntensity === 'number') ambient.intensity = Math.max(0, ambientIntensity);
46
+ if (typeof diffuseIntensity === 'number') hemi.intensity = Math.max(0, diffuseIntensity);
47
+ if (lightColor != null) {
48
+ if (lightColor instanceof THREE.Color) {
49
+ hemi.color.copy(lightColor);
50
+ } else if (typeof lightColor === 'number') {
51
+ hemi.color.setHex(lightColor);
52
+ } else if (typeof lightColor === 'string') {
53
+ hemi.color.set(lightColor);
53
54
  }
54
55
  }
56
+ if (lightDirection && typeof lightDirection.x === 'number') {
57
+ hemi.position.set(lightDirection.x, lightDirection.y ?? 0, lightDirection.z ?? 0).normalize();
58
+ }
55
59
  }
56
60
 
57
61
  // Back-compat helper
@@ -140,10 +140,14 @@ function resolveUrls(faceKeys) {
140
140
  return resolved;
141
141
  }
142
142
 
143
- // Main loader function - always uses explicit face URLs
144
- export async function loadCubeMap({ faceKeys, skycubeName, preferKTX2 = true, forceLow } = {}) {
145
- const renderer = window.__EW_RENDERER;
146
-
143
+ /**
144
+ * Main loader function - always uses explicit face URLs.
145
+ * `renderer` binds the KTX2 transcoder to the right WebGL context; it falls back to the standalone
146
+ * app's `window.__EW_RENDERER` only transitionally. Pass it explicitly.
147
+ */
148
+ export async function loadCubeMap({ faceKeys, skycubeName, preferKTX2 = true, forceLow, renderer: rendererOpt } = {}) {
149
+ const renderer = rendererOpt ?? (typeof window !== 'undefined' ? window.__EW_RENDERER : null);
150
+
147
151
  // If no face URLs provided, build them from skycube name
148
152
  if (!faceKeys && skycubeName) {
149
153
  const low = typeof forceLow === 'boolean' ? forceLow : isLowPerformanceDevice();
@@ -248,8 +252,8 @@ export function setBackground(scene, resource) {
248
252
  }
249
253
  }
250
254
 
251
- export async function initSpaceScene(scene) {
255
+ export async function initSpaceScene(scene, { renderer } = {}) {
252
256
  const faceKeys = buildSkycubefaceKeys('001');
253
- const cube = await loadCubeMap({ faceKeys, preferKTX2: true });
257
+ const cube = await loadCubeMap({ faceKeys, preferKTX2: true, renderer });
254
258
  setBackground(scene, cube);
255
259
  }
@@ -3,8 +3,14 @@ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
3
3
  import { ensureStandardMaterial, applyFlagsToMaterial } from '../utils/materials.js';
4
4
  import { getKTX2LoaderForRenderer } from './ktx2.js';
5
5
 
6
- export async function loadGLB(url, { retries = 3, signal, onProgress, applyFlags, withCredentials = false } = {}) {
7
- const renderer = window.__EW_RENDERER;
6
+ /**
7
+ * @param {object} [opts]
8
+ * @param {import('three').WebGLRenderer} [opts.renderer] the renderer this GLB's textures bind to.
9
+ * Transitional default: `window.__EW_RENDERER` (the standalone app's publisher). Pass it explicitly
10
+ * — a page with several renderers has no single correct global, and the default goes away with it.
11
+ */
12
+ export async function loadGLB(url, { retries = 3, signal, onProgress, applyFlags, withCredentials = false, renderer: rendererOpt } = {}) {
13
+ const renderer = rendererOpt ?? (typeof window !== 'undefined' ? window.__EW_RENDERER : null);
8
14
  if (!renderer) throw new Error('Renderer not ready for KTX2');
9
15
 
10
16
  let attempt = 0;
@@ -1,4 +1,5 @@
1
1
  // Loaders exports
2
2
  export { loadCubeMap, setBackground, normalizeSkycubeName, buildSkycubefaceKeys } from './cubeMapLoader.js';
3
3
  export { loadGLB } from './glbLoader.js';
4
- export { loadTexture } from './bodyTextureLoader.js';
4
+ export { loadTexture } from './bodyTextureLoader.js';
5
+ export { getKTX2LoaderForRenderer, disposeKTX2LoaderForRenderer } from './ktx2.js';
@@ -25,11 +25,14 @@ function makeLoader(renderer) {
25
25
  * @param {import('three').WebGLRenderer} [renderer]
26
26
  * @returns {KTX2Loader}
27
27
  */
28
- export function getKTX2LoaderForRenderer(
29
- renderer = (typeof window !== 'undefined' ? window.__EW_RENDERER : null),
30
- ) {
28
+ export function getKTX2LoaderForRenderer(rendererArg) {
29
+ // `??` not a default param: callers thread an OPTIONAL renderer down several layers, so `null`
30
+ // arrives as often as `undefined` and must fall back the same way.
31
+ const renderer = rendererArg ?? (typeof window !== 'undefined' ? window.__EW_RENDERER : null);
31
32
  if (!renderer) {
32
- if (!fallbackLoader) fallbackLoader = makeLoader(renderer);
33
+ // No renderer anywhere: an unbound loader can't transcode (detectSupport needs a context), but
34
+ // returning one keeps the caller's error local to the texture instead of throwing mid-scene.
35
+ if (!fallbackLoader) fallbackLoader = new KTX2Loader().setTranscoderPath('/basis/');
33
36
  return fallbackLoader;
34
37
  }
35
38
  let loader = loadersByRenderer.get(renderer);
@@ -40,6 +40,9 @@ export async function generateMoons({
40
40
  // behavior flags
41
41
  preserveInstanceTint = false, // keep per-instance color even when textured
42
42
  useEmissiveMapFromTexture = true, // reuse color texture as emissiveMap to lift dark sides
43
+ // The renderer whose KTX2 transcoder decodes the moon texture. Required on any page with more than
44
+ // one renderer; falls back to the standalone app's global inside the loader.
45
+ renderer,
43
46
  } = {}) {
44
47
  if (count <= 0) return null;
45
48
 
@@ -95,11 +98,11 @@ export async function generateMoons({
95
98
  const fallbackTexture = textureFallback || texturePNG;
96
99
 
97
100
  try {
98
- sharedTexture = await loadTexture(primaryTexture);
101
+ sharedTexture = await loadTexture(primaryTexture, { renderer });
99
102
  } catch (eKTX) {
100
103
  if (debug) console.warn('[Moons] Primary texture failed, trying fallback:', eKTX);
101
104
  try {
102
- sharedTexture = await loadTexture(fallbackTexture);
105
+ sharedTexture = await loadTexture(fallbackTexture, { renderer });
103
106
  } catch (ePNG) {
104
107
  if (debug) console.warn('[Moons] Both primary and fallback texture loading failed');
105
108
  }
@@ -195,15 +198,7 @@ export async function generateMoons({
195
198
  group.add(instanced);
196
199
  group.name = 'moonsGroup';
197
200
 
198
- // Add disposal method for cleanup
199
- group.dispose = () => {
200
- if (moonGeometry) moonGeometry.dispose();
201
- if (moonMaterial.map && moonMaterial.map !== sharedTexture) moonMaterial.map.dispose();
202
- if (moonMaterial.emissiveMap && moonMaterial.emissiveMap !== sharedTexture) moonMaterial.emissiveMap.dispose();
203
- if (moonMaterial.bumpMap && moonMaterial.bumpMap !== sharedTexture) moonMaterial.bumpMap.dispose();
204
- moonMaterial.dispose();
205
- instanced.dispose();
206
- };
207
-
201
+ // Teardown goes through `disposeObject3D` (MoonsManager.dispose / setAmount), which frees the
202
+ // geometry, the material's textures and the instance buffers.
208
203
  return group;
209
204
  }
@@ -3,8 +3,14 @@ import * as THREE from 'three';
3
3
  import { generateMoons } from './moonsGeneration.js';
4
4
 
5
5
  export class MoonsManager {
6
- constructor(scene) {
6
+ /**
7
+ * @param {import('three').Scene} scene
8
+ * @param {object} [opts]
9
+ * @param {import('three').WebGLRenderer} [opts.renderer] renderer the moon texture binds to (KTX2).
10
+ */
11
+ constructor(scene, { renderer } = {}) {
7
12
  this.scene = scene;
13
+ this.renderer = renderer;
8
14
  this.group = null;
9
15
  this._opId = 0; // concurrency guard
10
16
  }
@@ -44,6 +50,7 @@ export class MoonsManager {
44
50
  textureKey, // Pass texture key for custom moon textures
45
51
  textureFallback,
46
52
  moonColor, // Pass moon color to generator
53
+ renderer: this.renderer,
47
54
  });
48
55
  } catch (e) {
49
56
  console.warn('[MoonsManager] Failed to generate moons:', e);
@@ -63,4 +70,16 @@ export class MoonsManager {
63
70
  console.warn('[MoonsManager] generateMoons did not return an Object3D');
64
71
  }
65
72
  }
73
+
74
+ /**
75
+ * Remove the moons from the scene and free their GPU resources. `scene.remove(group)` alone leaks
76
+ * the instanced geometry, materials and the shared moon texture on every view teardown.
77
+ */
78
+ dispose() {
79
+ this._opId++; // abandon any in-flight setAmount
80
+ if (this.group) {
81
+ disposeObject3D(this.group);
82
+ this.group = null;
83
+ }
84
+ }
66
85
  }
@@ -0,0 +1,2 @@
1
+ // Render-host exports
2
+ export { createViewportCompositor } from './viewportCompositor.js';
@@ -0,0 +1,382 @@
1
+ /**
2
+ * @file src/render/viewportCompositor.js
3
+ * @description ORB-128 — a multi-view render host: ONE canvas, ONE `WebGLRenderer`, ONE rAF loop.
4
+ * Each frame it iterates the registered voices and renders each voice's scene into its own cell via
5
+ * `setViewport`/`setScissor` (three's "multiple views, one renderer"). This replaces the N-canvas
6
+ * spike, which hit a hard wall: N canvases = N WebGL contexts, and KTX2/compressed textures only
7
+ * upload to the FIRST context, so only voice-0 ever textured. One context → every voice textures.
8
+ *
9
+ * Each voice's controller runs in `sharedRenderer` mode — it contributes a scene+camera and per-frame
10
+ * callbacks but owns no renderer and no loop; this host drives it through `renderViewport(rect)`.
11
+ *
12
+ * Cell geometry has two sources:
13
+ * - default: the compositor builds its own `⌈√n⌉` square grid (`ensureGrid`).
14
+ * - external: a `cellSource` supplies the cells — real DOM the layout owns, so the compositor
15
+ * renders each voice into its cell's rect and never builds or removes grid DOM.
16
+ *
17
+ * ## Host contract (this module is app-agnostic, but NOT assumption-free)
18
+ * A host adopting it must accept all of the following, or wrap it:
19
+ * - The canvas is appended to `document.body`, `position:fixed;inset:0`, `z-index:40`, opaque black
20
+ * CSS background. The default grid it may build is `#multi-orbiter-grid` at `z-index:41`.
21
+ * - A voice is `{ voiceId, cell, controller }`, where `controller` exposes `renderActive`,
22
+ * `renderViewport(rect)` and `setViewportSize(w, h)`. Rects are CSS px, GL bottom-left origin.
23
+ * - Browser-fullscreen on a registered CELL reparents the shared canvas into that cell and
24
+ * solo-renders that voice. The host must not move the canvas itself.
25
+ * - It owns the realm clear: every frame it re-asserts a fully transparent clear colour.
26
+ *
27
+ * @see `publishRendererGlobal` and `devHandleKey` — the two window globals it can write, both opt-in.
28
+ */
29
+ import * as THREE from 'three';
30
+
31
+ const GRID_ID = 'multi-orbiter-grid';
32
+
33
+ /** Renderer defaults. Overridable via `rendererProfile`; these values are the historical constants. */
34
+ const DEFAULT_RENDERER_PROFILE = {
35
+ antialias: true,
36
+ alpha: true,
37
+ preserveDrawingBuffer: false,
38
+ powerPreference: 'high-performance',
39
+ maxDevicePixelRatio: 1.5,
40
+ };
41
+
42
+ /**
43
+ * @param {object} [opts]
44
+ * @param {{ acquireCell: (index:number, total:number) => HTMLElement, releaseCell?: (index:number) => void } | null} [opts.cellSource]
45
+ * external owner of the cell DOM. When present, `createCell` delegates to it and the compositor
46
+ * builds no grid of its own; when absent, the default `⌈√n⌉` grid is used.
47
+ * @param {Partial<typeof DEFAULT_RENDERER_PROFILE>} [opts.rendererProfile] renderer construction +
48
+ * pixel-ratio cap. Omit for the historical defaults.
49
+ * @param {boolean} [opts.publishRendererGlobal=false] write `window.__EW_RENDERER = renderer`.
50
+ * OPT-IN, and at most ONE compositor per page may set it: it is the fallback the shared loaders read
51
+ * when no renderer is threaded to them, so two realms both publishing would make one realm's loads
52
+ * bind their KTX2 transcoder to the OTHER realm's context (untextured worlds). Consumers that thread
53
+ * the renderer explicitly must leave this off.
54
+ * @param {string|null} [opts.devHandleKey=null] when set, publish the returned api on
55
+ * `window[devHandleKey]` so tooling can force a composited frame while rAF is throttled (a hidden
56
+ * tab during automated checks). Off by default — a library does not name globals for its host.
57
+ * @returns {{
58
+ * renderer: THREE.WebGLRenderer,
59
+ * canvas: HTMLCanvasElement,
60
+ * createCell: (index: number, total: number) => HTMLElement,
61
+ * addVoice: (voice: { voiceId: string, cell: HTMLElement, controller: object }) => void,
62
+ * removeVoice: (voiceId: string) => void,
63
+ * renderOnce: () => void,
64
+ * dispose: () => void,
65
+ * }}
66
+ */
67
+ export function createViewportCompositor({
68
+ cellSource = null,
69
+ rendererProfile = null,
70
+ publishRendererGlobal = false,
71
+ devHandleKey = null,
72
+ } = {}) {
73
+ const profile = { ...DEFAULT_RENDERER_PROFILE, ...(rendererProfile || {}) };
74
+ // ONE full-bleed canvas behind the grid; the grid cells are transparent and only define geometry
75
+ // (and host the per-tile GUI later — G2). The canvas owns the pixels for ALL voices.
76
+ const canvas = document.createElement('canvas');
77
+ canvas.id = 'multi-orbiter-canvas';
78
+ // A <canvas> is a REPLACED element: `inset:0` alone leaves it at its intrinsic 300×150 (unlike a
79
+ // div, it does not stretch to the inset box). Explicit width/height make it fill the viewport so the
80
+ // drawing buffer matches the grid — without this every voice renders into a 300×150 buffer (off-screen).
81
+ canvas.style.cssText =
82
+ 'position:fixed;inset:0;width:100%;height:100%;z-index:40;display:block;background:#000;';
83
+ document.body.appendChild(canvas);
84
+
85
+ const renderer = createRenderer(canvas, profile);
86
+ // The loaders fall back to `window.__EW_RENDERER` when nothing threads a renderer to them. Publishing
87
+ // it lets a realm whose voices still rely on that fallback upload every texture to THIS context.
88
+ // Opt-in, single-writer: see `publishRendererGlobal`.
89
+ if (publishRendererGlobal && typeof window !== 'undefined') {
90
+ window.__EW_RENDERER = renderer;
91
+ }
92
+
93
+ /** voiceId → { cell, controller, lastW, lastH, rect } */
94
+ const voices = new Map();
95
+ let grid = null;
96
+ let raf = null;
97
+ let disposed = false;
98
+ let lastCanvasW = 0;
99
+ let lastCanvasH = 0;
100
+
101
+ // ORB-162: the rAF loop must NOT read layout every frame. `getBoundingClientRect()` / `clientHeight`
102
+ // force a synchronous reflow, and while dragging the camera the per-tile knob re-renders dirty the
103
+ // layout every frame — so N per-frame reads = N reflows = the janky, irregular knob response in multi.
104
+ // Instead we CACHE each cell's rect + the canvas size and refresh only when something actually changes
105
+ // (a cell resizes, the stack scrolls, or the window resizes). A steady drag then reads zero layout.
106
+ let rectsDirty = true;
107
+ const markRectsDirty = () => { rectsDirty = true; };
108
+ const cellObserver =
109
+ typeof ResizeObserver !== 'undefined' ? new ResizeObserver(markRectsDirty) : null;
110
+ // Cells move (without resizing) when the mobile vertical stage stack scrolls — capture-phase catches
111
+ // scrolling of any ancestor container, not just the window. Window resize changes the canvas box too.
112
+ if (typeof window !== 'undefined') {
113
+ window.addEventListener('scroll', markRectsDirty, { passive: true, capture: true });
114
+ window.addEventListener('resize', markRectsDirty);
115
+ }
116
+
117
+ // ORB-190: browser-fullscreen ownership for a voice. The shared canvas lives on `<body>` (fixed,
118
+ // inset:0) and scissor-renders every cell; a cell is a transparent div on top of it. When a voice's
119
+ // header fullscreens ITS cell, the fullscreen "top layer" draws the cell (and its chrome) above an
120
+ // opaque backdrop that HIDES the body-level canvas — so the orbiter's 3D pixels would vanish. Fix:
121
+ // reparent the one shared canvas INTO the fullscreened cell (so it paints inside the top layer) and
122
+ // solo-render that voice full-bleed; restore both on exit. Single-orbiter never builds a compositor,
123
+ // so this path is realm-only. The header only calls `requestFullscreen()` on the cell — this listener
124
+ // owns the canvas move + solo, keeping the compositor the single owner of its canvas.
125
+ let fullscreenVoiceId = null; // voiceId rendered solo full-bleed while fullscreen, else null
126
+ let canvasHome = null; // { parent, next, cssText } to restore the canvas to its realm home on exit
127
+ const fullscreenElement = () =>
128
+ (typeof document !== 'undefined'
129
+ ? document.fullscreenElement || document.webkitFullscreenElement || null
130
+ : null);
131
+ const voiceIdForCell = (el) => {
132
+ if (!el) return null;
133
+ for (const [id, voice] of voices) if (voice.cell === el) return id;
134
+ return null;
135
+ };
136
+ /** Put the canvas back to its realm home (`<body>`) and un-mutate its style. Safe to call when not
137
+ * fullscreen (no-op). `next` may have been detached from `parent` during fullscreen, so fall back
138
+ * to append rather than let `insertBefore` throw NotFoundError. Clears `fullscreenVoiceId`. */
139
+ const restoreCanvasHome = () => {
140
+ if (canvasHome) {
141
+ const { parent, next, cssText } = canvasHome;
142
+ canvas.style.cssText = cssText;
143
+ if (parent) parent.insertBefore(canvas, next && next.parentNode === parent ? next : null);
144
+ canvasHome = null;
145
+ }
146
+ fullscreenVoiceId = null;
147
+ };
148
+ const onFullscreenChange = () => {
149
+ const enteringId = voiceIdForCell(fullscreenElement());
150
+ if (enteringId) {
151
+ if (fullscreenVoiceId === enteringId) return;
152
+ // Direct switch from another fullscreen cell (no intervening exit): restore FIRST so canvasHome
153
+ // captures the true realm home, not the previous cell / already-mutated style.
154
+ if (fullscreenVoiceId) restoreCanvasHome();
155
+ const cell = voices.get(enteringId)?.cell; // enteringId came from a cell match, so this is set
156
+ // Remember where the canvas belongs, then move it into the fullscreened cell. z-index:0 keeps
157
+ // it BEHIND the cell's pointer-events:none chrome host (a positioned, z-auto later sibling), so
158
+ // the buttons still show; it stays position:fixed;inset:0 to fill the now-full-viewport cell.
159
+ canvasHome = { parent: canvas.parentNode, next: canvas.nextSibling, cssText: canvas.style.cssText };
160
+ canvas.style.zIndex = '0';
161
+ cell.insertBefore(canvas, cell.firstChild);
162
+ fullscreenVoiceId = enteringId;
163
+ } else if (fullscreenVoiceId) {
164
+ // Exit (or fullscreen moved off our cells): restore the canvas to its realm home and resume the grid.
165
+ restoreCanvasHome();
166
+ }
167
+ markRectsDirty();
168
+ };
169
+ if (typeof document !== 'undefined') {
170
+ document.addEventListener('fullscreenchange', onFullscreenChange);
171
+ document.addEventListener('webkitfullscreenchange', onFullscreenChange);
172
+ }
173
+
174
+ /** Recompute + cache every voice's GL rect (and the canvas size) in ONE layout pass. Called from the
175
+ * loop only when `rectsDirty`, so the steady state does no forced layout. GL uses a bottom-left origin,
176
+ * so y flips against the (fixed, full-viewport) canvas height. */
177
+ function refreshRects() {
178
+ syncCanvasSize();
179
+ const canvasH = lastCanvasH || canvas.clientHeight || window.innerHeight;
180
+ for (const voice of voices.values()) {
181
+ const box = voice.cell?.getBoundingClientRect();
182
+ voice.rect = box
183
+ ? { x: box.left, y: canvasH - box.bottom, width: box.width, height: box.height }
184
+ : null;
185
+ }
186
+ rectsDirty = false;
187
+ }
188
+
189
+ /** Create-or-get the CSS grid that lays the cells out (square-ish: ⌈√n⌉ columns). */
190
+ function ensureGrid(total) {
191
+ if (grid) return grid;
192
+ const cols = Math.ceil(Math.sqrt(total));
193
+ const rows = Math.ceil(total / cols);
194
+ grid = document.createElement('div');
195
+ grid.id = GRID_ID;
196
+ // Above the canvas, but transparent + click-through: cells exist to define viewport rects (and to
197
+ // anchor per-tile GUI later); the canvas underneath paints every voice.
198
+ grid.style.cssText = [
199
+ 'position:fixed',
200
+ 'inset:0',
201
+ 'z-index:41',
202
+ 'display:grid',
203
+ 'gap:2px',
204
+ 'pointer-events:none',
205
+ `grid-template-columns:repeat(${cols},1fr)`,
206
+ `grid-template-rows:repeat(${rows},1fr)`,
207
+ ].join(';');
208
+ document.body.appendChild(grid);
209
+ return grid;
210
+ }
211
+
212
+ function createCell(index, total) {
213
+ // Collection mode: the slot layout owns the cell DOM (a resizable slot). Borrow its element as this
214
+ // voice's viewport rect + GUI mount — the compositor builds no grid of its own.
215
+ if (cellSource) {
216
+ return cellSource.acquireCell(index, total);
217
+ }
218
+ const cell = document.createElement('div');
219
+ cell.className = 'multi-orbiter-cell';
220
+ cell.dataset.voiceIndex = String(index);
221
+ // ORB-176: the cell is this voice's own interactive input surface (pointer-events:auto), so its
222
+ // CameraController receives its planet drags directly and DOM hit-testing routes clicks to the right
223
+ // tile. The grid CONTAINER stays pointer-events:none, so the 2px inter-tile gaps still click through.
224
+ cell.style.cssText = 'position:relative;overflow:hidden;min-width:0;min-height:0;pointer-events:auto;';
225
+ ensureGrid(total).appendChild(cell);
226
+ return cell;
227
+ }
228
+
229
+ function addVoice({ voiceId, cell, controller }) {
230
+ if (!voiceId || !cell || !controller) return;
231
+ voices.set(voiceId, { cell, controller, lastW: 0, lastH: 0, rect: null });
232
+ cellObserver?.observe(cell); // resize of this tile → recompute its cached rect
233
+ markRectsDirty();
234
+ }
235
+
236
+ function removeVoice(voiceId) {
237
+ const voice = voices.get(voiceId);
238
+ if (!voice) return;
239
+ // ORB-190: if this voice is fullscreen its cell hosts the shared canvas — restore the canvas to its
240
+ // realm home FIRST, so removing the cell below (grid mode) can't take the canvas down with it.
241
+ if (voiceId === fullscreenVoiceId) restoreCanvasHome();
242
+ voices.delete(voiceId);
243
+ if (voice.cell) cellObserver?.unobserve(voice.cell);
244
+ markRectsDirty();
245
+ // Collection mode: the slot layout owns the cell DOM — leave it in place (the layout reclaims or
246
+ // re-fills the slot). Only the compositor-built grid removes its own cells.
247
+ if (cellSource) return;
248
+ voice.cell?.remove();
249
+ if (grid && grid.childElementCount === 0) {
250
+ grid.remove();
251
+ grid = null;
252
+ }
253
+ }
254
+
255
+ /** Keep the renderer's drawing buffer matched to the canvas's CSS box (handles window resize). */
256
+ function syncCanvasSize() {
257
+ const w = canvas.clientWidth || window.innerWidth;
258
+ const h = canvas.clientHeight || window.innerHeight;
259
+ if (!w || !h || (w === lastCanvasW && h === lastCanvasH)) return;
260
+ lastCanvasW = w;
261
+ lastCanvasH = h;
262
+ renderer.setSize(w, h, false);
263
+ applyPixelRatio(renderer, profile);
264
+ }
265
+
266
+ /** Render exactly ONE composited frame (every active voice into its cell). Also the unit the rAF
267
+ * loop runs each tick. Exposed on the dev handle so a paused/hidden tab can force a frame.
268
+ * three's setViewport/setScissor multiply by the renderer's pixelRatio internally, so the cached
269
+ * rects are CSS px — pre-multiplying by dpr would double-scale (correct only at dpr=1, broken on retina). */
270
+ function renderOnce() {
271
+ if (disposed) return;
272
+ // Refresh cached rects/canvas size ONLY when layout changed (never per frame — see refreshRects).
273
+ if (rectsDirty) refreshRects();
274
+
275
+ // Clear the WHOLE buffer once (scissor off) so the gridline gaps and any paused/empty cell show
276
+ // the canvas CSS background, not stale pixels — then each voice scissor-renders into its own
277
+ // rect. The compositor owns the realm clear: re-assert a fully transparent clear color, because
278
+ // a voice's boot can leave an opaque clear on the SHARED renderer, which would paint the entire
279
+ // canvas over an embedding host page (the app's own full-screen canvas hides this — its CSS
280
+ // background is already black).
281
+ renderer.setClearColor(0x000000, 0);
282
+ renderer.setScissorTest(false);
283
+ renderer.clear();
284
+
285
+ const canvasW = lastCanvasW;
286
+ const canvasH = lastCanvasH;
287
+ for (const [voiceId, voice] of voices) {
288
+ // ORB-190: while one voice is browser-fullscreen its cell hosts the shared canvas in the top
289
+ // layer and fills the viewport (rect = whole canvas). Render ONLY that voice — the other cells
290
+ // sit behind the fullscreen backdrop, so scissor-rendering them would stamp stray tiles over it.
291
+ if (fullscreenVoiceId && voiceId !== fullscreenVoiceId) continue;
292
+ const { controller, rect } = voice;
293
+ if (!controller.renderActive || !rect) continue;
294
+ if (rect.width <= 0 || rect.height <= 0) continue;
295
+ // Cull stages scrolled fully off-canvas (the mobile vertical stage stack) — no point scissor-
296
+ // rendering a viewport with no on-screen pixels. rect is GL bottom-left; visible band is [0,canvasH].
297
+ if (rect.y + rect.height <= 0 || rect.y >= canvasH || rect.x + rect.width <= 0 || rect.x >= canvasW) {
298
+ continue;
299
+ }
300
+ // Re-aspect the camera only when the cell's box actually changed (avoids a per-frame projection rebuild).
301
+ if (rect.width !== voice.lastW || rect.height !== voice.lastH) {
302
+ controller.setViewportSize(rect.width, rect.height);
303
+ voice.lastW = rect.width;
304
+ voice.lastH = rect.height;
305
+ }
306
+ controller.renderViewport(rect);
307
+ }
308
+ }
309
+
310
+ function tick() {
311
+ if (disposed) return;
312
+ raf = requestAnimationFrame(tick);
313
+ renderOnce();
314
+ }
315
+
316
+ function dispose() {
317
+ if (disposed) return;
318
+ disposed = true;
319
+ if (raf != null) cancelAnimationFrame(raf);
320
+ raf = null;
321
+ cellObserver?.disconnect();
322
+ if (typeof window !== 'undefined') {
323
+ window.removeEventListener('scroll', markRectsDirty, { capture: true });
324
+ window.removeEventListener('resize', markRectsDirty);
325
+ }
326
+ if (typeof document !== 'undefined') {
327
+ document.removeEventListener('fullscreenchange', onFullscreenChange);
328
+ document.removeEventListener('webkitfullscreenchange', onFullscreenChange);
329
+ }
330
+ // If disposed while a voice was fullscreen, put the canvas style back before `canvas.remove()`.
331
+ restoreCanvasHome();
332
+ voices.clear();
333
+ if (typeof window !== 'undefined') {
334
+ if (window.__EW_RENDERER === renderer) delete window.__EW_RENDERER;
335
+ if (devHandleKey && window[devHandleKey]?.renderer === renderer) {
336
+ delete window[devHandleKey];
337
+ }
338
+ }
339
+ renderer.dispose();
340
+ grid?.remove();
341
+ grid = null;
342
+ canvas.remove();
343
+ }
344
+
345
+ raf = requestAnimationFrame(tick);
346
+
347
+ const api = {
348
+ renderer,
349
+ canvas,
350
+ createCell,
351
+ addVoice,
352
+ removeVoice,
353
+ renderOnce,
354
+ dispose,
355
+ };
356
+ // Dev handle: lets tooling force a composited frame when rAF is throttled (e.g. a hidden/background
357
+ // tab during automated checks). The HOST names it — this module invents no global of its own.
358
+ if (devHandleKey && typeof window !== 'undefined') {
359
+ window[devHandleKey] = api;
360
+ }
361
+ return api;
362
+ }
363
+
364
+ function createRenderer(canvas, profile) {
365
+ const renderer = new THREE.WebGLRenderer({
366
+ canvas,
367
+ antialias: profile.antialias,
368
+ alpha: profile.alpha,
369
+ preserveDrawingBuffer: profile.preserveDrawingBuffer,
370
+ powerPreference: profile.powerPreference,
371
+ });
372
+ applyPixelRatio(renderer, profile);
373
+ renderer.setSize(canvas.clientWidth || window.innerWidth, canvas.clientHeight || window.innerHeight, false);
374
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
375
+ return renderer;
376
+ }
377
+
378
+ function applyPixelRatio(renderer, profile) {
379
+ const dpr =
380
+ typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio) ? window.devicePixelRatio : 1;
381
+ renderer.setPixelRatio(Math.min(dpr, profile.maxDevicePixelRatio));
382
+ }
@@ -30,6 +30,9 @@ export function disposeObject3D(obj) {
30
30
  if (child.isMesh) {
31
31
  if (child.geometry) child.geometry.dispose();
32
32
  disposeMaterial(child.material);
33
+ // An InstancedMesh also owns `instanceMatrix` / `instanceColor` GPU buffers, which hang off the
34
+ // mesh rather than the (shared) geometry — only its own dispose() frees them.
35
+ if (child.isInstancedMesh) child.dispose();
33
36
  }
34
37
  });
35
38
  if (obj.parent) obj.parent.remove(obj);
@@ -1,7 +1,7 @@
1
1
  import { loadGLB } from '../loaders/glbLoader.js';
2
2
 
3
- export async function makeGLB({ modelUrl, flags, retries = 3, withCredentials = false } = {}) {
3
+ export async function makeGLB({ modelUrl, flags, retries = 3, withCredentials = false, renderer } = {}) {
4
4
  if (!modelUrl) throw new Error('modelUrl is required');
5
- const obj = await loadGLB(modelUrl, { retries, applyFlags: flags, withCredentials });
5
+ const obj = await loadGLB(modelUrl, { retries, applyFlags: flags, withCredentials, renderer });
6
6
  return obj;
7
7
  }