entangled-worlds-orbiters-shared 2.3.0 → 2.4.1

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/README.md CHANGED
@@ -6,7 +6,6 @@ A shared library containing core components for 3D world rendering, moons system
6
6
 
7
7
  - **World System**: Core world rendering with textured spheres, normal materials, GLB models, and cloud shells
8
8
  - **Moons System**: Orbital moon generation and management
9
- - **Arrow UI**: Reusable UI component system based on lil-gui
10
9
  - **Loaders**: Texture, GLB, and cubemap loading utilities
11
10
  - **Core Rendering**: Lighting, Fresnel effects, and material utilities
12
11
  - **Configuration**: Texture and configuration management system
@@ -38,7 +37,6 @@ npm link entangled-worlds-orbiters-shared
38
37
  // Import specific modules
39
38
  import { WorldManager } from 'entangled-worlds-orbiters-shared/world';
40
39
  import { MoonsManager } from 'entangled-worlds-orbiters-shared/moons';
41
- import { GUI } from 'entangled-worlds-orbiters-shared/arrow-ui';
42
40
 
43
41
  // Or import everything
44
42
  import * as EntangledWorlds from 'entangled-worlds-orbiters-shared';
@@ -57,10 +55,6 @@ import * as EntangledWorlds from 'entangled-worlds-orbiters-shared';
57
55
  - `MoonsManager` - Moons orchestration
58
56
  - `moonsGeneration` - Moon generation logic
59
57
 
60
- ### Arrow UI
61
- - Complete UI component system based on lil-gui
62
- - Controllers for boolean, color, function, number, option, and string inputs
63
-
64
58
  ### Loaders
65
59
  - `cubeMapLoader` - Skybox loading
66
60
  - `glbLoader` - GLB loading utilities
@@ -129,6 +123,4 @@ npm install three three-stdlib
129
123
 
130
124
  ## License
131
125
 
