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.
|
|
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
|
+
});
|
package/src/render/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|