entangled-worlds-orbiters-shared 2.4.2 → 2.5.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.5.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",
@@ -88,6 +88,8 @@
88
88
  "build:clock": "tsc -p tsconfig.build.json",
89
89
  "prepare": "npm run build:clock",
90
90
  "typecheck": "tsc -p tsconfig.json --noEmit",
91
- "test:clock": "node --test --import tsx src/clock/*.test.ts"
91
+ "test:clock": "node --test --import tsx src/clock/*.test.ts",
92
+ "test:render": "node --test src/render/*.test.js",
93
+ "test": "npm run test:clock && npm run test:render"
92
94
  }
93
95
  }
@@ -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
+ }
@@ -0,0 +1,62 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createFrameBudget, CAPPED_FPS } from './frameBudget.js';
4
+
5
+ test('the first frame is always allowed', () => {
6
+ const budget = createFrameBudget();
7
+ assert.equal(budget.shouldRender(0), true);
8
+ });
9
+
10
+ test('a 60Hz display yields 30fps, not 20', () => {
11
+ // The whole reason the tolerance exists. rAF on a 60Hz display delivers at ~16.7ms intervals; a
12
+ // strict >= 33.3 test rejects the frame at 33.3 and waits for 50, which is 20fps.
13
+ const budget = createFrameBudget();
14
+ const accepted = [];
15
+ for (let i = 0; i < 12; i += 1) {
16
+ const now = i * (1000 / 60);
17
+ if (budget.shouldRender(now)) accepted.push(i);
18
+ }
19
+ // Every other frame: 0, 2, 4, ... — half of 60 is the 30 that was asked for.
20
+ assert.deepEqual(accepted, [0, 2, 4, 6, 8, 10]);
21
+ });
22
+
23
+ test('a 120Hz display is capped to the same rate', () => {
24
+ const budget = createFrameBudget();
25
+ let accepted = 0;
26
+ for (let i = 0; i < 120; i += 1) {
27
+ if (budget.shouldRender(i * (1000 / 120))) accepted += 1;
28
+ }
29
+ // One second of 120Hz ticks, capped at 30 — allow the boundary frame either way.
30
+ assert.ok(accepted >= CAPPED_FPS && accepted <= CAPPED_FPS + 1, `accepted ${accepted}`);
31
+ });
32
+
33
+ test('a rejected frame does not move the clock forward', () => {
34
+ // Otherwise a burst of rejected frames would keep pushing the next allowed one further out.
35
+ const budget = createFrameBudget();
36
+ budget.shouldRender(0);
37
+ assert.equal(budget.shouldRender(10), false);
38
+ assert.equal(budget.shouldRender(20), false);
39
+ assert.equal(budget.shouldRender(30), true);
40
+ });
41
+
42
+ test('reset makes the next frame the first one again', () => {
43
+ const budget = createFrameBudget();
44
+ budget.shouldRender(0);
45
+ assert.equal(budget.shouldRender(5), false);
46
+ budget.reset();
47
+ assert.equal(budget.shouldRender(5), true);
48
+ });
49
+
50
+ test('a missing timestamp never blocks a frame', () => {
51
+ // rAF always supplies one, but `renderOnce` can be driven by tooling that does not.
52
+ const budget = createFrameBudget();
53
+ assert.equal(budget.shouldRender(undefined), true);
54
+ assert.equal(budget.shouldRender(Number.NaN), true);
55
+ });
56
+
57
+ test('a custom rate is honoured', () => {
58
+ const budget = createFrameBudget({ fps: 10, toleranceMs: 0 });
59
+ assert.equal(budget.shouldRender(0), true);
60
+ assert.equal(budget.shouldRender(99), false);
61
+ assert.equal(budget.shouldRender(100), true);
62
+ });
@@ -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