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.
- 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/core/lights.js +1 -1
- package/src/loaders/cubeMapLoader.js +4 -4
- package/src/loaders/glbLoader.js +5 -6
- package/src/loaders/ktx2.js +7 -12
- package/src/render/viewportCompositor.js +207 -58
- package/src/world/worldManager.js +4 -4
- package/src/world/worldNormal.js +2 -0
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
|
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
|
|
@@ -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
|
|
146
|
-
*
|
|
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
|
|
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) {
|
package/src/loaders/glbLoader.js
CHANGED
|
@@ -5,13 +5,12 @@ import { getKTX2LoaderForRenderer } from './ktx2.js';
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* @param {object} [opts]
|
|
8
|
-
* @param {import('three').WebGLRenderer}
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
13
|
-
|
|
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) {
|
package/src/loaders/ktx2.js
CHANGED
|
@@ -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
|
|
24
|
-
* sites
|
|
25
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
* -
|
|
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
|
-
* @see `
|
|
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
|
-
|
|
75
|
-
//
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
118
|
-
// inset:0) and scissor-renders every cell; a cell is a transparent div on top of
|
|
119
|
-
// header fullscreens ITS cell, the fullscreen "top layer" draws the cell (and
|
|
120
|
-
// opaque backdrop that HIDES the body-level canvas — so the orbiter's 3D
|
|
121
|
-
// reparent the one shared canvas INTO the fullscreened cell (so it
|
|
122
|
-
// solo-render that voice full-bleed; restore both on exit.
|
|
123
|
-
// so this path is realm-only. The header only calls
|
|
124
|
-
// 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).
|
|
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
|
|
175
|
-
* loop only when `rectsDirty`, so the steady state does no forced layout.
|
|
176
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
267
|
-
*
|
|
268
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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).
|
|
11
|
-
//
|
|
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
|
|
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
|
|
63
|
+
const renderer = this.renderer;
|
|
64
64
|
|
|
65
65
|
// Check for custom creators first
|
|
66
66
|
if (this.customCreators.has(mode)) {
|
package/src/world/worldNormal.js
CHANGED
|
@@ -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
|
}
|