0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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,
13 DEFAULT_WARMUP,
14 type RunSpec,
15} from './bench/runSpec.ts';
16import {
17 mGeometries,
18 mGeometryByKey,
19 defaultGeometryParams,
20 SPHERE_KEY,
21 DEFAULT_GEOMETRY_KEY,
22 type MGeometry,
23} from './geom/registry.ts';
24import {
25 buildTopology,
26 fillFieldValues,
27 fillPositions,
28 fillColors,
29 type SphereMeshTopology,
30} from './render/sphereMesh.ts';
31import { SphereScene } from './render/SphereScene.ts';
32import { Colorbar, fmtValue } from './render/colorbar.ts';
33import { colormaps, colormapNames } from './render/colormaps.ts';
34import { MovieRecorder } from './render/movie.ts';
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 35import { CompareRun } from './compare/compareRun.ts';
36import {
37 crossProduct,
38 mostResolved,
39 variantKey,
40 variantLabel,
41 type Variant,
42} from './compare/variants.ts';
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 43
44const $ = <T extends HTMLElement>(id: string): T =>
45 document.getElementById(id) as T;
47const elModel = $<HTMLSelectElement>('model');
48const elGeometry = $<HTMLSelectElement>('geometry');
49const elMorph = $<HTMLInputElement>('morph');
50const elNiter = $<HTMLSelectElement>('niter');
51const elLmax = $<HTMLSelectElement>('lmax');
52const elOversample = $<HTMLSelectElement>('oversample');
53const elColormap = $<HTMLSelectElement>('colormap');
54const elRunPause = $<HTMLButtonElement>('runpause');
55const elBenchmark = $<HTMLButtonElement>('benchmark');
56const elReseed = $<HTMLButtonElement>('reseed');
57const elResetView = $<HTMLButtonElement>('resetview');
58const elMovieToggle = $<HTMLButtonElement>('movietoggle');
59const elMovieBar = $('moviebar');
60const elMovieSpeed = $<HTMLSelectElement>('moviespeed');
61const elMovieRes = $<HTMLSelectElement>('movieres');
62const elMovieRotate = $<HTMLInputElement>('movierotate');
63const elMovie = $<HTMLButtonElement>('movie');
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 64const elCompareToggle = $<HTMLButtonElement>('comparetoggle');
65const elCompareBar = $('comparebar');
66const elCmpNiter = $('cmp-niter');
67const elCmpLmax = $('cmp-lmax');
68const elCmpDt = $('cmp-dt');
69const elCmpRef = $<HTMLSelectElement>('cmp-ref');
70const elCmpStart = $<HTMLButtonElement>('cmp-start');
71const elCmpCount = $('cmp-count');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 72const elParams = $('params');
73const elGeomParams = $('geomparams');
74const elGeomNote = $('geomnote');
75const elPanels = $('panels');
76const elStats = $('stats');
77const elBenchResult = $('benchresult');
78const elCmd = $('cmd');
79const elCopyCmd = $<HTMLButtonElement>('copycmd');
80const elBlurb = $('blurb');
81const elErr = $('err');
82const elSource = $<HTMLTextAreaElement>('source');
83const elHighlight = $('highlight');
84const elCompiled = $('compiled');
85const elEditorTitle = $('editor-title');
86const elEditorFile = $<HTMLSelectElement>('editor-file');
87const elRecompile = $<HTMLButtonElement>('recompile');
88const elRevert = $<HTMLButtonElement>('revert');
90for (const p of presets) {
91 const o = document.createElement('option');
92 o.value = p.key;
93 o.textContent = p.label;
94 elModel.append(o);
95}
96for (const g of mGeometries) {
97 const o = document.createElement('option');
98 o.value = g.key;
99 o.textContent = g.label;
100 elGeometry.append(o);
101}
102for (const [value, label] of [['model', 'the solver'], ['geometry', 'the surface']]) {
103 const o = document.createElement('option');
104 o.value = value;
105 o.textContent = label;
106 elEditorFile.append(o);
107}
108for (const name of colormapNames) {
109 const o = document.createElement('option');
110 o.value = name;
111 o.textContent = name;
112 elColormap.append(o);
113}
114elColormap.value = 'jet';
116/** Whichever .m is open: the solver or the surface. Both are MATLAB, compiled
117 * by the same backend, so one editor serves both. The host-provided operations
118 * are marked so the boundary between the file and what it is given is
119 * visible. */
120const editor = new CodeEditor({
121 textarea: elSource,
122 overlay: elHighlight,
123 external: EXTERNAL_OPS,
124 onInput: (value) => {
125 if (editing === 'geometry') editedGeomSource = value;
126 else editedSource = value;
127 elRecompile.textContent = 'Recompile *';
128 },
129});
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 131/**
132 * Timesteps submitted per rendered frame, at most. Nothing is read back
133 * between them, so the batch costs one submit and one readback regardless of
134 * size — but a compute pass is still real GPU work, and a browser's GPU
135 * process enforces a watchdog timeout a headless desktop run does not: a
136 * submission with enough dispatches in it can trip "device lost" outright,
137 * on weak-enough hardware, well before it would ever show up as merely slow.
138 * The `for k = 1:niter` correction loop makes a step's dispatch count scale
139 * with niter (each iteration is ~15 dispatches per species — see
140 * models/schnakenberg.m), so a fixed per-frame step count that was safe when
141 * every model's step was a handful of dispatches is not safe once niter is
142 * large. `stepsPerFrame`/`measureBurst` below scale it down — never up, so
143 * the common case does not change — to keep one submission's total dispatch
144 * count under DISPATCH_BUDGET regardless of how expensive the compiled step
145 * is.
146 */
147const STEPS_PER_FRAME_BASE = 4;
148/** See STEPS_PER_FRAME_BASE. Recomputed per rebuild in `rebuild()`. */
149let stepsPerFrame = STEPS_PER_FRAME_BASE;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 150
151/**
152 * Steps in a solver-timing burst, and how often to run one.
153 *
154 * Timing the solver needs a `queue.onSubmittedWorkDone()` to know the work
155 * finished, and in a browser that is an IPC round trip into the GPU process — a
156 * fixed cost of a few milliseconds. Spread over one frame's four steps it would
157 * swamp them on a fast GPU and make the solver look far slower than it is. So the
158 * rate is measured in an occasional larger batch, where the single sync is
159 * amortized the way the desktop benchmark amortizes its own. The state is
160 * snapshotted and restored around the batch, so measuring never advances the
161 * simulation — otherwise the pattern would visibly lurch forward at every
162 * measurement.
163 */
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 164const MEASURE_BURST_BASE = 32;
165/** See STEPS_PER_FRAME_BASE — the measurement burst is one submission too,
166 * and a bigger one: 32 steps is the single largest batch this app ever
167 * submits, so it is the first thing to cross DISPATCH_BUDGET as niter grows. */
168let measureBurst = MEASURE_BURST_BASE;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 169const MEASURE_EVERY_MS = 2000;
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 171/**
172 * Upper bound on dispatches in one submission — the frame batch and the
173 * measurement burst are both scaled down to stay under this, never up, so
174 * a cheap model's pacing is unchanged. Chosen well under what this project's
175 * own desktop benchmark measures as trivially fast (single-digit ms even at
176 * niter=8's ~450 dispatches/step), because the risk here is not GPU time on
177 * capable hardware — it is a browser's GPU-process watchdog on weak
178 * (integrated-graphics) hardware, which a headless desktop run never
179 * exercises and this project has no way to benchmark directly.
180 */
181const DISPATCH_BUDGET = 1000;
184 * 'auto' display oversampling targets this many render latitudes: the factor is
185 * the smallest power of two (up to 4) that reaches it. A solver grid already
186 * this fine gains nothing visually and is not oversampled.
187 */
188const AUTO_RENDER_NLAT = 256;
190/** The display oversampling factor the UI currently asks for. */
191function resolveOversample(): number {
192 if (elOversample.value !== 'auto') return Number(elOversample.value);
193 const { nlat } = gridForLmax(Number(elLmax.value), model.pdeg);
194 let os = 1;
195 while (os < 4 && os * nlat < AUTO_RENDER_NLAT) os *= 2;
196 return os;
197}
199/**
200 * Movie frame rate, and a cap on frames per movie. Playback speed comes from
201 * the UI, in simulation-time units per second of video; the movie's length is
202 * the run's t at that speed, and the frame count follows from it — capped by
203 * the run's own step count (a step is at most one frame) and by
204 * MOVIE_MAX_FRAMES to bound encode time and file size. Frame timestamps are
205 * derived from simulation time, so a capped movie keeps its duration and
206 * speed exactly, at a lower effective frame rate.
207 */
208const MOVIE_FPS = 30;
209const MOVIE_MAX_FRAMES = 3600;
211/** Movie auto-rotation: camera revolutions per second of video. Measured in
212 * video time, so the orbit pace on screen is the same at every export speed. */
213const MOVIE_ROTATE_RPS = 1 / 120;
215// ---------------------------------------------------------------- state
216let device: GPUDevice | null = null;
217let session: ModelSession | null = null;
218let topo: SphereMeshTopology | null = null;
219let scenes: SphereScene[] = [];
220let colorbars: Colorbar[] = [];
221let valueBufs: Float32Array[] = [];
222let colorBufs: Float32Array[] = [];
223let ranges: { lo: number; hi: number }[] = [];
224let resizeObs: ResizeObserver | null = null;
226const initial = resolvePreset(presets[0].key);
227let model: MModel = mModelByKey(initial.model.key)!;
228let params: Params = initial.params;
229let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
230let geomParams: Params = defaultGeometryParams(geometry);
231/** Which file the editor is showing. */
232let editing: 'model' | 'geometry' = 'model';
233/** Each .m as edited in the page; `null` while it matches the file. */
234let editedSource: string | null = null;
235let editedGeomSource: string | null = null;
236/** Sphere (0) to surface (1). Display only; does not touch the solver. */
237let morph = 1;
238let seed = 1;
239let running = false;
240let adapterName = '';
241let pumping = false;
242let movieBusy = false;
243let movieCancel = false;
244let solverMs = 0;
245let frameMs = 0;
246let lastMeasure = 0;
247let generation = 0; // bumped on every rebuild to cancel stale pumps
248/** Surface coordinates on the render grid, interleaved xyz; null before the
249 * first build. Kept so the morph slider can re-fill positions without
250 * re-synthesizing. */
251let coords: Float32Array | null = null;
252let posBuf: Float32Array | null = null;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 253/** The convergence study, when one is running; null in ordinary single-run
254 * mode. While it is non-null there is no `session`: the study owns one per
255 * variant, and the panels area is its grid. */
256let compareRun: CompareRun | null = null;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 257
258const source = (): string => editedSource ?? model.source;
259const geomSource = (): string => editedGeomSource ?? geometry.source;
261// ---------------------------------------------------------------- UI wiring
262function buildParamInputs(): void {
263 elParams.replaceChildren();
264 for (const spec of model.params) {
265 const label = document.createElement('label');
266 label.textContent = `${spec.label} `;
267 const input = document.createElement('input');
268 input.type = 'number';
269 input.min = String(spec.min);
270 input.max = String(spec.max);
271 input.step = String(spec.step);
272 input.value = String(params[spec.key]);
273 input.addEventListener('change', () => {
274 const v = Number(input.value);
275 if (Number.isFinite(v)) params[spec.key] = v;
276 // Parameters are uniforms, not constants baked into the kernels, so a
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 277 // change costs an upload rather than a recompile. In compare mode `dt`
278 // is the *base* timestep each variant's divisor divides, so the study
279 // re-derives every variant's dt from it.
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 280 session?.setParams(params);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 281 compareRun?.setParams(params);
283 });
284 label.append(input);
285 elParams.append(label);
286 }
287}
289/**
290 * The shape's own parameters. Unlike the model's, these are NOT uniforms: the
291 * surface is evaluated once at build time and reduced to coefficients, so
292 * moving one rebuilds the geometry (and with it the mesh), though not the
293 * simulation's compiled step.
294 */
295function buildGeomParamInputs(): void {
296 elGeomParams.replaceChildren();
297 if (geometry.params.length === 0) return;
298 const tag = document.createElement('label');
299 tag.textContent = `${geometry.key}.m`;
300 elGeomParams.append(tag);
301 for (const spec of geometry.params) {
302 const label = document.createElement('label');
303 label.textContent = `${spec.label} `;
304 const input = document.createElement('input');
305 input.type = 'number';
306 input.min = String(spec.min);
307 input.max = String(spec.max);
308 input.step = String(spec.step);
309 input.value = String(geomParams[spec.key]);
310 input.addEventListener('change', () => {
311 const v = Number(input.value);
312 if (Number.isFinite(v)) geomParams[spec.key] = v;
313 viewChange = viewChange.then(() => applyGeometry());
314 });
315 label.append(input);
316 elGeomParams.append(label);
317 }
318}
320function applyPreset(presetKey: string): void {
321 const resolved = resolvePreset(presetKey);
322 const next = mModelByKey(resolved.model.key);
323 if (!next) {
324 elErr.textContent = `No .m model for '${resolved.model.key}'`;
325 return;
326 }
327 model = next;
328 params = resolved.params;
329 editedSource = null;
330 buildParamInputs();
331 elBlurb.textContent = model.blurb;
332 showEditorFile();
333 updateCommand();
334}
336function applyGeometryChoice(key: string): void {
337 const next = mGeometryByKey(key);
338 if (!next) {
339 elErr.textContent = `No .m geometry for '${key}'`;
340 return;
341 }
342 geometry = next;
343 geomParams = defaultGeometryParams(geometry);
344 editedGeomSource = null;
345 buildGeomParamInputs();
346 showEditorFile();
347}
349/** Load the chosen file into the editor, keeping any unsaved edit to it. */
350function showEditorFile(): void {
351 editing = elEditorFile.value === 'geometry' ? 'geometry' : 'model';
352 if (editing === 'geometry') {
353 editor.value = geomSource();
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 354 elEditorTitle.textContent = `geometries/${geometry.key}.m`;
356 editor.value = source();
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 357 elEditorTitle.textContent = `models/${model.key}.m`;
359}
362 * The run currently on screen, as the benchmark's RunSpec. While a study is
363 * running there is no single run, so this describes its *reference* variant —
364 * the one the other rows are measured against, and the only one of them whose
365 * numbers mean anything on their own.
366 */
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 367function currentSpec(): RunSpec {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 368 const ref = compareRun?.variants[compareRefIndex()];
369 const dt = ref ? { dt: (params.dt ?? 0) / ref.dtDiv } : null;
371 preset: elModel.value,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 372 lmax: ref ? ref.lmax : Number(elLmax.value),
374 steps: DEFAULT_STEPS,
375 warmup: DEFAULT_WARMUP,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 376 params: dt ? { ...params, ...dt } : params,
378 geometryParams: geomParams,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 379 niter: ref ? ref.niter : Number(elNiter.value),
381}
383function updateCommand(): void {
384 elCmd.textContent = formatCommand(currentSpec());
385}
387elModel.addEventListener('change', () => {
388 applyPreset(elModel.value);
389 void rebuild();
390});
391elLmax.addEventListener('change', () => void rebuild());
392// The solve iteration count is unrolled into the compiled step, so unlike a
393// parameter it cannot be changed without recompiling.
394elNiter.addEventListener('change', () => void rebuild());
395// Oversampling and geometry are display-or-data changes, not code ones, so
396// they swap things in place rather than rebuilding the run. Serialized through
397// one chain: a rapid second change waits its turn.
398let viewChange = Promise.resolve();
399elOversample.addEventListener('change', () => {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 400 // The study picks its own display grid — one grid common to every variant is
401 // what makes their fields comparable — so this control is inert (and
402 // disabled) while one is running.
403 if (compareRun) return;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 404 viewChange = viewChange.then(() => applyOversample());
405});
406elGeometry.addEventListener('change', () => {
407 applyGeometryChoice(elGeometry.value);
408 viewChange = viewChange.then(() => applyGeometry());
409});
410// Morph is pure rendering: no readback, no GPU work, just the vertex buffer.
411elMorph.addEventListener('input', () => {
412 morph = Number(elMorph.value);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 413 if (compareRun) compareRun.setMorph(morph);
414 else applyMorph();
415});
416elColormap.addEventListener('change', () => {
417 if (compareRun) void compareRun.draw();
418 else void draw();
420elEditorFile.addEventListener('change', () => showEditorFile());
422function setRunning(next: boolean): void {
423 running = next;
424 elRunPause.textContent = running ? 'Pause' : 'Run';
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 425 if (compareRun) {
426 compareRun.setRunning(next);
427 return;
428 }
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 429 if (running) void pump();
430}
432elRunPause.addEventListener('click', () => setRunning(!running));
433elBenchmark.addEventListener('click', () => void benchmark());
434elReseed.addEventListener('click', () => {
435 seed = (Math.random() * 2 ** 31) >>> 0;
436 setRunning(false);
437 updateCommand();
438 void reseed();
439});
440elResetView.addEventListener('click', () => {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 441 compareRun?.resetView();
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 442 for (const s of scenes) s.resetCamera();
443});
444elMovieToggle.addEventListener('click', () => {
445 elMovieBar.hidden = !elMovieBar.hidden;
446});
447elMovie.addEventListener('click', () => {
448 if (movieBusy) movieCancel = true;
449 else void recordMovie();
450});
452elRecompile.addEventListener('click', () => {
453 if (editing === 'geometry') editedGeomSource = editor.value;
454 else editedSource = editor.value;
455 void rebuild();
456});
457elRevert.addEventListener('click', () => {
458 if (editing === 'geometry') editedGeomSource = null;
459 else editedSource = null;
460 showEditorFile();
461 void rebuild();
462});
464// The command reproduces this run's parameters on the desktop; keep it
465// selectable even where the clipboard API is unavailable.
466elCopyCmd.addEventListener('click', () => {
467 const text = elCmd.textContent ?? '';
468 const flash = (msg: string): void => {
469 elCopyCmd.textContent = msg;
470 setTimeout(() => (elCopyCmd.textContent = 'Copy'), 1200);
471 };
472 const selectCommand = (): void => {
473 const range = document.createRange();
474 range.selectNodeContents(elCmd);
475 const sel = getSelection();
476 sel?.removeAllRanges();
477 sel?.addRange(range);
478 flash('Selected');
479 };
480 if (!navigator.clipboard) return selectCommand();
481 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
482});
484// ---------------------------------------------------------------- setup
485function disposeView(): void {
486 for (const s of scenes) s.dispose();
487 scenes = [];
488 colorbars = [];
489 topo = null;
490 coords = null;
491 posBuf = null;
492 resizeObs?.disconnect();
493 resizeObs = null;
494 elPanels.replaceChildren();
495}
497/**
498 * Build the mesh, scenes, colorbars and per-species buffers on the current
499 * render grid, from surface coordinates already synthesized there. Call
500 * disposeView() first. The color ranges are kept if present, so a display-only
501 * rebuild (an oversampling change) does not pop the shading; a full rebuild
502 * clears `ranges` beforehand.
503 */
504function buildView(surface: Float32Array): void {
505 if (!session) return;
506 const view = session.viewSht;
507 const { nphi } = view.cfg;
508 const phi = new Float64Array(nphi);
509 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
510 topo = buildTopology(view.cosTheta, phi);
511 coords = surface;
512 posBuf = new Float32Array(topo.numVertices * 3);
513 fillPositions(posBuf, coords, topo, morph);
515 const sphereBg = getComputedStyle(document.documentElement)
516 .getPropertyValue('--sphere-bg')
517 .trim();
518 for (let k = 0; k < model.species.length; k++) {
519 const panel = document.createElement('div');
520 panel.className = 'panel';
521 const box = document.createElement('div');
522 box.className = 'sphere-box';
523 const tag = document.createElement('div');
524 tag.className = 'species-tag';
525 tag.textContent = model.species[k];
526 box.append(tag);
527 const side = document.createElement('div');
528 panel.append(box, side);
529 elPanels.append(panel);
531 const scene = new SphereScene(
532 box,
533 topo.numVertices,
534 topo.indices,
535 // Each scene owns its position buffer: three.js uploads from it, and the
536 // morph rewrites all of them from the one shared `coords`.
537 Float32Array.from(posBuf),
538 sphereBg || undefined,
539 );
540 scene.fitCamera();
541 scenes.push(scene);
542 colorbars.push(new Colorbar(side));
543 valueBufs[k] = new Float32Array(topo.numVertices);
544 colorBufs[k] = new Float32Array(topo.numVertices * 3);
545 if (!ranges[k]) ranges[k] = { lo: NaN, hi: NaN };
546 }
547 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
549 resizeObs = new ResizeObserver(() => {
550 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
551 boxes.forEach((box, i) => {
552 scenes[i]?.resize(box.clientWidth, box.clientHeight);
553 });
554 });
555 elPanels
556 .querySelectorAll<HTMLElement>('.sphere-box')
557 .forEach((box) => resizeObs!.observe(box));
558}
560/**
561 * Apply the UI's oversampling choice to the running session. Display-only: the
562 * session and its state survive; only the display plan, mesh and scenes are
563 * rebuilt, keeping the camera pose and color ranges. The pump is drained first
564 * so no readback is in flight on the plan being replaced.
565 */
566async function applyOversample(): Promise<void> {
567 if (!session) return;
568 const gen = generation;
569 const os = resolveOversample();
570 if (os === session.oversample) return;
571 const wasRunning = running;
572 setRunning(false);
573 while (pumping) await nextFrame();
574 if (gen !== generation || !session) return;
575 await session.setOversample(os);
576 if (gen !== generation || !session) return;
577 const surface = await session.renderPositions();
578 if (gen !== generation || !session) return;
579 const cam = scenes[0]?.cameraState();
580 disposeView();
581 buildView(surface);
582 if (cam) for (const s of scenes) s.setCameraState(cam);
583 await draw();
584 updateStats();
585 if (wasRunning) setRunning(true);
586}
588/**
589 * Re-evaluate the surface and swap it in. Data, not code: the compiled step is
590 * untouched and the simulation keeps its state and its model time, so a shape
591 * can be changed mid-run. Only the mesh is rebuilt.
592 */
593async function applyGeometry(): Promise<void> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 594 // The in-place swap below is a single session's trick. Each variant carries
595 // the surface band-limited at its own lmax, and the study's meshes are built
596 // from those, so a shape change goes through the full rebuild instead.
597 if (compareRun) return rebuildCompare();
599 const gen = generation;
600 const wasRunning = running;
601 setRunning(false);
602 while (pumping) await nextFrame();
603 if (gen !== generation || !session) return;
604 try {
605 await session.setGeometry(geometry, geomParams, geomSource());
606 } catch (e) {
607 reportCompileError(e);
608 return;
609 }
610 if (gen !== generation || !session) return;
611 const surface = await session.renderPositions();
612 if (gen !== generation || !session) return;
613 const cam = scenes[0]?.cameraState();
614 disposeView();
615 buildView(surface);
616 if (cam) for (const s of scenes) s.setCameraState(cam);
617 elErr.textContent = '';
618 await draw();
619 updateGeomNote();
620 updateStats();
621 if (wasRunning) setRunning(true);
622}
624/** Re-place the vertices for the current morph. No GPU work and no readback —
625 * the surface is already on the CPU, so this is a buffer fill per panel. */
626function applyMorph(): void {
627 if (!topo || !coords || !posBuf) return;
628 fillPositions(posBuf, coords, topo, morph);
629 for (const s of scenes) s.updatePositions(posBuf);
630}
632/** What the surface is, and the standing caveat about where it is not. */
633function updateGeomNote(): void {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 634 // In compare mode each variant carries the surface band-limited at its own
635 // lmax; the reference's is the one quoted, as everywhere else.
636 const s = session ?? compareRun?.referenceSession ?? null;
637 if (!s) {
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 638 elGeomNote.textContent = '';
639 return;
640 }
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 641 const { lo, hi } = s.geometry.radiusRange();
642 const isSphere = s.geometryModel.key === SPHERE_KEY;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 644 `<b>${s.geometryModel.label}</b> — ${s.geometryModel.blurb} ` +
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 645 `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}.` +
646 (isSphere ? '' : ' <b>Rendered only</b> — not yet in the operator.');
649/** Report a compile failure, and select the offending text in the editor. */
650function reportCompileError(e: unknown): void {
651 elErr.textContent = formatFailure(e, source());
652 elCompiled.textContent = '';
653 if (e instanceof ModelCompileError && e.start !== undefined) {
654 editor.select(e.start, e.end ?? e.start);
655 }
656}
658async function rebuild(): Promise<void> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 659 // A study is several runs, so "rebuild the run" means rebuild all of them.
660 // Everything that recompiles — a model or preset change, an edit to either
661 // .m, a revert — arrives here, and none of it needs to know which mode is up.
662 if (compareRun) return rebuildCompare();
664 const gen = generation;
665 setRunning(false);
666 disposeView();
667 session?.destroy();
668 session = null;
669 solverMs = 0;
670 frameMs = 0;
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 671 // Not 0: with a large niter's dispatch count not yet known (that needs the
672 // compiled plan below), the first measurement burst should wait for the
673 // ordinary per-frame batch — already sized to this model — to prove itself
674 // first, rather than firing a possibly-oversized burst before a single
675 // frame has run.
676 lastMeasure = performance.now();
678 updateCommand();
679 if (!device) return;
681 try {
682 session = await ModelSession.create({
683 device,
684 model,
685 params,
686 lmax: Number(elLmax.value),
687 source: source(),
688 oversample: resolveOversample(),
689 geometry,
690 geometryParams: geomParams,
691 geometrySource: geomSource(),
692 niter: Number(elNiter.value),
693 });
694 } catch (e) {
695 reportCompileError(e);
696 return;
697 }
698 if (gen !== generation) return;
700 session.seed(seed);
702 const plan = session.describe();
703 elCompiled.textContent =
704 `one step compiled to ${plan.step.length} GPU operations:\n` +
705 plan.step.map((l) => ` ${l}`).join('\n');
706 elRecompile.textContent = 'Recompile';
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 708 // Scale the frame batch and the measurement burst down — never up — so
709 // neither submission's total dispatch count exceeds DISPATCH_BUDGET, no
710 // matter how expensive niter has made one step. See STEPS_PER_FRAME_BASE.
711 const opsPerStep = Math.max(1, plan.step.length);
712 stepsPerFrame = Math.max(1, Math.min(STEPS_PER_FRAME_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
713 measureBurst = Math.max(1, Math.min(MEASURE_BURST_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 715 const surface = await session.renderPositions();
716 if (gen !== generation) return;
718 ranges = [];
719 buildView(surface);
721 await draw();
722 updateGeomNote();
723 updateStats();
724 void pump();
725}
727async function reseed(): Promise<void> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 728 // One new perturbation for the whole study, band-limited at its coarsest
729 // variant and evaluated on each grid — see src/compare/sharedStart.ts.
730 if (compareRun) return compareRun.reseed(seed);
732 const gen = generation;
733 session.seed(seed);
734 if (gen !== generation) return;
735 for (const r of ranges) {
736 r.lo = NaN;
737 r.hi = NaN;
738 }
739 await draw();
740 updateStats();
741}
743// ---------------------------------------------------------------- drawing
744async function draw(): Promise<void> {
745 if (!session || !topo) return;
746 const gen = generation;
747 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
748 for (let k = 0; k < model.species.length; k++) {
749 // The one readback per frame — the loop is otherwise entirely on the GPU.
750 // A rebuild can land while this is in flight and destroy the buffer being
751 // mapped, which rejects the map; that result is stale anyway, so drop it.
752 let field: Float32Array;
753 try {
754 field = await session.readSpecies(k);
755 } catch (e) {
756 if (gen !== generation) return;
757 throw e;
758 }
759 if (gen !== generation || !topo) return;
760 fillFieldValues(valueBufs[k], field, topo);
761 let lo = Infinity;
762 let hi = -Infinity;
763 for (const v of valueBufs[k]) {
764 if (v < lo) lo = v;
765 if (v > hi) hi = v;
766 }
767 // smooth the color range in both directions so the shading evolves
768 // gently as the pattern grows (out-of-range values clamp meanwhile)
769 const r = ranges[k];
770 if (!Number.isFinite(r.lo)) {
771 r.lo = lo;
772 r.hi = hi;
773 } else {
774 const a = 0.15;
775 r.lo += a * (lo - r.lo);
776 r.hi += a * (hi - r.hi);
777 }
778 if (r.hi - r.lo < 1e-9) {
779 const mid = (r.hi + r.lo) / 2;
780 r.lo = mid - 5e-10;
781 r.hi = mid + 5e-10;
782 }
783 fillColors(colorBufs[k], valueBufs[k], r.lo, r.hi, cmap);
784 scenes[k]?.updateColors(colorBufs[k]);
785 colorbars[k]?.update(cmap, r.lo, r.hi);
786 }
787}
789function updateStats(): void {
790 if (!session) return;
791 const { nlat, nphi } = session.cfg;
792 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
793 const solver =
794 solverMs > 0
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 795 ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s)`
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 797 const frame = frameMs > 0 ? `${frameMs.toFixed(1)} ms/frame` : '—';
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 798 const view = session.viewSht.cfg;
799 const render =
800 session.oversample > 1
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 801 ? ` (display ${view.nlat}×${view.nphi})`
803 elStats.innerHTML =
804 `<b>${kind}</b> · grid ${nlat}×${nphi}${render} · nlm ${session.sht.nlm.toLocaleString()} · ` +
805 `solver ${solver} · ${frame} · ` +
806 `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
807}
809// ---------------------------------------------------------------- sim loop
810const nextFrame = () => new Promise<number>(requestAnimationFrame);
812async function pump(): Promise<void> {
813 if (pumping) return;
814 pumping = true;
815 const gen = generation;
816 try {
817 while (running && session && gen === generation) {
818 // Occasionally, a burst purely to measure the solver rate: many steps,
819 // one sync, nothing read back — directly comparable to the desktop
820 // benchmark's throughput number. State-preserving: the display and
821 // model time are unaffected.
822 if (performance.now() - lastMeasure > MEASURE_EVERY_MS) {
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 823 const ms = await session.measure(measureBurst);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 824 if (gen !== generation) break;
825 solverMs = ms;
826 lastMeasure = performance.now();
827 }
829 // The frame itself. No explicit sync here — draw()'s readback already
830 // waits for the steps, so asking twice would only add a round trip.
831 const t0 = performance.now();
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 832 session.step(stepsPerFrame);
834 if (gen !== generation) break;
835 frameMs = frameMs === 0
836 ? performance.now() - t0
837 : frameMs + 0.05 * (performance.now() - t0 - frameMs);
838 updateStats();
839 await nextFrame();
840 }
841 if (gen === generation) {
842 await draw();
843 updateStats();
844 }
845 } finally {
846 pumping = false;
847 }
848}
850/**
851 * Sustained solver benchmark, in the page.
852 *
853 * The same measurement `npm run bench` makes: batches of steps submitted
854 * together, waited for, never read back, with no rendering and no animation
855 * pacing in between. That makes it directly comparable to the terminal number,
856 * which is the only way to tell a genuinely slower browser GPU stack apart from
857 * the costs the app adds on top.
858 *
859 * It also reports the ramp — the first third of the run against the last. GPUs
860 * downclock when idle, and an animation-paced loop leaves them idle most of every
861 * frame, so a large ramp means the app's steady-state number is limited by clocks
862 * rather than by the work.
863 *
864 * These are ordinary steps: the simulation advances by them.
865 */
866async function benchmark(): Promise<void> {
867 if (!session || movieBusy) return;
868 setRunning(false);
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 869 // Same base size and the same DISPATCH_BUDGET scaling as the automatic
870 // measurement burst (see STEPS_PER_FRAME_BASE) — this is a user-triggered
871 // 32-step submission, exactly the shape of thing that risks a browser's
872 // GPU-process watchdog on weak hardware once niter makes a step expensive.
873 const BATCH = measureBurst;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 874 const DURATION_MS = 2000;
875 elBenchResult.textContent = 'benchmarking…';
876 // A movie started mid-benchmark would replay while this loop still steps.
877 elMovie.disabled = true;
878 try {
879 await nextFrame();
881 const gen = generation;
882 const perStep: number[] = [];
883 const t0 = performance.now();
884 while (performance.now() - t0 < DURATION_MS) {
885 const b0 = performance.now();
886 session.step(BATCH);
887 await session.sync();
888 if (gen !== generation) return;
889 perStep.push((performance.now() - b0) / BATCH);
890 }
892 const mean = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
893 const all = mean(perStep);
894 const best = Math.min(...perStep);
895 const third = Math.max(1, Math.floor(perStep.length / 3));
896 const first = mean(perStep.slice(0, third));
897 const last = mean(perStep.slice(-third));
898 const steps = perStep.length * BATCH;
900 elBenchResult.innerHTML =
901 `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
902 `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 903 `ramp ${(first / last).toFixed(2)}× · ${steps} steps · ` +
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 904 `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
905 await draw();
906 updateStats();
907 } finally {
908 elMovie.disabled = false;
909 }
910}
912// ---------------------------------------------------------------- movie
913function saveBlob(blob: Blob, filename: string): void {
914 const url = URL.createObjectURL(blob);
915 const a = document.createElement('a');
916 a.href = url;
917 a.download = filename;
918 a.click();
919 setTimeout(() => URL.revokeObjectURL(url), 10_000);
920}
922/** Submit `n` steps in bounded command buffers — a single buffer encoding
923 * many thousands of steps can exhaust the encoder. */
924function submitSteps(n: number): void {
925 while (n > 0 && session) {
926 const chunk = Math.min(512, n);
927 session.step(chunk);
928 n -= chunk;
929 }
930}
932/** While recording, lock everything that could change the run mid-replay;
933 * the Movie button itself becomes the cancel button. */
934function setMovieUi(on: boolean): void {
935 const locked = [
936 elModel, elGeometry, elMorph, elNiter, elLmax, elOversample, elColormap,
937 elRunPause, elBenchmark, elReseed, elRecompile, elRevert, elEditorFile,
938 elMovieSpeed, elMovieRes, elMovieRotate, elMovieToggle,
939 ];
940 for (const el of locked) el.disabled = on;
941 elParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
942 elGeomParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
943 elMovie.textContent = on ? 'Cancel · 0%' : 'Export';
944}
946/**
947 * Recompute the run from t = 0 and download it as an MP4.
948 *
949 * The movie is not a recording of what already happened — it is the same
950 * trajectory recomputed: same seed, same source, and the *current* parameters
951 * and colormap throughout. Determinism makes this exact: after the replay the
952 * state is where it was, so the one session is reused and the app resumes as
953 * if nothing happened. Frames are composited from the live panels, so the
954 * movie shows the spheres at the current camera orientation — and the replay
955 * doubles as the progress display, since it is visible on screen.
956 */
957async function recordMovie(): Promise<void> {
958 if (!session || movieBusy) return;
959 if (session.steps === 0) {
960 elMovie.textContent = 'run first';
961 setTimeout(() => (elMovie.textContent = 'Export'), 1200);
962 return;
963 }
964 movieBusy = true;
965 movieCancel = false;
966 const gen = generation;
967 setMovieUi(true);
968 let wasRunning = false;
969 let total = 0;
970 let done = 0;
971 let seeded = false;
972 let camBefore: ReturnType<SphereScene['cameraState']> | undefined;
973 try {
974 // An in-flight display-grid swap replaces the scenes whose canvases the
975 // recorder captures, and resumes the run when it lands — let it finish.
976 await viewChange;
977 if (gen !== generation || !session) return;
978 wasRunning = running;
979 setRunning(false);
980 while (pumping) await nextFrame(); // let an in-flight live frame drain
981 if (gen !== generation || !session) return;
982 total = session.steps;
983 const speed = Number(elMovieSpeed.value) || 10;
984 const sphere = Number(elMovieRes.value) || 768;
985 const rotate = elMovieRotate.checked;
986 if (rotate) camBefore = scenes[0]?.cameraState();
987 // Render the scenes at exactly the chosen resolution for the recording —
988 // independent of the window size — and restore afterwards.
989 for (const s of scenes) s.captureSize(sphere);
990 const durationS = Math.max(session.t / speed, 2 / MOVIE_FPS);
991 const frames = Math.max(
992 2,
993 Math.min(Math.round(durationS * MOVIE_FPS) + 1, total + 1, MOVIE_MAX_FRAMES),
994 );
995 /** The step index captured as frame `i`; both endpoints land exactly. */
996 const stepAt = (i: number): number => Math.round((i * total) / (frames - 1));
998 const title =
999 (presets.find((p) => p.key === elModel.value)?.label ?? model.label) +
1000 ` on ${geometry.label.toLowerCase()}` +
1001 (editedSource !== null || editedGeomSource !== null ? ' (edited)' : '');
1002 const subtitle = model.params
1003 .map((spec) => `${spec.label} ${fmtValue(params[spec.key])}`)
1004 .join(' · ');
1005 const rec = await MovieRecorder.create({
1006 panels: model.species.map((label, k) => ({ canvas: scenes[k].canvas, label })),
1007 title,
1008 subtitle,
1009 speed,
1010 fps: (frames - 1) / durationS,
1011 sphere,
1012 });
1014 let finished = false;
1015 try {
1016 // Reset the color-range smoothing as a re-seed does, so the shading
1017 // evolves in the movie the way it did live.
1018 session.seed(seed);
1019 seeded = true;
1020 for (const r of ranges) {
1021 r.lo = NaN;
1022 r.hi = NaN;
1023 }
1024 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
1025 let lastVideoS = 0;
1026 for (let frame = 0; ; ) {
1027 await draw();
1028 if (gen !== generation) return;
1029 if (movieCancel) break;
1030 if (rotate) {
1031 // Advance the orbit by this frame's share of video time; siblings
1032 // follow scenes[0] through the usual camera sync.
1033 const videoS = session.t / speed;
1034 scenes[0]?.orbitBy(2 * Math.PI * MOVIE_ROTATE_RPS * (videoS - lastVideoS));
1035 lastVideoS = videoS;
1036 }
1037 for (const s of scenes) s.renderNow();
1038 await rec.addFrame(
1039 session.t,
1040 model.species.map((_, k) => ({ cmap, lo: ranges[k].lo, hi: ranges[k].hi })),
1041 );
1042 if (++frame >= frames) {
1043 finished = true;
1044 break;
1045 }
1046 const target = stepAt(frame);
1047 submitSteps(target - done);
1048 done = target;
1049 elMovie.textContent = `Cancel · ${Math.round((100 * done) / total)}%`;
1050 }
1051 if (finished) {
1052 const blob = await rec.finish();
1053 saveBlob(
1054 blob,
1055 `turing-surface-${model.key}-${geometry.key}-` +
1056 `t${session.t.toFixed(2)}-${speed}x.mp4`,
1057 );
1058 }
1059 } finally {
1060 if (!finished) rec.cancel();
1061 }
1062 } catch (e) {
1063 elErr.textContent = `movie: ${e instanceof Error ? e.message : e}`;
1064 } finally {
1065 // A cancelled replay stopped short of where the run was; step the
1066 // remainder — determinism makes this land exactly there.
1067 if (seeded && gen === generation && session) {
1068 while (done < total && gen === generation && session) {
1069 const n = Math.min(4096, total - done);
1070 submitSteps(n);
1071 done += n;
1072 elMovie.textContent = `restoring · ${Math.round((100 * done) / total)}%`;
1073 await session.sync();
1074 }
1075 await draw();
1076 updateStats();
1077 }
1078 if (gen === generation) {
1079 for (const s of scenes) s.restoreSize();
1080 }
1081 if (camBefore && gen === generation) {
1082 for (const s of scenes) s.setCameraState(camBefore);
1083 }
1084 movieBusy = false;
1085 setMovieUi(false);
1086 if (gen === generation) setRunning(wasRunning);
1087 }
1088}
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1090// ---------------------------------------------------------------- compare
1091/**
1092 * Comparing several solver settings at once.
1093 *
1094 * Deliberately a mode rather than a widening of the ordinary controls: the
1095 * single-run path above is untouched, and with the bar closed nothing about
1096 * using this page has changed. Opening it and pressing Compare tears down the
1097 * one session and hands the panels area to a CompareRun, which owns a session
1098 * per variant; pressing it again puts the single run back.
1099 *
1100 * The ceilings below are not arbitrary. Each variant compiles its whole
1101 * unrolled step with no pipeline cache between sessions (a solve iteration is
1102 * ~15 kernels per species), so the variant count is what you wait for; and
1103 * each panel is a WebGL context and a full mesh, so the panel count is what
1104 * the browser has to keep alive at once.
1105 */
1106const MAX_VARIANTS = 6;
1107const MAX_PANELS = 12;
1108/** dt divisors. Powers of two so that dtBase/K is exact in binary and every
1109 * variant lands on the same model time with no accumulated drift. */
1110const DT_DIVISORS = [1, 2, 4, 8];
1112/**
1113 * What the bar opens on: the default iteration count against the next step up,
1114 * at the default band. Two variants, so the first study is quick to compile,
1115 * and it asks the question the control exists for — is the default already
1116 * converged? A flat, low curve says yes; one that climbs says the answer is
1117 * still moving at niter 8 and the default is not enough for this shape.
1118 */
1119const cmpSelected = {
1120 niter: new Set<number>([DEFAULT_NITER, 2 * DEFAULT_NITER]),
1121 lmax: new Set<number>([63]),
1122 dt: new Set<number>([1]),
1123};
1125/** A row of toggle chips backed by a Set. At least one stays selected — an
1126 * empty axis has no meaning here, and silently falling back to a default
1127 * would hide which values are actually being run. */
1128function buildChips(host: HTMLElement, values: number[], selected: Set<number>, label: (v: number) => string): void {
1129 host.replaceChildren();
1130 for (const value of values) {
1131 const chip = document.createElement('button');
1132 chip.type = 'button';
1133 chip.className = 'chip';
1134 chip.textContent = label(value);
1135 const paint = (): void => chip.setAttribute('aria-pressed', String(selected.has(value)));
1136 paint();
1137 chip.addEventListener('click', () => {
1138 if (selected.has(value)) {
1139 if (selected.size === 1) return;
1140 selected.delete(value);
1141 } else {
1142 selected.add(value);
1143 }
1144 paint();
1145 refreshVariants();
1146 });
1147 host.append(chip);
1148 }
1149}
1151const cmpVariants = (): Variant[] =>
1152 crossProduct([...cmpSelected.niter], [...cmpSelected.lmax], [...cmpSelected.dt]);
1154/** The reference the user picked, clamped to the current variant list. */
1155let cmpRefKey = '';
1157/** Index of the reference in the current variant list, never negative. */
1158function compareRefIndex(): number {
1159 const i = cmpVariants().map(variantKey).indexOf(cmpRefKey);
1160 return i < 0 ? 0 : i;
1161}
1163function refreshVariants(): void {
1164 const variants = cmpVariants();
1165 const showDt = cmpSelected.dt.size > 1;
1166 const panels = variants.length * model.species.length;
1168 const prev = cmpRefKey;
1169 elCmpRef.replaceChildren();
1170 for (const v of variants) {
1171 const o = document.createElement('option');
1172 o.value = variantKey(v);
1173 o.textContent = variantLabel(v, showDt);
1174 elCmpRef.append(o);
1175 }
1176 const keys = variants.map(variantKey);
1177 cmpRefKey = keys.includes(prev) ? prev : keys[mostResolved(variants)];
1178 elCmpRef.value = cmpRefKey;
1180 const tooMany =
1181 variants.length > MAX_VARIANTS
1182 ? `${variants.length} variants — at most ${MAX_VARIANTS}`
1183 : panels > MAX_PANELS
1184 ? `${panels} panels — at most ${MAX_PANELS}`
1185 : '';
1186 elCmpCount.textContent = tooMany
1187 ? `too many: ${tooMany}`
1188 : `${variants.length} variants × ${model.species.length} species = ${panels} panels`;
1189 elCmpCount.style.color = tooMany ? '#b35900' : '';
1190 elCmpStart.disabled = tooMany !== '' && compareRun === null;
1191}
1193buildChips(
1194 elCmpNiter,
1195 [...elNiter.options].map((o) => Number(o.value)),
1196 cmpSelected.niter,
1197 String,
1198);
1199buildChips(
1200 elCmpLmax,
1201 [...elLmax.options].map((o) => Number(o.value)),
1202 cmpSelected.lmax,
1203 String,
1204);
1205buildChips(elCmpDt, DT_DIVISORS, cmpSelected.dt, (v) => (v === 1 ? 'dt' : `dt/${v}`));
1206refreshVariants();
1208elCmpRef.addEventListener('change', () => {
1209 cmpRefKey = elCmpRef.value;
1210 if (compareRun) void rebuildCompare();
1211});
1213elCompareToggle.addEventListener('click', () => {
1214 elCompareBar.hidden = !elCompareBar.hidden;
1215});
1217elCmpStart.addEventListener('click', () => {
1218 if (compareRun) void stopCompare();
1219 else void startCompare();
1220});
1222/** Controls the study supersedes or cannot honour while it is running. */
1223function setCompareUi(on: boolean): void {
1224 for (const el of [elNiter, elLmax, elOversample, elBenchmark, elMovieToggle]) {
1225 el.disabled = on;
1226 }
1227 elCmpNiter.querySelectorAll('button').forEach((b) => (b.disabled = on));
1228 elCmpLmax.querySelectorAll('button').forEach((b) => (b.disabled = on));
1229 elCmpDt.querySelectorAll('button').forEach((b) => (b.disabled = on));
1230 elCmpStart.textContent = on ? 'Stop comparing' : 'Compare';
1231 elCompareToggle.textContent = on ? 'Comparing' : 'Compare';
1232 if (on) elMovieBar.hidden = true;
1233}
1235async function startCompare(): Promise<void> {
1236 if (compareRun || !device) return;
1237 const variants = cmpVariants();
1238 if (variants.length > MAX_VARIANTS || variants.length * model.species.length > MAX_PANELS) {
1239 return;
1240 }
1241 // Take down the single run first: its pump, its scenes, its session. The
1242 // generation bump makes any readback already in flight drop its result.
1243 generation++;
1244 setRunning(false);
1245 while (pumping) await nextFrame();
1246 disposeView();
1247 session?.destroy();
1248 session = null;
1249 elBenchResult.textContent = '';
1250 elErr.textContent = '';
1251 setCompareUi(true);
1253 try {
1254 compareRun = await CompareRun.create({
1255 device,
1256 model,
1257 params,
1258 source: source(),
1259 geometry,
1260 geometryParams: geomParams,
1261 geometrySource: geomSource(),
1262 variants,
1263 reference: compareRefIndex(),
1264 seed,
1265 morph,
1266 colormapName: () => elColormap.value,
1267 container: elPanels,
1268 onStatus: (html) => (elStats.innerHTML = html),
1269 });
1270 } catch (e) {
1271 compareRun = null;
1272 setCompareUi(false);
1273 refreshVariants();
1274 reportCompileError(e);
1275 await rebuild();
1276 return;
1277 }
1278 updateGeomNote();
1279 // The command describes the reference variant, which only exists now.
1280 updateCommand();
1281 elRunPause.textContent = 'Run';
1282}
1284async function stopCompare(): Promise<void> {
1285 if (!compareRun) return;
1286 compareRun.dispose();
1287 compareRun = null;
1288 setCompareUi(false);
1289 refreshVariants();
1290 elStats.textContent = '';
1291 await rebuild();
1292}
1294/** Rebuild the study in place — after a model, geometry, source or reference
1295 * change. Same teardown as stopping, without leaving the mode. */
1296async function rebuildCompare(): Promise<void> {
1297 if (!compareRun) return;
1298 compareRun.dispose();
1299 compareRun = null;
1300 setCompareUi(false);
1301 await startCompare();
1302}
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1304// ---------------------------------------------------------------- boot
1305async function boot(): Promise<void> {
1306 elModel.value = presets[0].key;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1307 // The iteration count is one default shared with the benchmark, like the
1308 // rest of the RunSpec's — take it from there rather than from the markup, so
1309 // the page and `npm run bench` cannot start out disagreeing about it.
1310 elNiter.value = String(DEFAULT_NITER);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1311 elGeometry.value = DEFAULT_GEOMETRY_KEY;
1312 elMorph.value = String(morph);
1313 applyGeometryChoice(DEFAULT_GEOMETRY_KEY);
1314 applyPreset(presets[0].key);
1315 try {
1316 device = await requestShtDevice();
1317 adapterName = await describeAdapter(device);
1318 } catch (e) {
1319 device = null;
1320 elErr.textContent =
1321 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 1322 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1324 }
1325 device.lost.then((info) => {
1326 if (info.reason !== 'destroyed') {
1327 elErr.textContent = `WebGPU device lost: ${info.message}`;
1328 }
1329 });
1330 await rebuild();
1331}
1333void boot();