@es3d/es3d 1.1.2 → 1.2.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.
@@ -18,6 +18,16 @@
18
18
  // - Keeps geometry between visits: the meshes a document's shapes produced
19
19
  // are stored in the browser (IndexedDB, src/cache/geometry-store.js) once
20
20
  // the page is idle, and read back instead of tessellated on the next visit.
21
+ // - Shows fragments: stage.add(source) puts a whole other document into the
22
+ // scene (a map tile, a room, a route), and only that document is compiled
23
+ // when it is added, replaced or removed (scene/fragments.js). Changes are
24
+ // applied in the frame loop within changeBudget milliseconds per frame.
25
+ // - Compiles off the main thread: stage.prepare(source) compiles in a worker
26
+ // started from this same file (render/compile-worker.js), and add() or
27
+ // show() of the result takes about a millisecond here.
28
+ //
29
+ // Until a page calls add(), show() does exactly what it did before fragments
30
+ // existed: the host's scene is handed to the renderer as it is.
21
31
  //
22
32
  // Reduced motion (prefers-reduced-motion) stops clock animations and the
23
33
  // automatic orbit; scroll-driven cameras jump between stops. stage.reducedMotion
@@ -27,18 +37,30 @@
27
37
  // background: true selects that clock and leaves the camera still, for a
28
38
  // page that moves shapes itself with stage.setTransform(id, matrix).
29
39
 
40
+ // first: in the compiler worker it keeps messages that arrive while the modules load
41
+ import './worker-boot.js';
30
42
  import { compile } from '../pipeline.js';
31
43
  import { formatDiagnostics } from '../format/parse.js';
32
44
  import { applyLocalMatrices, updateDynamic } from '../scene/flatten.js';
33
- import { pick } from '../scene/picking.js';
45
+ import { pick, ancestorNames } from '../scene/picking.js';
46
+ import { GeometryCache, DOCUMENT } from '../geometry/geometry-cache.js';
47
+ import { FragmentStage } from '../scene/fragments.js';
48
+ import { Prepared } from '../scene/prepared.js';
34
49
  import { createRenderer, PROGRESSIVE_BUDGET_MS } from './renderer.js';
35
50
  import { openGeometryStore, persistGeometry } from '../cache/geometry-store.js';
36
51
  import { Camera } from './camera.js';
37
52
  import { OrbitControls } from './orbit-controls.js';
38
53
  import { ScrollTimeline } from './scroll-timeline.js';
54
+ import { CompilerClient, isWorkerScope, serveCompiler } from './compile-worker.js';
55
+
56
+ export { ancestorNames };
57
+
58
+ // In a worker started by stage.prepare(), this file is the compiler.
59
+ if (isWorkerScope()) serveCompiler(self);
39
60
 
40
61
  const prefersReducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
41
62
  const idle = (fn) => (typeof requestIdleCallback === 'function' ? requestIdleCallback(fn, { timeout: 500 }) : setTimeout(fn, 16));
63
+ const clock = () => performance.now();
42
64
 
43
65
  /**
44
66
  * Clock and orbit flags for createStage. background: true runs the clock
@@ -67,24 +89,17 @@ export function orbitAdvances(state, reduced) {
67
89
  return !!(state.auto && !reduced);
68
90
  }
69
91
 
70
- /** Names (name or id) on an element and its ancestors, nearest first. */
71
- export function ancestorNames(doc, uid) {
72
- const names = [];
73
- let el = doc && doc.byUid.get(uid);
74
- while (el) {
75
- const n = el.values && (el.values.name || el.values.id);
76
- if (n) names.push(n);
77
- el = el.parent;
78
- }
79
- return names;
80
- }
81
-
82
92
  /** Views sampled along a scroll path to warm up its meshes. */
83
93
  const PREFETCH_SAMPLES = 24;
84
94
 
85
95
  /** How long a new document waits for its stored meshes before drawing without them (ms). */
86
96
  const RESTORE_WAIT_MS = 50;
87
97
 
