entangled-worlds-orbiters-shared 2.0.0 → 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": "2.0.0",
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",
@@ -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
+ }