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 mGeometries,
17 mGeometryByKey,
18 defaultGeometryParams,
19 DEFAULT_GEOMETRY_KEY,
20 type MGeometry,
21} from './geom/registry.ts';
22import {
23 buildTopology,
24 fillFieldValues,
25 fillPositions,
26 fillColors,
27 type SphereMeshTopology,
28} from './render/sphereMesh.ts';
29import { SphereScene } from './render/SphereScene.ts';
30import { Colorbar, fmtValue } from './render/colorbar.ts';
31import { colormaps, colormapNames } from './render/colormaps.ts';
32import { MovieRecorder } from './render/movie.ts';
34const $ = <T extends HTMLElement>(id: string): T =>
35 document.getElementById(id) as T;
37const elModel = $<HTMLSelectElement>('model');
38const elGeometry = $<HTMLSelectElement>('geometry');
39const elMorph = $<HTMLInputElement>('morph');
40const elNiter = $<HTMLSelectElement>('niter');
41const elLmax = $<HTMLSelectElement>('lmax');
42const elOversample = $<HTMLSelectElement>('oversample');
43const elColormap = $<HTMLSelectElement>('colormap');
44const elRunPause = $<HTMLButtonElement>('runpause');
45const elBenchmark = $<HTMLButtonElement>('benchmark');
46const elReseed = $<HTMLButtonElement>('reseed');
47const elResetView = $<HTMLButtonElement>('resetview');
48const elMovieToggle = $<HTMLButtonElement>('movietoggle');
49const elMovieBar = $('moviebar');
50const elMovieSpeed = $<HTMLSelectElement>('moviespeed');
51const elMovieRes = $<HTMLSelectElement>('movieres');
52const elMovieRotate = $<HTMLInputElement>('movierotate');
53const elMovie = $<HTMLButtonElement>('movie');
54const elParams = $('params');
55const elGeomParams = $('geomparams');
56const elGeomNote = $('geomnote');
57const elPanels = $('panels');
58const elStats = $('stats');
59const elBenchResult = $('benchresult');
60const elCmd = $('cmd');
61const elCopyCmd = $<HTMLButtonElement>('copycmd');
62const elBlurb = $('blurb');
63const elErr = $('err');
64const elSource = $<HTMLTextAreaElement>('source');
65const elHighlight = $('highlight');
66const elCompiled = $('compiled');
67const elEditorTitle = $('editor-title');
68const elEditorFile = $<HTMLSelectElement>('editor-file');
69const elRecompile = $<HTMLButtonElement>('recompile');
70const elRevert = $<HTMLButtonElement>('revert');
72for (const p of presets) {
73 const o = document.createElement('option');
74 o.value = p.key;
75 o.textContent = p.label;
76 elModel.append(o);
77}
78for (const g of mGeometries) {
79 const o = document.createElement('option');
80 o.value = g.key;
81 o.textContent = g.label;
82 elGeometry.append(o);
83}
84for (const [value, label] of [['model', 'the solver'], ['geometry', 'the surface']]) {
85 const o = document.createElement('option');
86 o.value = value;
87 o.textContent = label;
88 elEditorFile.append(o);
89}
90for (const name of colormapNames) {
91 const o = document.createElement('option');
92 o.value = name;
93 o.textContent = name;
94 elColormap.append(o);
95}
96elColormap.value = 'jet';
98/** Whichever .m is open: the solver or the surface. Both are MATLAB, compiled
99 * by the same backend, so one editor serves both. The host-provided operations
100 * are marked so the boundary between the file and what it is given is
101 * visible. */
102const editor = new CodeEditor({
103 textarea: elSource,
104 overlay: elHighlight,
105 external: new Set(EXTERNAL_OPS.keys()),
106 onInput: (value) => {
107 if (editing === 'geometry') editedGeomSource = value;
108 else editedSource = value;
109 elRecompile.textContent = 'Recompile *';
110 },
111});
113/**
114 * Timesteps submitted per rendered frame, at most. Nothing is read back
115 * between them, so the batch costs one submit and one readback regardless of
116 * size — but a compute pass is still real GPU work, and a browser's GPU
117 * process enforces a watchdog timeout a headless desktop run does not: a
118 * submission with enough dispatches in it can trip "device lost" outright,
119 * on weak-enough hardware, well before it would ever show up as merely slow.
120 * The `for k = 1:niter` correction loop makes a step's dispatch count scale
121 * with niter (each iteration is ~15 dispatches per species — see
122 * models/schnakenberg.m), so a fixed per-frame step count that was safe when
123 * every model's step was a handful of dispatches is not safe once niter is
124 * large. `stepsPerFrame`/`measureBurst` below scale it down — never up, so
125 * the common case does not change — to keep one submission's total dispatch
126 * count under DISPATCH_BUDGET regardless of how expensive the compiled step
127 * is.
128 */
129const STEPS_PER_FRAME_BASE = 4;
130/** See STEPS_PER_FRAME_BASE. Recomputed per rebuild in `rebuild()`. */
131let stepsPerFrame = STEPS_PER_FRAME_BASE;
133/**
134 * Steps in a solver-timing burst, and how often to run one.
135 *
136 * Timing the solver needs a `queue.onSubmittedWorkDone()` to know the work
137 * finished, and in a browser that is an IPC round trip into the GPU process — a
138 * fixed cost of a few milliseconds. Spread over one frame's four steps it would
139 * swamp them on a fast GPU and make the solver look far slower than it is. So the
140 * rate is measured in an occasional larger batch, where the single sync is
141 * amortized the way the desktop benchmark amortizes its own. The state is
142 * snapshotted and restored around the batch, so measuring never advances the
143 * simulation — otherwise the pattern would visibly lurch forward at every
144 * measurement.
145 */
146const MEASURE_BURST_BASE = 32;
147/** See STEPS_PER_FRAME_BASE — the measurement burst is one submission too,
148 * and a bigger one: 32 steps is the single largest batch this app ever
149 * submits, so it is the first thing to cross DISPATCH_BUDGET as niter grows. */
150let measureBurst = MEASURE_BURST_BASE;
151const MEASURE_EVERY_MS = 2000;
153/**
154 * Upper bound on dispatches in one submission — the frame batch and the
155 * measurement burst are both scaled down to stay under this, never up, so
156 * a cheap model's pacing is unchanged. Chosen well under what this project's
157 * own desktop benchmark measures as trivially fast (single-digit ms even at
158 * niter=8's ~450 dispatches/step), because the risk here is not GPU time on
159 * capable hardware — it is a browser's GPU-process watchdog on weak
160 * (integrated-graphics) hardware, which a headless desktop run never
161 * exercises and this project has no way to benchmark directly.
162 */
163const DISPATCH_BUDGET = 1000;
165/**
166 * 'auto' display oversampling targets this many render latitudes: the factor is
167 * the smallest power of two (up to 4) that reaches it. A solver grid already
168 * this fine gains nothing visually and is not oversampled.
169 */
170const AUTO_RENDER_NLAT = 256;
172/** The display oversampling factor the UI currently asks for. */
173function resolveOversample(): number {
174 if (elOversample.value !== 'auto') return Number(elOversample.value);
175 const { nlat } = gridForLmax(Number(elLmax.value), model.pdeg);
176 let os = 1;
177 while (os < 4 && os * nlat < AUTO_RENDER_NLAT) os *= 2;
178 return os;
179}
181/**
182 * Movie frame rate, and a cap on frames per movie. Playback speed comes from
183 * the UI, in simulation-time units per second of video; the movie's length is
184 * the run's t at that speed, and the frame count follows from it — capped by
185 * the run's own step count (a step is at most one frame) and by
186 * MOVIE_MAX_FRAMES to bound encode time and file size. Frame timestamps are
187 * derived from simulation time, so a capped movie keeps its duration and
188 * speed exactly, at a lower effective frame rate.
189 */
190const MOVIE_FPS = 30;
191const MOVIE_MAX_FRAMES = 3600;
193/** Movie auto-rotation: camera revolutions per second of video. Measured in
194 * video time, so the orbit pace on screen is the same at every export speed. */
195const MOVIE_ROTATE_RPS = 1 / 120;
197// ---------------------------------------------------------------- state
198let device: GPUDevice | null = null;
199let session: ModelSession | null = null;
200let topo: SphereMeshTopology | null = null;
201let scenes: SphereScene[] = [];
202let colorbars: Colorbar[] = [];
203let valueBufs: Float32Array[] = [];
204let colorBufs: Float32Array[] = [];
205let ranges: { lo: number; hi: number }[] = [];
206let resizeObs: ResizeObserver | null = null;
208const initial = resolvePreset(presets[0].key);
209let model: MModel = mModelByKey(initial.model.key)!;
210let params: Params = initial.params;
211let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
212let geomParams: Params = defaultGeometryParams(geometry);
213/** Which file the editor is showing. */
214let editing: 'model' | 'geometry' = 'model';
215/** Each .m as edited in the page; `null` while it matches the file. */
216let editedSource: string | null = null;
217let editedGeomSource: string | null = null;
218/** Sphere (0) to surface (1). Display only; does not touch the solver. */
219let morph = 1;
220let seed = 1;
221let running = false;
222let adapterName = '';
223let pumping = false;
224let movieBusy = false;
225let movieCancel = false;
226let solverMs = 0;
227let frameMs = 0;
228let lastMeasure = 0;
229let generation = 0; // bumped on every rebuild to cancel stale pumps
230/** Surface coordinates on the render grid, interleaved xyz; null before the
231 * first build. Kept so the morph slider can re-fill positions without
232 * re-synthesizing. */
233let coords: Float32Array | null = null;
234let posBuf: Float32Array | null = null;
236const source = (): string => editedSource ?? model.source;
237const geomSource = (): string => editedGeomSource ?? geometry.source;
239// ---------------------------------------------------------------- UI wiring
240function buildParamInputs(): void {
241 elParams.replaceChildren();
242 for (const spec of model.params) {
243 const label = document.createElement('label');
244 label.textContent = `${spec.label} `;
245 const input = document.createElement('input');
246 input.type = 'number';
247 input.min = String(spec.min);
248 input.max = String(spec.max);
249 input.step = String(spec.step);
250 input.value = String(params[spec.key]);
251 input.addEventListener('change', () => {
252 const v = Number(input.value);
253 if (Number.isFinite(v)) params[spec.key] = v;
254 // Parameters are uniforms, not constants baked into the kernels, so a
255 // change costs an upload rather than a recompile.
256 session?.setParams(params);
257 updateCommand();
258 });
259 label.append(input);
260 elParams.append(label);
261 }
262}
264/**
265 * The shape's own parameters. Unlike the model's, these are NOT uniforms: the
266 * surface is evaluated once at build time and reduced to coefficients, so
267 * moving one rebuilds the geometry (and with it the mesh), though not the
268 * simulation's compiled step.
269 */
270function buildGeomParamInputs(): void {
271 elGeomParams.replaceChildren();
272 if (geometry.params.length === 0) return;
273 const tag = document.createElement('label');
274 tag.textContent = `${geometry.key}.m`;
275 elGeomParams.append(tag);
276 for (const spec of geometry.params) {
277 const label = document.createElement('label');
278 label.textContent = `${spec.label} `;
279 const input = document.createElement('input');
280 input.type = 'number';
281 input.min = String(spec.min);
282 input.max = String(spec.max);
283 input.step = String(spec.step);
284 input.value = String(geomParams[spec.key]);
285 input.addEventListener('change', () => {
286 const v = Number(input.value);
287 if (Number.isFinite(v)) geomParams[spec.key] = v;
288 viewChange = viewChange.then(() => applyGeometry());
289 });
290 label.append(input);
291 elGeomParams.append(label);
292 }
293}
295function applyPreset(presetKey: string): void {
296 const resolved = resolvePreset(presetKey);
297 const next = mModelByKey(resolved.model.key);
298 if (!next) {
299 elErr.textContent = `No .m model for '${resolved.model.key}'`;
300 return;
301 }
302 model = next;
303 params = resolved.params;
304 editedSource = null;
305 buildParamInputs();
306 elBlurb.textContent = model.blurb;
307 showEditorFile();
308 updateCommand();
309}
311function applyGeometryChoice(key: string): void {
312 const next = mGeometryByKey(key);
313 if (!next) {
314 elErr.textContent = `No .m geometry for '${key}'`;
315 return;
316 }
317 geometry = next;
318 geomParams = defaultGeometryParams(geometry);
319 editedGeomSource = null;
320 buildGeomParamInputs();
321 showEditorFile();
322}
324/** Load the chosen file into the editor, keeping any unsaved edit to it. */
325function showEditorFile(): void {
326 editing = elEditorFile.value === 'geometry' ? 'geometry' : 'model';
327 if (editing === 'geometry') {
328 editor.value = geomSource();
329 elEditorTitle.textContent = `geometries/${geometry.key}.m`;
330 } else {
331 editor.value = source();
332 elEditorTitle.textContent = `models/${model.key}.m`;
333 }
334}
336/** The run currently on screen, as the benchmark's RunSpec. */
337function currentSpec(): RunSpec {
338 return {
339 preset: elModel.value,
340 lmax: Number(elLmax.value),
341 seed,
342 steps: DEFAULT_STEPS,
343 warmup: DEFAULT_WARMUP,
344 params,
345 geometry: geometry.key,
346 geometryParams: geomParams,
347 niter: Number(elNiter.value),
348 };
349}
351function updateCommand(): void {
352 elCmd.textContent = formatCommand(currentSpec());
353}
355elModel.addEventListener('change', () => {
356 applyPreset(elModel.value);
357 void rebuild();
358});
359elLmax.addEventListener('change', () => void rebuild());
360// The solve iteration count is unrolled into the compiled step, so unlike a
361// parameter it cannot be changed without recompiling.
362elNiter.addEventListener('change', () => void rebuild());
363// Oversampling and geometry are display-or-data changes, not code ones, so
364// they swap things in place rather than rebuilding the run. Serialized through
365// one chain: a rapid second change waits its turn.
366let viewChange = Promise.resolve();
367elOversample.addEventListener('change', () => {
368 viewChange = viewChange.then(() => applyOversample());
369});
370elGeometry.addEventListener('change', () => {
371 applyGeometryChoice(elGeometry.value);
372 viewChange = viewChange.then(() => applyGeometry());
373});
374// Morph is pure rendering: no readback, no GPU work, just the vertex buffer.
375elMorph.addEventListener('input', () => {
376 morph = Number(elMorph.value);
377 applyMorph();
378});
379elColormap.addEventListener('change', () => void draw());
380elEditorFile.addEventListener('change', () => showEditorFile());
382function setRunning(next: boolean): void {
383 running = next;
384 elRunPause.textContent = running ? 'Pause' : 'Run';
385 if (running) void pump();
386}
388elRunPause.addEventListener('click', () => setRunning(!running));
389elBenchmark.addEventListener('click', () => void benchmark());
390elReseed.addEventListener('click', () => {
391 seed = (Math.random() * 2 ** 31) >>> 0;
392 setRunning(false);
393 updateCommand();
394 void reseed();
395});
396elResetView.addEventListener('click', () => {
397 for (const s of scenes) s.resetCamera();
398});
399elMovieToggle.addEventListener('click', () => {
400 elMovieBar.hidden = !elMovieBar.hidden;
401});
402elMovie.addEventListener('click', () => {
403 if (movieBusy) movieCancel = true;
404 else void recordMovie();
405});
407elRecompile.addEventListener('click', () => {
408 if (editing === 'geometry') editedGeomSource = editor.value;
409 else editedSource = editor.value;
410 void rebuild();
411});
412elRevert.addEventListener('click', () => {
413 if (editing === 'geometry') editedGeomSource = null;
414 else editedSource = null;
415 showEditorFile();
416 void rebuild();
417});
419// The command reproduces this run's parameters on the desktop; keep it
420// selectable even where the clipboard API is unavailable.
421elCopyCmd.addEventListener('click', () => {
422 const text = elCmd.textContent ?? '';
423 const flash = (msg: string): void => {
424 elCopyCmd.textContent = msg;
425 setTimeout(() => (elCopyCmd.textContent = 'Copy'), 1200);
426 };
427 const selectCommand = (): void => {
428 const range = document.createRange();
429 range.selectNodeContents(elCmd);
430 const sel = getSelection();
431 sel?.removeAllRanges();
432 sel?.addRange(range);
433 flash('Selected');
434 };
435 if (!navigator.clipboard) return selectCommand();
436 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
437});
439// ---------------------------------------------------------------- setup
440function disposeView(): void {
441 for (const s of scenes) s.dispose();
442 scenes = [];
443 colorbars = [];
444 topo = null;
445 coords = null;
446 posBuf = null;
447 resizeObs?.disconnect();
448 resizeObs = null;
449 elPanels.replaceChildren();
450}
452/**
453 * Build the mesh, scenes, colorbars and per-species buffers on the current
454 * render grid, from surface coordinates already synthesized there. Call
455 * disposeView() first. The color ranges are kept if present, so a display-only
456 * rebuild (an oversampling change) does not pop the shading; a full rebuild
457 * clears `ranges` beforehand.
458 */
459function buildView(surface: Float32Array): void {
460 if (!session) return;
461 const view = session.viewSht;
462 const { nphi } = view.cfg;
463 const phi = new Float64Array(nphi);
464 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
465 topo = buildTopology(view.cosTheta, phi);
466 coords = surface;
467 posBuf = new Float32Array(topo.numVertices * 3);
468 fillPositions(posBuf, coords, topo, morph);
470 const sphereBg = getComputedStyle(document.documentElement)
471 .getPropertyValue('--sphere-bg')
472 .trim();
473 for (let k = 0; k < model.species.length; k++) {
474 const panel = document.createElement('div');
475 panel.className = 'panel';
476 const box = document.createElement('div');
477 box.className = 'sphere-box';
478 const tag = document.createElement('div');
479 tag.className = 'species-tag';
480 tag.textContent = model.species[k];
481 box.append(tag);
482 const side = document.createElement('div');
483 panel.append(box, side);
484 elPanels.append(panel);
486 const scene = new SphereScene(
487 box,
488 topo.numVertices,
489 topo.indices,
490 // Each scene owns its position buffer: three.js uploads from it, and the
491 // morph rewrites all of them from the one shared `coords`.
492 Float32Array.from(posBuf),
493 sphereBg || undefined,
494 );
495 scene.fitCamera();
496 scenes.push(scene);
497 colorbars.push(new Colorbar(side));
498 valueBufs[k] = new Float32Array(topo.numVertices);
499 colorBufs[k] = new Float32Array(topo.numVertices * 3);
500 if (!ranges[k]) ranges[k] = { lo: NaN, hi: NaN };
501 }
502 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
504 resizeObs = new ResizeObserver(() => {
505 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
506 boxes.forEach((box, i) => {
507 scenes[i]?.resize(box.clientWidth, box.clientHeight);
508 });
509 });
510 elPanels
511 .querySelectorAll<HTMLElement>('.sphere-box')
512 .forEach((box) => resizeObs!.observe(box));
513}
515/**
516 * Apply the UI's oversampling choice to the running session. Display-only: the
517 * session and its state survive; only the display plan, mesh and scenes are
518 * rebuilt, keeping the camera pose and color ranges. The pump is drained first
519 * so no readback is in flight on the plan being replaced.
520 */
521async function applyOversample(): Promise<void> {
522 if (!session) return;
523 const gen = generation;
524 const os = resolveOversample();
525 if (os === session.oversample) return;
526 const wasRunning = running;
527 setRunning(false);
528 while (pumping) await nextFrame();
529 if (gen !== generation || !session) return;
530 await session.setOversample(os);
531 if (gen !== generation || !session) return;
532 const surface = await session.renderPositions();
533 if (gen !== generation || !session) return;
534 const cam = scenes[0]?.cameraState();
535 disposeView();
536 buildView(surface);
537 if (cam) for (const s of scenes) s.setCameraState(cam);
538 await draw();
539 updateStats();
540 if (wasRunning) setRunning(true);
541}
543/**
544 * Re-evaluate the surface and swap it in. Data, not code: the compiled step is
545 * untouched and the simulation keeps its state and its model time, so a shape
546 * can be changed mid-run. Only the mesh is rebuilt.
547 */
548async function applyGeometry(): Promise<void> {
549 if (!session) return;
550 const gen = generation;
551 const wasRunning = running;
552 setRunning(false);
553 while (pumping) await nextFrame();
554 if (gen !== generation || !session) return;
555 try {
556 await session.setGeometry(geometry, geomParams, geomSource());
557 } catch (e) {
558 reportCompileError(e);
559 return;
560 }
561 if (gen !== generation || !session) return;
562 const surface = await session.renderPositions();
563 if (gen !== generation || !session) return;
564 const cam = scenes[0]?.cameraState();
565 disposeView();
566 buildView(surface);
567 if (cam) for (const s of scenes) s.setCameraState(cam);
568 elErr.textContent = '';
569 await draw();
570 updateGeomNote();
571 updateStats();
572 if (wasRunning) setRunning(true);
573}
575/** Re-place the vertices for the current morph. No GPU work and no readback —
576 * the surface is already on the CPU, so this is a buffer fill per panel. */
577function applyMorph(): void {
578 if (!topo || !coords || !posBuf) return;
579 fillPositions(posBuf, coords, topo, morph);
580 for (const s of scenes) s.updatePositions(posBuf);
581}
583/** What the surface is, and the standing caveat about where it is not. */
584function updateGeomNote(): void {
585 if (!session) {
586 elGeomNote.textContent = '';
587 return;
588 }
589 const { lo, hi } = session.geometry.radiusRange();
590 elGeomNote.innerHTML =
591 `<b>${session.geometryModel.label}</b> — ${session.geometryModel.blurb} ` +
592 `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}.`;
593}
595/** Report a compile failure, and select the offending text in the editor. */
596function reportCompileError(e: unknown): void {
597 elErr.textContent = formatFailure(e, source());
598 elCompiled.textContent = '';
599 if (e instanceof ModelCompileError && e.start !== undefined) {
600 editor.select(e.start, e.end ?? e.start);
601 }
602}
604async function rebuild(): Promise<void> {
605 generation++;
606 const gen = generation;
607 setRunning(false);
608 disposeView();
609 session?.destroy();
610 session = null;
611 solverMs = 0;
612 frameMs = 0;
613 // Not 0: with a large niter's dispatch count not yet known (that needs the
614 // compiled plan below), the first measurement burst should wait for the
615 // ordinary per-frame batch — already sized to this model — to prove itself
616 // first, rather than firing a possibly-oversized burst before a single
617 // frame has run.
618 lastMeasure = performance.now();
619 elErr.textContent = '';
620 updateCommand();
621 if (!device) return;
623 try {
624 session = await ModelSession.create({
625 device,
626 model,
627 params,
628 lmax: Number(elLmax.value),
629 source: source(),
630 oversample: resolveOversample(),
631 geometry,
632 geometryParams: geomParams,
633 geometrySource: geomSource(),
634 niter: Number(elNiter.value),
635 });
636 } catch (e) {
637 reportCompileError(e);
638 return;
639 }
640 if (gen !== generation) return;
642 session.seed(seed);
644 const plan = session.describe();
645 elCompiled.textContent =
646 `one step compiled to ${plan.step.length} GPU operations:\n` +
647 plan.step.map((l) => ` ${l}`).join('\n');
648 elRecompile.textContent = 'Recompile';
650 // Scale the frame batch and the measurement burst down — never up — so
651 // neither submission's total dispatch count exceeds DISPATCH_BUDGET, no
652 // matter how expensive niter has made one step. See STEPS_PER_FRAME_BASE.
653 const opsPerStep = Math.max(1, plan.step.length);
654 stepsPerFrame = Math.max(1, Math.min(STEPS_PER_FRAME_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
655 measureBurst = Math.max(1, Math.min(MEASURE_BURST_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
657 const surface = await session.renderPositions();
658 if (gen !== generation) return;
660 ranges = [];
661 buildView(surface);
663 await draw();
664 updateGeomNote();
665 updateStats();
666 void pump();
667}
669async function reseed(): Promise<void> {
670 if (!session) return;
671 const gen = generation;
672 session.seed(seed);
673 if (gen !== generation) return;
674 for (const r of ranges) {
675 r.lo = NaN;
676 r.hi = NaN;
677 }
678 await draw();
679 updateStats();
680}
682// ---------------------------------------------------------------- drawing
683async function draw(): Promise<void> {
684 if (!session || !topo) return;
685 const gen = generation;
686 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
687 for (let k = 0; k < model.species.length; k++) {
688 // The one readback per frame — the loop is otherwise entirely on the GPU.
689 // A rebuild can land while this is in flight and destroy the buffer being
690 // mapped, which rejects the map; that result is stale anyway, so drop it.
691 let field: Float32Array;
692 try {
693 field = await session.readSpecies(k);
694 } catch (e) {
695 if (gen !== generation) return;
696 throw e;
697 }
698 if (gen !== generation || !topo) return;
699 fillFieldValues(valueBufs[k], field, topo);
700 let lo = Infinity;
701 let hi = -Infinity;
702 for (const v of valueBufs[k]) {
703 if (v < lo) lo = v;
704 if (v > hi) hi = v;
705 }
706 // smooth the color range in both directions so the shading evolves
707 // gently as the pattern grows (out-of-range values clamp meanwhile)
708 const r = ranges[k];
709 if (!Number.isFinite(r.lo)) {
710 r.lo = lo;
711 r.hi = hi;
712 } else {
713 const a = 0.15;
714 r.lo += a * (lo - r.lo);
715 r.hi += a * (hi - r.hi);
716 }
717 if (r.hi - r.lo < 1e-9) {
718 const mid = (r.hi + r.lo) / 2;
719 r.lo = mid - 5e-10;
720 r.hi = mid + 5e-10;
721 }
722 fillColors(colorBufs[k], valueBufs[k], r.lo, r.hi, cmap);
723 scenes[k]?.updateColors(colorBufs[k]);
724 colorbars[k]?.update(cmap, r.lo, r.hi);
725 }
726}
728function updateStats(): void {
729 if (!session) return;
730 const { nlat, nphi } = session.cfg;
731 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
732 const solver =
733 solverMs > 0
734 ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s)`
735 : '—';
736 const frame = frameMs > 0 ? `${frameMs.toFixed(1)} ms/frame` : '—';
737 const view = session.viewSht.cfg;
738 const render =
739 session.oversample > 1
740 ? ` (display ${view.nlat}×${view.nphi})`
741 : '';
742 elStats.innerHTML =
743 `<b>${kind}</b> · grid ${nlat}×${nphi}${render} · nlm ${session.sht.nlm.toLocaleString()} · ` +
744 `solver ${solver} · ${frame} · ` +
745 `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
746}
748// ---------------------------------------------------------------- sim loop
749const nextFrame = () => new Promise<number>(requestAnimationFrame);
751async function pump(): Promise<void> {
752 if (pumping) return;
753 pumping = true;
754 const gen = generation;
755 try {
756 while (running && session && gen === generation) {
757 // Occasionally, a burst purely to measure the solver rate: many steps,
758 // one sync, nothing read back — directly comparable to the desktop
759 // benchmark's throughput number. State-preserving: the display and
760 // model time are unaffected.
761 if (performance.now() - lastMeasure > MEASURE_EVERY_MS) {
762 const ms = await session.measure(measureBurst);
763 if (gen !== generation) break;
764 solverMs = ms;
765 lastMeasure = performance.now();
766 }
768 // The frame itself. No explicit sync here — draw()'s readback already
769 // waits for the steps, so asking twice would only add a round trip.
770 const t0 = performance.now();
771 session.step(stepsPerFrame);
772 await draw();
773 if (gen !== generation) break;
774 frameMs = frameMs === 0
775 ? performance.now() - t0
776 : frameMs + 0.05 * (performance.now() - t0 - frameMs);
777 updateStats();
778 await nextFrame();
779 }
780 if (gen === generation) {
781 await draw();
782 updateStats();
783 }
784 } finally {
785 pumping = false;
786 }
787}
789/**
790 * Sustained solver benchmark, in the page.
791 *
792 * The same measurement `npm run bench` makes: batches of steps submitted
793 * together, waited for, never read back, with no rendering and no animation
794 * pacing in between. That makes it directly comparable to the terminal number,
795 * which is the only way to tell a genuinely slower browser GPU stack apart from
796 * the costs the app adds on top.
797 *
798 * It also reports the ramp — the first third of the run against the last. GPUs
799 * downclock when idle, and an animation-paced loop leaves them idle most of every
800 * frame, so a large ramp means the app's steady-state number is limited by clocks
801 * rather than by the work.
802 *
803 * These are ordinary steps: the simulation advances by them.
804 */
805async function benchmark(): Promise<void> {
806 if (!session || movieBusy) return;
807 setRunning(false);
808 // Same base size and the same DISPATCH_BUDGET scaling as the automatic
809 // measurement burst (see STEPS_PER_FRAME_BASE) — this is a user-triggered
810 // 32-step submission, exactly the shape of thing that risks a browser's
811 // GPU-process watchdog on weak hardware once niter makes a step expensive.
812 const BATCH = measureBurst;
813 const DURATION_MS = 2000;
814 elBenchResult.textContent = 'benchmarking…';
815 // A movie started mid-benchmark would replay while this loop still steps.
816 elMovie.disabled = true;
817 try {
818 await nextFrame();
820 const gen = generation;
821 const perStep: number[] = [];
822 const t0 = performance.now();
823 while (performance.now() - t0 < DURATION_MS) {
824 const b0 = performance.now();
825 session.step(BATCH);
826 await session.sync();
827 if (gen !== generation) return;
828 perStep.push((performance.now() - b0) / BATCH);
829 }
831 const mean = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
832 const all = mean(perStep);
833 const best = Math.min(...perStep);
834 const third = Math.max(1, Math.floor(perStep.length / 3));
835 const first = mean(perStep.slice(0, third));
836 const last = mean(perStep.slice(-third));
837 const steps = perStep.length * BATCH;
839 elBenchResult.innerHTML =
840 `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
841 `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
842 `ramp ${(first / last).toFixed(2)}× · ${steps} steps · ` +
843 `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
844 await draw();
845 updateStats();
846 } finally {
847 elMovie.disabled = false;
848 }
849}
851// ---------------------------------------------------------------- movie
852function saveBlob(blob: Blob, filename: string): void {
853 const url = URL.createObjectURL(blob);
854 const a = document.createElement('a');
855 a.href = url;
856 a.download = filename;
857 a.click();
858 setTimeout(() => URL.revokeObjectURL(url), 10_000);
859}
861/** Submit `n` steps in bounded command buffers — a single buffer encoding
862 * many thousands of steps can exhaust the encoder. */
863function submitSteps(n: number): void {
864 while (n > 0 && session) {
865 const chunk = Math.min(512, n);
866 session.step(chunk);
867 n -= chunk;
868 }
869}
871/** While recording, lock everything that could change the run mid-replay;
872 * the Movie button itself becomes the cancel button. */
873function setMovieUi(on: boolean): void {
874 const locked = [
875 elModel, elGeometry, elMorph, elNiter, elLmax, elOversample, elColormap,
876 elRunPause, elBenchmark, elReseed, elRecompile, elRevert, elEditorFile,
877 elMovieSpeed, elMovieRes, elMovieRotate, elMovieToggle,
878 ];
879 for (const el of locked) el.disabled = on;
880 elParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
881 elGeomParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
882 elMovie.textContent = on ? 'Cancel · 0%' : 'Export';
883}
885/**
886 * Recompute the run from t = 0 and download it as an MP4.
887 *
888 * The movie is not a recording of what already happened — it is the same
889 * trajectory recomputed: same seed, same source, and the *current* parameters
890 * and colormap throughout. Determinism makes this exact: after the replay the
891 * state is where it was, so the one session is reused and the app resumes as
892 * if nothing happened. Frames are composited from the live panels, so the
893 * movie shows the spheres at the current camera orientation — and the replay
894 * doubles as the progress display, since it is visible on screen.
895 */
896async function recordMovie(): Promise<void> {
897 if (!session || movieBusy) return;
898 if (session.steps === 0) {
899 elMovie.textContent = 'run first';
900 setTimeout(() => (elMovie.textContent = 'Export'), 1200);
901 return;
902 }
903 movieBusy = true;
904 movieCancel = false;
905 const gen = generation;
906 setMovieUi(true);
907 let wasRunning = false;
908 let total = 0;
909 let done = 0;
910 let seeded = false;
911 let camBefore: ReturnType<SphereScene['cameraState']> | undefined;
912 try {
913 // An in-flight display-grid swap replaces the scenes whose canvases the
914 // recorder captures, and resumes the run when it lands — let it finish.
915 await viewChange;
916 if (gen !== generation || !session) return;
917 wasRunning = running;
918 setRunning(false);
919 while (pumping) await nextFrame(); // let an in-flight live frame drain
920 if (gen !== generation || !session) return;
921 total = session.steps;
922 const speed = Number(elMovieSpeed.value) || 10;
923 const sphere = Number(elMovieRes.value) || 768;
924 const rotate = elMovieRotate.checked;
925 if (rotate) camBefore = scenes[0]?.cameraState();
926 // Render the scenes at exactly the chosen resolution for the recording —
927 // independent of the window size — and restore afterwards.
928 for (const s of scenes) s.captureSize(sphere);
929 const durationS = Math.max(session.t / speed, 2 / MOVIE_FPS);
930 const frames = Math.max(
931 2,
932 Math.min(Math.round(durationS * MOVIE_FPS) + 1, total + 1, MOVIE_MAX_FRAMES),
933 );
934 /** The step index captured as frame `i`; both endpoints land exactly. */
935 const stepAt = (i: number): number => Math.round((i * total) / (frames - 1));
937 const title =
938 (presets.find((p) => p.key === elModel.value)?.label ?? model.label) +
939 ` on ${geometry.label.toLowerCase()}` +
940 (editedSource !== null || editedGeomSource !== null ? ' (edited)' : '');
941 const subtitle = model.params
942 .map((spec) => `${spec.label} ${fmtValue(params[spec.key])}`)
943 .join(' · ');
944 const rec = await MovieRecorder.create({
945 panels: model.species.map((label, k) => ({ canvas: scenes[k].canvas, label })),
946 title,
947 subtitle,
948 speed,
949 fps: (frames - 1) / durationS,
950 sphere,
951 });
953 let finished = false;
954 try {
955 // Reset the color-range smoothing as a re-seed does, so the shading
956 // evolves in the movie the way it did live.
957 session.seed(seed);
958 seeded = true;
959 for (const r of ranges) {
960 r.lo = NaN;
961 r.hi = NaN;
962 }
963 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
964 let lastVideoS = 0;
965 for (let frame = 0; ; ) {
966 await draw();
967 if (gen !== generation) return;
968 if (movieCancel) break;
969 if (rotate) {
970 // Advance the orbit by this frame's share of video time; siblings
971 // follow scenes[0] through the usual camera sync.
972 const videoS = session.t / speed;
973 scenes[0]?.orbitBy(2 * Math.PI * MOVIE_ROTATE_RPS * (videoS - lastVideoS));
974 lastVideoS = videoS;
975 }
976 for (const s of scenes) s.renderNow();
977 await rec.addFrame(
978 session.t,
979 model.species.map((_, k) => ({ cmap, lo: ranges[k].lo, hi: ranges[k].hi })),
980 );
981 if (++frame >= frames) {
982 finished = true;
983 break;
984 }
985 const target = stepAt(frame);
986 submitSteps(target - done);
987 done = target;
988 elMovie.textContent = `Cancel · ${Math.round((100 * done) / total)}%`;
989 }
990 if (finished) {
991 const blob = await rec.finish();
992 saveBlob(
993 blob,
994 `turing-surface-${model.key}-${geometry.key}-` +
995 `t${session.t.toFixed(2)}-${speed}x.mp4`,
996 );
997 }
998 } finally {
999 if (!finished) rec.cancel();
1000 }
1001 } catch (e) {
1002 elErr.textContent = `movie: ${e instanceof Error ? e.message : e}`;
1003 } finally {
1004 // A cancelled replay stopped short of where the run was; step the
1005 // remainder — determinism makes this land exactly there.
1006 if (seeded && gen === generation && session) {
1007 while (done < total && gen === generation && session) {
1008 const n = Math.min(4096, total - done);
1009 submitSteps(n);
1010 done += n;
1011 elMovie.textContent = `restoring · ${Math.round((100 * done) / total)}%`;
1012 await session.sync();
1013 }
1014 await draw();
1015 updateStats();
1016 }
1017 if (gen === generation) {
1018 for (const s of scenes) s.restoreSize();
1019 }
1020 if (camBefore && gen === generation) {
1021 for (const s of scenes) s.setCameraState(camBefore);
1022 }
1023 movieBusy = false;
1024 setMovieUi(false);
1025 if (gen === generation) setRunning(wasRunning);
1026 }
1027}
1029// ---------------------------------------------------------------- boot
1030async function boot(): Promise<void> {
1031 elModel.value = presets[0].key;
1032 elGeometry.value = DEFAULT_GEOMETRY_KEY;
1033 elMorph.value = String(morph);
1034 applyGeometryChoice(DEFAULT_GEOMETRY_KEY);
1035 applyPreset(presets[0].key);
1036 try {
1037 device = await requestShtDevice();
1038 adapterName = await describeAdapter(device);
1039 } catch (e) {
1040 device = null;
1041 elErr.textContent =
1042 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1043 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1044 return;
1045 }
1046 device.lost.then((info) => {
1047 if (info.reason !== 'destroyed') {
1048 elErr.textContent = `WebGPU device lost: ${info.message}`;
1049 }
1050 });
1051 await rebuild();
1052}
1054void boot();