concept-collection / turing-sphere
820 lines · 27.3 KBBlameHistoryRaw
1import { requestShtDevice, describeAdapter } from './sht/sht.ts';
2import { gridForLmax } from './sht/layout.ts';
3import { ModelSession } from './mgpu/session.ts';
4import { mModelByKey, presets, type MModel, type Params } from './mgpu/registry.ts';
5import { ModelCompileError, formatFailure } from './mgpu/errors.ts';
6import { EXTERNAL_OPS } from './mgpu/externals.ts';
7import { CodeEditor } from './editor/codeEditor.ts';
8import {
9 formatCommand,
10 resolvePreset,
11 DEFAULT_STEPS,
12 DEFAULT_WARMUP,
13 type RunSpec,
14} from './bench/runSpec.ts';
15import {
16 buildTopology,
17 fillFieldValues,
18 fillColors,
19 type SphereMeshTopology,
20} from './render/sphereMesh.ts';
21import { SphereScene } from './render/SphereScene.ts';
22import { Colorbar, fmtValue } from './render/colorbar.ts';
23import { colormaps, colormapNames } from './render/colormaps.ts';
24import { MovieRecorder } from './render/movie.ts';
26const $ = <T extends HTMLElement>(id: string): T =>
27 document.getElementById(id) as T;
29const elModel = $<HTMLSelectElement>('model');
30const elLmax = $<HTMLSelectElement>('lmax');
31const elOversample = $<HTMLSelectElement>('oversample');
32const elColormap = $<HTMLSelectElement>('colormap');
33const elRunPause = $<HTMLButtonElement>('runpause');
34const elBenchmark = $<HTMLButtonElement>('benchmark');
35const elReseed = $<HTMLButtonElement>('reseed');
36const elResetView = $<HTMLButtonElement>('resetview');
37const elMovieToggle = $<HTMLButtonElement>('movietoggle');
38const elMovieBar = $('moviebar');
39const elMovieSpeed = $<HTMLSelectElement>('moviespeed');
40const elMovieRes = $<HTMLSelectElement>('movieres');
41const elMovieRotate = $<HTMLInputElement>('movierotate');
42const elMovie = $<HTMLButtonElement>('movie');
43const elParams = $('params');
44const elPanels = $('panels');
45const elStats = $('stats');
46const elBenchResult = $('benchresult');
47const elCmd = $('cmd');
48const elCopyCmd = $<HTMLButtonElement>('copycmd');
49const elBlurb = $('blurb');
50const elErr = $('err');
51const elSource = $<HTMLTextAreaElement>('source');
52const elHighlight = $('highlight');
53const elCompiled = $('compiled');
54const elEditorTitle = $('editor-title');
55const elRecompile = $<HTMLButtonElement>('recompile');
56const elRevert = $<HTMLButtonElement>('revert');
58for (const p of presets) {
59 const o = document.createElement('option');
60 o.value = p.key;
61 o.textContent = p.label;
62 elModel.append(o);
64for (const name of colormapNames) {
65 const o = document.createElement('option');
66 o.value = name;
67 o.textContent = name;
68 elColormap.append(o);
70elColormap.value = 'jet';
72/** The model source, with MATLAB highlighting. The host-provided operations are
73 * marked so the boundary between the model and what it is given is visible. */
74const editor = new CodeEditor({
75 textarea: elSource,
76 overlay: elHighlight,
77 external: EXTERNAL_OPS,
78 onInput: (value) => {
79 editedSource = value;
80 elRecompile.textContent = 'Recompile *';
81 },
82});
84/** Timesteps submitted per rendered frame. Nothing is read back between them,
85 * so the batch costs one submit and one readback regardless of size. */
86const STEPS_PER_FRAME = 4;
88/**
89 * Steps in a solver-timing burst, and how often to run one.
90 *
91 * Timing the solver needs a `queue.onSubmittedWorkDone()` to know the work
92 * finished, and in a browser that is an IPC round trip into the GPU process — a
93 * fixed cost of a few milliseconds. Spread over one frame's four steps it would
94 * swamp them on a fast GPU and make the solver look far slower than it is. So the
95 * rate is measured in an occasional larger batch, where the single sync is
96 * amortized the way the desktop benchmark amortizes its own. The state is
97 * snapshotted and restored around the batch, so measuring never advances the
98 * simulation — otherwise the pattern would visibly lurch forward at every
99 * measurement.
100 */
101const MEASURE_BURST = 32;
102const MEASURE_EVERY_MS = 2000;
104/**
105 * 'auto' display oversampling targets this many render latitudes: the factor is
106 * the smallest power of two (up to 4) that reaches it. A solver grid already
107 * this fine gains nothing visually and is not oversampled.
108 */
109const AUTO_RENDER_NLAT = 256;
111/** The display oversampling factor the UI currently asks for. */
112function resolveOversample(): number {
113 if (elOversample.value !== 'auto') return Number(elOversample.value);
114 const { nlat } = gridForLmax(Number(elLmax.value), model.pdeg);
115 let os = 1;
116 while (os < 4 && os * nlat < AUTO_RENDER_NLAT) os *= 2;
117 return os;
120/**
121 * Movie frame rate, and a cap on frames per movie. Playback speed comes from
122 * the UI, in simulation-time units per second of video; the movie's length is
123 * the run's t at that speed, and the frame count follows from it — capped by
124 * the run's own step count (a step is at most one frame) and by
125 * MOVIE_MAX_FRAMES to bound encode time and file size. Frame timestamps are
126 * derived from simulation time, so a capped movie keeps its duration and
127 * speed exactly, at a lower effective frame rate.
128 */
129const MOVIE_FPS = 30;
130const MOVIE_MAX_FRAMES = 3600;
132/** Movie auto-rotation: camera revolutions per second of video. Measured in
133 * video time, so the orbit pace on screen is the same at every export speed. */
134const MOVIE_ROTATE_RPS = 1 / 120;
136// ---------------------------------------------------------------- state
137let device: GPUDevice | null = null;
138let session: ModelSession | null = null;
139let topo: SphereMeshTopology | null = null;
140let scenes: SphereScene[] = [];
141let colorbars: Colorbar[] = [];
142let valueBufs: Float32Array[] = [];
143let colorBufs: Float32Array[] = [];
144let ranges: { lo: number; hi: number }[] = [];
145let resizeObs: ResizeObserver | null = null;
147const initial = resolvePreset(presets[0].key);
148let model: MModel = mModelByKey(initial.model.key)!;
149let params: Params = initial.params;
150/** The .m as edited in the page; `null` while it matches the file. */
151let editedSource: string | null = null;
152let seed = 1;
153let running = false;
154let adapterName = '';
155let pumping = false;
156let movieBusy = false;
157let movieCancel = false;
158let solverMs = 0;
159let frameMs = 0;
160let lastMeasure = 0;
161let generation = 0; // bumped on every rebuild to cancel stale pumps
163const source = (): string => editedSource ?? model.source;
165// ---------------------------------------------------------------- UI wiring
166function buildParamInputs(): void {
167 elParams.replaceChildren();
168 for (const spec of model.params) {
169 const label = document.createElement('label');
170 label.textContent = `${spec.label} `;
171 const input = document.createElement('input');
172 input.type = 'number';
173 input.min = String(spec.min);
174 input.max = String(spec.max);
175 input.step = String(spec.step);
176 input.value = String(params[spec.key]);
177 input.addEventListener('change', () => {
178 const v = Number(input.value);
179 if (Number.isFinite(v)) params[spec.key] = v;
180 // Parameters are uniforms, not constants baked into the kernels, so a
181 // change costs an upload rather than a recompile.
182 session?.setParams(params);
183 updateCommand();
184 });
185 label.append(input);
186 elParams.append(label);
187 }
190function applyPreset(presetKey: string): void {
191 const resolved = resolvePreset(presetKey);
192 const next = mModelByKey(resolved.model.key);
193 if (!next) {
194 elErr.textContent = `No .m model for '${resolved.model.key}'`;
195 return;
196 }
197 model = next;
198 params = resolved.params;
199 editedSource = null;
200 editor.value = model.source;
201 elEditorTitle.textContent =
202 `models/${model.key}.m — init() and step(), compiled to WebGPU`;
203 buildParamInputs();
204 elBlurb.textContent = model.blurb;
205 updateCommand();
208/** The run currently on screen, as the benchmark's RunSpec. */
209function currentSpec(): RunSpec {
210 return {
211 preset: elModel.value,
212 lmax: Number(elLmax.value),
213 seed,
214 steps: DEFAULT_STEPS,
215 warmup: DEFAULT_WARMUP,
216 params,
217 };
220function updateCommand(): void {
221 elCmd.textContent = formatCommand(currentSpec());
224elModel.addEventListener('change', () => {
225 applyPreset(elModel.value);
226 void rebuild();
227});
228elLmax.addEventListener('change', () => void rebuild());
229// Oversampling is display-only, so it swaps the render grid in place rather
230// than rebuilding the run. Serialized: a rapid second change waits its turn.
231let viewChange = Promise.resolve();
232elOversample.addEventListener('change', () => {
233 viewChange = viewChange.then(() => applyOversample());
234});
235elColormap.addEventListener('change', () => void draw());
237function setRunning(next: boolean): void {
238 running = next;
239 elRunPause.textContent = running ? 'Pause' : 'Run';
240 if (running) void pump();
243elRunPause.addEventListener('click', () => setRunning(!running));
244elBenchmark.addEventListener('click', () => void benchmark());
245elReseed.addEventListener('click', () => {
246 seed = (Math.random() * 2 ** 31) >>> 0;
247 setRunning(false);
248 updateCommand();
249 void reseed();
250});
251elResetView.addEventListener('click', () => {
252 for (const s of scenes) s.resetCamera();
253});
254elMovieToggle.addEventListener('click', () => {
255 elMovieBar.hidden = !elMovieBar.hidden;
256});
257elMovie.addEventListener('click', () => {
258 if (movieBusy) movieCancel = true;
259 else void recordMovie();
260});
262elRecompile.addEventListener('click', () => {
263 editedSource = editor.value;
264 void rebuild();
265});
266elRevert.addEventListener('click', () => {
267 editedSource = null;
268 editor.value = model.source;
269 void rebuild();
270});
272// The command reproduces this run's parameters on the desktop; keep it
273// selectable even where the clipboard API is unavailable.
274elCopyCmd.addEventListener('click', () => {
275 const text = elCmd.textContent ?? '';
276 const flash = (msg: string): void => {
277 elCopyCmd.textContent = msg;
278 setTimeout(() => (elCopyCmd.textContent = 'Copy'), 1200);
279 };
280 const selectCommand = (): void => {
281 const range = document.createRange();
282 range.selectNodeContents(elCmd);
283 const sel = getSelection();
284 sel?.removeAllRanges();
285 sel?.addRange(range);
286 flash('Selected');
287 };
288 if (!navigator.clipboard) return selectCommand();
289 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
290});
292// ---------------------------------------------------------------- setup
293function disposeView(): void {
294 for (const s of scenes) s.dispose();
295 scenes = [];
296 colorbars = [];
297 resizeObs?.disconnect();
298 resizeObs = null;
299 elPanels.replaceChildren();
302/**
303 * Build the mesh, scenes, colorbars and per-species buffers on the current
304 * render grid. Call disposeView() first. The color ranges are kept if present,
305 * so a display-only rebuild (an oversampling change) does not pop the shading;
306 * a full rebuild clears `ranges` beforehand.
307 */
308function buildView(): void {
309 if (!session) return;
310 const view = session.viewSht;
311 const { nphi } = view.cfg;
312 const phi = new Float64Array(nphi);
313 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
314 topo = buildTopology(view.cosTheta, phi);
316 const sphereBg = getComputedStyle(document.documentElement)
317 .getPropertyValue('--sphere-bg')
318 .trim();
319 for (let k = 0; k < model.species.length; k++) {
320 const panel = document.createElement('div');
321 panel.className = 'panel';
322 const box = document.createElement('div');
323 box.className = 'sphere-box';
324 const tag = document.createElement('div');
325 tag.className = 'species-tag';
326 tag.textContent = model.species[k];
327 box.append(tag);
328 const side = document.createElement('div');
329 panel.append(box, side);
330 elPanels.append(panel);
332 const scene = new SphereScene(
333 box,
334 topo.numVertices,
335 topo.indices,
336 topo.sphereRef,
337 sphereBg || undefined,
338 );
339 scene.fitCamera();
340 scenes.push(scene);
341 colorbars.push(new Colorbar(side));
342 valueBufs[k] = new Float32Array(topo.numVertices);
343 colorBufs[k] = new Float32Array(topo.numVertices * 3);
344 if (!ranges[k]) ranges[k] = { lo: NaN, hi: NaN };
345 }
346 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
348 resizeObs = new ResizeObserver(() => {
349 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
350 boxes.forEach((box, i) => {
351 scenes[i]?.resize(box.clientWidth, box.clientHeight);
352 });
353 });
354 elPanels
355 .querySelectorAll<HTMLElement>('.sphere-box')
356 .forEach((box) => resizeObs!.observe(box));
359/**
360 * Apply the UI's oversampling choice to the running session. Display-only: the
361 * session and its state survive; only the display plan, mesh and scenes are
362 * rebuilt, keeping the camera pose and color ranges. The pump is drained first
363 * so no readback is in flight on the plan being replaced.
364 */
365async function applyOversample(): Promise<void> {
366 if (!session) return;
367 const gen = generation;
368 const os = resolveOversample();
369 if (os === session.oversample) return;
370 const wasRunning = running;
371 setRunning(false);
372 while (pumping) await nextFrame();
373 if (gen !== generation || !session) return;
374 await session.setOversample(os);
375 if (gen !== generation || !session) return;
376 const cam = scenes[0]?.cameraState();
377 disposeView();
378 buildView();
379 if (cam) for (const s of scenes) s.setCameraState(cam);
380 await draw();
381 updateStats();
382 if (wasRunning) setRunning(true);
385/** Report a compile failure, and select the offending text in the editor. */
386function reportCompileError(e: unknown): void {
387 elErr.textContent = formatFailure(e, source());
388 elCompiled.textContent = '';
389 if (e instanceof ModelCompileError && e.start !== undefined) {
390 editor.select(e.start, e.end ?? e.start);
391 }
394async function rebuild(): Promise<void> {
395 generation++;
396 const gen = generation;
397 setRunning(false);
398 disposeView();
399 session?.destroy();
400 session = null;
401 solverMs = 0;
402 frameMs = 0;
403 lastMeasure = 0;
404 elErr.textContent = '';
405 updateCommand();
406 if (!device) return;
408 try {
409 session = await ModelSession.create({
410 device,
411 model,
412 params,
413 lmax: Number(elLmax.value),
414 source: source(),
415 oversample: resolveOversample(),
416 });
417 } catch (e) {
418 reportCompileError(e);
419 return;
420 }
421 if (gen !== generation) return;
423 session.seed(seed);
425 const plan = session.describe();
426 elCompiled.textContent =
427 `one step compiled to ${plan.step.length} GPU operations:\n` +
428 plan.step.map((l) => ` ${l}`).join('\n');
429 elRecompile.textContent = 'Recompile';
431 ranges = [];
432 buildView();
434 await draw();
435 updateStats();
436 void pump();
439async function reseed(): Promise<void> {
440 if (!session) return;
441 const gen = generation;
442 session.seed(seed);
443 if (gen !== generation) return;
444 for (const r of ranges) {
445 r.lo = NaN;
446 r.hi = NaN;
447 }
448 await draw();
449 updateStats();
452// ---------------------------------------------------------------- drawing
453async function draw(): Promise<void> {
454 if (!session || !topo) return;
455 const gen = generation;
456 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
457 for (let k = 0; k < model.species.length; k++) {
458 // The one readback per frame — the loop is otherwise entirely on the GPU.
459 // A rebuild can land while this is in flight and destroy the buffer being
460 // mapped, which rejects the map; that result is stale anyway, so drop it.
461 let field: Float32Array;
462 try {
463 field = await session.readSpecies(k);
464 } catch (e) {
465 if (gen !== generation) return;
466 throw e;
467 }
468 if (gen !== generation || !topo) return;
469 fillFieldValues(valueBufs[k], field, topo);
470 let lo = Infinity;
471 let hi = -Infinity;
472 for (const v of valueBufs[k]) {
473 if (v < lo) lo = v;
474 if (v > hi) hi = v;
475 }
476 // smooth the color range in both directions so the shading evolves
477 // gently as the pattern grows (out-of-range values clamp meanwhile)
478 const r = ranges[k];
479 if (!Number.isFinite(r.lo)) {
480 r.lo = lo;
481 r.hi = hi;
482 } else {
483 const a = 0.15;
484 r.lo += a * (lo - r.lo);
485 r.hi += a * (hi - r.hi);
486 }
487 if (r.hi - r.lo < 1e-9) {
488 const mid = (r.hi + r.lo) / 2;
489 r.lo = mid - 5e-10;
490 r.hi = mid + 5e-10;
491 }
492 fillColors(colorBufs[k], valueBufs[k], r.lo, r.hi, cmap);
493 scenes[k]?.updateColors(colorBufs[k]);
494 colorbars[k]?.update(cmap, r.lo, r.hi);
495 }
498function updateStats(): void {
499 if (!session) return;
500 const { nlat, nphi } = session.cfg;
501 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
502 const solver =
503 solverMs > 0
504 ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s, ` +
505 `batch of ${MEASURE_BURST}, no readback)`
506 : '—';
507 const frame =
508 frameMs > 0
509 ? `${frameMs.toFixed(1)} ms/frame (${STEPS_PER_FRAME} steps + readback + render)`
510 : '—';
511 const view = session.viewSht.cfg;
512 const render =
513 session.oversample > 1
514 ? ` (display ${view.nlat}×${view.nphi}, ${session.oversample}×)`
515 : '';
516 elStats.innerHTML =
517 `<b>${kind}</b> · grid ${nlat}×${nphi}${render} · nlm ${session.sht.nlm.toLocaleString()} · ` +
518 `${session.sht.fourierMode.toUpperCase()} · solver ${solver} · ${frame} · ` +
519 `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
522// ---------------------------------------------------------------- sim loop
523const nextFrame = () => new Promise<number>(requestAnimationFrame);
525async function pump(): Promise<void> {
526 if (pumping) return;
527 pumping = true;
528 const gen = generation;
529 try {
530 while (running && session && gen === generation) {
531 // Occasionally, a burst purely to measure the solver rate: many steps,
532 // one sync, nothing read back — directly comparable to the desktop
533 // benchmark's throughput number. State-preserving: the display and
534 // model time are unaffected.
535 if (performance.now() - lastMeasure > MEASURE_EVERY_MS) {
536 const ms = await session.measure(MEASURE_BURST);
537 if (gen !== generation) break;
538 solverMs = ms;
539 lastMeasure = performance.now();
540 }
542 // The frame itself. No explicit sync here — draw()'s readback already
543 // waits for the steps, so asking twice would only add a round trip.
544 const t0 = performance.now();
545 session.step(STEPS_PER_FRAME);
546 await draw();
547 if (gen !== generation) break;
548 frameMs = frameMs === 0
549 ? performance.now() - t0
550 : frameMs + 0.05 * (performance.now() - t0 - frameMs);
551 updateStats();
552 await nextFrame();
553 }
554 if (gen === generation) {
555 await draw();
556 updateStats();
557 }
558 } finally {
559 pumping = false;
560 }
563/**
564 * Sustained solver benchmark, in the page.
565 *
566 * The same measurement `npm run bench` makes: batches of steps submitted
567 * together, waited for, never read back, with no rendering and no animation
568 * pacing in between. That makes it directly comparable to the terminal number,
569 * which is the only way to tell a genuinely slower browser GPU stack apart from
570 * the costs the app adds on top.
571 *
572 * It also reports the ramp — the first third of the run against the last. GPUs
573 * downclock when idle, and an animation-paced loop leaves them idle most of every
574 * frame, so a large ramp means the app's steady-state number is limited by clocks
575 * rather than by the work.
576 *
577 * These are ordinary steps: the simulation advances by them.
578 */
579async function benchmark(): Promise<void> {
580 if (!session || movieBusy) return;
581 setRunning(false);
582 const BATCH = 32;
583 const DURATION_MS = 2000;
584 elBenchResult.textContent = 'benchmarking…';
585 // A movie started mid-benchmark would replay while this loop still steps.
586 elMovie.disabled = true;
587 try {
588 await nextFrame();
590 const gen = generation;
591 const perStep: number[] = [];
592 const t0 = performance.now();
593 while (performance.now() - t0 < DURATION_MS) {
594 const b0 = performance.now();
595 session.step(BATCH);
596 await session.sync();
597 if (gen !== generation) return;
598 perStep.push((performance.now() - b0) / BATCH);
599 }
601 const mean = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
602 const all = mean(perStep);
603 const best = Math.min(...perStep);
604 const third = Math.max(1, Math.floor(perStep.length / 3));
605 const first = mean(perStep.slice(0, third));
606 const last = mean(perStep.slice(-third));
607 const steps = perStep.length * BATCH;
609 elBenchResult.innerHTML =
610 `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
611 `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
612 `ramp ${(first / last).toFixed(2)}× (${first.toFixed(2)}${last.toFixed(2)}) · ` +
613 `${steps} steps in batches of ${BATCH} · ` +
614 `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
615 await draw();
616 updateStats();
617 } finally {
618 elMovie.disabled = false;
619 }
622// ---------------------------------------------------------------- movie
623function saveBlob(blob: Blob, filename: string): void {
624 const url = URL.createObjectURL(blob);
625 const a = document.createElement('a');
626 a.href = url;
627 a.download = filename;
628 a.click();
629 setTimeout(() => URL.revokeObjectURL(url), 10_000);
632/** Submit `n` steps in bounded command buffers — a single buffer encoding
633 * many thousands of steps can exhaust the encoder. */
634function submitSteps(n: number): void {
635 while (n > 0 && session) {
636 const chunk = Math.min(512, n);
637 session.step(chunk);
638 n -= chunk;
639 }
642/** While recording, lock everything that could change the run mid-replay;
643 * the Movie button itself becomes the cancel button. */
644function setMovieUi(on: boolean): void {
645 const locked = [
646 elModel, elLmax, elOversample, elColormap, elRunPause, elBenchmark,
647 elReseed, elRecompile, elRevert, elMovieSpeed, elMovieRes, elMovieRotate,
648 elMovieToggle,
649 ];
650 for (const el of locked) el.disabled = on;
651 elParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
652 elMovie.textContent = on ? 'Cancel · 0%' : 'Export';
655/**
656 * Recompute the run from t = 0 and download it as an MP4.
657 *
658 * The movie is not a recording of what already happened — it is the same
659 * trajectory recomputed: same seed, same source, and the *current* parameters
660 * and colormap throughout. Determinism makes this exact: after the replay the
661 * state is where it was, so the one session is reused and the app resumes as
662 * if nothing happened. Frames are composited from the live panels, so the
663 * movie shows the spheres at the current camera orientation — and the replay
664 * doubles as the progress display, since it is visible on screen.
665 */
666async function recordMovie(): Promise<void> {
667 if (!session || movieBusy) return;
668 if (session.steps === 0) {
669 elMovie.textContent = 'run first';
670 setTimeout(() => (elMovie.textContent = 'Export'), 1200);
671 return;
672 }
673 movieBusy = true;
674 movieCancel = false;
675 const gen = generation;
676 setMovieUi(true);
677 let wasRunning = false;
678 let total = 0;
679 let done = 0;
680 let seeded = false;
681 let camBefore: ReturnType<SphereScene['cameraState']> | undefined;
682 try {
683 // An in-flight display-grid swap replaces the scenes whose canvases the
684 // recorder captures, and resumes the run when it lands — let it finish.
685 await viewChange;
686 if (gen !== generation || !session) return;
687 wasRunning = running;
688 setRunning(false);
689 while (pumping) await nextFrame(); // let an in-flight live frame drain
690 if (gen !== generation || !session) return;
691 total = session.steps;
692 const speed = Number(elMovieSpeed.value) || 10;
693 const sphere = Number(elMovieRes.value) || 768;
694 const rotate = elMovieRotate.checked;
695 if (rotate) camBefore = scenes[0]?.cameraState();
696 // Render the scenes at exactly the chosen resolution for the recording —
697 // independent of the window size — and restore afterwards.
698 for (const s of scenes) s.captureSize(sphere);
699 const durationS = Math.max(session.t / speed, 2 / MOVIE_FPS);
700 const frames = Math.max(
701 2,
702 Math.min(Math.round(durationS * MOVIE_FPS) + 1, total + 1, MOVIE_MAX_FRAMES),
703 );
704 /** The step index captured as frame `i`; both endpoints land exactly. */
705 const stepAt = (i: number): number => Math.round((i * total) / (frames - 1));
707 const title =
708 (presets.find((p) => p.key === elModel.value)?.label ?? model.label) +
709 (editedSource !== null ? ' (edited)' : '');
710 const subtitle = model.params
711 .map((spec) => `${spec.label} ${fmtValue(params[spec.key])}`)
712 .join(' · ');
713 const rec = await MovieRecorder.create({
714 panels: model.species.map((label, k) => ({ canvas: scenes[k].canvas, label })),
715 title,
716 subtitle,
717 speed,
718 fps: (frames - 1) / durationS,
719 sphere,
720 });
722 let finished = false;
723 try {
724 // Reset the color-range smoothing as a re-seed does, so the shading
725 // evolves in the movie the way it did live.
726 session.seed(seed);
727 seeded = true;
728 for (const r of ranges) {
729 r.lo = NaN;
730 r.hi = NaN;
731 }
732 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
733 let lastVideoS = 0;
734 for (let frame = 0; ; ) {
735 await draw();
736 if (gen !== generation) return;
737 if (movieCancel) break;
738 if (rotate) {
739 // Advance the orbit by this frame's share of video time; siblings
740 // follow scenes[0] through the usual camera sync.
741 const videoS = session.t / speed;
742 scenes[0]?.orbitBy(2 * Math.PI * MOVIE_ROTATE_RPS * (videoS - lastVideoS));
743 lastVideoS = videoS;
744 }
745 for (const s of scenes) s.renderNow();
746 await rec.addFrame(
747 session.t,
748 model.species.map((_, k) => ({ cmap, lo: ranges[k].lo, hi: ranges[k].hi })),
749 );
750 if (++frame >= frames) {
751 finished = true;
752 break;
753 }
754 const target = stepAt(frame);
755 submitSteps(target - done);
756 done = target;
757 elMovie.textContent = `Cancel · ${Math.round((100 * done) / total)}%`;
758 }
759 if (finished) {
760 const blob = await rec.finish();
761 saveBlob(
762 blob,
763 `turing-sphere-${model.key}-t${session.t.toFixed(2)}-${speed}x.mp4`,
764 );
765 }
766 } finally {
767 if (!finished) rec.cancel();
768 }
769 } catch (e) {
770 elErr.textContent = `movie: ${e instanceof Error ? e.message : e}`;
771 } finally {
772 // A cancelled replay stopped short of where the run was; step the
773 // remainder — determinism makes this land exactly there.
774 if (seeded && gen === generation && session) {
775 while (done < total && gen === generation && session) {
776 const n = Math.min(4096, total - done);
777 submitSteps(n);
778 done += n;
779 elMovie.textContent = `restoring · ${Math.round((100 * done) / total)}%`;
780 await session.sync();
781 }
782 await draw();
783 updateStats();
784 }
785 if (gen === generation) {
786 for (const s of scenes) s.restoreSize();
787 }
788 if (camBefore && gen === generation) {
789 for (const s of scenes) s.setCameraState(camBefore);
790 }
791 movieBusy = false;
792 setMovieUi(false);
793 if (gen === generation) setRunning(wasRunning);
794 }
797// ---------------------------------------------------------------- boot
798async function boot(): Promise<void> {
799 elModel.value = presets[0].key;
800 applyPreset(presets[0].key);
801 try {
802 device = await requestShtDevice();
803 adapterName = await describeAdapter(device);
804 } catch (e) {
805 device = null;
806 elErr.textContent =
807 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
808 `This demo compiles the MATLAB solver to WebGPU compute shaders, so it ` +
809 `needs a WebGPU-capable browser (Chrome/Edge 113+).`;
810 return;
811 }
812 device.lost.then((info) => {
813 if (info.reason !== 'destroyed') {
814 elErr.textContent = `WebGPU device lost: ${info.message}`;
815 }
816 });
817 await rebuild();
820void boot();