132
- [MIT](LICENSE) © Plantasia Space.
133
-
134
- `src/arrow-ui/` is derived from [lil-gui](https://github.com/georgealways/lil-gui) (MIT) — see [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
126
+ [MIT](LICENSE) © Plantasia Space.
@@ -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.3.0",
3
+ "version": "2.4.1",
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
@@ -94,7 +94,7 @@ export async function loadTextureConfig(configUrl = null, useCache = true) {
94
94
  * Get texture options for a specific category (clouds, moons, etc.)
95
95
  * @param {string} category - Texture category (clouds, moons, skyboxes)
96
96
  * @param {Object} config - Optional pre-loaded config object
97
- * @returns {Promise<Object>} Options object suitable for lil-gui dropdowns
97
+ * @returns {Promise<Object>} Options object suitable for dropdown option lists
98
98
  */
99
99
  export async function getTextureOptions(category, config = null) {
100
100
  const textureConfig = config || await loadTextureConfig();
@@ -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
@@ -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,14 +14,33 @@
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
46
  * @see `devHandleKey` — the one window global it can write, opt-in.
@@ -39,11 +58,19 @@ 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
76
  * @param {string|null} [opts.devHandleKey=null] when set, publish the returned api on
@@ -61,55 +88,72 @@ const DEFAULT_RENDERER_PROFILE = {
61
88
  */
62
89
  export function createViewportCompositor({
63
90
  cellSource = null,
91
+ presentation = 'fixed-overlay',
64
92
  rendererProfile = null,
65
93
  devHandleKey = null,
66
94
  } = {}) {
67
95
  const profile = { ...DEFAULT_RENDERER_PROFILE, ...(rendererProfile || {}) };
68
- // ONE full-bleed canvas behind the grid; the grid cells are transparent and only define geometry
69
- // (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.
70
99
  const canvas = document.createElement('canvas');
71
100
  canvas.id = 'multi-orbiter-canvas';
72
- // A <canvas> is a REPLACED element: `inset:0` alone leaves it at its intrinsic 300×150 (unlike a
73
- // div, it does not stretch to the inset box). Explicit width/height make it fill the viewport so the
74
- // drawing buffer matches the grid — without this every voice renders into a 300×150 buffer (off-screen).
75
- canvas.style.cssText =
76
- 'position:fixed;inset:0;width:100%;height:100%;z-index:40;display:block;background:#000;';
77
- 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
+ }
78
109
 
79
110
  const renderer = createRenderer(canvas, profile);
80
111
 
81
- /** 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). */
82
115
  const voices = new Map();
83
116
  let grid = null;
84
117
  let raf = null;
85
118
  let disposed = false;
86
119
  let lastCanvasW = 0;
87
120
  let lastCanvasH = 0;
121
+ let viewportH = 0; // per-cell cull band (CSS px), refreshed with the rects
122
+ let viewportW = 0;
88
123
 
89
- // 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`
90
125
  // force a synchronous reflow, and while dragging the camera the per-tile knob re-renders dirty the
91
126
  // layout every frame — so N per-frame reads = N reflows = the janky, irregular knob response in multi.
92
127
  // Instead we CACHE each cell's rect + the canvas size and refresh only when something actually changes
93
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.)
94
131
  let rectsDirty = true;
95
132
  const markRectsDirty = () => { rectsDirty = true; };
96
133
  const cellObserver =
97
134
  typeof ResizeObserver !== 'undefined' ? new ResizeObserver(markRectsDirty) : null;
98
135
  // Cells move (without resizing) when the mobile vertical stage stack scrolls — capture-phase catches
99
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.
100
140
  if (typeof window !== 'undefined') {
101
141
  window.addEventListener('scroll', markRectsDirty, { passive: true, capture: true });
102
142
  window.addEventListener('resize', markRectsDirty);
103
143
  }
144
+ if (typeof document !== 'undefined' && document.body) cellObserver?.observe(document.body);
104
145
 
105
- // ORB-190: browser-fullscreen ownership for a voice. The shared canvas lives on `<body>` (fixed,
106
- // inset:0) and scissor-renders every cell; a cell is a transparent div on top of it. When a voice's
107
- // header fullscreens ITS cell, the fullscreen "top layer" draws the cell (and its chrome) above an
108
- // opaque backdrop that HIDES the body-level canvas — so the orbiter's 3D pixels would vanish. Fix:
109
- // reparent the one shared canvas INTO the fullscreened cell (so it paints inside the top layer) and
110
- // solo-render that voice full-bleed; restore both on exit. Single-orbiter never builds a compositor,
111
- // so this path is realm-only. The header only calls `requestFullscreen()` on the cell — this listener
112
- // 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).
113
157
  let fullscreenVoiceId = null; // voiceId rendered solo full-bleed while fullscreen, else null
114
158
  let canvasHome = null; // { parent, next, cssText } to restore the canvas to its realm home on exit
115
159
  const fullscreenElement = () =>
@@ -137,6 +181,13 @@ export function createViewportCompositor({
137
181
  const enteringId = voiceIdForCell(fullscreenElement());
138
182
  if (enteringId) {
139
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
+ }
140
191
  // Direct switch from another fullscreen cell (no intervening exit): restore FIRST so canvasHome
141
192
  // captures the true realm home, not the previous cell / already-mutated style.
142
193
  if (fullscreenVoiceId) restoreCanvasHome();
@@ -159,10 +210,37 @@ export function createViewportCompositor({
159
210
  document.addEventListener('webkitfullscreenchange', onFullscreenChange);
160
211
  }
161
212
 
162
- /** Recompute + cache every voice's GL rect (and the canvas size) in ONE layout pass. Called from the
163
- * loop only when `rectsDirty`, so the steady state does no forced layout. GL uses a bottom-left origin,
164
- * 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. */
165
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
+ }
166
244
  syncCanvasSize();
167
245
  const canvasH = lastCanvasH || canvas.clientHeight || window.innerHeight;
168
246
  for (const voice of voices.values()) {
@@ -206,7 +284,7 @@ export function createViewportCompositor({
206
284
  const cell = document.createElement('div');
207
285
  cell.className = 'multi-orbiter-cell';
208
286
  cell.dataset.voiceIndex = String(index);
209
- // 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
210
288
  // CameraController receives its planet drags directly and DOM hit-testing routes clicks to the right
211
289
  // tile. The grid CONTAINER stays pointer-events:none, so the 2px inter-tile gaps still click through.
212
290
  cell.style.cssText = 'position:relative;overflow:hidden;min-width:0;min-height:0;pointer-events:auto;';
@@ -214,9 +292,29 @@ export function createViewportCompositor({
214
292
  return cell;
215
293
  }
216
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
+
217
314
  function addVoice({ voiceId, cell, controller }) {
218
315
  if (!voiceId || !cell || !controller) return;
219
- 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 });
220
318
  cellObserver?.observe(cell); // resize of this tile → recompute its cached rect
221
319
  markRectsDirty();
222
320
  }
@@ -224,11 +322,13 @@ export function createViewportCompositor({
224
322
  function removeVoice(voiceId) {
225
323
  const voice = voices.get(voiceId);
226
324
  if (!voice) return;
227
- // 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
228
326
  // realm home FIRST, so removing the cell below (grid mode) can't take the canvas down with it.
229
327
  if (voiceId === fullscreenVoiceId) restoreCanvasHome();
230
328
  voices.delete(voiceId);
231
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();
232
332
  markRectsDirty();
233
333
  // Collection mode: the slot layout owns the cell DOM — leave it in place (the layout reclaims or
234
334
  // re-fills the slot). Only the compositor-built grid removes its own cells.
@@ -240,7 +340,8 @@ export function createViewportCompositor({
240
340
  }
241
341
  }
242
342
 
243
- /** 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`. */
244
345
  function syncCanvasSize() {
245
346
  const w = canvas.clientWidth || window.innerWidth;
246
347
  const h = canvas.clientHeight || window.innerHeight;
@@ -251,15 +352,9 @@ export function createViewportCompositor({
251
352
  applyPixelRatio(renderer, profile);
252
353
  }
253
354
 
254
- /** Render exactly ONE composited frame (every active voice into its cell). Also the unit the rAF
255
- * loop runs each tick. Exposed on the dev handle so a paused/hidden tab can force a frame.
256
- * three's setViewport/setScissor multiply by the renderer's pixelRatio internally, so the cached
257
- * rects are CSS px — pre-multiplying by dpr would double-scale (correct only at dpr=1, broken on retina). */
258
- function renderOnce() {
259
- if (disposed) return;
260
- // Refresh cached rects/canvas size ONLY when layout changed (never per frame — see refreshRects).
261
- if (rectsDirty) refreshRects();
262
-
355
+ /** Fixed-overlay frame: clear the displayed canvas, then scissor-render every visible voice at its
356
+ * on-screen rect. */
357
+ function renderOverlayFrame() {
263
358
  // Clear the WHOLE buffer once (scissor off) so the gridline gaps and any paused/empty cell show
264
359
  // the canvas CSS background, not stale pixels — then each voice scissor-renders into its own
265
360
  // rect. The compositor owns the realm clear: re-assert a fully transparent clear color, because
@@ -273,7 +368,7 @@ export function createViewportCompositor({
273
368
  const canvasW = lastCanvasW;
274
369
  const canvasH = lastCanvasH;
275
370
  for (const [voiceId, voice] of voices) {
276
- // 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
277
372
  // layer and fills the viewport (rect = whole canvas). Render ONLY that voice — the other cells
278
373
  // sit behind the fullscreen backdrop, so scissor-rendering them would stamp stray tiles over it.
279
374
  if (fullscreenVoiceId && voiceId !== fullscreenVoiceId) continue;
@@ -295,6 +390,74 @@ export function createViewportCompositor({
295
390
  }
296
391
  }
297
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
+
298
461
  function tick() {
299
462
  if (disposed) return;
300
463
  raf = requestAnimationFrame(tick);
@@ -317,6 +480,7 @@ export function createViewportCompositor({
317
480
  }
318
481
  // If disposed while a voice was fullscreen, put the canvas style back before `canvas.remove()`.
319
482
  restoreCanvasHome();
483
+ for (const voice of voices.values()) voice.blit?.el.remove();
320
484
  voices.clear();
321
485
  if (typeof window !== 'undefined' && devHandleKey && window[devHandleKey]?.renderer === renderer) {
322
486
  delete window[devHandleKey];
@@ -296,6 +296,9 @@ export async function makeSphere({
296
296
 
297
297
  if (flags) applyFlagsToMaterial(material, flags);
298
298
  const mesh = new THREE.Mesh(meshGeometry, material);
299
+ // Named so consumers can find the planet body by name (mirrors the
300
+ // normal-mode body's `worldNormalSphere`) — e.g. to modulate its material.
301
+ mesh.name = 'worldTextureSphere';
299
302
  mesh.position.set(0, 0, 0);
300
303
 
301
304
  // NEW: add cloud shell before glow
@@ -1,5 +0,0 @@
1
- # Third-party notices
2
-
3
- ## Arrow UI (`src/arrow-ui/`)
4
-
5
- Derived from [lil-gui](https://github.com/georgealways/lil-gui) by George Michael Brower, licensed under the [MIT License](https://github.com/georgealways/lil-gui/blob/master/LICENSE). The controllers and GUI core have been adapted for the Entangled Worlds / Orbiters apps (external stylesheet loading, custom controllers and behavior); the original license applies to the derived portions alongside this project's MIT license.