entangled-worlds-orbiters-shared 3.3.0 → 4.0.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/README.md +3 -2
- package/dist/config/env.d.ts +8 -0
- package/dist/config/env.js +36 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.js +4 -0
- package/dist/config/textureConfig.d.ts +140 -0
- package/dist/config/textureConfig.js +318 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +3 -0
- package/dist/core/lightFresnel.d.ts +6 -0
- package/dist/core/lightFresnel.js +63 -0
- package/dist/core/lights.d.ts +48 -0
- package/dist/core/lights.js +79 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/loaders/bodyTextureLoader.d.ts +10 -0
- package/dist/loaders/bodyTextureLoader.js +28 -0
- package/dist/loaders/cubeMapLoader.d.ts +58 -0
- package/dist/loaders/cubeMapLoader.js +291 -0
- package/dist/loaders/glbLoader.d.ts +24 -0
- package/dist/loaders/glbLoader.js +72 -0
- package/dist/loaders/index.d.ts +5 -0
- package/{src → dist}/loaders/index.js +0 -2
- package/dist/{types/loaders → loaders}/ktx2.d.ts +4 -6
- package/dist/loaders/ktx2.js +48 -0
- package/dist/moons/index.d.ts +3 -0
- package/dist/moons/index.js +3 -0
- package/dist/moons/moonsGeneration.d.ts +64 -0
- package/dist/moons/moonsGeneration.js +238 -0
- package/dist/moons/moonsManager.d.ts +43 -0
- package/dist/moons/moonsManager.js +93 -0
- package/dist/{types/render → render}/frameBudget.d.ts +18 -17
- package/dist/render/frameBudget.js +42 -0
- package/dist/render/index.d.ts +4 -0
- package/dist/render/viewportCompositor.d.ts +124 -0
- package/dist/render/viewportCompositor.js +643 -0
- package/dist/studio/headerBroadcast.d.ts +10 -1
- package/dist/studio/hostTransport.d.ts +2 -2
- package/dist/studio/index.d.ts +1 -1
- package/dist/utils/dispose.d.ts +5 -0
- package/dist/utils/dispose.js +50 -0
- package/dist/utils/index.d.ts +3 -0
- package/{src → dist}/utils/index.js +1 -1
- package/dist/utils/materials.d.ts +10 -0
- package/dist/utils/materials.js +36 -0
- package/dist/world/cloudShell.d.ts +16 -0
- package/dist/world/cloudShell.js +73 -0
- package/dist/world/index.d.ts +6 -0
- package/{src → dist}/world/index.js +0 -2
- package/dist/world/worldGLB.d.ts +17 -0
- package/dist/world/worldGLB.js +22 -0
- package/dist/world/worldManager.d.ts +57 -0
- package/dist/world/worldManager.js +125 -0
- package/dist/world/worldNormal.d.ts +30 -0
- package/dist/world/worldNormal.js +58 -0
- package/dist/world/worldTexture.d.ts +56 -0
- package/dist/world/worldTexture.js +299 -0
- package/package.json +29 -39
- package/src/config/env.ts +48 -0
- package/src/config/{index.js → index.ts} +7 -0
- package/src/config/{textureConfig.js → textureConfig.ts} +126 -60
- package/src/core/{lightFresnel.js → lightFresnel.ts} +1 -1
- package/src/core/{lights.js → lights.ts} +21 -21
- package/src/loaders/{bodyTextureLoader.js → bodyTextureLoader.ts} +10 -7
- package/src/loaders/{cubeMapLoader.js → cubeMapLoader.ts} +83 -49
- package/src/loaders/{glbLoader.js → glbLoader.ts} +41 -18
- package/src/loaders/index.ts +6 -0
- package/src/loaders/{ktx2.js → ktx2.ts} +5 -7
- package/src/moons/{index.js → index.ts} +1 -0
- package/src/moons/{moonsGeneration.js → moonsGeneration.ts} +45 -32
- package/src/moons/{moonsManager.js → moonsManager.ts} +37 -11
- package/src/render/{frameBudget.js → frameBudget.ts} +15 -8
- package/src/render/index.ts +14 -0
- package/src/render/{viewportCompositor.js → viewportCompositor.ts} +121 -59
- package/src/studio/headerBroadcast.ts +14 -1
- package/src/studio/hostTransport.ts +2 -1
- package/src/studio/index.ts +1 -1
- package/src/utils/dispose.ts +47 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/materials.ts +44 -0
- package/src/world/{cloudShell.js → cloudShell.ts} +19 -12
- package/src/world/index.ts +7 -0
- package/src/world/worldGLB.ts +41 -0
- package/src/world/{worldManager.js → worldManager.ts} +67 -32
- package/src/world/{worldNormal.js → worldNormal.ts} +25 -20
- package/src/world/{worldTexture.js → worldTexture.ts} +70 -51
- package/dist/types/config/env.d.ts +0 -12
- package/dist/types/config/index.d.ts +0 -2
- package/dist/types/config/textureConfig.d.ts +0 -121
- package/dist/types/core/index.d.ts +0 -2
- package/dist/types/core/lightFresnel.d.ts +0 -5
- package/dist/types/core/lights.d.ts +0 -58
- package/dist/types/index.d.ts +0 -6
- package/dist/types/loaders/bodyTextureLoader.d.ts +0 -12
- package/dist/types/loaders/cubeMapLoader.d.ts +0 -70
- package/dist/types/loaders/glbLoader.d.ts +0 -24
- package/dist/types/loaders/index.d.ts +0 -5
- package/dist/types/moons/index.d.ts +0 -2
- package/dist/types/moons/moonsGeneration.d.ts +0 -72
- package/dist/types/moons/moonsManager.d.ts +0 -27
- package/dist/types/render/index.d.ts +0 -2
- package/dist/types/render/viewportCompositor.d.ts +0 -58
- package/dist/types/utils/dispose.d.ts +0 -2
- package/dist/types/utils/index.d.ts +0 -2
- package/dist/types/utils/materials.d.ts +0 -2
- package/dist/types/world/cloudShell.d.ts +0 -25
- package/dist/types/world/index.d.ts +0 -6
- package/dist/types/world/worldGLB.d.ts +0 -20
- package/dist/types/world/worldManager.d.ts +0 -63
- package/dist/types/world/worldNormal.d.ts +0 -43
- package/dist/types/world/worldTexture.d.ts +0 -80
- package/src/config/env.js +0 -38
- package/src/utils/dispose.js +0 -39
- package/src/utils/materials.js +0 -31
- package/src/world/worldGLB.js +0 -19
- /package/{src → dist}/render/index.js +0 -0
- /package/src/core/{index.js → index.ts} +0 -0
- /package/src/{index.js → index.ts} +0 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/render/viewportCompositor.ts
|
|
3
|
+
* @description A multi-view render host: ONE `WebGLRenderer`, ONE rAF loop, many cells.
|
|
4
|
+
* Each frame it iterates the registered voices and renders each voice's scene into its own cell via
|
|
5
|
+
* `setViewport`/`setScissor` (three's "multiple views, one renderer"). This replaces the N-canvas
|
|
6
|
+
* spike, which hit a hard wall: N canvases = N WebGL contexts, and KTX2/compressed textures only
|
|
7
|
+
* upload to the FIRST context, so only voice-0 ever textured. One context → every voice textures.
|
|
8
|
+
*
|
|
9
|
+
* Each voice's controller runs in `sharedRenderer` mode — it contributes a scene+camera and per-frame
|
|
10
|
+
* callbacks but owns no renderer and no loop; this host drives it through `renderViewport(rect)`.
|
|
11
|
+
*
|
|
12
|
+
* Cell geometry has two sources:
|
|
13
|
+
* - default: the compositor builds its own `⌈√n⌉` square grid (`ensureGrid`).
|
|
14
|
+
* - external: a `cellSource` supplies the cells — real DOM the layout owns, so the compositor
|
|
15
|
+
* renders each voice into its cell's rect and never builds or removes grid DOM.
|
|
16
|
+
*
|
|
17
|
+
* ## 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
|
+
*
|
|
36
|
+
* ## Host contract (this module is app-agnostic, but NOT assumption-free)
|
|
37
|
+
* A host adopting it must accept all of the following, or wrap it:
|
|
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`.
|
|
42
|
+
* - A voice is `{ voiceId, cell, controller }`, where `controller` exposes `renderActive`,
|
|
43
|
+
* `renderViewport(rect)` and `setViewportSize(w, h)`. Rects are CSS px, GL bottom-left origin.
|
|
44
|
+
* - It owns the realm clear: every frame it re-asserts a fully transparent clear colour.
|
|
45
|
+
*
|
|
46
|
+
* @see `devHandleKey` — the one window global it can write, opt-in.
|
|
47
|
+
*/
|
|
48
|
+
import * as THREE from 'three';
|
|
49
|
+
import { createFrameBudget } from './frameBudget.js';
|
|
50
|
+
const GRID_ID = 'multi-orbiter-grid';
|
|
51
|
+
/** Renderer defaults. Overridable via `rendererProfile`; these values are the historical constants. */
|
|
52
|
+
const DEFAULT_RENDERER_PROFILE = {
|
|
53
|
+
antialias: true,
|
|
54
|
+
alpha: true,
|
|
55
|
+
preserveDrawingBuffer: false,
|
|
56
|
+
powerPreference: 'high-performance',
|
|
57
|
+
maxDevicePixelRatio: 1.5,
|
|
58
|
+
};
|
|
59
|
+
/** `'per-cell'`: how far past the viewport (CSS px) a cell still renders. Cull geometry is a cached
|
|
60
|
+
* rect that can be a frame stale during fast scrolls — the slop keeps a cell entering the viewport
|
|
61
|
+
* from flashing empty on that frame. */
|
|
62
|
+
const CULL_SLOP_PX = 256;
|
|
63
|
+
/**
|
|
64
|
+
* Options, all optional:
|
|
65
|
+
* - `cellSource`: external owner of the cell DOM. When present, `createCell` delegates to it and the
|
|
66
|
+
* compositor builds no grid of its own; when absent, the default `⌈√n⌉` grid is used.
|
|
67
|
+
* - `presentation` (default `'fixed-overlay'`): how pixels reach the screen — the displayed fixed
|
|
68
|
+
* canvas (full-screen stage layouts) or a per-cell blit canvas the DOM positions (cells inside
|
|
69
|
+
* scrolling content). See the module doc.
|
|
70
|
+
* - `rendererProfile`: renderer construction + pixel-ratio cap. Omit for the historical defaults.
|
|
71
|
+
* - `performanceMode` (default false): start with the realm's renderer savings on — pixel ratio
|
|
72
|
+
* clamped to 1, shadow map off, this loop capped at 30fps. Off by default, and off means the
|
|
73
|
+
* renderer and the cadence are exactly what they were before this option existed. Toggle it live
|
|
74
|
+
* with `setPerformanceMode`.
|
|
75
|
+
* - `devHandleKey` (default null): when set, publish the returned api on `window[devHandleKey]` so
|
|
76
|
+
* tooling can force a composited frame while rAF is throttled (a hidden tab during automated
|
|
77
|
+
* checks). Off by default — a library does not name globals for its host.
|
|
78
|
+
*/
|
|
79
|
+
export function createViewportCompositor({ cellSource = null, presentation = 'fixed-overlay', rendererProfile = null, devHandleKey = null, performanceMode = false, } = {}) {
|
|
80
|
+
const profile = { ...DEFAULT_RENDERER_PROFILE, ...(rendererProfile || {}) };
|
|
81
|
+
// The realm's renderer and the realm's loop are this module's, and they are the only things a
|
|
82
|
+
// single voice cannot change for itself: pixel ratio, shadow map and frame rate are one per realm,
|
|
83
|
+
// so a voice reaching for them would be deciding for its siblings. When the host says the realm is
|
|
84
|
+
// in performance mode, this is where that decision is made once, for all of them.
|
|
85
|
+
//
|
|
86
|
+
// Off is the default and off means untouched — a host that never mentions it (the feed embeds)
|
|
87
|
+
// gets exactly the renderer and the cadence it got before this existed.
|
|
88
|
+
let performanceOn = Boolean(performanceMode);
|
|
89
|
+
// What the realm's shadow map was before performance mode touched it, so turning the mode off puts
|
|
90
|
+
// the host's own choice back rather than asserting a default over it.
|
|
91
|
+
let baseShadowMapEnabled = null;
|
|
92
|
+
const frameBudget = createFrameBudget();
|
|
93
|
+
const perCell = presentation === 'per-cell';
|
|
94
|
+
// The one WebGL canvas. Fixed-overlay: full-bleed behind the grid, it owns the pixels for ALL
|
|
95
|
+
// voices. Per-cell: never enters the DOM — a render source the per-cell canvases copy from.
|
|
96
|
+
const canvas = document.createElement('canvas');
|
|
97
|
+
canvas.id = 'multi-orbiter-canvas';
|
|
98
|
+
if (!perCell) {
|
|
99
|
+
// A <canvas> is a REPLACED element: `inset:0` alone leaves it at its intrinsic 300×150 (unlike a
|
|
100
|
+
// div, it does not stretch to the inset box). Explicit width/height make it fill the viewport so the
|
|
101
|
+
// drawing buffer matches the grid — without this every voice renders into a 300×150 buffer (off-screen).
|
|
102
|
+
canvas.style.cssText =
|
|
103
|
+
'position:fixed;inset:0;width:100%;height:100%;z-index:40;display:block;background:#000;';
|
|
104
|
+
document.body.appendChild(canvas);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The renderer settings actually in force. Performance mode overrides the host's profile rather
|
|
108
|
+
* than replacing it, so whatever the host asked for comes back untouched when the mode goes off.
|
|
109
|
+
*
|
|
110
|
+
* `antialias` is deliberately not in here: it is fixed when the WebGL context is created, so
|
|
111
|
+
* turning it off would cost the host its antialiasing for the rest of the session the moment the
|
|
112
|
+
* mode was switched back on. The saving that IS live is the pixel ratio, and at dpr 2 that is
|
|
113
|
+
* three quarters of the fragments.
|
|
114
|
+
*/
|
|
115
|
+
function effectiveProfile() {
|
|
116
|
+
return performanceOn ? { ...profile, maxDevicePixelRatio: 1 } : profile;
|
|
117
|
+
}
|
|
118
|
+
/** Re-assert the realm-wide knobs. Called at construction and whenever the mode changes. */
|
|
119
|
+
function applyRealmRendererProfile() {
|
|
120
|
+
applyPixelRatio(renderer, effectiveProfile());
|
|
121
|
+
// Only ever called after construction has read the base value, so it is a boolean here.
|
|
122
|
+
renderer.shadowMap.enabled = performanceOn ? false : baseShadowMapEnabled;
|
|
123
|
+
}
|
|
124
|
+
const renderer = createRenderer(canvas, effectiveProfile());
|
|
125
|
+
// Read before the mode has touched anything, so leaving performance mode restores the host's own
|
|
126
|
+
// setting instead of asserting three's default over it.
|
|
127
|
+
baseShadowMapEnabled = renderer.shadowMap.enabled;
|
|
128
|
+
if (performanceOn)
|
|
129
|
+
renderer.shadowMap.enabled = false;
|
|
130
|
+
/** voiceId → { cell, controller, lastW, lastH, rect, blit } — `rect` is the GL draw rect
|
|
131
|
+
* (fixed-overlay) or the viewport-relative cull/size box (per-cell); `blit` is the per-cell
|
|
132
|
+
* presentation canvas + its 2d context (per-cell only). */
|
|
133
|
+
const voices = new Map();
|
|
134
|
+
let grid = null;
|
|
135
|
+
let raf = null;
|
|
136
|
+
let disposed = false;
|
|
137
|
+
// A load meter's watch on the realm's frames (`setFrameProbe`): null when nobody is watching.
|
|
138
|
+
let frameProbe = null;
|
|
139
|
+
let frameProbeAt = 0;
|
|
140
|
+
let probePixel = null;
|
|
141
|
+
let lastCanvasW = 0;
|
|
142
|
+
let lastCanvasH = 0;
|
|
143
|
+
let viewportH = 0; // per-cell cull band (CSS px), refreshed with the rects
|
|
144
|
+
let viewportW = 0;
|
|
145
|
+
// The rAF loop must NOT read layout every frame. `getBoundingClientRect()` / `clientHeight`
|
|
146
|
+
// force a synchronous reflow, and while dragging the camera the per-tile knob re-renders dirty the
|
|
147
|
+
// layout every frame — so N per-frame reads = N reflows = the janky, irregular knob response in multi.
|
|
148
|
+
// Instead we CACHE each cell's rect + the canvas size and refresh only when something actually changes
|
|
149
|
+
// (a cell resizes, the stack scrolls, or the window resizes). A steady drag then reads zero layout.
|
|
150
|
+
// (Per-cell mode keeps the same cache: there the rect only culls and sizes — a stale frame merely
|
|
151
|
+
// renders one extra cell; it can't misplace pixels, the DOM owns their position.)
|
|
152
|
+
let rectsDirty = true;
|
|
153
|
+
const markRectsDirty = () => { rectsDirty = true; };
|
|
154
|
+
const cellObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(markRectsDirty) : null;
|
|
155
|
+
// Cells move (without resizing) when the mobile vertical stage stack scrolls — capture-phase catches
|
|
156
|
+
// scrolling of any ancestor container, not just the window. Window resize changes the canvas box too.
|
|
157
|
+
// The BODY observer catches layout shifts with no scroll/resize event at all: content above a cell
|
|
158
|
+
// expanding or collapsing moves the cell (and changes the body box) while every cell keeps its own
|
|
159
|
+
// size — without it, per-cell culling could keep skipping a voice that shifted into the viewport.
|
|
160
|
+
if (typeof window !== 'undefined') {
|
|
161
|
+
window.addEventListener('scroll', markRectsDirty, { passive: true, capture: true });
|
|
162
|
+
window.addEventListener('resize', markRectsDirty);
|
|
163
|
+
}
|
|
164
|
+
if (typeof document !== 'undefined' && document.body)
|
|
165
|
+
cellObserver?.observe(document.body);
|
|
166
|
+
// Browser-fullscreen ownership for a voice. Fixed-overlay: the shared canvas lives on
|
|
167
|
+
// `<body>` (fixed, inset:0) and scissor-renders every cell; a cell is a transparent div on top of
|
|
168
|
+
// it. When a voice's header fullscreens ITS cell, the fullscreen "top layer" draws the cell (and
|
|
169
|
+
// its chrome) above an opaque backdrop that HIDES the body-level canvas — so the orbiter's 3D
|
|
170
|
+
// pixels would vanish. Fix: reparent the one shared canvas INTO the fullscreened cell (so it
|
|
171
|
+
// paints inside the top layer) and solo-render that voice full-bleed; restore both on exit.
|
|
172
|
+
// Single-orbiter never builds a compositor, so this path is realm-only. The header only calls
|
|
173
|
+
// `requestFullscreen()` on the cell — this listener owns the canvas move + solo, keeping the
|
|
174
|
+
// compositor the single owner of its canvas. Per-cell: the cell's own canvas rides into the top
|
|
175
|
+
// layer with it — no reparenting, only the solo-render (and the buffer grows to the cell's new
|
|
176
|
+
// size through the normal resize path).
|
|
177
|
+
let fullscreenVoiceId = null; // voiceId rendered solo full-bleed while fullscreen, else null
|
|
178
|
+
let canvasHome = null; // { parent, next, cssText } to restore the canvas to its realm home on exit
|
|
179
|
+
const fullscreenElement = () => (typeof document !== 'undefined'
|
|
180
|
+
? document.fullscreenElement || document.webkitFullscreenElement || null
|
|
181
|
+
: null);
|
|
182
|
+
const voiceIdForCell = (el) => {
|
|
183
|
+
if (!el)
|
|
184
|
+
return null;
|
|
185
|
+
for (const [id, voice] of voices)
|
|
186
|
+
if (voice.cell === el)
|
|
187
|
+
return id;
|
|
188
|
+
return null;
|
|
189
|
+
};
|
|
190
|
+
/** Put the canvas back to its realm home (`<body>`) and un-mutate its style. Safe to call when not
|
|
191
|
+
* fullscreen (no-op). `next` may have been detached from `parent` during fullscreen, so fall back
|
|
192
|
+
* to append rather than let `insertBefore` throw NotFoundError. Clears `fullscreenVoiceId`. */
|
|
193
|
+
const restoreCanvasHome = () => {
|
|
194
|
+
if (canvasHome) {
|
|
195
|
+
const { parent, next, cssText } = canvasHome;
|
|
196
|
+
canvas.style.cssText = cssText;
|
|
197
|
+
if (parent)
|
|
198
|
+
parent.insertBefore(canvas, next && next.parentNode === parent ? next : null);
|
|
199
|
+
canvasHome = null;
|
|
200
|
+
}
|
|
201
|
+
fullscreenVoiceId = null;
|
|
202
|
+
};
|
|
203
|
+
const onFullscreenChange = () => {
|
|
204
|
+
const enteringId = voiceIdForCell(fullscreenElement());
|
|
205
|
+
if (enteringId) {
|
|
206
|
+
if (fullscreenVoiceId === enteringId)
|
|
207
|
+
return;
|
|
208
|
+
if (perCell) {
|
|
209
|
+
// The fullscreened cell carries its own presentation canvas into the top layer; solo-render
|
|
210
|
+
// is all the compositor contributes here.
|
|
211
|
+
fullscreenVoiceId = enteringId;
|
|
212
|
+
markRectsDirty();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Direct switch from another fullscreen cell (no intervening exit): restore FIRST so canvasHome
|
|
216
|
+
// captures the true realm home, not the previous cell / already-mutated style.
|
|
217
|
+
if (fullscreenVoiceId)
|
|
218
|
+
restoreCanvasHome();
|
|
219
|
+
// enteringId came from a cell match, so this is set.
|
|
220
|
+
const cell = voices.get(enteringId)?.cell;
|
|
221
|
+
// Remember where the canvas belongs, then move it into the fullscreened cell. z-index:0 keeps
|
|
222
|
+
// it BEHIND the cell's pointer-events:none chrome host (a positioned, z-auto later sibling), so
|
|
223
|
+
// the buttons still show; it stays position:fixed;inset:0 to fill the now-full-viewport cell.
|
|
224
|
+
canvasHome = { parent: canvas.parentNode, next: canvas.nextSibling, cssText: canvas.style.cssText };
|
|
225
|
+
canvas.style.zIndex = '0';
|
|
226
|
+
cell.insertBefore(canvas, cell.firstChild);
|
|
227
|
+
fullscreenVoiceId = enteringId;
|
|
228
|
+
}
|
|
229
|
+
else if (fullscreenVoiceId) {
|
|
230
|
+
// Exit (or fullscreen moved off our cells): restore the canvas to its realm home and resume the grid.
|
|
231
|
+
restoreCanvasHome();
|
|
232
|
+
}
|
|
233
|
+
markRectsDirty();
|
|
234
|
+
};
|
|
235
|
+
if (typeof document !== 'undefined') {
|
|
236
|
+
document.addEventListener('fullscreenchange', onFullscreenChange);
|
|
237
|
+
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
|
|
238
|
+
}
|
|
239
|
+
/** Recompute + cache every voice's rect (and the buffer size) in ONE layout pass. Called from the
|
|
240
|
+
* loop only when `rectsDirty`, so the steady state does no forced layout.
|
|
241
|
+
* Fixed-overlay: rect = GL draw rect on the displayed canvas (bottom-left origin, so y flips
|
|
242
|
+
* against the fixed full-viewport canvas height). Per-cell: rect = the cell's viewport-relative
|
|
243
|
+
* box (top-left origin) — used only to cull and size; the buffer grows (never shrinks) to hold
|
|
244
|
+
* the largest cell, since every voice renders at the buffer origin. */
|
|
245
|
+
function refreshRects() {
|
|
246
|
+
if (perCell) {
|
|
247
|
+
viewportW = window.innerWidth;
|
|
248
|
+
viewportH = window.innerHeight;
|
|
249
|
+
let maxW = lastCanvasW;
|
|
250
|
+
let maxH = lastCanvasH;
|
|
251
|
+
for (const voice of voices.values()) {
|
|
252
|
+
const box = voice.cell?.getBoundingClientRect();
|
|
253
|
+
voice.rect = box
|
|
254
|
+
? { x: box.left, y: box.top, width: box.width, height: box.height }
|
|
255
|
+
: null;
|
|
256
|
+
if (box) {
|
|
257
|
+
if (box.width > maxW)
|
|
258
|
+
maxW = Math.ceil(box.width);
|
|
259
|
+
if (box.height > maxH)
|
|
260
|
+
maxH = Math.ceil(box.height);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (maxW > lastCanvasW || maxH > lastCanvasH) {
|
|
264
|
+
lastCanvasW = maxW;
|
|
265
|
+
lastCanvasH = maxH;
|
|
266
|
+
renderer.setSize(maxW, maxH, false);
|
|
267
|
+
// The EFFECTIVE profile, not the host's: a cell growing must not quietly hand the realm its
|
|
268
|
+
// full pixel ratio back while performance mode is still on.
|
|
269
|
+
applyPixelRatio(renderer, effectiveProfile());
|
|
270
|
+
}
|
|
271
|
+
rectsDirty = false;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
syncCanvasSize();
|
|
275
|
+
const canvasH = lastCanvasH || canvas.clientHeight || window.innerHeight;
|
|
276
|
+
for (const voice of voices.values()) {
|
|
277
|
+
const box = voice.cell?.getBoundingClientRect();
|
|
278
|
+
voice.rect = box
|
|
279
|
+
? { x: box.left, y: canvasH - box.bottom, width: box.width, height: box.height }
|
|
280
|
+
: null;
|
|
281
|
+
}
|
|
282
|
+
rectsDirty = false;
|
|
283
|
+
}
|
|
284
|
+
/** Create-or-get the CSS grid that lays the cells out (square-ish: ⌈√n⌉ columns). */
|
|
285
|
+
function ensureGrid(total) {
|
|
286
|
+
if (grid)
|
|
287
|
+
return grid;
|
|
288
|
+
const cols = Math.ceil(Math.sqrt(total));
|
|
289
|
+
const rows = Math.ceil(total / cols);
|
|
290
|
+
grid = document.createElement('div');
|
|
291
|
+
grid.id = GRID_ID;
|
|
292
|
+
// Above the canvas, but transparent + click-through: cells exist to define viewport rects (and to
|
|
293
|
+
// anchor per-tile GUI later); the canvas underneath paints every voice.
|
|
294
|
+
grid.style.cssText = [
|
|
295
|
+
'position:fixed',
|
|
296
|
+
'inset:0',
|
|
297
|
+
'z-index:41',
|
|
298
|
+
'display:grid',
|
|
299
|
+
'gap:2px',
|
|
300
|
+
'pointer-events:none',
|
|
301
|
+
`grid-template-columns:repeat(${cols},1fr)`,
|
|
302
|
+
`grid-template-rows:repeat(${rows},1fr)`,
|
|
303
|
+
].join(';');
|
|
304
|
+
document.body.appendChild(grid);
|
|
305
|
+
return grid;
|
|
306
|
+
}
|
|
307
|
+
function createCell(index, total) {
|
|
308
|
+
// Collection mode: the slot layout owns the cell DOM (a resizable slot). Borrow its element as this
|
|
309
|
+
// voice's viewport rect + GUI mount — the compositor builds no grid of its own.
|
|
310
|
+
if (cellSource) {
|
|
311
|
+
return cellSource.acquireCell(index, total);
|
|
312
|
+
}
|
|
313
|
+
const cell = document.createElement('div');
|
|
314
|
+
cell.className = 'multi-orbiter-cell';
|
|
315
|
+
cell.dataset.voiceIndex = String(index);
|
|
316
|
+
// The cell is this voice's own interactive input surface (pointer-events:auto), so its
|
|
317
|
+
// CameraController receives its planet drags directly and DOM hit-testing routes clicks to the right
|
|
318
|
+
// tile. The grid CONTAINER stays pointer-events:none, so the 2px inter-tile gaps still click through.
|
|
319
|
+
cell.style.cssText = 'position:relative;overflow:hidden;min-width:0;min-height:0;pointer-events:auto;';
|
|
320
|
+
ensureGrid(total).appendChild(cell);
|
|
321
|
+
return cell;
|
|
322
|
+
}
|
|
323
|
+
/** Per-cell mode: the compositor-owned presentation canvas living INSIDE a voice's cell. The DOM
|
|
324
|
+
* positions it (absolute, inset:0), so it scrolls with the page on the compositor thread — the
|
|
325
|
+
* whole point of the mode. First child + z-index:0 keeps it under the cell's chrome; pointer
|
|
326
|
+
* events pass through to the cell, which owns the voice's camera input binding. */
|
|
327
|
+
function createBlitCanvas(cell) {
|
|
328
|
+
// An absolutely-positioned child anchors to the nearest positioned ancestor — make sure that's
|
|
329
|
+
// the cell itself, not something above it.
|
|
330
|
+
if (typeof getComputedStyle === 'function' && getComputedStyle(cell).position === 'static') {
|
|
331
|
+
cell.style.position = 'relative';
|
|
332
|
+
}
|
|
333
|
+
const el = document.createElement('canvas');
|
|
334
|
+
el.className = 'multi-orbiter-cell-canvas';
|
|
335
|
+
el.style.cssText =
|
|
336
|
+
'position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;z-index:0;';
|
|
337
|
+
cell.insertBefore(el, cell.firstChild);
|
|
338
|
+
const ctx = el.getContext('2d');
|
|
339
|
+
return { el, ctx };
|
|
340
|
+
}
|
|
341
|
+
function addVoice({ voiceId, cell, controller }) {
|
|
342
|
+
if (!voiceId || !cell || !controller)
|
|
343
|
+
return;
|
|
344
|
+
const blit = perCell ? createBlitCanvas(cell) : null;
|
|
345
|
+
voices.set(voiceId, { cell, controller, lastW: 0, lastH: 0, rect: null, blit });
|
|
346
|
+
cellObserver?.observe(cell); // resize of this tile → recompute its cached rect
|
|
347
|
+
markRectsDirty();
|
|
348
|
+
}
|
|
349
|
+
function removeVoice(voiceId) {
|
|
350
|
+
const voice = voices.get(voiceId);
|
|
351
|
+
if (!voice)
|
|
352
|
+
return;
|
|
353
|
+
// If this voice is fullscreen its cell hosts the shared canvas — restore the canvas to its
|
|
354
|
+
// realm home FIRST, so removing the cell below (grid mode) can't take the canvas down with it.
|
|
355
|
+
if (voiceId === fullscreenVoiceId)
|
|
356
|
+
restoreCanvasHome();
|
|
357
|
+
voices.delete(voiceId);
|
|
358
|
+
if (voice.cell)
|
|
359
|
+
cellObserver?.unobserve(voice.cell);
|
|
360
|
+
// The blit canvas is compositor-owned even when the HOST owns the cell — always take it out.
|
|
361
|
+
voice.blit?.el.remove();
|
|
362
|
+
markRectsDirty();
|
|
363
|
+
// Collection mode: the slot layout owns the cell DOM — leave it in place (the layout reclaims or
|
|
364
|
+
// re-fills the slot). Only the compositor-built grid removes its own cells.
|
|
365
|
+
if (cellSource)
|
|
366
|
+
return;
|
|
367
|
+
voice.cell?.remove();
|
|
368
|
+
if (grid && grid.childElementCount === 0) {
|
|
369
|
+
grid.remove();
|
|
370
|
+
grid = null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** Keep the renderer's drawing buffer matched to the canvas's CSS box (handles window resize).
|
|
374
|
+
* Fixed-overlay only — per-cell sizes the buffer to the largest cell in `refreshRects`. */
|
|
375
|
+
function syncCanvasSize() {
|
|
376
|
+
const w = canvas.clientWidth || window.innerWidth;
|
|
377
|
+
const h = canvas.clientHeight || window.innerHeight;
|
|
378
|
+
if (!w || !h || (w === lastCanvasW && h === lastCanvasH))
|
|
379
|
+
return;
|
|
380
|
+
lastCanvasW = w;
|
|
381
|
+
lastCanvasH = h;
|
|
382
|
+
renderer.setSize(w, h, false);
|
|
383
|
+
// The EFFECTIVE profile, not the host's. A window resize — or dragging the window to a display
|
|
384
|
+
// with a different pixel ratio — must not restore full-resolution fragments while performance
|
|
385
|
+
// mode is still on, which is what re-applying the raw profile here would do.
|
|
386
|
+
applyPixelRatio(renderer, effectiveProfile());
|
|
387
|
+
}
|
|
388
|
+
/** Fixed-overlay frame: clear the displayed canvas, then scissor-render every visible voice at its
|
|
389
|
+
* on-screen rect. */
|
|
390
|
+
function renderOverlayFrame() {
|
|
391
|
+
// Clear the WHOLE buffer once (scissor off) so the gridline gaps and any paused/empty cell show
|
|
392
|
+
// the canvas CSS background, not stale pixels — then each voice scissor-renders into its own
|
|
393
|
+
// rect. The compositor owns the realm clear: re-assert a fully transparent clear color, because
|
|
394
|
+
// a voice's boot can leave an opaque clear on the SHARED renderer, which would paint the entire
|
|
395
|
+
// canvas over an embedding host page (the app's own full-screen canvas hides this — its CSS
|
|
396
|
+
// background is already black).
|
|
397
|
+
renderer.setClearColor(0x000000, 0);
|
|
398
|
+
renderer.setScissorTest(false);
|
|
399
|
+
renderer.clear();
|
|
400
|
+
const canvasW = lastCanvasW;
|
|
401
|
+
const canvasH = lastCanvasH;
|
|
402
|
+
for (const [voiceId, voice] of voices) {
|
|
403
|
+
// While one voice is browser-fullscreen its cell hosts the shared canvas in the top
|
|
404
|
+
// layer and fills the viewport (rect = whole canvas). Render ONLY that voice — the other cells
|
|
405
|
+
// sit behind the fullscreen backdrop, so scissor-rendering them would stamp stray tiles over it.
|
|
406
|
+
if (fullscreenVoiceId && voiceId !== fullscreenVoiceId)
|
|
407
|
+
continue;
|
|
408
|
+
const { controller, rect } = voice;
|
|
409
|
+
if (!controller.renderActive || !rect)
|
|
410
|
+
continue;
|
|
411
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
412
|
+
continue;
|
|
413
|
+
// Cull stages scrolled fully off-canvas (the mobile vertical stage stack) — no point scissor-
|
|
414
|
+
// rendering a viewport with no on-screen pixels. rect is GL bottom-left; visible band is [0,canvasH].
|
|
415
|
+
if (rect.y + rect.height <= 0 || rect.y >= canvasH || rect.x + rect.width <= 0 || rect.x >= canvasW) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
// Re-aspect the camera only when the cell's box actually changed (avoids a per-frame projection rebuild).
|
|
419
|
+
if (rect.width !== voice.lastW || rect.height !== voice.lastH) {
|
|
420
|
+
controller.setViewportSize(rect.width, rect.height);
|
|
421
|
+
voice.lastW = rect.width;
|
|
422
|
+
voice.lastH = rect.height;
|
|
423
|
+
}
|
|
424
|
+
controller.renderViewport(rect);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/** Per-cell frame: render each visible voice at the buffer ORIGIN, then blit that region into the
|
|
428
|
+
* voice's own canvas. Every voice reuses the same origin region (cleared per voice), so the
|
|
429
|
+
* buffer only ever needs to hold the largest single cell. The blit is `drawImage` straight from
|
|
430
|
+
* the WebGL canvas — same-task with the render, so `preserveDrawingBuffer:false` stays safe. */
|
|
431
|
+
function renderPerCellFrame() {
|
|
432
|
+
renderer.setClearColor(0x000000, 0);
|
|
433
|
+
const dpr = renderer.getPixelRatio();
|
|
434
|
+
// The blit reads DEVICE pixels; the canvas attribute is the buffer's true height (three floors
|
|
435
|
+
// internally — recomputing from CSS px could drift by a pixel).
|
|
436
|
+
const bufH = canvas.height;
|
|
437
|
+
for (const [voiceId, voice] of voices) {
|
|
438
|
+
if (fullscreenVoiceId && voiceId !== fullscreenVoiceId)
|
|
439
|
+
continue;
|
|
440
|
+
const { controller, rect, blit } = voice;
|
|
441
|
+
if (!controller.renderActive || !rect || !blit?.ctx)
|
|
442
|
+
continue;
|
|
443
|
+
const { width, height } = rect;
|
|
444
|
+
if (width <= 0 || height <= 0)
|
|
445
|
+
continue;
|
|
446
|
+
// Cull cells fully outside the viewport (+slop: the cached rect may be a frame stale during a
|
|
447
|
+
// fast scroll — better one extra render than a blank cell entering the screen).
|
|
448
|
+
if (rect.y + height <= -CULL_SLOP_PX ||
|
|
449
|
+
rect.y >= viewportH + CULL_SLOP_PX ||
|
|
450
|
+
rect.x + width <= -CULL_SLOP_PX ||
|
|
451
|
+
rect.x >= viewportW + CULL_SLOP_PX) {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
// Floor to match three's own device-px math (setViewport/setScissor floor after the dpr
|
|
455
|
+
// multiply) — rounding UP would blit a stale 1px edge the render never touched.
|
|
456
|
+
const sw = Math.floor(width * dpr);
|
|
457
|
+
const sh = Math.floor(height * dpr);
|
|
458
|
+
if (width !== voice.lastW || height !== voice.lastH) {
|
|
459
|
+
controller.setViewportSize(width, height);
|
|
460
|
+
voice.lastW = width;
|
|
461
|
+
voice.lastH = height;
|
|
462
|
+
}
|
|
463
|
+
// Setting a canvas's backing-store size clears it, so only do it on actual change.
|
|
464
|
+
if (blit.el.width !== sw || blit.el.height !== sh) {
|
|
465
|
+
blit.el.width = sw;
|
|
466
|
+
blit.el.height = sh;
|
|
467
|
+
}
|
|
468
|
+
// Clear this voice's region (previous voice's pixels live there), then let it render.
|
|
469
|
+
renderer.setViewport(0, 0, width, height);
|
|
470
|
+
renderer.setScissor(0, 0, width, height);
|
|
471
|
+
renderer.setScissorTest(true);
|
|
472
|
+
renderer.clear();
|
|
473
|
+
controller.renderViewport({ x: 0, y: 0, width, height });
|
|
474
|
+
// GL origin is bottom-left: the region sits at the BOTTOM of the buffer in 2D (top-left) coords.
|
|
475
|
+
// 'copy' replaces the destination (alpha included) — no clearRect pass needed.
|
|
476
|
+
blit.ctx.globalCompositeOperation = 'copy';
|
|
477
|
+
blit.ctx.drawImage(canvas, 0, bufH - sh, sw, sh, 0, 0, sw, sh);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
/** Render exactly ONE composited frame (every active voice into its cell). Also the unit the rAF
|
|
481
|
+
* loop runs each tick. Exposed on the dev handle so a paused/hidden tab can force a frame.
|
|
482
|
+
* three's setViewport/setScissor multiply by the renderer's pixelRatio internally, so the cached
|
|
483
|
+
* rects are CSS px — pre-multiplying by dpr would double-scale (correct only at dpr=1, broken on retina). */
|
|
484
|
+
function renderOnce() {
|
|
485
|
+
if (disposed)
|
|
486
|
+
return;
|
|
487
|
+
// Refresh cached rects/canvas size ONLY when layout changed (never per frame — see refreshRects).
|
|
488
|
+
if (rectsDirty)
|
|
489
|
+
refreshRects();
|
|
490
|
+
if (perCell) {
|
|
491
|
+
renderPerCellFrame();
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
renderOverlayFrame();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
function tick(nowMs) {
|
|
498
|
+
if (disposed)
|
|
499
|
+
return;
|
|
500
|
+
raf = requestAnimationFrame(tick);
|
|
501
|
+
// The cap belongs HERE, to the realm's own loop — never to a voice declining to draw inside
|
|
502
|
+
// `renderViewport`. A composited frame clears the whole canvas and then scissor-renders each cell
|
|
503
|
+
// into it, so a voice that skipped its draw would leave a black hole where its cell is and flash
|
|
504
|
+
// at half the realm's rate. Skipping the whole frame instead leaves every cell showing the last
|
|
505
|
+
// frame that was drawn — together, which is the only way a shared canvas can be consistent.
|
|
506
|
+
if (performanceOn && !frameBudget.shouldRender(nowMs))
|
|
507
|
+
return;
|
|
508
|
+
if (!frameProbe) {
|
|
509
|
+
renderOnce();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const probe = frameProbe;
|
|
513
|
+
probe.onFrame?.(nowMs);
|
|
514
|
+
const timed = nowMs - frameProbeAt >= probe.everyMs;
|
|
515
|
+
if (!timed) {
|
|
516
|
+
renderOnce();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
frameProbeAt = nowMs;
|
|
520
|
+
// Every cell's draw counted together: one composited frame is several renders.
|
|
521
|
+
const info = renderer.info;
|
|
522
|
+
const autoReset = info.autoReset;
|
|
523
|
+
info.autoReset = false;
|
|
524
|
+
info.reset();
|
|
525
|
+
try {
|
|
526
|
+
const started = performance.now();
|
|
527
|
+
renderOnce();
|
|
528
|
+
// A lost context draws nothing and answers nothing: no reading, not a good one.
|
|
529
|
+
if (waitForDraw()) {
|
|
530
|
+
const drawn = performance.now();
|
|
531
|
+
// Asked again with nothing left to draw: what the asking itself costs, taken off the reading.
|
|
532
|
+
waitForDraw();
|
|
533
|
+
probe.onDraw(Math.max(0, drawn - started - (performance.now() - drawn)), info.render.calls, info.render.triangles);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
// Put back as it was, even when a voice throws mid-frame.
|
|
538
|
+
info.autoReset = autoReset;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/** Block until the graphics chip has drawn what was sent: reading a pixel back is the one wait every
|
|
542
|
+
* browser honours. False when there is no context to ask. */
|
|
543
|
+
function waitForDraw() {
|
|
544
|
+
const gl = renderer.getContext?.();
|
|
545
|
+
if (!gl || gl.isContextLost())
|
|
546
|
+
return false;
|
|
547
|
+
probePixel ??= new Uint8Array(4);
|
|
548
|
+
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, probePixel);
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Watch the realm's frames, for a load meter: `onFrame(nowMs)` for every frame drawn, and one frame
|
|
553
|
+
* every `everyMs` timed to the end of its drawing (`onDraw(workMs, calls, triangles)`, every cell
|
|
554
|
+
* together). Only the timed frame waits for the graphics chip; every other frame is untouched.
|
|
555
|
+
* `null` stops it.
|
|
556
|
+
*/
|
|
557
|
+
function setFrameProbe(probe) {
|
|
558
|
+
frameProbe = probe ?? null;
|
|
559
|
+
frameProbeAt = 0;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Turn the realm's performance mode on or off, live. Clamps the realm pixel ratio to 1, turns the
|
|
563
|
+
* shadow map off and caps this loop at 30fps while on; puts all three back when off. Returns
|
|
564
|
+
* whether the mode actually changed.
|
|
565
|
+
*/
|
|
566
|
+
function setPerformanceMode(enabled) {
|
|
567
|
+
const next = Boolean(enabled);
|
|
568
|
+
if (disposed || next === performanceOn)
|
|
569
|
+
return false;
|
|
570
|
+
performanceOn = next;
|
|
571
|
+
applyRealmRendererProfile();
|
|
572
|
+
// The next frame is the first of the new cadence — it must not be judged against a frame drawn
|
|
573
|
+
// under the old one.
|
|
574
|
+
frameBudget.reset();
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
function dispose() {
|
|
578
|
+
if (disposed)
|
|
579
|
+
return;
|
|
580
|
+
disposed = true;
|
|
581
|
+
if (raf != null)
|
|
582
|
+
cancelAnimationFrame(raf);
|
|
583
|
+
raf = null;
|
|
584
|
+
cellObserver?.disconnect();
|
|
585
|
+
if (typeof window !== 'undefined') {
|
|
586
|
+
window.removeEventListener('scroll', markRectsDirty, { capture: true });
|
|
587
|
+
window.removeEventListener('resize', markRectsDirty);
|
|
588
|
+
}
|
|
589
|
+
if (typeof document !== 'undefined') {
|
|
590
|
+
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
|
591
|
+
document.removeEventListener('webkitfullscreenchange', onFullscreenChange);
|
|
592
|
+
}
|
|
593
|
+
// If disposed while a voice was fullscreen, put the canvas style back before `canvas.remove()`.
|
|
594
|
+
restoreCanvasHome();
|
|
595
|
+
for (const voice of voices.values())
|
|
596
|
+
voice.blit?.el.remove();
|
|
597
|
+
voices.clear();
|
|
598
|
+
if (typeof window !== 'undefined' && devHandleKey) {
|
|
599
|
+
const devWindow = window;
|
|
600
|
+
if (devWindow[devHandleKey]?.renderer === renderer)
|
|
601
|
+
delete devWindow[devHandleKey];
|
|
602
|
+
}
|
|
603
|
+
renderer.dispose();
|
|
604
|
+
grid?.remove();
|
|
605
|
+
grid = null;
|
|
606
|
+
canvas.remove();
|
|
607
|
+
}
|
|
608
|
+
raf = requestAnimationFrame(tick);
|
|
609
|
+
const api = {
|
|
610
|
+
renderer,
|
|
611
|
+
canvas,
|
|
612
|
+
createCell,
|
|
613
|
+
addVoice,
|
|
614
|
+
removeVoice,
|
|
615
|
+
renderOnce,
|
|
616
|
+
setPerformanceMode,
|
|
617
|
+
setFrameProbe,
|
|
618
|
+
dispose,
|
|
619
|
+
};
|
|
620
|
+
// Dev handle: lets tooling force a composited frame when rAF is throttled (e.g. a hidden/background
|
|
621
|
+
// tab during automated checks). The HOST names it — this module invents no global of its own.
|
|
622
|
+
if (devHandleKey && typeof window !== 'undefined') {
|
|
623
|
+
window[devHandleKey] = api;
|
|
624
|
+
}
|
|
625
|
+
return api;
|
|
626
|
+
}
|
|
627
|
+
function createRenderer(canvas, profile) {
|
|
628
|
+
const renderer = new THREE.WebGLRenderer({
|
|
629
|
+
canvas,
|
|
630
|
+
antialias: profile.antialias,
|
|
631
|
+
alpha: profile.alpha,
|
|
632
|
+
preserveDrawingBuffer: profile.preserveDrawingBuffer,
|
|
633
|
+
powerPreference: profile.powerPreference,
|
|
634
|
+
});
|
|
635
|
+
applyPixelRatio(renderer, profile);
|
|
636
|
+
renderer.setSize(canvas.clientWidth || window.innerWidth, canvas.clientHeight || window.innerHeight, false);
|
|
637
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
638
|
+
return renderer;
|
|
639
|
+
}
|
|
640
|
+
function applyPixelRatio(renderer, profile) {
|
|
641
|
+
const dpr = typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio) ? window.devicePixelRatio : 1;
|
|
642
|
+
renderer.setPixelRatio(Math.min(dpr, profile.maxDevicePixelRatio));
|
|
643
|
+
}
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
type Setter = (arg: never) => void;
|
|
2
2
|
/** A header sink implements any subset of the setters. */
|
|
3
3
|
export type HeaderSinkOf<K extends string> = Partial<Record<K, Setter>>;
|
|
4
|
-
|
|
4
|
+
/** The setter the wrapper puts on `P`: the shell's own one-argument signature, or any argument. */
|
|
5
|
+
type ForwardingSetter<Shell, P extends string> = P extends keyof Shell ? Exclude<Shell[P], undefined> extends (arg: infer A) => unknown ? (arg: A) => void : (arg: unknown) => void : (arg: unknown) => void;
|
|
6
|
+
/**
|
|
7
|
+
* The wrapped shell: the shell's own members, a forwarding setter (always there) for every header
|
|
8
|
+
* key, including keys the shell lacks (they reach the sink only), and `replayInto`. Header keys
|
|
9
|
+
* typed only as `string` name no setter.
|
|
10
|
+
*/
|
|
11
|
+
export type HeaderBroadcast<Shell extends object, K extends string> = Shell & (string extends K ? unknown : {
|
|
12
|
+
[P in K]-?: ForwardingSetter<Shell, P>;
|
|
13
|
+
}) & {
|
|
5
14
|
/** Push the last value of every header setter into a freshly registered sink. */
|
|
6
15
|
replayInto(sink: HeaderSinkOf<K> | null | undefined): void;
|
|
7
16
|
};
|