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 +1 -9
- package/dist/clock/sync.js +2 -2
- package/dist/clock/track-metadata.js +1 -1
- package/package.json +1 -1
- package/src/clock/sync.ts +2 -2
- package/src/clock/track-metadata.ts +1 -1
- package/src/config/textureConfig.js +1 -1
- package/src/core/lights.js +1 -1
- package/src/render/viewportCompositor.js +204 -40
- package/src/world/worldTexture.js +3 -0
- package/THIRD-PARTY-NOTICES.md +0 -5
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.
|
package/dist/clock/sync.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// BeatTimeline — leaderless shared beat-timeline sync (conceptually based on Ableton Link). Design doc
|
|
2
|
-
//
|
|
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
|
|
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
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
|
-
//
|
|
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
|
|
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
|
|
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();
|
package/src/core/lights.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
* -
|
|
20
|
-
*
|
|
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
|
-
|
|
69
|
-
//
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
106
|
-
// inset:0) and scissor-renders every cell; a cell is a transparent div on top of
|
|
107
|
-
// header fullscreens ITS cell, the fullscreen "top layer" draws the cell (and
|
|
108
|
-
// opaque backdrop that HIDES the body-level canvas — so the orbiter's 3D
|
|
109
|
-
// reparent the one shared canvas INTO the fullscreened cell (so it
|
|
110
|
-
// solo-render that voice full-bleed; restore both on exit.
|
|
111
|
-
// so this path is realm-only. The header only calls
|
|
112
|
-
// owns the canvas move + solo, keeping the
|
|
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
|
|
163
|
-
* loop only when `rectsDirty`, so the steady state does no forced layout.
|
|
164
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
255
|
-
*
|
|
256
|
-
|
|
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
|
-
//
|
|
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
|
package/THIRD-PARTY-NOTICES.md
DELETED
|
@@ -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.
|