entangled-worlds-orbiters-shared 2.2.0 → 2.4.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.
@@ -1,5 +1,5 @@
1
- // BeatTimeline — leaderless shared beat-timeline sync (conceptually based on Ableton Link). Design doc
2
- // planning/shared-timeline/ORB-102-beat-timeline-sync.md. Replaces the authority/conductor sync model.
1
+ // BeatTimeline — leaderless shared beat-timeline sync (conceptually based on Ableton Link). Design doc:
2
+ // see this package's clock design docs. Replaces the authority/conductor sync model.
3
3
  //
4
4
  // What's shared: a BEAT TIMELINE (tempo + a beat<->sessionTime bijection). NOT a transport position.
5
5
  // - Tempo is LEADERLESS: any peer proposes; each adopts the newest proposal (last-writer-wins);
@@ -2,7 +2,7 @@
2
2
  // (downbeat) time from the loosely-typed metadata both apps already carry. Framework-free, no three,
3
3
  // no react: it walks a fixed precedence (owner setting → per-user setting → auto-detected analysis)
4
4
  // and returns the first finite value, so a consumer can feed `new MusicalProjection({ tempoMap: [{
5
- // startSec: gridSec, bpm }] })`. Promoted out of the orbiters app (ORB-137) so EW reuses it verbatim.
5
+ // startSec: gridSec, bpm }] })`. Promoted out of the orbiters app so EW reuses it verbatim.
6
6
  function toPositiveNumber(value) {
7
7
  const numeric = Number(value);
8
8
  return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "2.2.0",
3
+ "version": "2.4.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",
package/src/clock/sync.ts CHANGED
@@ -1,5 +1,5 @@
1
- // BeatTimeline — leaderless shared beat-timeline sync (conceptually based on Ableton Link). Design doc
2
- // planning/shared-timeline/ORB-102-beat-timeline-sync.md. Replaces the authority/conductor sync model.
1
+ // BeatTimeline — leaderless shared beat-timeline sync (conceptually based on Ableton Link). Design doc:
2
+ // see this package's clock design docs. Replaces the authority/conductor sync model.
3
3
  //
4
4
  // What's shared: a BEAT TIMELINE (tempo + a beat<->sessionTime bijection). NOT a transport position.
5
5
  // - Tempo is LEADERLESS: any peer proposes; each adopts the newest proposal (last-writer-wins);
@@ -2,7 +2,7 @@
2
2
  // (downbeat) time from the loosely-typed metadata both apps already carry. Framework-free, no three,
3
3
  // no react: it walks a fixed precedence (owner setting → per-user setting → auto-detected analysis)
4
4
  // and returns the first finite value, so a consumer can feed `new MusicalProjection({ tempoMap: [{
5
- // startSec: gridSec, bpm }] })`. Promoted out of the orbiters app (ORB-137) so EW reuses it verbatim.
5
+ // startSec: gridSec, bpm }] })`. Promoted out of the orbiters app so EW reuses it verbatim.
6
6
 
7
7
  /**
8
8
  * The slice of a track's data these resolvers read (tempo here; meter in `./clock/meter`). Every field
@@ -3,7 +3,7 @@ import * as THREE from 'three';
3
3
  /**
4
4
  * Ensure a scene has the default hemisphere + ambient lights. Idempotent PER SCENE.
5
5
  *
6
- * ORB-128: a multi-orbiter realm renders N independent scenes through one renderer. A module-level
6
+ * A multi-orbiter realm renders N independent scenes through one renderer. A module-level
7
7
  * singleton light is wrong twice over — it's only added to the FIRST scene, and a THREE light can
8
8
  * live in just one scene (adding it to a second removes it from the first), so scenes 2..N rendered
9
9
  * DARK ("only one entangled world"). So create + add the lights PER scene, keyed by name so a
@@ -142,11 +142,11 @@ function resolveUrls(faceKeys) {
142
142
 
143
143
  /**
144
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.
145
+ * `renderer` binds the KTX2 transcoder to the right WebGL context. REQUIRED (3.0) when the KTX2
146
+ * path is taken — the old `window.__EW_RENDERER` fallback is gone; thread the owning view's
147
+ * renderer. (The PNG/JPG path is renderer-agnostic, so its absence only bites on KTX2.)
147
148
  */
148
- export async function loadCubeMap({ faceKeys, skycubeName, preferKTX2 = true, forceLow, renderer: rendererOpt } = {}) {
149
- const renderer = rendererOpt ?? (typeof window !== 'undefined' ? window.__EW_RENDERER : null);
149
+ export async function loadCubeMap({ faceKeys, skycubeName, preferKTX2 = true, forceLow, renderer } = {}) {
150
150
 
151
151
  // If no face URLs provided, build them from skycube name
152
152
  if (!faceKeys && skycubeName) {
@@ -5,13 +5,12 @@ import { getKTX2LoaderForRenderer } from './ktx2.js';
5
5
 
6
6
  /**
7
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.
8
+ * @param {import('three').WebGLRenderer} opts.renderer REQUIRED (3.0): the renderer this GLB's
9
+ * textures bind to. A page with several renderers has no single correct global, so the old
10
+ * `window.__EW_RENDERER` fallback is gone — thread the owning view's renderer.
11
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);
14
- if (!renderer) throw new Error('Renderer not ready for KTX2');
12
+ export async function loadGLB(url, { retries = 3, signal, onProgress, applyFlags, withCredentials = false, renderer } = {}) {
13
+ if (!renderer) throw new Error('[glb] loadGLB requires the owning renderer (KTX2 textures bind per WebGL context).');
15
14
 
16
15
  let attempt = 0;
17
16
  while (attempt < retries) {
@@ -13,27 +13,22 @@ import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
13
13
  * (Was: a single `window.__EW_KTX2_LOADER` global shared by bodyTexture/glb/cubeMap loaders.)
14
14
  */
15
15
  const loadersByRenderer = new WeakMap();
16
- let fallbackLoader = null;
17
16
 
18
17
  function makeLoader(renderer) {
19
18
  return new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
20
19
  }
21
20
 
22
21
  /**
23
- * The KTX2Loader bound to `renderer`. Defaults to the current `window.__EW_RENDERER` so existing call
24
- * sites need no change; pass the renderer explicitly when it is in scope (preferred).
25
- * @param {import('three').WebGLRenderer} [renderer]
22
+ * The KTX2Loader bound to `renderer` — REQUIRED (3.0). The old `window.__EW_RENDERER` fallback let
23
+ * call sites compile while silently binding the transcoder to whichever renderer a page published
24
+ * last; on any multi-view page that meant untextured worlds. Every caller now threads its own
25
+ * renderer, so a missing one is a caller bug and fails loudly here rather than as a dark planet.
26
+ * @param {import('three').WebGLRenderer} renderer
26
27
  * @returns {KTX2Loader}
27
28
  */
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);
29
+ export function getKTX2LoaderForRenderer(renderer) {
32
30
  if (!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/');
36
- return fallbackLoader;
31
+ throw new Error('[ktx2] getKTX2LoaderForRenderer requires the owning renderer — thread it from the view that renders this texture.');
37
32
  }
38
33
  let loader = loadersByRenderer.get(renderer);
39
34
  if (!loader) {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file src/render/viewportCompositor.js
3
- * @description ORB-128 — a multi-view render host: ONE canvas, ONE `WebGLRenderer`, ONE rAF loop.
3
+ * @description A multi-view render host: ONE `WebGLRenderer`, ONE rAF loop, many cells.
4
4
  * Each frame it iterates the registered voices and renders each voice's scene into its own cell via
5
5
  * `setViewport`/`setScissor` (three's "multiple views, one renderer"). This replaces the N-canvas
6
6
  * spike, which hit a hard wall: N canvases = N WebGL contexts, and KTX2/compressed textures only
@@ -14,17 +14,36 @@
14
14
  * - external: a `cellSource` supplies the cells — real DOM the layout owns, so the compositor
15
15
  * renders each voice into its cell's rect and never builds or removes grid DOM.
16
16
  *
17
+ * ## Presentation modes
18
+ * How rendered pixels reach the screen is a per-host choice (`presentation` option):
19
+ *
20
+ * - `'fixed-overlay'` (default): the WebGL canvas itself is displayed — appended to `document.body`,
21
+ * `position:fixed;inset:0`, each voice scissor-rendered at its cell's on-screen rect. Right for
22
+ * full-screen stage layouts the compositor was built for. Its structural limit: cell positions are
23
+ * re-read on the main thread each frame, while the page scrolls on the compositor thread — so
24
+ * inside a scrolling document the painted rect trails the DOM by a frame or more (the "viewport
25
+ * detaches from the page" symptom). Don't use it under scrolling content.
26
+ *
27
+ * - `'per-cell'`: the WebGL canvas stays detached and is only a render source. Every voice gets a
28
+ * compositor-owned 2D canvas inside its cell (`position:absolute;inset:0`), and each frame the
29
+ * voice is rendered at the buffer's origin and blitted into that canvas (`drawImage`, a GPU-side
30
+ * copy). The DOM owns the pixels' position, so cells scroll glued to the page — scroll can never
31
+ * detach them, no matter how the rAF loop is scheduled. One WebGL context still serves
32
+ * every voice, so the KTX2 single-context constraint holds. Requires each cell to be a positioned
33
+ * element (the compositor makes a `static` cell `relative`); browser-fullscreen needs no canvas
34
+ * reparenting in this mode — the cell brings its own canvas into the top layer.
35
+ *
17
36
  * ## Host contract (this module is app-agnostic, but NOT assumption-free)
18
37
  * 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`.
38
+ * - `'fixed-overlay'` only: the canvas is appended to `document.body`, `position:fixed;inset:0`,
39
+ * `z-index:40`, opaque black CSS background; browser-fullscreen on a registered CELL reparents the
40
+ * shared canvas into that cell. The host must not move the canvas itself.
41
+ * - The default grid it may build is `#multi-orbiter-grid` at `z-index:41`.
21
42
  * - A voice is `{ voiceId, cell, controller }`, where `controller` exposes `renderActive`,
22
43
  * `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
44
  * - It owns the realm clear: every frame it re-asserts a fully transparent clear colour.
26
45
  *
27
- * @see `publishRendererGlobal` and `devHandleKey` — the two window globals it can write, both opt-in.
46
+ * @see `devHandleKey` — the one window global it can write, opt-in.
28
47
  */
29
48
  import * as THREE from 'three';
30
49
 
@@ -39,18 +58,21 @@ const DEFAULT_RENDERER_PROFILE = {
39
58
  maxDevicePixelRatio: 1.5,
40
59
  };
41
60
 
61
+ /** `'per-cell'`: how far past the viewport (CSS px) a cell still renders. Cull geometry is a cached
62
+ * rect that can be a frame stale during fast scrolls — the slop keeps a cell entering the viewport
63
+ * from flashing empty on that frame. */
64
+ const CULL_SLOP_PX = 256;
65
+
42
66
  /**
43
67
  * @param {object} [opts]
44
68
  * @param {{ acquireCell: (index:number, total:number) => HTMLElement, releaseCell?: (index:number) => void } | null} [opts.cellSource]
45
69
  * external owner of the cell DOM. When present, `createCell` delegates to it and the compositor
46
70
  * builds no grid of its own; when absent, the default `⌈√n⌉` grid is used.
71
+ * @param {'fixed-overlay'|'per-cell'} [opts.presentation='fixed-overlay'] how pixels reach the
72
+ * screen — the displayed fixed canvas (full-screen stage layouts) or a per-cell blit canvas the
73
+ * DOM positions (cells inside scrolling content). See the module doc.
47
74
  * @param {Partial<typeof DEFAULT_RENDERER_PROFILE>} [opts.rendererProfile] renderer construction +
48
75
  * 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
76
  * @param {string|null} [opts.devHandleKey=null] when set, publish the returned api on
55
77
  * `window[devHandleKey]` so tooling can force a composited frame while rAF is throttled (a hidden
56
78
  * tab during automated checks). Off by default — a library does not name globals for its host.
@@ -66,62 +88,72 @@ const DEFAULT_RENDERER_PROFILE = {
66
88
  */
67
89
  export function createViewportCompositor({
68
90
  cellSource = null,
91
+ presentation = 'fixed-overlay',
69
92
  rendererProfile = null,
70
- publishRendererGlobal = false,
71
93
  devHandleKey = null,
72
94
  } = {}) {
73
95
  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.
96
+ const perCell = presentation === 'per-cell';
97
+ // The one WebGL canvas. Fixed-overlay: full-bleed behind the grid, it owns the pixels for ALL
98
+ // voices. Per-cell: never enters the DOM — a render source the per-cell canvases copy from.
76
99
  const canvas = document.createElement('canvas');
77
100
  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);
101
+ if (!perCell) {
102
+ // A <canvas> is a REPLACED element: `inset:0` alone leaves it at its intrinsic 300×150 (unlike a
103
+ // div, it does not stretch to the inset box). Explicit width/height make it fill the viewport so the
104
+ // drawing buffer matches the grid — without this every voice renders into a 300×150 buffer (off-screen).
105
+ canvas.style.cssText =
106
+ 'position:fixed;inset:0;width:100%;height:100%;z-index:40;display:block;background:#000;';
107
+ document.body.appendChild(canvas);
108
+ }
84
109
 
85
110
  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
111
 
93
- /** voiceId → { cell, controller, lastW, lastH, rect } */
112
+ /** voiceId → { cell, controller, lastW, lastH, rect, blit } — `rect` is the GL draw rect
113
+ * (fixed-overlay) or the viewport-relative cull/size box (per-cell); `blit` is the per-cell
114
+ * presentation canvas + its 2d context (per-cell only). */
94
115
  const voices = new Map();
95
116
  let grid = null;
96
117
  let raf = null;
97
118
  let disposed = false;
98
119
  let lastCanvasW = 0;
99
120
  let lastCanvasH = 0;
121
+ let viewportH = 0; // per-cell cull band (CSS px), refreshed with the rects
122
+ let viewportW = 0;
100
123
 
101
- // ORB-162: the rAF loop must NOT read layout every frame. `getBoundingClientRect()` / `clientHeight`
124
+ // The rAF loop must NOT read layout every frame. `getBoundingClientRect()` / `clientHeight`
102
125
  // force a synchronous reflow, and while dragging the camera the per-tile knob re-renders dirty the
103
126
  // layout every frame — so N per-frame reads = N reflows = the janky, irregular knob response in multi.
104
127
  // Instead we CACHE each cell's rect + the canvas size and refresh only when something actually changes
105
128
  // (a cell resizes, the stack scrolls, or the window resizes). A steady drag then reads zero layout.
129
+ // (Per-cell mode keeps the same cache: there the rect only culls and sizes — a stale frame merely
130
+ // renders one extra cell; it can't misplace pixels, the DOM owns their position.)
106
131
  let rectsDirty = true;
107
132
  const markRectsDirty = () => { rectsDirty = true; };
108
133
  const cellObserver =
109
134
  typeof ResizeObserver !== 'undefined' ? new ResizeObserver(markRectsDirty) : null;
110
135
  // Cells move (without resizing) when the mobile vertical stage stack scrolls — capture-phase catches
111
136
  // scrolling of any ancestor container, not just the window. Window resize changes the canvas box too.
137
+ // The BODY observer catches layout shifts with no scroll/resize event at all: content above a cell
138
+ // expanding or collapsing moves the cell (and changes the body box) while every cell keeps its own
139
+ // size — without it, per-cell culling could keep skipping a voice that shifted into the viewport.
112
140
  if (typeof window !== 'undefined') {
113
141
  window.addEventListener('scroll', markRectsDirty, { passive: true, capture: true });
114
142
  window.addEventListener('resize', markRectsDirty);
115
143
  }
144
+ if (typeof document !== 'undefined' && document.body) cellObserver?.observe(document.body);
116
145
 
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.
146
+ // Browser-fullscreen ownership for a voice. Fixed-overlay: the shared canvas lives on
147
+ // `<body>` (fixed, inset:0) and scissor-renders every cell; a cell is a transparent div on top of
148
+ // it. When a voice's header fullscreens ITS cell, the fullscreen "top layer" draws the cell (and
149
+ // its chrome) above an opaque backdrop that HIDES the body-level canvas — so the orbiter's 3D
150
+ // pixels would vanish. Fix: reparent the one shared canvas INTO the fullscreened cell (so it
151
+ // paints inside the top layer) and solo-render that voice full-bleed; restore both on exit.
152
+ // Single-orbiter never builds a compositor, so this path is realm-only. The header only calls
153
+ // `requestFullscreen()` on the cell — this listener owns the canvas move + solo, keeping the
154
+ // compositor the single owner of its canvas. Per-cell: the cell's own canvas rides into the top
155
+ // layer with it — no reparenting, only the solo-render (and the buffer grows to the cell's new
156
+ // size through the normal resize path).
125
157
  let fullscreenVoiceId = null; // voiceId rendered solo full-bleed while fullscreen, else null
126
158
  let canvasHome = null; // { parent, next, cssText } to restore the canvas to its realm home on exit
127
159
  const fullscreenElement = () =>
@@ -149,6 +181,13 @@ export function createViewportCompositor({
149
181
  const enteringId = voiceIdForCell(fullscreenElement());
150
182
  if (enteringId) {
151
183
  if (fullscreenVoiceId === enteringId) return;
184
+ if (perCell) {
185
+ // The fullscreened cell carries its own presentation canvas into the top layer; solo-render
186
+ // is all the compositor contributes here.
187
+ fullscreenVoiceId = enteringId;
188
+ markRectsDirty();
189
+ return;
190
+ }
152
191
  // Direct switch from another fullscreen cell (no intervening exit): restore FIRST so canvasHome
153
192
  // captures the true realm home, not the previous cell / already-mutated style.
154
193
  if (fullscreenVoiceId) restoreCanvasHome();
@@ -171,10 +210,37 @@ export function createViewportCompositor({
171
210
  document.addEventListener('webkitfullscreenchange', onFullscreenChange);
172
211
  }
173
212
 
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. */
213
+ /** Recompute + cache every voice's rect (and the buffer size) in ONE layout pass. Called from the
214
+ * loop only when `rectsDirty`, so the steady state does no forced layout.
215
+ * Fixed-overlay: rect = GL draw rect on the displayed canvas (bottom-left origin, so y flips
216
+ * against the fixed full-viewport canvas height). Per-cell: rect = the cell's viewport-relative
217
+ * box (top-left origin) — used only to cull and size; the buffer grows (never shrinks) to hold
218
+ * the largest cell, since every voice renders at the buffer origin. */
177
219
  function refreshRects() {
220
+ if (perCell) {
221
+ viewportW = window.innerWidth;
222
+ viewportH = window.innerHeight;
223
+ let maxW = lastCanvasW;
224
+ let maxH = lastCanvasH;
225
+ for (const voice of voices.values()) {
226
+ const box = voice.cell?.getBoundingClientRect();
227
+ voice.rect = box
228
+ ? { x: box.left, y: box.top, width: box.width, height: box.height }
229
+ : null;
230
+ if (box) {
231
+ if (box.width > maxW) maxW = Math.ceil(box.width);
232
+ if (box.height > maxH) maxH = Math.ceil(box.height);
233
+ }
234
+ }
235
+ if (maxW > lastCanvasW || maxH > lastCanvasH) {
236
+ lastCanvasW = maxW;
237
+ lastCanvasH = maxH;
238
+ renderer.setSize(maxW, maxH, false);
239
+ applyPixelRatio(renderer, profile);
240
+ }
241
+ rectsDirty = false;
242
+ return;
243
+ }
178
244
  syncCanvasSize();
179
245
  const canvasH = lastCanvasH || canvas.clientHeight || window.innerHeight;
180
246
  for (const voice of voices.values()) {
@@ -218,7 +284,7 @@ export function createViewportCompositor({
218
284
  const cell = document.createElement('div');
219
285
  cell.className = 'multi-orbiter-cell';
220
286
  cell.dataset.voiceIndex = String(index);
221
- // ORB-176: the cell is this voice's own interactive input surface (pointer-events:auto), so its
287
+ // The cell is this voice's own interactive input surface (pointer-events:auto), so its
222
288
  // CameraController receives its planet drags directly and DOM hit-testing routes clicks to the right
223
289
  // tile. The grid CONTAINER stays pointer-events:none, so the 2px inter-tile gaps still click through.
224
290
  cell.style.cssText = 'position:relative;overflow:hidden;min-width:0;min-height:0;pointer-events:auto;';
@@ -226,9 +292,29 @@ export function createViewportCompositor({
226
292
  return cell;
227
293
  }
228
294
 
295
+ /** Per-cell mode: the compositor-owned presentation canvas living INSIDE a voice's cell. The DOM
296
+ * positions it (absolute, inset:0), so it scrolls with the page on the compositor thread — the
297
+ * whole point of the mode. First child + z-index:0 keeps it under the cell's chrome; pointer
298
+ * events pass through to the cell, which owns the voice's camera input binding. */
299
+ function createBlitCanvas(cell) {
300
+ // An absolutely-positioned child anchors to the nearest positioned ancestor — make sure that's
301
+ // the cell itself, not something above it.
302
+ if (typeof getComputedStyle === 'function' && getComputedStyle(cell).position === 'static') {
303
+ cell.style.position = 'relative';
304
+ }
305
+ const el = document.createElement('canvas');
306
+ el.className = 'multi-orbiter-cell-canvas';
307
+ el.style.cssText =
308
+ 'position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;z-index:0;';
309
+ cell.insertBefore(el, cell.firstChild);
310
+ const ctx = el.getContext('2d');
311
+ return { el, ctx };
312
+ }
313
+
229
314
  function addVoice({ voiceId, cell, controller }) {
230
315
  if (!voiceId || !cell || !controller) return;
231
- voices.set(voiceId, { cell, controller, lastW: 0, lastH: 0, rect: null });
316
+ const blit = perCell ? createBlitCanvas(cell) : null;
317
+ voices.set(voiceId, { cell, controller, lastW: 0, lastH: 0, rect: null, blit });
232
318
  cellObserver?.observe(cell); // resize of this tile → recompute its cached rect
233
319
  markRectsDirty();
234
320
  }
@@ -236,11 +322,13 @@ export function createViewportCompositor({
236
322
  function removeVoice(voiceId) {
237
323
  const voice = voices.get(voiceId);
238
324
  if (!voice) return;
239
- // ORB-190: if this voice is fullscreen its cell hosts the shared canvas — restore the canvas to its
325
+ // If this voice is fullscreen its cell hosts the shared canvas — restore the canvas to its
240
326
  // realm home FIRST, so removing the cell below (grid mode) can't take the canvas down with it.
241
327
  if (voiceId === fullscreenVoiceId) restoreCanvasHome();
242
328
  voices.delete(voiceId);
243
329
  if (voice.cell) cellObserver?.unobserve(voice.cell);
330
+ // The blit canvas is compositor-owned even when the HOST owns the cell — always take it out.
331
+ voice.blit?.el.remove();
244
332
  markRectsDirty();
245
333
  // Collection mode: the slot layout owns the cell DOM — leave it in place (the layout reclaims or
246
334
  // re-fills the slot). Only the compositor-built grid removes its own cells.
@@ -252,7 +340,8 @@ export function createViewportCompositor({
252
340
  }
253
341
  }
254
342
 
255
- /** Keep the renderer's drawing buffer matched to the canvas's CSS box (handles window resize). */
343
+ /** Keep the renderer's drawing buffer matched to the canvas's CSS box (handles window resize).
344
+ * Fixed-overlay only — per-cell sizes the buffer to the largest cell in `refreshRects`. */
256
345
  function syncCanvasSize() {
257
346
  const w = canvas.clientWidth || window.innerWidth;
258
347
  const h = canvas.clientHeight || window.innerHeight;
@@ -263,15 +352,9 @@ export function createViewportCompositor({
263
352
  applyPixelRatio(renderer, profile);
264
353
  }
265
354
 
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
-
355
+ /** Fixed-overlay frame: clear the displayed canvas, then scissor-render every visible voice at its
356
+ * on-screen rect. */
357
+ function renderOverlayFrame() {
275
358
  // Clear the WHOLE buffer once (scissor off) so the gridline gaps and any paused/empty cell show
276
359
  // the canvas CSS background, not stale pixels — then each voice scissor-renders into its own
277
360
  // rect. The compositor owns the realm clear: re-assert a fully transparent clear color, because
@@ -285,7 +368,7 @@ export function createViewportCompositor({
285
368
  const canvasW = lastCanvasW;
286
369
  const canvasH = lastCanvasH;
287
370
  for (const [voiceId, voice] of voices) {
288
- // ORB-190: while one voice is browser-fullscreen its cell hosts the shared canvas in the top
371
+ // While one voice is browser-fullscreen its cell hosts the shared canvas in the top
289
372
  // layer and fills the viewport (rect = whole canvas). Render ONLY that voice — the other cells
290
373
  // sit behind the fullscreen backdrop, so scissor-rendering them would stamp stray tiles over it.
291
374
  if (fullscreenVoiceId && voiceId !== fullscreenVoiceId) continue;
@@ -307,6 +390,74 @@ export function createViewportCompositor({
307
390
  }
308
391
  }
309
392
 
393
+ /** Per-cell frame: render each visible voice at the buffer ORIGIN, then blit that region into the
394
+ * voice's own canvas. Every voice reuses the same origin region (cleared per voice), so the
395
+ * buffer only ever needs to hold the largest single cell. The blit is `drawImage` straight from
396
+ * the WebGL canvas — same-task with the render, so `preserveDrawingBuffer:false` stays safe. */
397
+ function renderPerCellFrame() {
398
+ renderer.setClearColor(0x000000, 0);
399
+ const dpr = renderer.getPixelRatio();
400
+ // The blit reads DEVICE pixels; the canvas attribute is the buffer's true height (three floors
401
+ // internally — recomputing from CSS px could drift by a pixel).
402
+ const bufH = canvas.height;
403
+ for (const [voiceId, voice] of voices) {
404
+ if (fullscreenVoiceId && voiceId !== fullscreenVoiceId) continue;
405
+ const { controller, rect, blit } = voice;
406
+ if (!controller.renderActive || !rect || !blit?.ctx) continue;
407
+ const { width, height } = rect;
408
+ if (width <= 0 || height <= 0) continue;
409
+ // Cull cells fully outside the viewport (+slop: the cached rect may be a frame stale during a
410
+ // fast scroll — better one extra render than a blank cell entering the screen).
411
+ if (
412
+ rect.y + height <= -CULL_SLOP_PX ||
413
+ rect.y >= viewportH + CULL_SLOP_PX ||
414
+ rect.x + width <= -CULL_SLOP_PX ||
415
+ rect.x >= viewportW + CULL_SLOP_PX
416
+ ) {
417
+ continue;
418
+ }
419
+ // Floor to match three's own device-px math (setViewport/setScissor floor after the dpr
420
+ // multiply) — rounding UP would blit a stale 1px edge the render never touched.
421
+ const sw = Math.floor(width * dpr);
422
+ const sh = Math.floor(height * dpr);
423
+ if (width !== voice.lastW || height !== voice.lastH) {
424
+ controller.setViewportSize(width, height);
425
+ voice.lastW = width;
426
+ voice.lastH = height;
427
+ }
428
+ // Setting a canvas's backing-store size clears it, so only do it on actual change.
429
+ if (blit.el.width !== sw || blit.el.height !== sh) {
430
+ blit.el.width = sw;
431
+ blit.el.height = sh;
432
+ }
433
+ // Clear this voice's region (previous voice's pixels live there), then let it render.
434
+ renderer.setViewport(0, 0, width, height);
435
+ renderer.setScissor(0, 0, width, height);
436
+ renderer.setScissorTest(true);
437
+ renderer.clear();
438
+ controller.renderViewport({ x: 0, y: 0, width, height });
439
+ // GL origin is bottom-left: the region sits at the BOTTOM of the buffer in 2D (top-left) coords.
440
+ // 'copy' replaces the destination (alpha included) — no clearRect pass needed.
441
+ blit.ctx.globalCompositeOperation = 'copy';
442
+ blit.ctx.drawImage(canvas, 0, bufH - sh, sw, sh, 0, 0, sw, sh);
443
+ }
444
+ }
445
+
446
+ /** Render exactly ONE composited frame (every active voice into its cell). Also the unit the rAF
447
+ * loop runs each tick. Exposed on the dev handle so a paused/hidden tab can force a frame.
448
+ * three's setViewport/setScissor multiply by the renderer's pixelRatio internally, so the cached
449
+ * rects are CSS px — pre-multiplying by dpr would double-scale (correct only at dpr=1, broken on retina). */
450
+ function renderOnce() {
451
+ if (disposed) return;
452
+ // Refresh cached rects/canvas size ONLY when layout changed (never per frame — see refreshRects).
453
+ if (rectsDirty) refreshRects();
454
+ if (perCell) {
455
+ renderPerCellFrame();
456
+ } else {
457
+ renderOverlayFrame();
458
+ }
459
+ }
460
+
310
461
  function tick() {
311
462
  if (disposed) return;
312
463
  raf = requestAnimationFrame(tick);
@@ -329,12 +480,10 @@ export function createViewportCompositor({
329
480
  }
330
481
  // If disposed while a voice was fullscreen, put the canvas style back before `canvas.remove()`.
331
482
  restoreCanvasHome();
483
+ for (const voice of voices.values()) voice.blit?.el.remove();
332
484
  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
- }
485
+ if (typeof window !== 'undefined' && devHandleKey && window[devHandleKey]?.renderer === renderer) {
486
+ delete window[devHandleKey];
338
487
  }
339
488
  renderer.dispose();
340
489
  grid?.remove();
@@ -7,8 +7,8 @@ export class WorldManager {
7
7
  constructor(scene, { onLoadStart, onLoadEnd, renderer, externalTick = false } = {}) {
8
8
  this.scene = scene;
9
9
  // The renderer this world belongs to — threaded to the texture loaders so each view's textures
10
- // bind to its OWN WebGL context (multi-view: N orbiters / N planets in one page). Falls back to
11
- // window.__EW_RENDERER for callers that don't pass one, keeping single-renderer apps unchanged.
10
+ // bind to its OWN WebGL context (multi-view: N orbiters / N planets in one page). REQUIRED for
11
+ // textured worlds (3.0): the window.__EW_RENDERER fallback is gone.
12
12
  this.renderer = renderer ?? null;
13
13
  // Shared-renderer hosts drive world animation from THEIR loop: with `externalTick` this manager
14
14
  // starts no internal rAF for `userData.update` worlds — the host calls `tick(timeSec)` per
@@ -29,7 +29,7 @@ export class WorldManager {
29
29
  if (!this.externalTick || !this.current) return;
30
30
  const updater = this.current.userData?.update;
31
31
  if (typeof updater !== 'function') return;
32
- const renderer = this.renderer ?? (typeof window !== 'undefined' ? window.__EW_RENDERER : null);
32
+ const renderer = this.renderer;
33
33
  if (!renderer) return;
34
34
  try {
35
35
  updater(renderer, timeSec);
@@ -60,7 +60,7 @@ export class WorldManager {
60
60
  this.onLoadStart?.();
61
61
  try {
62
62
  let obj;
63
- const renderer = this.renderer ?? (typeof window !== 'undefined' ? window.__EW_RENDERER : null);
63
+ const renderer = this.renderer;
64
64
 
65
65
  // Check for custom creators first
66
66
  if (this.customCreators.has(mode)) {
@@ -27,6 +27,7 @@ export async function makeNormalSphere({
27
27
  cloudsColor = 0xffffff,
28
28
  cloudsOpacity = 0.6,
29
29
  cloudsSpeed = 0.015,
30
+ renderer = null,
30
31
  } = {}) {
31
32
  const radius = typeof sphereRadius === 'number' ? Math.max(0.0001, sphereRadius) : EARTH_RADIUS_UNITS;
32
33
  const widthSegs = Number.isFinite(sphereWidthSegments) ? Math.max(3, Math.floor(sphereWidthSegments)) : 64;
@@ -63,6 +64,7 @@ export async function makeNormalSphere({
63
64
  color: cloudsColor,
64
65
  opacity: cloudsOpacity,
65
66
  speed: cloudsSpeed,
67
+ renderer,
66
68
  });
67
69
  mesh.add(cloudMesh);
68
70
  }