entangled-worlds-orbiters-shared 2.4.2 → 2.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "2.4.2",
3
+ "version": "2.6.0",
4
4
  "description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,6 +8,7 @@
8
8
  "exports": {
9
9
  ".": "./src/index.js",
10
10
  "./world": "./src/world/index.js",
11
+ "./minimal": "./src/minimal/index.js",
11
12
  "./moons": "./src/moons/index.js",
12
13
  "./loaders": "./src/loaders/index.js",
13
14
  "./render": "./src/render/index.js",
@@ -42,6 +43,7 @@
42
43
  "files": [
43
44
  "src/**/*",
44
45
  "!src/**/*.test.ts",
46
+ "!src/**/*.test.js",
45
47
  "dist/**/*",
46
48
  "public/**/*",
47
49
  "README.md",
@@ -88,6 +90,10 @@
88
90
  "build:clock": "tsc -p tsconfig.build.json",
89
91
  "prepare": "npm run build:clock",
90
92
  "typecheck": "tsc -p tsconfig.json --noEmit",
91
- "test:clock": "node --test --import tsx src/clock/*.test.ts"
93
+ "test:clock": "node --test --import tsx src/clock/*.test.ts",
94
+ "test:render": "node --test src/render/*.test.js",
95
+ "test:minimal": "node --test src/minimal/*.test.js",
96
+ "test": "npm run test:clock && npm run test:render && npm run test:minimal && npm run test:moons",
97
+ "test:moons": "node --test src/moons/*.test.js"
92
98
  }
93
99
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The minimal ("performance mode") look: one cheap lit sphere, one key light, a weak ambient fill,
3
+ * and nothing else — no texture, no halo, no cloud shell, no skybox.
4
+ *
5
+ * Its own entry point rather than a few more names on `./world`, because both consumers want exactly
6
+ * this and nothing around it: the Orbiters renderer and the Entangled Worlds editor's performance
7
+ * preview. Keeping it separate means importing the look never drags in the texture/GLB/cloud loaders
8
+ * that the look exists to avoid.
9
+ */
10
+ export {
11
+ createMinimalWorld,
12
+ resolvePlanetColor,
13
+ MINIMAL_WORLD_MODE,
14
+ MINIMAL_MATERIAL_KINDS,
15
+ MINIMAL_PLANET_COLOR_HEX,
16
+ } from './minimalWorld.js';
17
+
18
+ export {
19
+ applyMinimalLighting,
20
+ removeMinimalLighting,
21
+ updateMinimalKeyLight,
22
+ takeSceneLights,
23
+ DEFAULT_LIGHT_NAMES,
24
+ MINIMAL_LIGHT_NAME,
25
+ MINIMAL_FILL_NAME,
26
+ } from './minimalLighting.js';
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @file src/minimal/minimalLighting.js
3
+ * @description The one light the minimal look keeps.
4
+ *
5
+ * Shared for the same reason as `minimalWorld.js`, and more sharply: lighting is what makes two
6
+ * renders of the same world look like the same world. The editor's performance preview and the
7
+ * Orbiters render MUST take their key light, their fill and their restore behaviour from this one
8
+ * module, or an author would tune a world against lighting the public never sees.
9
+ *
10
+ * Minimal is not an unlit scene. The moons are a lit material, so a scene with no lights renders them
11
+ * as black discs, and the planet needs a light for its shape to be legible at all. What it does not
12
+ * need is the world's usual pair — a hemisphere and an ambient, both white-on-white at full strength,
13
+ * which light every surface identically and are themselves part of why a sphere reads as a flat disc.
14
+ *
15
+ * So: exactly one directional light, no shadows. Its colour and direction come from the world's own
16
+ * authored lighting where the world provides them, which is how a minimal scene still carries some of
17
+ * the world's identity without loading anything.
18
+ */
19
+ import * as THREE from 'three';
20
+
21
+ /** The shared default lights, by the names the shared package gives them. */
22
+ export const DEFAULT_LIGHT_NAMES = Object.freeze(['ew_HemisphereLight', 'ew_AmbientLight']);
23
+ /** The single light the minimal look keeps. */
24
+ export const MINIMAL_LIGHT_NAME = 'orb_MinimalKeyLight';
25
+ /** The weak ambient that keeps the unlit side from going pure black. */
26
+ export const MINIMAL_FILL_NAME = 'orb_MinimalFillLight';
27
+
28
+ const DEFAULT_INTENSITY = 1.8;
29
+ const DEFAULT_DIRECTION = Object.freeze({ x: 1, y: 0.6, z: 1 });
30
+ /**
31
+ * The fill. A single directional light alone puts the unlit side at pure black, which cuts the
32
+ * silhouette in half instead of rounding it — the terminator reads as a hard edge rather than a
33
+ * curve. A weak ambient lifts that side just enough for the shape to close.
34
+ *
35
+ * Ambient is the cheapest light there is: a constant added per fragment, no direction, no attenuation,
36
+ * no extra pass, and no shadow map. It is the right way to soften this and the only one worth its cost.
37
+ */
38
+ const FILL_INTENSITY = 0.55;
39
+
40
+ function removeLightByName(scene, name) {
41
+ const light = scene.getObjectByName(name);
42
+ if (!light) return;
43
+ scene.remove(light);
44
+ // Lights hold no geometry or texture, but they do hold a shadow map + camera once shadows have
45
+ // been on, and `dispose` is what releases it.
46
+ light.dispose?.();
47
+ }
48
+
49
+ /**
50
+ * Detach the world's default lights and hand them back — WITHOUT disposing them.
51
+ *
52
+ * They carry the world's authored colour, direction and intensities, and nothing re-applies those
53
+ * when the mode is switched off inside Studio. Keeping the objects means leaving minimal restores
54
+ * exactly what the world had, rather than a generic pair at 1.0/1.0.
55
+ *
56
+ * @param {THREE.Scene} scene
57
+ * @returns {THREE.Light[]} the detached lights, in the order they were found
58
+ */
59
+ export function takeSceneLights(scene) {
60
+ if (!scene) return [];
61
+ const taken = [];
62
+ DEFAULT_LIGHT_NAMES.forEach((name) => {
63
+ const light = scene.getObjectByName(name);
64
+ if (!light) return;
65
+ scene.remove(light);
66
+ taken.push(light);
67
+ });
68
+ return taken;
69
+ }
70
+
71
+ /**
72
+ * Add the key light plus a weak ambient fill. Idempotent. Two lights, both of the cheapest kinds,
73
+ * and neither casting a shadow.
74
+ *
75
+ * The world's own lights are NOT removed here — that is `takeSceneLights`, so the caller decides
76
+ * whether it is keeping them to put back.
77
+ */
78
+ export function applyMinimalLighting(scene) {
79
+ if (!scene) return null;
80
+
81
+ if (!scene.getObjectByName(MINIMAL_FILL_NAME)) {
82
+ const fill = new THREE.AmbientLight(0xffffff, FILL_INTENSITY);
83
+ fill.name = MINIMAL_FILL_NAME;
84
+ scene.add(fill);
85
+ }
86
+
87
+ const existing = scene.getObjectByName(MINIMAL_LIGHT_NAME);
88
+ if (existing) return existing;
89
+ const key = new THREE.DirectionalLight(0xffffff, DEFAULT_INTENSITY);
90
+ key.position.set(DEFAULT_DIRECTION.x, DEFAULT_DIRECTION.y, DEFAULT_DIRECTION.z);
91
+ key.castShadow = false;
92
+ key.name = MINIMAL_LIGHT_NAME;
93
+ scene.add(key);
94
+ return key;
95
+ }
96
+
97
+ export function removeMinimalLighting(scene) {
98
+ if (!scene) return;
99
+ removeLightByName(scene, MINIMAL_LIGHT_NAME);
100
+ removeLightByName(scene, MINIMAL_FILL_NAME);
101
+ }
102
+
103
+ /**
104
+ * Point the key light the way the world says its light points.
105
+ *
106
+ * DIRECTION only — the world's light COLOUR is deliberately not applied. Minimal is a monochrome
107
+ * look, and a world that authored a strongly tinted light would drag the whole scene into that hue
108
+ * and break the consistency the mode is for. Direction gives the shape somewhere to be lit from,
109
+ * which is what actually varies usefully between worlds.
110
+ *
111
+ * A no-op when the key light is not in the scene, so a full-profile world load never has to check
112
+ * which mode it is in.
113
+ *
114
+ * @param {THREE.Scene} scene
115
+ * @param {{ lightDirection?: object, direction?: object,
116
+ * diffuseIntensity?: number, diffuse?: number }} lighting
117
+ */
118
+ export function updateMinimalKeyLight(scene, lighting = {}) {
119
+ const key = scene?.getObjectByName(MINIMAL_LIGHT_NAME);
120
+ if (!key) return;
121
+
122
+ const direction = lighting.lightDirection ?? lighting.direction;
123
+ if (direction && typeof direction === 'object') {
124
+ const x = Number.isFinite(direction.x) ? direction.x : DEFAULT_DIRECTION.x;
125
+ const y = Number.isFinite(direction.y) ? direction.y : DEFAULT_DIRECTION.y;
126
+ const z = Number.isFinite(direction.z) ? direction.z : DEFAULT_DIRECTION.z;
127
+ // A direction of all zeros would put the light inside the planet and black the scene out.
128
+ if (x !== 0 || y !== 0 || z !== 0) key.position.set(x, y, z);
129
+ }
130
+
131
+ const intensity = lighting.diffuseIntensity ?? lighting.diffuse;
132
+ if (Number.isFinite(intensity) && intensity > 0) {
133
+ // The world's intensities are authored against TWO lights at full strength; this is one, so it
134
+ // keeps the default as a floor rather than dimming the only light in the scene.
135
+ key.intensity = Math.max(DEFAULT_INTENSITY, intensity);
136
+ }
137
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @file src/minimal/minimalWorld.js
3
+ * @description The planet as performance mode draws it: one sphere, one cheap lit material, no
4
+ * texture, no halo, no cloud shell.
5
+ *
6
+ * Shared, not Orbiters-local, because TWO surfaces draw this look: Orbiters renders it in
7
+ * performance mode, and the Entangled Worlds editor previews it so an author can see what they are
8
+ * authoring. If each kept its own copy the preview would drift from the render and start lying about
9
+ * what the public will see — the one failure this module exists to prevent.
10
+ *
11
+ * Why this exists rather than reusing the shared normal-material sphere: `MeshNormalMaterial` colours
12
+ * each pixel by the direction the surface faces, which is a gradient, not shading. It cannot respond
13
+ * to a light at all — so however many lights the scene has, the planet reads as a flat disc while the
14
+ * moons beside it (a lit material) read as spheres. A lit material is what makes the shape legible.
15
+ *
16
+ * The cost of that is close to nothing. Lambert is one dot product per fragment against a single
17
+ * directional light — no specular, no roughness, no environment lookup, no texture fetch. What it
18
+ * costs over the normal material is far less than what a texture, a halo or a cloud shell cost, all
19
+ * of which this mode drops.
20
+ *
21
+ * `kind` exists so the options can be compared on real hardware without a rebuild
22
+ * (`?minimalMaterial=lambert|phong|normal`), and so the choice is one line if a cheaper one wins.
23
+ */
24
+ import * as THREE from 'three';
25
+
26
+ export const MINIMAL_WORLD_MODE = 90;
27
+
28
+ /**
29
+ * The minimal planet's colour when the world names none.
30
+ *
31
+ * Deliberately neutral, and deliberately NOT read from the `--color*` theme variables: those are the
32
+ * app chrome's palette (white, black, the UI accent), not the world's, so reading them would paint
33
+ * every planet the same interface colour. A world that DOES name a colour is honoured — see
34
+ * `resolvePlanetColor` — and what carries the identity of one that does not is its own authored
35
+ * light (`minimalLighting.js`).
36
+ */
37
+ const DEFAULT_PLANET_COLOR = 0xb8c2cc;
38
+
39
+ /**
40
+ * The same neutral as a CSS hex, for surfaces that have to SHOW it rather than render it — the
41
+ * editor's colour swatch displays this when a world has authored nothing, so the control never
42
+ * claims a colour the renderer is not actually using. Exported so there is one constant, not an
43
+ * editor copy that has to be kept equal to this one.
44
+ */
45
+ export const MINIMAL_PLANET_COLOR_HEX = `#${DEFAULT_PLANET_COLOR.toString(16).padStart(6, '0')}`;
46
+
47
+ /**
48
+ * The floor an authored colour is lifted to, as HSL lightness in the sRGB the author picked in.
49
+ *
50
+ * Minimal drops the skybox, so the planet is drawn against black (`SceneSpace` sets the background to
51
+ * BLACK when there is no cubemap). The scene's whole light budget is one key at 1.8 plus a 0.55
52
+ * ambient fill, so a near-black body reflects almost nothing and disappears into that background
53
+ * entirely — not a dark planet, no planet. Lifting the lightness while keeping hue and saturation
54
+ * means a world that authored near-black still reads as its own hue, just at the darkest level that
55
+ * is still a visible sphere.
56
+ *
57
+ * 0.10 sits below anything an author would recognise as a colour rather than as black: `#101030`, a
58
+ * genuinely dark navy, measures 0.125 and passes through untouched.
59
+ */
60
+ const MIN_PLANET_LIGHTNESS = 0.1;
61
+
62
+ export const MINIMAL_MATERIAL_KINDS = Object.freeze(['lambert', 'phong', 'normal']);
63
+
64
+ /**
65
+ * Segment counts used only when the world names none.
66
+ *
67
+ * The world's OWN resolution is never overridden, including when it is very low. A low-segment world
68
+ * is a faceted world on purpose — that faceting is the world's shape, not a defect to be smoothed
69
+ * away — and performance mode changes what is DRAWN, never the world's configuration.
70
+ */
71
+ const FALLBACK_WIDTH_SEGMENTS = 48;
72
+ const FALLBACK_HEIGHT_SEGMENTS = 32;
73
+
74
+ function resolveSegments(value, fallback) {
75
+ const numeric = Number(value);
76
+ if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
77
+ return Math.round(numeric);
78
+ }
79
+
80
+ /**
81
+ * Parse a colour, or report that it could not be parsed.
82
+ *
83
+ * `new THREE.Color(x)` does NOT throw on a value it cannot read: `Color.set` ignores anything that is
84
+ * not a Color, a number or a string, and `setStyle` warns and leaves the colour untouched. Either way
85
+ * the object keeps whatever it was constructed with, so a `try/catch` around it catches nothing and
86
+ * the caller silently gets that starting value — white, by default.
87
+ *
88
+ * That matters here because the colour arrives from world metadata, which is `Schema.Types.Mixed` and
89
+ * validated nowhere: any non-empty string persists, so `'blueish'` would paint a WHITE planet rather
90
+ * than falling back to the neutral one.
91
+ *
92
+ * So: probe from black. If the result is black the value was either genuinely black or not read at
93
+ * all — probe again from white to tell those apart. The common case parses on the first probe and
94
+ * costs one object.
95
+ *
96
+ * @returns {THREE.Color|null} null when the value could not be read as a colour
97
+ */
98
+ function parseColor(value) {
99
+ const fromBlack = new THREE.Color(0x000000).set(value);
100
+ if (fromBlack.getHex() !== 0x000000) return fromBlack;
101
+ const fromWhite = new THREE.Color(0xffffff).set(value);
102
+ return fromWhite.getHex() === 0x000000 ? fromBlack : null;
103
+ }
104
+
105
+ /**
106
+ * The planet colour to use, given the colour the world authored (if any).
107
+ *
108
+ * The only thing done to an authored colour is the legibility floor above. Hue and saturation are
109
+ * never touched: the point of the control is that the world picks its own colour, so anything that
110
+ * moved it toward a house palette would defeat it.
111
+ *
112
+ * @param {THREE.Color|number|string|null|undefined} override
113
+ * @returns {THREE.Color}
114
+ */
115
+ export function resolvePlanetColor(override) {
116
+ if (override === undefined || override === null) return new THREE.Color(DEFAULT_PLANET_COLOR);
117
+ // An unreadable colour is never worth failing a world load over — the world just keeps the neutral.
118
+ const color = parseColor(override);
119
+ if (!color) return new THREE.Color(DEFAULT_PLANET_COLOR);
120
+ // Read and write HSL in sRGB — the space the author picked the colour in. The working space is
121
+ // linear, where the same swatch measures several times darker, so a floor applied there would
122
+ // lift colours the author would not call dark at all.
123
+ const hsl = { h: 0, s: 0, l: 0 };
124
+ color.getHSL(hsl, THREE.SRGBColorSpace);
125
+ if (hsl.l < MIN_PLANET_LIGHTNESS) {
126
+ color.setHSL(hsl.h, hsl.s, MIN_PLANET_LIGHTNESS, THREE.SRGBColorSpace);
127
+ }
128
+ return color;
129
+ }
130
+
131
+ function createMaterial(kind, color) {
132
+ switch (kind) {
133
+ case 'normal':
134
+ // The previous look, kept so it can be compared directly rather than from memory.
135
+ return new THREE.MeshNormalMaterial();
136
+ case 'phong':
137
+ // One specular highlight on top of Lambert. Reads glossier; costs a little more.
138
+ return new THREE.MeshPhongMaterial({ color, shininess: 12, specular: 0x222222 });
139
+ default:
140
+ return new THREE.MeshLambertMaterial({ color });
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Build the minimal planet. Signature matches what `WorldManager.setMode` hands a custom creator.
146
+ *
147
+ * @param {{
148
+ * sphereRadius?: number,
149
+ * sphereWidthSegments?: number,
150
+ * sphereHeightSegments?: number,
151
+ * materialKind?: 'lambert'|'phong'|'normal',
152
+ * planetColor?: THREE.Color|number|string,
153
+ * }} args
154
+ * @returns {Promise<THREE.Mesh>}
155
+ */
156
+ export async function createMinimalWorld({
157
+ sphereRadius = 1,
158
+ sphereWidthSegments,
159
+ sphereHeightSegments,
160
+ materialKind = 'lambert',
161
+ planetColor,
162
+ } = {}) {
163
+ const kind = MINIMAL_MATERIAL_KINDS.includes(materialKind) ? materialKind : 'lambert';
164
+ const color = resolvePlanetColor(planetColor);
165
+
166
+ // The world's own radius and segment counts, as authored. Same shape as the full look — this mode
167
+ // changes the material and what surrounds the planet, never the geometry the world describes.
168
+ const geometry = new THREE.SphereGeometry(
169
+ sphereRadius,
170
+ resolveSegments(sphereWidthSegments, FALLBACK_WIDTH_SEGMENTS),
171
+ resolveSegments(sphereHeightSegments, FALLBACK_HEIGHT_SEGMENTS),
172
+ );
173
+ const material = createMaterial(kind, color);
174
+ const mesh = new THREE.Mesh(geometry, material);
175
+ mesh.name = 'orb_MinimalWorld';
176
+ mesh.castShadow = false;
177
+ mesh.receiveShadow = false;
178
+ return mesh;
179
+ }
@@ -38,7 +38,6 @@ export async function generateMoons({
38
38
  debugShowReferenceMoon = false, // changed from true to prevent extra moon
39
39
  referenceMoonScale = 0.25,
40
40
  // behavior flags
41
- preserveInstanceTint = false, // keep per-instance color even when textured
42
41
  useEmissiveMapFromTexture = true, // reuse color texture as emissiveMap to lift dark sides
43
42
  // The renderer whose KTX2 transcoder decodes the moon texture. Required on any page with more than
44
43
  // one renderer; falls back to the standalone app's global inside the loader.
@@ -118,10 +117,20 @@ export async function generateMoons({
118
117
  }
119
118
  }
120
119
 
121
- const enableVertexColors = !sharedTexture || preserveInstanceTint;
120
+ // `vertexColors` is deliberately NOT set, and setting it here was a bug worth spelling out.
121
+ //
122
+ // The per-moon tint rides on `instanceColor` (set by `setColorAt` below). Three defines
123
+ // USE_INSTANCING_COLOR for that on its own and multiplies it into `vColor` — no material flag
124
+ // needed. `vertexColors` is a DIFFERENT feature: it multiplies by the geometry's `color`
125
+ // ATTRIBUTE. This geometry is a plain SphereGeometry and has no such attribute, so enabling it
126
+ // made the shader multiply by an absent attribute — zero — and every moon rendered BLACK, with the
127
+ // tint annihilated along with it.
128
+ //
129
+ // It only bit the untextured path (`!sharedTexture`), which is the one the performance look uses:
130
+ // moons there carry no texture, so their colour IS the moon, and it was the one case where the
131
+ // colour silently could not work.
122
132
  const moonMaterial = new THREE.MeshStandardMaterial({
123
133
  map: sharedTexture || null,
124
- vertexColors: enableVertexColors,
125
134
  roughness,
126
135
  metalness,
127
136
  emissive: new THREE.Color(emissiveColor),
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @file src/render/frameBudget.js
3
+ * @description "Has enough time passed to draw again?" — the whole of a frame cap, kept apart from
4
+ * whatever is being capped so it can be reasoned about and tested on its own.
5
+ *
6
+ * The tolerance is the part that is easy to get wrong. rAF does not deliver frames at arbitrary
7
+ * times; it delivers them on the display's ticks. On a 60Hz display those arrive every ~16.7ms, so a
8
+ * strict `elapsed >= 33.3` test rejects the frame at 33.3ms by a hair and waits for the next one at
9
+ * 50ms — 20fps, not the 30 that was asked for. A few milliseconds of slack lets the frame that is
10
+ * near enough through, and the cap lands where it was aimed.
11
+ */
12
+
13
+ /** Frames a capped loop is allowed per second. */
14
+ export const CAPPED_FPS = 30;
15
+ /** Slack for rAF landing on display ticks — see the file note. */
16
+ export const FRAME_TOLERANCE_MS = 4;
17
+
18
+ /**
19
+ * A frame gate. Not a clock and not a scheduler: it answers one question and remembers the last time
20
+ * the answer was yes.
21
+ *
22
+ * @param {{ fps?: number, toleranceMs?: number }} [options]
23
+ * @returns {{ shouldRender(nowMs: number): boolean, reset(): void }}
24
+ */
25
+ export function createFrameBudget({ fps = CAPPED_FPS, toleranceMs = FRAME_TOLERANCE_MS } = {}) {
26
+ const intervalMs = 1000 / (Number.isFinite(fps) && fps > 0 ? fps : CAPPED_FPS);
27
+ const minElapsedMs = Math.max(0, intervalMs - toleranceMs);
28
+ let lastAtMs = null;
29
+
30
+ return {
31
+ /**
32
+ * True when `nowMs` is far enough past the last accepted frame. Accepting records the time, so
33
+ * the caller must only ask when it intends to act on the answer.
34
+ */
35
+ shouldRender(nowMs) {
36
+ if (!Number.isFinite(nowMs)) return true;
37
+ if (lastAtMs !== null && nowMs - lastAtMs < minElapsedMs) return false;
38
+ lastAtMs = nowMs;
39
+ return true;
40
+ },
41
+ /** Forget the last frame, so the next question is answered as if none had been drawn. */
42
+ reset() {
43
+ lastAtMs = null;
44
+ },
45
+ };
46
+ }
@@ -1,2 +1,3 @@
1
1
  // Render-host exports
2
2
  export { createViewportCompositor } from './viewportCompositor.js';
3
+ export { createFrameBudget, CAPPED_FPS, FRAME_TOLERANCE_MS } from './frameBudget.js';
@@ -46,6 +46,7 @@
46
46
  * @see `devHandleKey` — the one window global it can write, opt-in.
47
47
  */
48
48
  import * as THREE from 'three';
49
+ import { createFrameBudget } from './frameBudget.js';
49
50
 
50
51
  const GRID_ID = 'multi-orbiter-grid';
51
52
 
@@ -73,6 +74,10 @@ const CULL_SLOP_PX = 256;
73
74
  * DOM positions (cells inside scrolling content). See the module doc.
74
75
  * @param {Partial<typeof DEFAULT_RENDERER_PROFILE>} [opts.rendererProfile] renderer construction +
75
76
  * pixel-ratio cap. Omit for the historical defaults.
77
+ * @param {boolean} [opts.performanceMode=false] start with the realm's renderer savings on — pixel
78
+ * ratio clamped to 1, shadow map off, this loop capped at 30fps. Off by default, and off means the
79
+ * renderer and the cadence are exactly what they were before this option existed. Toggle it live
80
+ * with `setPerformanceMode`.
76
81
  * @param {string|null} [opts.devHandleKey=null] when set, publish the returned api on
77
82
  * `window[devHandleKey]` so tooling can force a composited frame while rAF is throttled (a hidden
78
83
  * tab during automated checks). Off by default — a library does not name globals for its host.
@@ -83,6 +88,7 @@ const CULL_SLOP_PX = 256;
83
88
  * addVoice: (voice: { voiceId: string, cell: HTMLElement, controller: object }) => void,
84
89
  * removeVoice: (voiceId: string) => void,
85
90
  * renderOnce: () => void,
91
+ * setPerformanceMode: (enabled: boolean) => boolean,
86
92
  * dispose: () => void,
87
93
  * }}
88
94
  */
@@ -91,8 +97,21 @@ export function createViewportCompositor({
91
97
  presentation = 'fixed-overlay',
92
98
  rendererProfile = null,
93
99
  devHandleKey = null,
100
+ performanceMode = false,
94
101
  } = {}) {
95
102
  const profile = { ...DEFAULT_RENDERER_PROFILE, ...(rendererProfile || {}) };
103
+ // The realm's renderer and the realm's loop are this module's, and they are the only things a
104
+ // single voice cannot change for itself: pixel ratio, shadow map and frame rate are one per realm,
105
+ // so a voice reaching for them would be deciding for its siblings. When the host says the realm is
106
+ // in performance mode, this is where that decision is made once, for all of them.
107
+ //
108
+ // Off is the default and off means untouched — a host that never mentions it (the feed embeds)
109
+ // gets exactly the renderer and the cadence it got before this existed.
110
+ let performanceOn = Boolean(performanceMode);
111
+ // What the realm's shadow map was before performance mode touched it, so turning the mode off puts
112
+ // the host's own choice back rather than asserting a default over it.
113
+ let baseShadowMapEnabled = null;
114
+ const frameBudget = createFrameBudget();
96
115
  const perCell = presentation === 'per-cell';
97
116
  // The one WebGL canvas. Fixed-overlay: full-bleed behind the grid, it owns the pixels for ALL
98
117
  // voices. Per-cell: never enters the DOM — a render source the per-cell canvases copy from.
@@ -107,7 +126,30 @@ export function createViewportCompositor({
107
126
  document.body.appendChild(canvas);
108
127
  }
109
128
 
110
- const renderer = createRenderer(canvas, profile);
129
+ /**
130
+ * The renderer settings actually in force. Performance mode overrides the host's profile rather
131
+ * than replacing it, so whatever the host asked for comes back untouched when the mode goes off.
132
+ *
133
+ * `antialias` is deliberately not in here: it is fixed when the WebGL context is created, so
134
+ * turning it off would cost the host its antialiasing for the rest of the session the moment the
135
+ * mode was switched back on. The saving that IS live is the pixel ratio, and at dpr 2 that is
136
+ * three quarters of the fragments.
137
+ */
138
+ function effectiveProfile() {
139
+ return performanceOn ? { ...profile, maxDevicePixelRatio: 1 } : profile;
140
+ }
141
+
142
+ /** Re-assert the realm-wide knobs. Called at construction and whenever the mode changes. */
143
+ function applyRealmRendererProfile() {
144
+ applyPixelRatio(renderer, effectiveProfile());
145
+ renderer.shadowMap.enabled = performanceOn ? false : baseShadowMapEnabled;
146
+ }
147
+
148
+ const renderer = createRenderer(canvas, effectiveProfile());
149
+ // Read before the mode has touched anything, so leaving performance mode restores the host's own
150
+ // setting instead of asserting three's default over it.
151
+ baseShadowMapEnabled = renderer.shadowMap.enabled;
152
+ if (performanceOn) renderer.shadowMap.enabled = false;
111
153
 
112
154
  /** voiceId → { cell, controller, lastW, lastH, rect, blit } — `rect` is the GL draw rect
113
155
  * (fixed-overlay) or the viewport-relative cull/size box (per-cell); `blit` is the per-cell
@@ -236,7 +278,9 @@ export function createViewportCompositor({
236
278
  lastCanvasW = maxW;
237
279
  lastCanvasH = maxH;
238
280
  renderer.setSize(maxW, maxH, false);
239
- applyPixelRatio(renderer, profile);
281
+ // The EFFECTIVE profile, not the host's: a cell growing must not quietly hand the realm its
282
+ // full pixel ratio back while performance mode is still on.
283
+ applyPixelRatio(renderer, effectiveProfile());
240
284
  }
241
285
  rectsDirty = false;
242
286
  return;
@@ -349,7 +393,10 @@ export function createViewportCompositor({
349
393
  lastCanvasW = w;
350
394
  lastCanvasH = h;
351
395
  renderer.setSize(w, h, false);
352
- applyPixelRatio(renderer, profile);
396
+ // The EFFECTIVE profile, not the host's. A window resize — or dragging the window to a display
397
+ // with a different pixel ratio — must not restore full-resolution fragments while performance
398
+ // mode is still on, which is what re-applying the raw profile here would do.
399
+ applyPixelRatio(renderer, effectiveProfile());
353
400
  }
354
401
 
355
402
  /** Fixed-overlay frame: clear the displayed canvas, then scissor-render every visible voice at its
@@ -458,12 +505,36 @@ export function createViewportCompositor({
458
505
  }
459
506
  }
460
507
 
461
- function tick() {
508
+ function tick(nowMs) {
462
509
  if (disposed) return;
463
510
  raf = requestAnimationFrame(tick);
511
+ // The cap belongs HERE, to the realm's own loop — never to a voice declining to draw inside
512
+ // `renderViewport`. A composited frame clears the whole canvas and then scissor-renders each cell
513
+ // into it, so a voice that skipped its draw would leave a black hole where its cell is and flash
514
+ // at half the realm's rate. Skipping the whole frame instead leaves every cell showing the last
515
+ // frame that was drawn — together, which is the only way a shared canvas can be consistent.
516
+ if (performanceOn && !frameBudget.shouldRender(nowMs)) return;
464
517
  renderOnce();
465
518
  }
466
519
 
520
+ /**
521
+ * Turn the realm's performance mode on or off, live. Clamps the realm pixel ratio to 1, turns the
522
+ * shadow map off and caps this loop at 30fps while on; puts all three back when off.
523
+ *
524
+ * @param {boolean} enabled
525
+ * @returns {boolean} whether the mode actually changed
526
+ */
527
+ function setPerformanceMode(enabled) {
528
+ const next = Boolean(enabled);
529
+ if (disposed || next === performanceOn) return false;
530
+ performanceOn = next;
531
+ applyRealmRendererProfile();
532
+ // The next frame is the first of the new cadence — it must not be judged against a frame drawn
533
+ // under the old one.
534
+ frameBudget.reset();
535
+ return true;
536
+ }
537
+
467
538
  function dispose() {
468
539
  if (disposed) return;
469
540
  disposed = true;
@@ -500,6 +571,7 @@ export function createViewportCompositor({
500
571
  addVoice,
501
572
  removeVoice,
502
573
  renderOnce,
574
+ setPerformanceMode,
503
575
  dispose,
504
576
  };
505
577
  // Dev handle: lets tooling force a composited frame when rAF is throttled (e.g. a hidden/background