@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.
- package/README.md +24 -0
- package/browser/es3d-stage.min.js +243 -10232
- package/browser/es3d.min.js +534 -0
- package/docs/STAGE.md +95 -0
- package/package.json +6 -3
- package/src/browser.js +16 -0
- package/src/cache/geometry-store.js +56 -18
- package/src/geometry/geometry-cache.js +93 -8
- package/src/io/compression.js +4 -1
- package/src/io/convert.js +14 -1
- package/src/pipeline.js +2 -1
- package/src/render/compile-worker.js +143 -0
- package/src/render/frame-builder.js +114 -77
- package/src/render/renderer.js +28 -2
- package/src/render/stage.js +305 -50
- package/src/render/worker-boot.js +14 -0
- package/src/scene/fragments.js +527 -0
- package/src/scene/picking.js +31 -2
- package/src/scene/prepared.js +398 -0
- package/src/scene/scene-graph.js +1 -1
- package/src/scene/scene-set.js +251 -0
package/src/render/stage.js
CHANGED
|
@@ -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]
|
|
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.
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
+
persistChanged();
|
|
243
310
|
idle(step);
|
|
244
311
|
};
|
|
245
312
|
idle(step);
|
|
246
313
|
};
|
|
247
314
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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 =
|
|
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))
|
|
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
|
|
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
|
-
*
|
|
337
|
-
* @param {
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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))
|
|
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
|
-
|
|
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);
|