@johnmorrisdotca/kazoku-ui 0.7.3 → 0.9.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/CHANGELOG.md +23 -0
- package/README.md +29 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/renderer/cache.d.ts +38 -0
- package/dist/renderer/cache.js +104 -0
- package/dist/renderer/index.d.ts +5 -0
- package/dist/renderer/index.js +7 -0
- package/dist/renderer/pixels.d.ts +16 -0
- package/dist/renderer/pixels.js +25 -0
- package/dist/renderer/renderer.d.ts +118 -0
- package/dist/renderer/renderer.js +472 -0
- package/dist/renderer/scheduler.d.ts +42 -0
- package/dist/renderer/scheduler.js +91 -0
- package/dist/renderer/stills.d.ts +27 -0
- package/dist/renderer/stills.js +50 -0
- package/dist/testing/index.d.ts +34 -0
- package/dist/testing/index.js +64 -0
- package/dist/view-controls/address.d.ts +4 -0
- package/dist/view-controls/address.js +9 -1
- package/dist/view-controls/element.d.ts +1 -0
- package/dist/view-controls/element.js +101 -12
- package/dist/view-controls/index.d.ts +1 -0
- package/dist/view-controls/index.js +1 -0
- package/dist/view-controls/levels.d.ts +12 -0
- package/dist/view-controls/levels.js +9 -0
- package/dist/view-controls/styles.d.ts +1 -1
- package/dist/view-controls/styles.js +13 -5
- package/dist/view-controls/words.d.ts +7 -0
- package/dist/view-controls/words.js +14 -0
- package/package.json +26 -3
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// The family's shared 3D renderer: ONE WebGL context for the page, handed to the view that has the focus. Every other view shows a still. A browser keeps
|
|
2
|
+
// about 16 contexts and silently kills the older ones, so a page of 40 live views is a page of broken ones; one context moved between hosts has no such limit.
|
|
3
|
+
//
|
|
4
|
+
// three.js is never imported here: the page passes `load: () => import("three")`, so three stays the page's own (peer) dependency, loads only when a view first
|
|
5
|
+
// goes live, and a page of stills ships no 3D code at all. What a view shows is an adapter the package supplies (`Item3D`): it builds a scene from shared assets.
|
|
6
|
+
import { createAssetCache } from "./cache.js";
|
|
7
|
+
import { MAX_PIXELS, MAX_RATIO, MAX_SIDE, pixelRatioFor } from "./pixels.js";
|
|
8
|
+
import { createScheduler } from "./scheduler.js";
|
|
9
|
+
import { createStillCache } from "./stills.js";
|
|
10
|
+
export function createSharedRenderer(options) {
|
|
11
|
+
const limits = { maxPixels: options.maxPixels ?? MAX_PIXELS, maxSide: options.maxSide ?? MAX_SIDE, maxRatio: options.maxRatio ?? MAX_RATIO };
|
|
12
|
+
const stillSize = options.stillSize ?? 320;
|
|
13
|
+
const releaseMs = options.releaseMs ?? 30_000;
|
|
14
|
+
const doc = () => options.document ?? globalThis.document;
|
|
15
|
+
const scheduler = options.scheduler ?? createScheduler(options.clock === undefined ? {} : { clock: options.clock });
|
|
16
|
+
const assets = options.assets ?? createAssetCache(options.lingerMs === undefined ? {} : { lingerMs: options.lingerMs });
|
|
17
|
+
const views = new Set();
|
|
18
|
+
const byKey = (key) => [...views].filter((v) => v.item.key === key);
|
|
19
|
+
const stills = createStillCache({
|
|
20
|
+
...(options.stillBytes === undefined ? {} : { maxBytes: options.stillBytes }),
|
|
21
|
+
onEvict: (key) => {
|
|
22
|
+
for (const v of byKey(key))
|
|
23
|
+
if (v.img !== null && !v.live)
|
|
24
|
+
v.img.removeAttribute("src");
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const counts = { contexts: 0, draws: 0, snapshots: 0 };
|
|
28
|
+
let three;
|
|
29
|
+
let gl;
|
|
30
|
+
let canvas;
|
|
31
|
+
let creating;
|
|
32
|
+
let focused;
|
|
33
|
+
let token = 0;
|
|
34
|
+
let lost = false;
|
|
35
|
+
let lostPause = false;
|
|
36
|
+
let sightPause = false;
|
|
37
|
+
let onScreen = true;
|
|
38
|
+
let idleTimer;
|
|
39
|
+
let listening = false;
|
|
40
|
+
let queue = Promise.resolve();
|
|
41
|
+
const shooting = new Map();
|
|
42
|
+
let sized = "";
|
|
43
|
+
// --- the one context ---------------------------------------------------------------------------------------------------------------------------------
|
|
44
|
+
const onLost = (event) => {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
lost = true;
|
|
47
|
+
if (!lostPause) {
|
|
48
|
+
lostPause = true;
|
|
49
|
+
scheduler.pause();
|
|
50
|
+
}
|
|
51
|
+
if (canvas !== undefined)
|
|
52
|
+
canvas.style.visibility = "hidden";
|
|
53
|
+
if (focused?.img)
|
|
54
|
+
focused.img.style.visibility = "";
|
|
55
|
+
options.onContext?.("lost");
|
|
56
|
+
};
|
|
57
|
+
const onRestored = () => {
|
|
58
|
+
lost = false;
|
|
59
|
+
if (lostPause) {
|
|
60
|
+
lostPause = false;
|
|
61
|
+
scheduler.resume();
|
|
62
|
+
}
|
|
63
|
+
if (canvas !== undefined)
|
|
64
|
+
canvas.style.visibility = "";
|
|
65
|
+
sized = "";
|
|
66
|
+
if (focused?.live) {
|
|
67
|
+
applySize(focused);
|
|
68
|
+
scheduler.invalidate(focused.job);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
function ensureGL() {
|
|
72
|
+
if (gl !== undefined && !lost)
|
|
73
|
+
return Promise.resolve(gl);
|
|
74
|
+
if (gl !== undefined && lost)
|
|
75
|
+
releaseGL();
|
|
76
|
+
creating ??= (async () => {
|
|
77
|
+
try {
|
|
78
|
+
three ??= await options.load();
|
|
79
|
+
const element = doc().createElement("canvas");
|
|
80
|
+
element.addEventListener("webglcontextlost", onLost);
|
|
81
|
+
element.addEventListener("webglcontextrestored", onRestored);
|
|
82
|
+
const Renderer = three.WebGLRenderer;
|
|
83
|
+
const made = new Renderer({ canvas: element, antialias: options.antialias ?? true, alpha: true, powerPreference: "default" });
|
|
84
|
+
canvas = element;
|
|
85
|
+
gl = made;
|
|
86
|
+
counts.contexts += 1;
|
|
87
|
+
sized = "";
|
|
88
|
+
return made;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
creating = undefined;
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
return creating;
|
|
95
|
+
}
|
|
96
|
+
/** Let go of the context (forced, so the GPU memory is free now) and of the scene using it. The views keep their stills. */
|
|
97
|
+
function releaseGL() {
|
|
98
|
+
if (focused?.live)
|
|
99
|
+
detach(focused, true);
|
|
100
|
+
if (canvas !== undefined) {
|
|
101
|
+
canvas.removeEventListener("webglcontextlost", onLost);
|
|
102
|
+
canvas.removeEventListener("webglcontextrestored", onRestored);
|
|
103
|
+
canvas.remove();
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
gl?.dispose();
|
|
107
|
+
gl?.forceContextLoss();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* already gone */
|
|
111
|
+
}
|
|
112
|
+
gl = undefined;
|
|
113
|
+
canvas = undefined;
|
|
114
|
+
lost = false;
|
|
115
|
+
if (lostPause) {
|
|
116
|
+
lostPause = false;
|
|
117
|
+
scheduler.resume();
|
|
118
|
+
}
|
|
119
|
+
sized = "";
|
|
120
|
+
}
|
|
121
|
+
// --- size: the pixel budget ----------------------------------------------------------------------------------------------------------------------------
|
|
122
|
+
/** The size the canvas will fill: the host's padding box (the canvas is laid inside its border), whole CSS pixels. */
|
|
123
|
+
function measure(host) {
|
|
124
|
+
const r = host.getBoundingClientRect();
|
|
125
|
+
return { width: Math.max(1, Math.floor(host.clientWidth || r.width)), height: Math.max(1, Math.floor(host.clientHeight || r.height)) };
|
|
126
|
+
}
|
|
127
|
+
function applySize(v) {
|
|
128
|
+
if (gl === undefined || canvas === undefined)
|
|
129
|
+
return;
|
|
130
|
+
const { width, height } = measure(v.host);
|
|
131
|
+
const ratio = pixelRatioFor(width, height, globalThis.devicePixelRatio || 1, limits);
|
|
132
|
+
const mark = `${width}x${height}@${ratio}`;
|
|
133
|
+
if (mark === sized)
|
|
134
|
+
return;
|
|
135
|
+
sized = mark;
|
|
136
|
+
gl.setPixelRatio(ratio);
|
|
137
|
+
gl.setSize(width, height, false);
|
|
138
|
+
v.scene?.resize?.(width, height);
|
|
139
|
+
}
|
|
140
|
+
// --- going live and back to a still --------------------------------------------------------------------------------------------------------------------
|
|
141
|
+
function showImage(v, shown) {
|
|
142
|
+
if (v.img !== null)
|
|
143
|
+
v.img.style.visibility = shown ? "" : "hidden";
|
|
144
|
+
}
|
|
145
|
+
function paintStill(v) {
|
|
146
|
+
const still = stills.get(v.item.key);
|
|
147
|
+
if (v.img !== null && still !== undefined && v.img.getAttribute("src") !== still.url)
|
|
148
|
+
v.img.src = still.url;
|
|
149
|
+
return still;
|
|
150
|
+
}
|
|
151
|
+
async function build(v, scope, width, height) {
|
|
152
|
+
return v.item.build({ three: three, assets: scope, width, height });
|
|
153
|
+
}
|
|
154
|
+
/** Take the canvas, with a scene, into the host. */
|
|
155
|
+
async function goLive(v, mine) {
|
|
156
|
+
let renderer;
|
|
157
|
+
try {
|
|
158
|
+
renderer = await ensureGL();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
if (mine === token)
|
|
162
|
+
options.onContext?.("unavailable");
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
if (mine !== token || v.disposed)
|
|
166
|
+
return false;
|
|
167
|
+
const scope = assets.scope();
|
|
168
|
+
const size = measure(v.host);
|
|
169
|
+
let scene;
|
|
170
|
+
try {
|
|
171
|
+
scene = await build(v, scope, size.width, size.height);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
scope.release();
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (mine !== token || v.disposed || gl !== renderer || canvas === undefined) {
|
|
178
|
+
scene.dispose?.();
|
|
179
|
+
scope.release();
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
v.scene = scene;
|
|
183
|
+
v.assets = scope;
|
|
184
|
+
v.live = true;
|
|
185
|
+
v.seen = false;
|
|
186
|
+
if (globalThis.getComputedStyle(v.host).position === "static")
|
|
187
|
+
v.host.style.position = "relative";
|
|
188
|
+
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block";
|
|
189
|
+
v.host.append(canvas);
|
|
190
|
+
sized = "";
|
|
191
|
+
applySize(v);
|
|
192
|
+
watch(v);
|
|
193
|
+
scheduler.invalidate(v.job);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
/** Take the canvas out of the host and let the scene go; the host shows its still. `keep` leaves the wish to be live (the context is being let go). */
|
|
197
|
+
function detach(v, keep = false) {
|
|
198
|
+
if (!keep)
|
|
199
|
+
unwatch(v);
|
|
200
|
+
scheduler.remove(v.job);
|
|
201
|
+
if (canvas !== undefined && canvas.parentElement === v.host)
|
|
202
|
+
canvas.remove();
|
|
203
|
+
v.scene?.dispose?.();
|
|
204
|
+
v.assets?.release();
|
|
205
|
+
v.scene = undefined;
|
|
206
|
+
v.assets = undefined;
|
|
207
|
+
v.live = false;
|
|
208
|
+
if (!keep)
|
|
209
|
+
v.wantsLive = false;
|
|
210
|
+
if (sightPause) {
|
|
211
|
+
sightPause = false;
|
|
212
|
+
scheduler.resume();
|
|
213
|
+
}
|
|
214
|
+
clearTimeout(idleTimer);
|
|
215
|
+
idleTimer = undefined;
|
|
216
|
+
showImage(v, true);
|
|
217
|
+
if (v.img !== null)
|
|
218
|
+
void showStill(v);
|
|
219
|
+
}
|
|
220
|
+
// --- out of sight: no frames, then no context ----------------------------------------------------------------------------------------------------------
|
|
221
|
+
function watch(v) {
|
|
222
|
+
unwatch(v);
|
|
223
|
+
onScreen = true;
|
|
224
|
+
if (typeof ResizeObserver === "function") {
|
|
225
|
+
v.watchers.resize = new ResizeObserver(() => {
|
|
226
|
+
if (!v.live)
|
|
227
|
+
return;
|
|
228
|
+
sized = "";
|
|
229
|
+
applySize(v);
|
|
230
|
+
scheduler.invalidate(v.job);
|
|
231
|
+
});
|
|
232
|
+
v.watchers.resize.observe(v.host);
|
|
233
|
+
}
|
|
234
|
+
if (typeof IntersectionObserver === "function") {
|
|
235
|
+
v.watchers.sight = new IntersectionObserver((entries) => {
|
|
236
|
+
const last = entries[entries.length - 1];
|
|
237
|
+
if (last === undefined)
|
|
238
|
+
return;
|
|
239
|
+
onScreen = last.isIntersecting;
|
|
240
|
+
sight();
|
|
241
|
+
});
|
|
242
|
+
v.watchers.sight.observe(v.host);
|
|
243
|
+
}
|
|
244
|
+
if (!listening) {
|
|
245
|
+
listening = true;
|
|
246
|
+
doc().addEventListener("visibilitychange", sight);
|
|
247
|
+
}
|
|
248
|
+
sight();
|
|
249
|
+
}
|
|
250
|
+
function unwatch(v) {
|
|
251
|
+
v.watchers.resize?.disconnect();
|
|
252
|
+
v.watchers.sight?.disconnect();
|
|
253
|
+
v.watchers.resize = undefined;
|
|
254
|
+
v.watchers.sight = undefined;
|
|
255
|
+
}
|
|
256
|
+
function sight() {
|
|
257
|
+
const v = focused;
|
|
258
|
+
if (v === undefined || !v.wantsLive)
|
|
259
|
+
return;
|
|
260
|
+
const idle = !onScreen || doc().visibilityState === "hidden";
|
|
261
|
+
if (idle && !sightPause && gl !== undefined) {
|
|
262
|
+
sightPause = true;
|
|
263
|
+
scheduler.pause();
|
|
264
|
+
idleTimer = setTimeout(() => {
|
|
265
|
+
// Out of sight for good: free the GPU. The view keeps its still, and goes live again when it is seen.
|
|
266
|
+
if (focused === v)
|
|
267
|
+
releaseGL();
|
|
268
|
+
}, releaseMs);
|
|
269
|
+
}
|
|
270
|
+
else if (!idle && sightPause) {
|
|
271
|
+
sightPause = false;
|
|
272
|
+
scheduler.resume();
|
|
273
|
+
clearTimeout(idleTimer);
|
|
274
|
+
idleTimer = undefined;
|
|
275
|
+
if (gl === undefined && v.wantsLive)
|
|
276
|
+
void goLive(v, ++token);
|
|
277
|
+
else
|
|
278
|
+
scheduler.invalidate(v.job);
|
|
279
|
+
}
|
|
280
|
+
else if (!idle && gl === undefined && v.wantsLive && !v.live)
|
|
281
|
+
void goLive(v, ++token);
|
|
282
|
+
}
|
|
283
|
+
// --- stills --------------------------------------------------------------------------------------------------------------------------------------------
|
|
284
|
+
function showStill(v) {
|
|
285
|
+
const have = paintStill(v);
|
|
286
|
+
if (have !== undefined)
|
|
287
|
+
return Promise.resolve(have);
|
|
288
|
+
const key = v.item.key;
|
|
289
|
+
let pending = shooting.get(key);
|
|
290
|
+
if (pending === undefined) {
|
|
291
|
+
pending = (queue = queue.then(() => shoot(v)).catch(() => undefined));
|
|
292
|
+
shooting.set(key, pending);
|
|
293
|
+
void pending.finally(() => shooting.delete(key));
|
|
294
|
+
}
|
|
295
|
+
return pending.then((still) => {
|
|
296
|
+
if (still !== undefined && !v.disposed)
|
|
297
|
+
paintStill(v);
|
|
298
|
+
return still;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/** One still, made now, from the same renderer, then the canvas is put back as it was (all in one task, so nothing is shown at the wrong size). */
|
|
302
|
+
async function shoot(v) {
|
|
303
|
+
const have = stills.get(v.item.key);
|
|
304
|
+
if (have !== undefined)
|
|
305
|
+
return have;
|
|
306
|
+
// A lost context is left for its owner to restore or rebuild; a still waits for it.
|
|
307
|
+
if (lost || v.disposed)
|
|
308
|
+
return undefined;
|
|
309
|
+
let renderer;
|
|
310
|
+
try {
|
|
311
|
+
renderer = await ensureGL();
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
options.onContext?.("unavailable");
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
if (lost)
|
|
318
|
+
return undefined;
|
|
319
|
+
const host = measure(v.host);
|
|
320
|
+
const scale = stillSize / Math.max(host.width, host.height);
|
|
321
|
+
const width = Math.max(1, Math.round(host.width * scale));
|
|
322
|
+
const height = Math.max(1, Math.round(host.height * scale));
|
|
323
|
+
const scope = assets.scope();
|
|
324
|
+
let scene;
|
|
325
|
+
try {
|
|
326
|
+
scene = await build(v, scope, width, height);
|
|
327
|
+
if (gl !== renderer || canvas === undefined || lost)
|
|
328
|
+
return undefined;
|
|
329
|
+
const element = canvas;
|
|
330
|
+
scene.resize?.(width, height);
|
|
331
|
+
renderer.setPixelRatio(1);
|
|
332
|
+
renderer.setSize(width, height, false);
|
|
333
|
+
renderer.render(scene.scene, scene.camera);
|
|
334
|
+
const blob = new Promise((resolve) => element.toBlob(resolve, "image/png"));
|
|
335
|
+
sized = "";
|
|
336
|
+
if (focused?.live) {
|
|
337
|
+
applySize(focused);
|
|
338
|
+
scheduler.invalidate(focused.job);
|
|
339
|
+
}
|
|
340
|
+
const made = await blob;
|
|
341
|
+
if (made === null)
|
|
342
|
+
return undefined;
|
|
343
|
+
const still = { url: URL.createObjectURL(made), width, height, bytes: width * height * 4 };
|
|
344
|
+
stills.set(v.item.key, still);
|
|
345
|
+
counts.snapshots += 1;
|
|
346
|
+
return still;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
scene?.dispose?.();
|
|
353
|
+
scope.release();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// --- views ---------------------------------------------------------------------------------------------------------------------------------------------
|
|
357
|
+
const renderer = {
|
|
358
|
+
view(host, item, viewOptions = {}) {
|
|
359
|
+
const own = viewOptions.still === undefined;
|
|
360
|
+
let img = viewOptions.still ?? null;
|
|
361
|
+
if (own) {
|
|
362
|
+
img = doc().createElement("img");
|
|
363
|
+
img.alt = "";
|
|
364
|
+
img.decoding = "async";
|
|
365
|
+
img.className = "kazoku-still";
|
|
366
|
+
img.style.cssText = "display:block;width:100%;height:100%;object-fit:contain";
|
|
367
|
+
host.append(img);
|
|
368
|
+
}
|
|
369
|
+
const v = {
|
|
370
|
+
host,
|
|
371
|
+
item: item,
|
|
372
|
+
img,
|
|
373
|
+
ownImg: own,
|
|
374
|
+
scene: undefined,
|
|
375
|
+
assets: undefined,
|
|
376
|
+
live: false,
|
|
377
|
+
wantsLive: false,
|
|
378
|
+
disposed: false,
|
|
379
|
+
seen: false,
|
|
380
|
+
watchers: { resize: undefined, sight: undefined },
|
|
381
|
+
job: {
|
|
382
|
+
draw: (now, dt) => {
|
|
383
|
+
const scene = v.scene;
|
|
384
|
+
if (scene === undefined || gl === undefined || lost)
|
|
385
|
+
return;
|
|
386
|
+
const moving = scene.update?.(dt, now) === true;
|
|
387
|
+
gl.render(scene.scene, scene.camera);
|
|
388
|
+
counts.draws += 1;
|
|
389
|
+
if (!v.seen) {
|
|
390
|
+
v.seen = true;
|
|
391
|
+
showImage(v, false);
|
|
392
|
+
}
|
|
393
|
+
return moving;
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
api: undefined,
|
|
397
|
+
};
|
|
398
|
+
v.api = {
|
|
399
|
+
host,
|
|
400
|
+
item: item,
|
|
401
|
+
get live() {
|
|
402
|
+
return v.live;
|
|
403
|
+
},
|
|
404
|
+
get scene() {
|
|
405
|
+
return v.scene;
|
|
406
|
+
},
|
|
407
|
+
async focus() {
|
|
408
|
+
if (v.disposed)
|
|
409
|
+
return false;
|
|
410
|
+
if (focused === v && v.live) {
|
|
411
|
+
if (!lost)
|
|
412
|
+
return true;
|
|
413
|
+
detach(v, true);
|
|
414
|
+
}
|
|
415
|
+
const mine = ++token;
|
|
416
|
+
if (focused !== undefined && focused !== v)
|
|
417
|
+
detach(focused);
|
|
418
|
+
focused = v;
|
|
419
|
+
v.wantsLive = true;
|
|
420
|
+
return goLive(v, mine);
|
|
421
|
+
},
|
|
422
|
+
blur() {
|
|
423
|
+
if (focused !== v)
|
|
424
|
+
return;
|
|
425
|
+
token += 1;
|
|
426
|
+
detach(v);
|
|
427
|
+
focused = undefined;
|
|
428
|
+
},
|
|
429
|
+
invalidate() {
|
|
430
|
+
if (v.live)
|
|
431
|
+
scheduler.invalidate(v.job);
|
|
432
|
+
},
|
|
433
|
+
showStill: () => showStill(v),
|
|
434
|
+
dispose() {
|
|
435
|
+
if (v.disposed)
|
|
436
|
+
return;
|
|
437
|
+
v.api.blur();
|
|
438
|
+
v.disposed = true;
|
|
439
|
+
views.delete(v);
|
|
440
|
+
if (v.ownImg)
|
|
441
|
+
v.img?.remove();
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
views.add(v);
|
|
445
|
+
return v.api;
|
|
446
|
+
},
|
|
447
|
+
get focused() {
|
|
448
|
+
return focused?.api;
|
|
449
|
+
},
|
|
450
|
+
get stats() {
|
|
451
|
+
const width = canvas?.width ?? 0;
|
|
452
|
+
const height = canvas?.height ?? 0;
|
|
453
|
+
return { ...counts, live: focused?.live === true, lost, canvas: { width, height, pixels: width * height }, frames: { ...scheduler.stats }, assets: assets.stats(), stills: { count: stills.size, bytes: stills.bytes } };
|
|
454
|
+
},
|
|
455
|
+
assets,
|
|
456
|
+
stills,
|
|
457
|
+
scheduler,
|
|
458
|
+
dispose() {
|
|
459
|
+
token += 1;
|
|
460
|
+
for (const v of [...views])
|
|
461
|
+
v.api.dispose();
|
|
462
|
+
focused = undefined;
|
|
463
|
+
releaseGL();
|
|
464
|
+
if (listening)
|
|
465
|
+
doc().removeEventListener("visibilitychange", sight);
|
|
466
|
+
listening = false;
|
|
467
|
+
assets.clear();
|
|
468
|
+
stills.clear();
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
return renderer;
|
|
472
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface FrameClock {
|
|
2
|
+
request(callback: (now: number) => void): number;
|
|
3
|
+
cancel(id: number): void;
|
|
4
|
+
now(): number;
|
|
5
|
+
}
|
|
6
|
+
export declare const browserClock: () => FrameClock;
|
|
7
|
+
export interface Job {
|
|
8
|
+
/** Draw once. `dt` is seconds since this job's last draw while it kept animating, else 0. Return true to be drawn again next frame (an animation); anything else leaves it at rest. */
|
|
9
|
+
draw(now: number, dt: number): boolean | void;
|
|
10
|
+
}
|
|
11
|
+
export type Priority = "focus" | "ambient";
|
|
12
|
+
export interface SchedulerOptions {
|
|
13
|
+
clock?: FrameClock;
|
|
14
|
+
/** Most jobs drawn in one frame (2). */
|
|
15
|
+
maxDraws?: number;
|
|
16
|
+
/** Milliseconds after which no further job is started in a frame (10). A job already started is finished. */
|
|
17
|
+
budgetMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface SchedulerStats {
|
|
20
|
+
/** Animation frames asked for. */
|
|
21
|
+
requests: number;
|
|
22
|
+
/** Frames that ran. */
|
|
23
|
+
frames: number;
|
|
24
|
+
/** Jobs drawn. */
|
|
25
|
+
draws: number;
|
|
26
|
+
/** Jobs left for the next frame because the budget was spent. */
|
|
27
|
+
deferred: number;
|
|
28
|
+
}
|
|
29
|
+
export interface Scheduler {
|
|
30
|
+
/** Ask for a draw. Asking again before it happens changes nothing. */
|
|
31
|
+
invalidate(job: Job, priority?: Priority): void;
|
|
32
|
+
/** Forget a job (a view that is gone). */
|
|
33
|
+
remove(job: Job): void;
|
|
34
|
+
/** Stop asking for frames (nothing can see the views). Counts, so pauses nest. Drawing resumes when the last is undone. */
|
|
35
|
+
pause(): void;
|
|
36
|
+
resume(): void;
|
|
37
|
+
readonly paused: boolean;
|
|
38
|
+
/** True while a frame is requested and has not run yet. */
|
|
39
|
+
readonly pending: boolean;
|
|
40
|
+
readonly stats: SchedulerStats;
|
|
41
|
+
}
|
|
42
|
+
export declare function createScheduler(options?: SchedulerOptions): Scheduler;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// One frame loop for every view on the page. A view is drawn only when it asked to be (`invalidate`), so a page at rest asks the browser for nothing;
|
|
2
|
+
// exactly one animation frame is requested at a time, whoever invalidates and whenever (a change event during a draw adds nothing, which is the fault that once
|
|
3
|
+
// doubled a package's frame requests every frame); and a frame has a budget (draws and milliseconds), with the focused view first and ambient work shed first.
|
|
4
|
+
export const browserClock = () => ({
|
|
5
|
+
request: (callback) => globalThis.requestAnimationFrame(callback),
|
|
6
|
+
cancel: (id) => globalThis.cancelAnimationFrame(id),
|
|
7
|
+
now: () => globalThis.performance.now(),
|
|
8
|
+
});
|
|
9
|
+
export function createScheduler(options = {}) {
|
|
10
|
+
const clock = options.clock ?? browserClock();
|
|
11
|
+
const maxDraws = options.maxDraws ?? 2;
|
|
12
|
+
const budgetMs = options.budgetMs ?? 10;
|
|
13
|
+
const dirty = new Map();
|
|
14
|
+
const lastDraw = new Map();
|
|
15
|
+
const stats = { requests: 0, frames: 0, draws: 0, deferred: 0 };
|
|
16
|
+
let frame = 0;
|
|
17
|
+
let running = false;
|
|
18
|
+
let pauses = 0;
|
|
19
|
+
// The only place a frame is asked for, and it asks only if none is.
|
|
20
|
+
const ensure = () => {
|
|
21
|
+
if (frame !== 0 || running || pauses > 0 || dirty.size === 0)
|
|
22
|
+
return;
|
|
23
|
+
stats.requests += 1;
|
|
24
|
+
frame = clock.request(run);
|
|
25
|
+
};
|
|
26
|
+
function run(now) {
|
|
27
|
+
frame = 0;
|
|
28
|
+
running = true;
|
|
29
|
+
stats.frames += 1;
|
|
30
|
+
const started = clock.now();
|
|
31
|
+
const order = [...dirty].sort((a, b) => Number(a[1] === "ambient") - Number(b[1] === "ambient"));
|
|
32
|
+
let drawn = 0;
|
|
33
|
+
try {
|
|
34
|
+
for (const [job, priority] of order) {
|
|
35
|
+
if (drawn >= maxDraws || (drawn > 0 && clock.now() - started > budgetMs)) {
|
|
36
|
+
stats.deferred += order.length - drawn;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
dirty.delete(job);
|
|
40
|
+
const before = lastDraw.get(job);
|
|
41
|
+
const dt = before === undefined ? 0 : Math.min(0.1, (now - before) / 1000);
|
|
42
|
+
drawn += 1;
|
|
43
|
+
stats.draws += 1;
|
|
44
|
+
if (job.draw(now, dt) === true) {
|
|
45
|
+
lastDraw.set(job, now);
|
|
46
|
+
dirty.set(job, priority);
|
|
47
|
+
}
|
|
48
|
+
else
|
|
49
|
+
lastDraw.delete(job);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
running = false;
|
|
54
|
+
ensure();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
invalidate(job, priority = "focus") {
|
|
59
|
+
if (!dirty.has(job))
|
|
60
|
+
dirty.set(job, priority);
|
|
61
|
+
ensure();
|
|
62
|
+
},
|
|
63
|
+
remove(job) {
|
|
64
|
+
dirty.delete(job);
|
|
65
|
+
lastDraw.delete(job);
|
|
66
|
+
},
|
|
67
|
+
pause() {
|
|
68
|
+
pauses += 1;
|
|
69
|
+
if (frame !== 0) {
|
|
70
|
+
clock.cancel(frame);
|
|
71
|
+
frame = 0;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
resume() {
|
|
75
|
+
if (pauses === 0)
|
|
76
|
+
return;
|
|
77
|
+
pauses -= 1;
|
|
78
|
+
// Time spent paused is not a frame's worth of animation.
|
|
79
|
+
if (pauses === 0)
|
|
80
|
+
lastDraw.clear();
|
|
81
|
+
ensure();
|
|
82
|
+
},
|
|
83
|
+
get paused() {
|
|
84
|
+
return pauses > 0;
|
|
85
|
+
},
|
|
86
|
+
get pending() {
|
|
87
|
+
return frame !== 0;
|
|
88
|
+
},
|
|
89
|
+
stats,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface Still {
|
|
2
|
+
/** An object URL (or any image URL) for an `<img>`. */
|
|
3
|
+
url: string;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
/** What it costs in memory once decoded: width x height x 4. */
|
|
7
|
+
bytes: number;
|
|
8
|
+
}
|
|
9
|
+
export interface StillCacheOptions {
|
|
10
|
+
/** The most decoded bytes the list may hold (32 MB). The picture just set is always kept. */
|
|
11
|
+
maxBytes?: number;
|
|
12
|
+
/** Called with a picture that leaves the list, to revoke its URL (`URL.revokeObjectURL` by default). */
|
|
13
|
+
free?: (still: Still) => void;
|
|
14
|
+
onEvict?: (key: string, still: Still) => void;
|
|
15
|
+
}
|
|
16
|
+
export interface StillCache {
|
|
17
|
+
/** The picture for `key`, marked as just used. */
|
|
18
|
+
get(key: string): Still | undefined;
|
|
19
|
+
set(key: string, still: Still): void;
|
|
20
|
+
delete(key: string): void;
|
|
21
|
+
clear(): void;
|
|
22
|
+
readonly bytes: number;
|
|
23
|
+
readonly size: number;
|
|
24
|
+
keys(): string[];
|
|
25
|
+
}
|
|
26
|
+
export declare const STILL_BYTES: number;
|
|
27
|
+
export declare function createStillCache(options?: StillCacheOptions): StillCache;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Stills: a picture of a view, made once and shown whenever the view is not live. They sit in a least-recently-used list with a byte budget, so a wall of
|
|
2
|
+
// hundreds of views holds only the pictures it has used lately; an evicted picture is made again when its view next needs it.
|
|
3
|
+
export const STILL_BYTES = 32 * 1024 * 1024;
|
|
4
|
+
export function createStillCache(options = {}) {
|
|
5
|
+
const maxBytes = options.maxBytes ?? STILL_BYTES;
|
|
6
|
+
const free = options.free ?? ((still) => globalThis.URL?.revokeObjectURL?.(still.url));
|
|
7
|
+
const list = new Map();
|
|
8
|
+
let total = 0;
|
|
9
|
+
const remove = (key) => {
|
|
10
|
+
const still = list.get(key);
|
|
11
|
+
if (still === undefined)
|
|
12
|
+
return;
|
|
13
|
+
list.delete(key);
|
|
14
|
+
total -= still.bytes;
|
|
15
|
+
free(still);
|
|
16
|
+
options.onEvict?.(key, still);
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
get(key) {
|
|
20
|
+
const still = list.get(key);
|
|
21
|
+
if (still === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
list.delete(key);
|
|
24
|
+
list.set(key, still);
|
|
25
|
+
return still;
|
|
26
|
+
},
|
|
27
|
+
set(key, still) {
|
|
28
|
+
remove(key);
|
|
29
|
+
list.set(key, still);
|
|
30
|
+
total += still.bytes;
|
|
31
|
+
for (const old of [...list.keys()]) {
|
|
32
|
+
if (total <= maxBytes || old === key)
|
|
33
|
+
break;
|
|
34
|
+
remove(old);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
delete: remove,
|
|
38
|
+
clear() {
|
|
39
|
+
for (const key of [...list.keys()])
|
|
40
|
+
remove(key);
|
|
41
|
+
},
|
|
42
|
+
get bytes() {
|
|
43
|
+
return total;
|
|
44
|
+
},
|
|
45
|
+
get size() {
|
|
46
|
+
return list.size;
|
|
47
|
+
},
|
|
48
|
+
keys: () => [...list.keys()],
|
|
49
|
+
};
|
|
50
|
+
}
|