98
+ /** Default changeBudget: ms per frame for compiling and placing fragments. */
99
+ export const CHANGE_BUDGET_MS = 4;
100
+
101
+ const waitAtMost = (promise, ms) => Promise.race([promise, new Promise((resolve) => setTimeout(resolve, ms))]);
102
+
88
103
  /**
89
104
  * @param {HTMLCanvasElement} canvas sized by its parent element
90
105
  * @param {object} [opts]
@@ -100,17 +115,23 @@ const RESTORE_WAIT_MS = 50;
100
115
  * @param {HTMLElement} [opts.scroll] section whose scroll position drives timeline="scroll"
101
116
  * animations (default: the whole page)
102
117
  * @param {string} [opts.backend] 'auto' | 'webgpu' | 'webgl2'
118
+ * @param {'canvas'|'readback'} [opts.present] WebGPU: 'readback' copies each frame into a 2D canvas
119
+ * (headless browsers, which do not show WebGPU canvases)
103
120
  * @param {(dt:number, stage:object) => void} [opts.onFrame] called every animation frame
104
- * @param {(hit: object|null) => void} [opts.onPick] click on the scene; hit.names lists name/id up the tree
121
+ * @param {(hit: object|null) => void} [opts.onPick] click on the scene; hit.names lists name/id up the tree,
122
+ * hit.fragment is the id of the fragment that was hit (absent for the stage's document)
105
123
  * @param {() => void} [opts.onInteract] the visitor moved the camera
106
- * @param {(message: string, result: object) => void} [opts.onError] the document did not compile
124
+ * @param {(message: string, result: object) => void} [opts.onError] a document or fragment did not compile,
125
+ * or a fragment has elements only the stage's document can set (result.ok is then true)
107
126
  * @param {object|Promise<object>|false} [opts.geometryStore] where generated meshes are kept
108
127
  * between visits (default: IndexedDB, openGeometryStore()); false keeps nothing
128
+ * @param {number} [opts.changeBudget=4] ms per frame for compiling and placing fragments
129
+ * @param {number} [opts.workers=1] 0 compiles prepare() on the main thread
109
130
  */
110
131
  export async function createStage(canvas, opts = {}) {
111
132
  // opened while the renderer starts, so the first show() rarely waits for it
112
133
  const persist = persistGeometry(opts.geometryStore === false ? null : opts.geometryStore || openGeometryStore());
113
- const renderer = await createRenderer(canvas, { backend: opts.backend });
134
+ const renderer = await createRenderer(canvas, { backend: opts.backend, present: opts.present });
114
135
  renderer.options.tessellationBudget = PROGRESSIVE_BUDGET_MS;
115
136
  const target = renderer.canvas || canvas;
116
137
  const parent = target.parentElement;
@@ -120,6 +141,8 @@ export async function createStage(canvas, opts = {}) {
120
141
  const motion = stageOptions(opts, prefersReducedMotion());
121
142
  const state = {
122
143
  result: null,
144
+ cache: new GeometryCache(), // one for the document and every fragment
145
+ setMode: false, // true once a fragment was added: the renderer draws a SceneSet
123
146
  time: 0,
124
147
  progress: 0,
125
148
  last: 0,
@@ -141,12 +164,36 @@ export async function createStage(canvas, opts = {}) {
141
164
  timeline: null,
142
165
  generation: 0, // bumps on show(), to cancel stale idle work
143
166
  holdUntil: 0, // no drawing before this time: a new document's stored meshes are on their way
167
+ changeBudget: opts.changeBudget ?? CHANGE_BUDGET_MS,
168
+ workers: opts.workers ?? 1,
169
+ compilerTried: false,
170
+ placedSinceDraw: false, // a change was placed that no frame has drawn yet
171
+ framesDrawn: 0,
172
+ waiters: [], // stage.drawn() promises
173
+ since: { changeMs: 0, compileMs: 0 }, // change work since the last drawn frame
174
+ stats: { frameMs: 0, changeMs: 0, compileMs: 0, tessellateMs: 0, renderMs: 0, instances: 0, drawCalls: 0, uploadBytes: 0, refining: 0, queued: 0 },
175
+ storeOwners: new Set(), // cache owners with meshes generated since the last save
144
176
  };
145
177
  const invalidate = () => {
146
178
  state.dirty = true;
147
179
  };
148
180
  renderer.textures.onReady = invalidate;
149
181
 
182
+ const frags = new FragmentStage({
183
+ cache: state.cache,
184
+ clock: () => ({ time: state.time, progress: state.progress }),
185
+ report: (message, result) => {
186
+ if (opts.onError) opts.onError(message, result);
187
+ else if (result.ok) console.warn(message);
188
+ else console.error(message);
189
+ },
190
+ forget: (owner) => {
191
+ persist.forget(owner);
192
+ state.storeOwners.delete(owner);
193
+ },
194
+ restore: (cache, owner) => (persist.store ? waitAtMost(persist.restore(cache, { entries: cache.owned(owner) }), RESTORE_WAIT_MS) : null),
195
+ });
196
+
150
197
  // Camera state at the last drawn frame; onFrame callbacks move the camera directly.
151
198
  const seen = new Float64Array(15).fill(NaN);
152
199
  let moved = false;
@@ -173,15 +220,30 @@ export async function createStage(canvas, opts = {}) {
173
220
  return moved;
174
221
  };
175
222
 
176
- const meshOf = (entry, lod) => state.result.cache.mesh(entry, lod);
177
- const lodVisible = (scene) => {
223
+ const meshOf = (entry, lod) => state.cache.mesh(entry, lod);
224
+ // what the renderer draws: the document's scene, or the set of it and the fragments
225
+ const drawScene = () => (state.setMode ? frags.set : state.result && state.result.scene);
226
+ const lodVisibleIn = (scene) => {
178
227
  const lods = renderer.frames.lodStates(scene, camera.position, (p) => Math.hypot(p[0] - camera.position[0], p[1] - camera.position[1], p[2] - camera.position[2]));
179
228
  return (i) => {
180
229
  const ln = scene.lodNode[i];
181
230
  return ln < 0 || (lods.visible[ln] && lods.active[ln] === scene.lodChild[i]);
182
231
  };
183
232
  };
184
- const world = () => (state.result ? { scene: state.result.scene, meshOf, isVisible: lodVisible(state.result.scene) } : null);
233
+ // a scene set is tested part by part (raycast passes the part's scene)
234
+ const lodVisible = (scene) => {
235
+ if (!scene.parts) return lodVisibleIn(scene);
236
+ const memo = new Map();
237
+ return (i, part) => {
238
+ let f = memo.get(part);
239
+ if (!f) memo.set(part, (f = lodVisibleIn(part)));
240
+ return f(i);
241
+ };
242
+ };
243
+ const world = () => {
244
+ const scene = drawScene();
245
+ return scene ? { scene, meshOf, isVisible: lodVisible(scene) } : null;
246
+ };
185
247
 
186
248
  controls.onChange = () => {
187
249
  state.auto = false;
@@ -189,15 +251,20 @@ export async function createStage(canvas, opts = {}) {
189
251
  if (opts.onInteract) opts.onInteract();
190
252
  };
191
253
  controls.onClick = (x, y) => {
192
- if (!state.result || !opts.onPick) return;
254
+ const scene = drawScene();
255
+ if (!scene || !opts.onPick) return;
193
256
  const w = target.clientWidth;
194
257
  const h = target.clientHeight;
195
258
  if (!w || !h) return;
196
- const scene = state.result.scene;
197
259
  camera.update(w / h, renderer.backend.zeroToOne, scene.bounds);
198
260
  const ray = camera.ray(x, y, w, h, renderer.backend.zeroToOne);
199
261
  const hit = pick(scene, meshOf, ray.origin, ray.dir, lodVisible(scene));
200
- opts.onPick(hit ? { ...hit, names: ancestorNames(state.result.doc, hit.uid) } : null);
262
+ if (!hit) return opts.onPick(null);
263
+ if (!scene.parts) return opts.onPick({ ...hit, names: ancestorNames(state.result.doc, hit.uid) });
264
+ const { part, ...rest } = hit;
265
+ const out = { ...rest, names: frags.names(part, hit.uid) };
266
+ if (part && part !== frags.host) out.fragment = part.id;
267
+ opts.onPick(out);
201
268
  };
202
269
 
203
270
  const io = new IntersectionObserver(([entry]) => {
@@ -239,15 +306,16 @@ export async function createStage(canvas, opts = {}) {
239
306
  renderer.prepare(cam, view());
240
307
  // put the scene back where the page is
241
308
  updateDynamic(res.scene, res.graph, state.sceneTime, state.sceneProgress);
242
- persist.changed(res.cache);
309
+ persistChanged();
243
310
  idle(step);
244
311
  };
245
312
  idle(step);
246
313
  };
247
314
 
248
- const configureScroll = () => {
249
- const scene = state.result.scene;
250
- if (!scene.animation.scroll) {
315
+ // reset: read the scroll position now (show(); a fragment placed later keeps the timeline as it is)
316
+ const configureScroll = (reset = true) => {
317
+ const scene = drawScene();
318
+ if (!scene || !scene.animation.scroll) {
251
319
  if (state.timeline) state.timeline.dispose();
252
320
  state.timeline = null;
253
321
  return;
@@ -255,9 +323,71 @@ export async function createStage(canvas, opts = {}) {
255
323
  if (!state.timeline) {
256
324
  const section = opts.scroll || document.scrollingElement || document.documentElement;
257
325
  state.timeline = new ScrollTimeline(section);
326
+ reset = true;
258
327
  }
259
- state.timeline.update(0);
260
- state.progress = state.timeline.value;
328
+ if (reset) {
329
+ state.timeline.update(0);
330
+ state.progress = state.timeline.value;
331
+ }
332
+ };
333
+
334
+ // Meshes generated since the last save, stored per owner once a fragment
335
+ // exists (one record for the document, one per fragment)
336
+ const persistChanged = () => {
337
+ if (!state.setMode) {
338
+ if (state.result) persist.changed(state.result.cache);
339
+ return;
340
+ }
341
+ for (const owner of state.storeOwners) persist.changed(state.cache, { owner, entries: () => state.cache.owned(owner) });
342
+ state.storeOwners.clear();
343
+ };
344
+
345
+ /** Draw a SceneSet from now on: the document and the fragments. */
346
+ const enterSetMode = () => {
347
+ if (state.setMode) return;
348
+ state.setMode = true;
349
+ if (state.result) frags.set.place(frags.host, state.result.scene);
350
+ renderer.setScene(frags.set, meshOf);
351
+ state.cache.onGenerate = (entry) => {
352
+ for (const o of entry.owners) state.storeOwners.add(o);
353
+ };
354
+ state.dirty = true;
355
+ };
356
+
357
+ /** The document's compile result with its scene graph (a prepared one is compiled here now, once). */
358
+ const hostResult = () => {
359
+ const res = state.result;
360
+ if (!res || res.graph || !res.prepared) return res;
361
+ const owner = frags.newOwner('document');
362
+ const full = compile(res.source, { cache: state.cache, owner, time: state.time, progress: state.progress });
363
+ state.result = full;
364
+ if (state.setMode) frags.setHost(full, owner);
365
+ else {
366
+ frags.adoptHost(full, owner);
367
+ renderer.setScene(full.scene, meshOf);
368
+ }
369
+ state.dirty = true;
370
+ return full;
371
+ };
372
+
373
+ const ensureCompiler = () => {
374
+ if (state.compilerTried) return;
375
+ state.compilerTried = true;
376
+ if (state.workers === 0) return;
377
+ const client = new CompilerClient(import.meta.url);
378
+ if (!client.failed) frags.compiler = client;
379
+ };
380
+
381
+ // promises from stage.drawn(), resolved after the first frame that shows
382
+ // every change queued before the call, fully refined
383
+ const settleWaiters = () => {
384
+ if (!state.waiters.length) return;
385
+ if (state.placedSinceDraw || !state.framesDrawn || renderer.stats.refining) return;
386
+ state.waiters = state.waiters.filter((w) => {
387
+ for (const t of w.tasks) if (!t.finished) return true;
388
+ w.resolve(state.stats);
389
+ return false;
390
+ });
261
391
  };
262
392
 
263
393
  const api = {
@@ -292,18 +422,35 @@ export async function createStage(canvas, opts = {}) {
292
422
  get reducedMotion() {
293
423
  return prefersReducedMotion();
294
424
  },
425
+ /** Milliseconds per frame for compiling and placing fragments (default 4). */
426
+ get changeBudget() {
427
+ return state.changeBudget;
428
+ },
429
+ set changeBudget(v) {
430
+ state.changeBudget = Math.max(0, +v || 0);
431
+ },
432
+ /**
433
+ * The last drawn frame, in main-thread milliseconds: frameMs is the whole
434
+ * frame callback; changeMs the fragment changes applied since the frame
435
+ * before (compileMs of it compiling); tessellateMs mesh generation while
436
+ * drawing; renderMs drawing without that generation.
437
+ */
438
+ get stats() {
439
+ return { ...state.stats };
440
+ },
295
441
  /**
296
442
  * Replace the local matrix of each element. An entry is `[id, matrix]`
297
443
  * or `{id, matrix}`. The matrix is column-major, 16 numbers, the same
298
444
  * layout as matrix(). Every matrix is stored first, then each affected
299
445
  * part of the scene is walked once. The returned array has false for an
300
446
  * id that is missing or drawn in more than one place (a shared
301
- * definition, or a copy made by repeat or scatter).
447
+ * definition, or a copy made by repeat or scatter). Ids are those of the
448
+ * stage's document; a fragment has fragment.setTransforms.
302
449
  * @param {Array<[string, ArrayLike<number>]|{id: string, matrix: ArrayLike<number>}>} entries
303
450
  */
304
451
  setTransforms(entries) {
305
452
  const list = Array.from(entries || []);
306
- const res = state.result;
453
+ const res = hostResult();
307
454
  const graph = res && res.graph;
308
455
  const byId = graph && graph.byId;
309
456
  const nodes = list.map((entry) => byId && byId.get(Array.isArray(entry) ? entry[0] : entry && entry.id));
@@ -311,7 +458,10 @@ export async function createStage(canvas, opts = {}) {
311
458
  const applied = graph && res.scene
312
459
  ? applyLocalMatrices(res.scene, graph, nodes, matrices, state.time, state.progress)
313
460
  : list.map(() => false);
314
- if (applied.some(Boolean)) state.dirty = true;
461
+ if (applied.some(Boolean)) {
462
+ state.dirty = true;
463
+ if (state.setMode) frags.set.invalidateBounds();
464
+ }
315
465
  return applied;
316
466
  },
317
467
  /**
@@ -327,32 +477,52 @@ export async function createStage(canvas, opts = {}) {
327
477
  },
328
478
  /** Draw the next frame even if nothing the stage tracks has changed. */
329
479
  invalidate,
480
+ /** The document's compile result (a prepared document is compiled here on first access). */
330
481
  get result() {
331
- return state.result;
482
+ return hostResult();
332
483
  },
333
484
  /**
334
485
  * Compile and show a document. Returns the compile result; on errors the
335
- * previous document stays on screen.
336
- * @param {string} source
337
- * @param {{keepCamera?: boolean, time?: number}} [showOpts]
486
+ * previous document stays on screen. Fragments stay, unless
487
+ * keepFragments is false.
488
+ * @param {string|ArrayBuffer|Uint8Array|Prepared} source
489
+ * @param {{keepCamera?: boolean, time?: number, keepFragments?: boolean}} [showOpts]
338
490
  */
339
491
  show(source, showOpts = {}) {
340
492
  if (showOpts.time !== undefined) state.time = showOpts.time;
341
- // Reusing the cache keeps every mesh whose definition did not change
342
- // (a new wrap or paint only changes materials), so nothing is re-tessellated
343
- // and the renderer finds those meshes already on the GPU.
344
- const result = compile(source, { time: state.time, progress: state.progress, cache: state.result ? state.result.cache : undefined });
493
+ let result;
494
+ let owner;
495
+ if (source instanceof Prepared) {
496
+ ({ result, owner } = frags.fromPrepared(source, 'document'));
497
+ } else {
498
+ // Reusing the cache keeps every mesh whose definition did not change
499
+ // (a new wrap or paint only changes materials), so nothing is re-tessellated
500
+ // and the renderer finds those meshes already on the GPU. Next to
501
+ // fragments the document builds as an owner of its own, so it does not
502
+ // evict theirs.
503
+ owner = state.setMode ? frags.newOwner('document') : DOCUMENT;
504
+ result = compile(source, { time: state.time, progress: state.progress, cache: state.cache, owner });
505
+ }
345
506
  if (!result.ok) {
507
+ if (owner && owner !== DOCUMENT) frags.release(owner);
346
508
  const msg = formatDiagnostics(result);
347
509
  if (opts.onError) opts.onError(msg, result);
348
510
  else console.error(msg);
349
511
  return result;
350
512
  }
513
+ if (state.setMode && showOpts.keepFragments === false) frags.clear();
351
514
  state.result = result;
352
515
  state.generation++;
353
- renderer.setScene(result.scene, meshOf);
516
+ if (state.setMode) {
517
+ frags.setHost(result, owner);
518
+ renderer.updateScene(frags.set);
519
+ state.placedSinceDraw = true;
520
+ } else {
521
+ frags.adoptHost(result, owner);
522
+ renderer.setScene(result.scene, meshOf);
523
+ }
354
524
  configureScroll();
355
- if (state.progress !== 0) updateDynamic(result.scene, result.graph, state.time, state.progress);
525
+ if (state.progress !== 0 && result.graph) updateDynamic(result.scene, result.graph, state.time, state.progress);
356
526
  state.sceneTime = state.time;
357
527
  state.sceneProgress = state.progress;
358
528
  state.dirty = true;
@@ -368,19 +538,63 @@ export async function createStage(canvas, opts = {}) {
368
538
  // the scroll path is prepared after that, so it only builds what was not stored
369
539
  const gen = state.generation;
370
540
  state.holdUntil = performance.now() + RESTORE_WAIT_MS;
371
- persist.restore(result.cache).finally(() => {
541
+ persist.restore(state.cache, state.setMode ? { entries: state.cache.owned(owner) } : undefined).finally(() => {
372
542
  if (gen !== state.generation) return;
373
543
  state.holdUntil = 0;
374
544
  state.dirty = true;
375
- if (result.scene.animation.scroll) prefetchScrollPath();
545
+ if (result.scene.animation.scroll && result.graph) prefetchScrollPath();
376
546
  });
377
547
  return result;
378
548
  },
549
+ /**
550
+ * Show another document inside this one: a fragment. Returns at once; the
551
+ * fragment is compiled and placed in the frame loop, within changeBudget
552
+ * (immediate: true does it now). fragment.ready resolves when it is placed.
553
+ * @param {string|ArrayBuffer|Uint8Array|Prepared|Promise<Prepared>} source a complete ES3D document
554
+ * @param {{id?: string, immediate?: boolean}} [addOpts]
555
+ * @returns {import('../scene/fragments.js').Fragment}
556
+ */
557
+ add(source, addOpts = {}) {
558
+ enterSetMode();
559
+ const f = frags.add(source, addOpts);
560
+ state.dirty = true;
561
+ return f;
562
+ },
563
+ /** The fragment with this id, or undefined. */
564
+ fragment(id) {
565
+ return frags.byId.get(String(id));
566
+ },
567
+ /** All fragments, in the order added. */
568
+ get fragments() {
569
+ return frags.list;
570
+ },
571
+ /**
572
+ * Compile a document off the main thread (in a worker, where the page
573
+ * allows one; otherwise here). Pass the result to add() or show().
574
+ * @param {string|ArrayBuffer|Uint8Array} source
575
+ * @param {{as?: 'fragment'|'document'}} [prepareOpts]
576
+ * @returns {Promise<Prepared>}
577
+ */
578
+ prepare(source, prepareOpts = {}) {
579
+ ensureCompiler();
580
+ return frags.prepare(source, prepareOpts);
581
+ },
582
+ /**
583
+ * Resolves after the first frame that shows every change queued before
584
+ * this call, fully refined (renderer.stats.refining is 0), with stage.stats.
585
+ */
586
+ drawn() {
587
+ return new Promise((resolve) => {
588
+ state.waiters.push({ resolve, tasks: new Set(frags.queue) });
589
+ state.dirty = true;
590
+ });
591
+ },
379
592
  dispose() {
380
593
  state.disposed = true;
381
594
  io.disconnect();
382
595
  ro.disconnect();
383
596
  if (state.timeline) state.timeline.dispose();
597
+ frags.dispose();
384
598
  controls.dispose();
385
599
  renderer.dispose();
386
600
  },
@@ -389,12 +603,27 @@ export async function createStage(canvas, opts = {}) {
389
603
  const frame = (now) => {
390
604
  if (state.disposed) return;
391
605
  requestAnimationFrame(frame);
606
+ const t0 = clock();
392
607
  const dt = state.last ? Math.min(0.05, (now - state.last) / 1000) : 0;
393
608
  state.last = now;
394
- if (!state.result || !state.onScreen) return;
609
+ if (state.setMode) {
610
+ // fragments compile and place within the budget, also while off screen
611
+ if (frags.queue.length) {
612
+ const c = frags.applyQueued(state.changeBudget);
613
+ state.since.changeMs += c.changeMs;
614
+ state.since.compileMs += c.compileMs;
615
+ }
616
+ if (frags.takeChanged()) {
617
+ renderer.updateScene(frags.set);
618
+ configureScroll(false);
619
+ state.placedSinceDraw = true;
620
+ state.dirty = true;
621
+ }
622
+ }
623
+ const scene = drawScene();
624
+ if (!scene || !state.onScreen) return;
395
625
  if (!state.width || !state.height) return;
396
626
  const res = state.result;
397
- const scene = res.scene;
398
627
  const reduced = prefersReducedMotion();
399
628
  if (clockAdvances(state, scene, reduced)) state.time += dt;
400
629
  if (state.timeline && state.timeline.update(dt)) state.progress = state.timeline.value;
@@ -410,13 +639,15 @@ export async function createStage(canvas, opts = {}) {
410
639
  camera.position[2] = camera.target[2] + dx * si + dz * co;
411
640
  controls.constrain(prev);
412
641
  }
413
- if (scene.dynamic.length && (state.time !== state.sceneTime || state.progress !== state.sceneProgress)) {
414
- updateDynamic(scene, res.graph, state.time, state.progress);
642
+ if (res && res.graph && res.scene.dynamic.length && (state.time !== state.sceneTime || state.progress !== state.sceneProgress)) {
643
+ updateDynamic(res.scene, res.graph, state.time, state.progress);
415
644
  state.sceneTime = state.time;
416
645
  state.sceneProgress = state.progress;
417
- if (followsDocument()) camera.fromSpec(scene.cameras[0]);
646
+ if (followsDocument()) camera.fromSpec(res.scene.cameras[0]);
647
+ if (state.setMode) frags.set.invalidateBounds();
418
648
  state.dirty = true;
419
649
  }
650
+ if (state.setMode && frags.updateDynamic(state.time, state.progress)) state.dirty = true;
420
651
  if (cameraMoved()) state.dirty = true;
421
652
  const dpr = renderer.pixelRatio(typeof devicePixelRatio === 'number' ? devicePixelRatio : 1);
422
653
  if (dpr !== state.dpr) {
@@ -428,12 +659,36 @@ export async function createStage(canvas, opts = {}) {
428
659
  state.gpuEpoch = epoch;
429
660
  state.dirty = true;
430
661
  }
431
- if (!state.dirty || (state.holdUntil && now < state.holdUntil)) return;
662
+ if (!state.dirty || (state.holdUntil && now < state.holdUntil)) {
663
+ settleWaiters();
664
+ return;
665
+ }
432
666
  state.dirty = false;
667
+ const generated = state.cache.stats.generateMs;
668
+ const r0 = clock();
433
669
  renderer.render(camera, { ...view(), time: state.time });
670
+ const renderMs = clock() - r0;
434
671
  // levels drawn coarser than wanted: refine them on the next frame
435
672
  if (renderer.stats.refining) state.dirty = true;
436
- persist.changed(res.cache);
673
+ persistChanged();
674
+ const tessellateMs = state.cache.stats.generateMs - generated;
675
+ const rs = renderer.stats;
676
+ const s = state.stats;
677
+ s.changeMs = state.since.changeMs;
678
+ s.compileMs = state.since.compileMs;
679
+ s.tessellateMs = tessellateMs;
680
+ s.renderMs = renderMs - tessellateMs;
681
+ s.instances = rs.instances;
682
+ s.drawCalls = rs.drawCalls;
683
+ s.uploadBytes = rs.uploadBytes;
684
+ s.refining = rs.refining;
685
+ s.queued = frags.queue.length;
686
+ state.since.changeMs = 0;
687
+ state.since.compileMs = 0;
688
+ state.placedSinceDraw = false;
689
+ state.framesDrawn++;
690
+ s.frameMs = clock() - t0;
691
+ settleWaiters();
437
692
  };
438
693
  requestAnimationFrame(frame);
439
694
  return api;
@@ -0,0 +1,14 @@
1
+ // The first module the stage evaluates (render/stage.js imports it before
2
+ // anything else). In the compiler worker it keeps every message that arrives
3
+ // before the compiler is ready: the WebAssembly kernels (wasm/kernels.js) are
4
+ // instantiated with a top-level await, and a module worker delivers messages
5
+ // while it waits, so a handler installed after that would miss the first
6
+ // requests. compile-worker.js answers the kept messages once it takes over.
7
+
8
+ /** True inside a dedicated worker. */
9
+ export const inWorker = typeof WorkerGlobalScope !== 'undefined' && typeof self !== 'undefined' && self instanceof WorkerGlobalScope;
10
+
11
+ /** Messages that arrived before the compiler was ready, oldest first. */
12
+ export const early = [];
13
+
14
+ if (inWorker) self.onmessage = (e) => early.push(e);