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 DEFAULT_GEOMETRY_KEY,
21 type MGeometry,
22} from './geom/registry.ts';
23import {
24 buildTopology,
25 fillFieldValues,
26 fillPositions,
27 fillColors,
28 type SphereMeshTopology,
29} from './render/sphereMesh.ts';
30import { SphereScene } from './render/SphereScene.ts';
ef3ae33Do not stretch the colormap across a constant field's roundoffJeremy Magland 31import { Colorbar, fmtValue, floorRange } from './render/colorbar.ts';
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 32import { colormaps, colormapNames } from './render/colormaps.ts';
33import { MovieRecorder } from './render/movie.ts';
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 34import { CompareRun } from './compare/compareRun.ts';
35import {
36 crossProduct,
37 mostResolved,
38 variantKey,
39 variantLabel,
40 type Variant,
41} from './compare/variants.ts';
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 42
43const $ = <T extends HTMLElement>(id: string): T =>
44 document.getElementById(id) as T;
46const elModel = $<HTMLSelectElement>('model');
47const elGeometry = $<HTMLSelectElement>('geometry');
48const elMorph = $<HTMLInputElement>('morph');
49const elNiter = $<HTMLSelectElement>('niter');
50const elLmax = $<HTMLSelectElement>('lmax');
51const elOversample = $<HTMLSelectElement>('oversample');
52const elColormap = $<HTMLSelectElement>('colormap');
53const elRunPause = $<HTMLButtonElement>('runpause');
54const elBenchmark = $<HTMLButtonElement>('benchmark');
55const elReseed = $<HTMLButtonElement>('reseed');
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 56const elLam3 = $<HTMLInputElement>('lam3');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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) {
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 302 // A random seed picks a draw and means nothing on its own, so it gets a
303 // button to the next one rather than a box to type a number into. The
304 // shape changes; the simulation running on it does not restart.
305 if (spec.reseed) {
306 const button = document.createElement('button');
307 button.textContent = 'Re-seed shape';
308 button.title =
309 `Draw another ${geometry.label.toLowerCase()} — a new random surface, ` +
310 `leaving the pattern running on it alone.`;
311 button.addEventListener('click', () => {
312 const span = spec.max - spec.min;
313 let next = geomParams[spec.key];
314 // Never hand back the shape that is already on screen.
315 while (next === geomParams[spec.key]) {
316 next = spec.min + Math.floor(Math.random() * (span + 1));
317 }
318 geomParams[spec.key] = next;
319 viewChange = viewChange.then(() => applyGeometry());
320 });
321 elGeomParams.append(button);
322 continue;
323 }
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 324 const label = document.createElement('label');
325 label.textContent = `${spec.label} `;
326 const input = document.createElement('input');
327 input.type = 'number';
328 input.min = String(spec.min);
329 input.max = String(spec.max);
330 input.step = String(spec.step);
331 input.value = String(geomParams[spec.key]);
332 input.addEventListener('change', () => {
333 const v = Number(input.value);
334 if (Number.isFinite(v)) geomParams[spec.key] = v;
335 viewChange = viewChange.then(() => applyGeometry());
336 });
337 label.append(input);
338 elGeomParams.append(label);
339 }
340}
342function applyPreset(presetKey: string): void {
343 const resolved = resolvePreset(presetKey);
344 const next = mModelByKey(resolved.model.key);
345 if (!next) {
346 elErr.textContent = `No .m model for '${resolved.model.key}'`;
347 return;
348 }
349 model = next;
350 params = resolved.params;
351 editedSource = null;
352 buildParamInputs();
353 elBlurb.textContent = model.blurb;
354 showEditorFile();
355 updateCommand();
356}
358function applyGeometryChoice(key: string): void {
359 const next = mGeometryByKey(key);
360 if (!next) {
361 elErr.textContent = `No .m geometry for '${key}'`;
362 return;
363 }
364 geometry = next;
365 geomParams = defaultGeometryParams(geometry);
366 editedGeomSource = null;
367 buildGeomParamInputs();
368 showEditorFile();
369}
371/** Load the chosen file into the editor, keeping any unsaved edit to it. */
372function showEditorFile(): void {
373 editing = elEditorFile.value === 'geometry' ? 'geometry' : 'model';
374 if (editing === 'geometry') {
375 editor.value = geomSource();
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 376 elEditorTitle.textContent = `geometries/${geometry.key}.m`;
378 editor.value = source();
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 379 elEditorTitle.textContent = `models/${model.key}.m`;
381}
384 * The run currently on screen, as the benchmark's RunSpec. While a study is
385 * running there is no single run, so this describes its *reference* variant —
386 * the one the other rows are measured against, and the only one of them whose
387 * numbers mean anything on their own.
388 */
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 389function currentSpec(): RunSpec {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 390 const ref = compareRun?.variants[compareRefIndex()];
391 const dt = ref ? { dt: (params.dt ?? 0) / ref.dtDiv } : null;
393 preset: elModel.value,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 394 lmax: ref ? ref.lmax : Number(elLmax.value),
396 steps: DEFAULT_STEPS,
397 warmup: DEFAULT_WARMUP,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 398 params: dt ? { ...params, ...dt } : params,
400 geometryParams: geomParams,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 401 niter: ref ? ref.niter : Number(elNiter.value),
403}
405function updateCommand(): void {
406 elCmd.textContent = formatCommand(currentSpec());
407}
409elModel.addEventListener('change', () => {
410 applyPreset(elModel.value);
411 void rebuild();
412});
413elLmax.addEventListener('change', () => void rebuild());
414// The solve iteration count is unrolled into the compiled step, so unlike a
415// parameter it cannot be changed without recompiling.
416elNiter.addEventListener('change', () => void rebuild());
417// Oversampling and geometry are display-or-data changes, not code ones, so
418// they swap things in place rather than rebuilding the run. Serialized through
419// one chain: a rapid second change waits its turn.
420let viewChange = Promise.resolve();
421elOversample.addEventListener('change', () => {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 422 // The study picks its own display grid — one grid common to every variant is
423 // what makes their fields comparable — so this control is inert (and
424 // disabled) while one is running.
425 if (compareRun) return;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 426 viewChange = viewChange.then(() => applyOversample());
427});
428elGeometry.addEventListener('change', () => {
429 applyGeometryChoice(elGeometry.value);
430 viewChange = viewChange.then(() => applyGeometry());
431});
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 432// The seed field's wavelength: a uniform plus a host-side redraw, so it
433// reseeds the run in place rather than recompiling it. Too small a value asks
434// for more Fourier modes than the table holds, which `drawModes` refuses —
435// report that like any other failure instead of leaving the run half-seeded.
436elLam3.addEventListener('change', () => {
437 const v = Number(elLam3.value);
438 if (!Number.isFinite(v) || v <= 0) return;
439 // Changing the wavelength redraws the field, which restarts the run — so
440 // pause first, exactly as the Re-seed button does. Without it the reseed's
441 // readback races the pump's own, and the two collide on the staging buffer.
442 setRunning(false);
443 viewChange = viewChange.then(async () => {
beac00aMerge main into random-fieldsJeremy Magland 444 // A study seeds every variant from one field at one wavelength, so this is
445 // the same control there — set on each variant, redrawn by the one reseed.
446 const target = compareRun ?? session;
447 if (!target) return;
448 const previous = target.lam3;
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 451 await reseed();
452 elErr.textContent = '';
453 } catch (e) {
454 // Too fine a wavelength asks for more Fourier modes than the table
455 // holds. Put the working value back rather than leaving the run seeded
456 // from a field that was never drawn.
457 elErr.textContent = e instanceof Error ? e.message : String(e);
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 459 elLam3.value = String(previous);
460 await reseed();
461 }
462 });
463});
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 464// Morph is pure rendering: no readback, no GPU work, just the vertex buffer.
465elMorph.addEventListener('input', () => {
466 morph = Number(elMorph.value);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 467 if (compareRun) compareRun.setMorph(morph);
468 else applyMorph();
469});
470elColormap.addEventListener('change', () => {
471 if (compareRun) void compareRun.draw();
472 else void draw();
474elEditorFile.addEventListener('change', () => showEditorFile());
476function setRunning(next: boolean): void {
477 running = next;
478 elRunPause.textContent = running ? 'Pause' : 'Run';
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 479 if (compareRun) {
480 compareRun.setRunning(next);
481 return;
482 }
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 483 if (running) void pump();
484}
486elRunPause.addEventListener('click', () => setRunning(!running));
487elBenchmark.addEventListener('click', () => void benchmark());
488elReseed.addEventListener('click', () => {
489 seed = (Math.random() * 2 ** 31) >>> 0;
490 setRunning(false);
491 updateCommand();
492 void reseed();
493});
494elResetView.addEventListener('click', () => {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 495 compareRun?.resetView();
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 496 for (const s of scenes) s.resetCamera();
497});
498elMovieToggle.addEventListener('click', () => {
499 elMovieBar.hidden = !elMovieBar.hidden;
500});
501elMovie.addEventListener('click', () => {
502 if (movieBusy) movieCancel = true;
503 else void recordMovie();
504});
506elRecompile.addEventListener('click', () => {
507 if (editing === 'geometry') editedGeomSource = editor.value;
508 else editedSource = editor.value;
509 void rebuild();
510});
511elRevert.addEventListener('click', () => {
512 if (editing === 'geometry') editedGeomSource = null;
513 else editedSource = null;
514 showEditorFile();
515 void rebuild();
516});
518// The command reproduces this run's parameters on the desktop; keep it
519// selectable even where the clipboard API is unavailable.
520elCopyCmd.addEventListener('click', () => {
521 const text = elCmd.textContent ?? '';
522 const flash = (msg: string): void => {
523 elCopyCmd.textContent = msg;
524 setTimeout(() => (elCopyCmd.textContent = 'Copy'), 1200);
525 };
526 const selectCommand = (): void => {
527 const range = document.createRange();
528 range.selectNodeContents(elCmd);
529 const sel = getSelection();
530 sel?.removeAllRanges();
531 sel?.addRange(range);
532 flash('Selected');
533 };
534 if (!navigator.clipboard) return selectCommand();
535 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
536});
538// ---------------------------------------------------------------- setup
539function disposeView(): void {
540 for (const s of scenes) s.dispose();
541 scenes = [];
542 colorbars = [];
543 topo = null;
544 coords = null;
545 posBuf = null;
546 resizeObs?.disconnect();
547 resizeObs = null;
548 elPanels.replaceChildren();
549}
551/**
552 * Build the mesh, scenes, colorbars and per-species buffers on the current
553 * render grid, from surface coordinates already synthesized there. Call
554 * disposeView() first. The color ranges are kept if present, so a display-only
555 * rebuild (an oversampling change) does not pop the shading; a full rebuild
556 * clears `ranges` beforehand.
557 */
558function buildView(surface: Float32Array): void {
559 if (!session) return;
560 const view = session.viewSht;
561 const { nphi } = view.cfg;
562 const phi = new Float64Array(nphi);
563 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
564 topo = buildTopology(view.cosTheta, phi);
565 coords = surface;
566 posBuf = new Float32Array(topo.numVertices * 3);
567 fillPositions(posBuf, coords, topo, morph);
569 const sphereBg = getComputedStyle(document.documentElement)
570 .getPropertyValue('--sphere-bg')
571 .trim();
572 for (let k = 0; k < model.species.length; k++) {
573 const panel = document.createElement('div');
574 panel.className = 'panel';
575 const box = document.createElement('div');
576 box.className = 'sphere-box';
577 const tag = document.createElement('div');
578 tag.className = 'species-tag';
579 tag.textContent = model.species[k];
580 box.append(tag);
581 const side = document.createElement('div');
582 panel.append(box, side);
583 elPanels.append(panel);
585 const scene = new SphereScene(
586 box,
587 topo.numVertices,
588 topo.indices,
589 // Each scene owns its position buffer: three.js uploads from it, and the
590 // morph rewrites all of them from the one shared `coords`.
591 Float32Array.from(posBuf),
592 sphereBg || undefined,
593 );
594 scene.fitCamera();
595 scenes.push(scene);
596 colorbars.push(new Colorbar(side));
597 valueBufs[k] = new Float32Array(topo.numVertices);
598 colorBufs[k] = new Float32Array(topo.numVertices * 3);
599 if (!ranges[k]) ranges[k] = { lo: NaN, hi: NaN };
600 }
601 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
603 resizeObs = new ResizeObserver(() => {
604 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
605 boxes.forEach((box, i) => {
606 scenes[i]?.resize(box.clientWidth, box.clientHeight);
607 });
608 });
609 elPanels
610 .querySelectorAll<HTMLElement>('.sphere-box')
611 .forEach((box) => resizeObs!.observe(box));
612}
614/**
615 * Apply the UI's oversampling choice to the running session. Display-only: the
616 * session and its state survive; only the display plan, mesh and scenes are
617 * rebuilt, keeping the camera pose and color ranges. The pump is drained first
618 * so no readback is in flight on the plan being replaced.
619 */
620async function applyOversample(): Promise<void> {
621 if (!session) return;
622 const gen = generation;
623 const os = resolveOversample();
624 if (os === session.oversample) return;
625 const wasRunning = running;
626 setRunning(false);
627 while (pumping) await nextFrame();
628 if (gen !== generation || !session) return;
629 await session.setOversample(os);
630 if (gen !== generation || !session) return;
631 const surface = await session.renderPositions();
632 if (gen !== generation || !session) return;
633 const cam = scenes[0]?.cameraState();
634 disposeView();
635 buildView(surface);
636 if (cam) for (const s of scenes) s.setCameraState(cam);
637 await draw();
638 updateStats();
639 if (wasRunning) setRunning(true);
640}
642/**
643 * Re-evaluate the surface and swap it in. Data, not code: the compiled step is
644 * untouched and the simulation keeps its state and its model time, so a shape
645 * can be changed mid-run. Only the mesh is rebuilt.
646 */
647async function applyGeometry(): Promise<void> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 648 // The in-place swap below is a single session's trick. Each variant carries
649 // the surface band-limited at its own lmax, and the study's meshes are built
650 // from those, so a shape change goes through the full rebuild instead.
651 if (compareRun) return rebuildCompare();
653 const gen = generation;
654 const wasRunning = running;
655 setRunning(false);
656 while (pumping) await nextFrame();
657 if (gen !== generation || !session) return;
658 try {
659 await session.setGeometry(geometry, geomParams, geomSource());
660 } catch (e) {
661 reportCompileError(e);
662 return;
663 }
664 if (gen !== generation || !session) return;
665 const surface = await session.renderPositions();
666 if (gen !== generation || !session) return;
667 const cam = scenes[0]?.cameraState();
668 disposeView();
669 buildView(surface);
670 if (cam) for (const s of scenes) s.setCameraState(cam);
671 elErr.textContent = '';
672 await draw();
673 updateGeomNote();
674 updateStats();
675 if (wasRunning) setRunning(true);
676}
678/** Re-place the vertices for the current morph. No GPU work and no readback —
679 * the surface is already on the CPU, so this is a buffer fill per panel. */
680function applyMorph(): void {
681 if (!topo || !coords || !posBuf) return;
682 fillPositions(posBuf, coords, topo, morph);
683 for (const s of scenes) s.updatePositions(posBuf);
684}
f132fe4Drop the work-in-progress bannerDan Fortunato 686/** What the surface is, and how far it departs from the sphere. */
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 687function updateGeomNote(): void {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 688 // In compare mode each variant carries the surface band-limited at its own
689 // lmax; the reference's is the one quoted, as everywhere else.
690 const s = session ?? compareRun?.referenceSession ?? null;
691 if (!s) {
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 692 elGeomNote.textContent = '';
693 return;
694 }
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 695 const { lo, hi } = s.geometry.radiusRange();
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 697 `<b>${s.geometryModel.label}</b> — ${s.geometryModel.blurb} ` +
f132fe4Drop the work-in-progress bannerDan Fortunato 698 `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}.`;
701/** Report a compile failure, and select the offending text in the editor. */
702function reportCompileError(e: unknown): void {
703 elErr.textContent = formatFailure(e, source());
704 elCompiled.textContent = '';
705 if (e instanceof ModelCompileError && e.start !== undefined) {
706 editor.select(e.start, e.end ?? e.start);
707 }
708}
710async function rebuild(): Promise<void> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 711 // A study is several runs, so "rebuild the run" means rebuild all of them.
712 // Everything that recompiles — a model or preset change, an edit to either
713 // .m, a revert — arrives here, and none of it needs to know which mode is up.
714 if (compareRun) return rebuildCompare();
716 const gen = generation;
717 setRunning(false);
718 disposeView();
719 session?.destroy();
720 session = null;
721 solverMs = 0;
722 frameMs = 0;
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 723 // Not 0: with a large niter's dispatch count not yet known (that needs the
724 // compiled plan below), the first measurement burst should wait for the
725 // ordinary per-frame batch — already sized to this model — to prove itself
726 // first, rather than firing a possibly-oversized burst before a single
727 // frame has run.
728 lastMeasure = performance.now();
730 updateCommand();
731 if (!device) return;
733 try {
734 session = await ModelSession.create({
735 device,
736 model,
737 params,
738 lmax: Number(elLmax.value),
739 source: source(),
740 oversample: resolveOversample(),
741 geometry,
742 geometryParams: geomParams,
743 geometrySource: geomSource(),
744 niter: Number(elNiter.value),
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 745 lam3: Number(elLam3.value),
747 } catch (e) {
748 reportCompileError(e);
749 return;
750 }
751 if (gen !== generation) return;
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 753 await session.seed(seed);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 754
755 const plan = session.describe();
756 elCompiled.textContent =
757 `one step compiled to ${plan.step.length} GPU operations:\n` +
758 plan.step.map((l) => ` ${l}`).join('\n');
759 elRecompile.textContent = 'Recompile';
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 761 // Scale the frame batch and the measurement burst down — never up — so
762 // neither submission's total dispatch count exceeds DISPATCH_BUDGET, no
763 // matter how expensive niter has made one step. See STEPS_PER_FRAME_BASE.
764 const opsPerStep = Math.max(1, plan.step.length);
765 stepsPerFrame = Math.max(1, Math.min(STEPS_PER_FRAME_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
766 measureBurst = Math.max(1, Math.min(MEASURE_BURST_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 768 const surface = await session.renderPositions();
769 if (gen !== generation) return;
771 ranges = [];
772 buildView(surface);
774 await draw();
775 updateGeomNote();
776 updateStats();
777 void pump();
778}
780async function reseed(): Promise<void> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 781 // One new perturbation for the whole study, band-limited at its coarsest
782 // variant and evaluated on each grid — see src/compare/sharedStart.ts.
783 if (compareRun) return compareRun.reseed(seed);
785 const gen = generation;
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 786 await session.seed(seed);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 787 if (gen !== generation) return;
788 for (const r of ranges) {
789 r.lo = NaN;
790 r.hi = NaN;
791 }
792 await draw();
793 updateStats();
794}
796// ---------------------------------------------------------------- drawing
797async function draw(): Promise<void> {
798 if (!session || !topo) return;
799 const gen = generation;
800 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
801 for (let k = 0; k < model.species.length; k++) {
802 // The one readback per frame — the loop is otherwise entirely on the GPU.
803 // A rebuild can land while this is in flight and destroy the buffer being
804 // mapped, which rejects the map; that result is stale anyway, so drop it.
805 let field: Float32Array;
806 try {
807 field = await session.readSpecies(k);
808 } catch (e) {
809 if (gen !== generation) return;
810 throw e;
811 }
812 if (gen !== generation || !topo) return;
813 fillFieldValues(valueBufs[k], field, topo);
814 let lo = Infinity;
815 let hi = -Infinity;
816 for (const v of valueBufs[k]) {
817 if (v < lo) lo = v;
818 if (v > hi) hi = v;
819 }
820 // smooth the color range in both directions so the shading evolves
821 // gently as the pattern grows (out-of-range values clamp meanwhile)
822 const r = ranges[k];
823 if (!Number.isFinite(r.lo)) {
824 r.lo = lo;
825 r.hi = hi;
826 } else {
827 const a = 0.15;
828 r.lo += a * (lo - r.lo);
829 r.hi += a * (hi - r.hi);
830 }
ef3ae33Do not stretch the colormap across a constant field's roundoffJeremy Magland 831 // A field that is uniform to fp32 precision — Schnakenberg's v at t = 0 is
832 // exactly constant — would otherwise have the colormap stretched across its
833 // roundoff and be drawn as vivid noise. See floorRange.
834 const shown = floorRange(r.lo, r.hi);
835 fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, cmap);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 836 scenes[k]?.updateColors(colorBufs[k]);
ef3ae33Do not stretch the colormap across a constant field's roundoffJeremy Magland 837 colorbars[k]?.update(cmap, shown.lo, shown.hi);
839}
841function updateStats(): void {
842 if (!session) return;
843 const { nlat, nphi } = session.cfg;
844 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
845 const solver =
846 solverMs > 0
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 847 ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s)`
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 849 const frame = frameMs > 0 ? `${frameMs.toFixed(1)} ms/frame` : '—';
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 850 const view = session.viewSht.cfg;
851 const render =
852 session.oversample > 1
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 853 ? ` (display ${view.nlat}×${view.nphi})`
855 elStats.innerHTML =
856 `<b>${kind}</b> · grid ${nlat}×${nphi}${render} · nlm ${session.sht.nlm.toLocaleString()} · ` +
857 `solver ${solver} · ${frame} · ` +
858 `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
859}
861// ---------------------------------------------------------------- sim loop
862const nextFrame = () => new Promise<number>(requestAnimationFrame);
864async function pump(): Promise<void> {
865 if (pumping) return;
866 pumping = true;
867 const gen = generation;
868 try {
869 while (running && session && gen === generation) {
870 // Occasionally, a burst purely to measure the solver rate: many steps,
871 // one sync, nothing read back — directly comparable to the desktop
872 // benchmark's throughput number. State-preserving: the display and
873 // model time are unaffected.
874 if (performance.now() - lastMeasure > MEASURE_EVERY_MS) {
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 875 const ms = await session.measure(measureBurst);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 876 if (gen !== generation) break;
877 solverMs = ms;
878 lastMeasure = performance.now();
879 }
881 // The frame itself. No explicit sync here — draw()'s readback already
882 // waits for the steps, so asking twice would only add a round trip.
883 const t0 = performance.now();
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 884 session.step(stepsPerFrame);
886 if (gen !== generation) break;
887 frameMs = frameMs === 0
888 ? performance.now() - t0
889 : frameMs + 0.05 * (performance.now() - t0 - frameMs);
890 updateStats();
891 await nextFrame();
892 }
893 if (gen === generation) {
894 await draw();
895 updateStats();
896 }
897 } finally {
898 pumping = false;
899 }
900}
902/**
903 * Sustained solver benchmark, in the page.
904 *
905 * The same measurement `npm run bench` makes: batches of steps submitted
906 * together, waited for, never read back, with no rendering and no animation
907 * pacing in between. That makes it directly comparable to the terminal number,
908 * which is the only way to tell a genuinely slower browser GPU stack apart from
909 * the costs the app adds on top.
910 *
911 * It also reports the ramp — the first third of the run against the last. GPUs
912 * downclock when idle, and an animation-paced loop leaves them idle most of every
913 * frame, so a large ramp means the app's steady-state number is limited by clocks
914 * rather than by the work.
915 *
916 * These are ordinary steps: the simulation advances by them.
917 */
918async function benchmark(): Promise<void> {
919 if (!session || movieBusy) return;
920 setRunning(false);
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 921 // Same base size and the same DISPATCH_BUDGET scaling as the automatic
922 // measurement burst (see STEPS_PER_FRAME_BASE) — this is a user-triggered
923 // 32-step submission, exactly the shape of thing that risks a browser's
924 // GPU-process watchdog on weak hardware once niter makes a step expensive.
925 const BATCH = measureBurst;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 926 const DURATION_MS = 2000;
927 elBenchResult.textContent = 'benchmarking…';
928 // A movie started mid-benchmark would replay while this loop still steps.
929 elMovie.disabled = true;
930 try {
931 await nextFrame();
933 const gen = generation;
934 const perStep: number[] = [];
935 const t0 = performance.now();
936 while (performance.now() - t0 < DURATION_MS) {
937 const b0 = performance.now();
938 session.step(BATCH);
939 await session.sync();
940 if (gen !== generation) return;
941 perStep.push((performance.now() - b0) / BATCH);
942 }
944 const mean = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
945 const all = mean(perStep);
946 const best = Math.min(...perStep);
947 const third = Math.max(1, Math.floor(perStep.length / 3));
948 const first = mean(perStep.slice(0, third));
949 const last = mean(perStep.slice(-third));
950 const steps = perStep.length * BATCH;
952 elBenchResult.innerHTML =
953 `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
954 `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 955 `ramp ${(first / last).toFixed(2)}× · ${steps} steps · ` +
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 956 `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
957 await draw();
958 updateStats();
959 } finally {
960 elMovie.disabled = false;
961 }
962}
964// ---------------------------------------------------------------- movie
965function saveBlob(blob: Blob, filename: string): void {
966 const url = URL.createObjectURL(blob);
967 const a = document.createElement('a');
968 a.href = url;
969 a.download = filename;
970 a.click();
971 setTimeout(() => URL.revokeObjectURL(url), 10_000);
972}
974/** Submit `n` steps in bounded command buffers — a single buffer encoding
975 * many thousands of steps can exhaust the encoder. */
976function submitSteps(n: number): void {
977 while (n > 0 && session) {
978 const chunk = Math.min(512, n);
979 session.step(chunk);
980 n -= chunk;
981 }
982}
984/** While recording, lock everything that could change the run mid-replay;
985 * the Movie button itself becomes the cancel button. */
986function setMovieUi(on: boolean): void {
987 const locked = [
988 elModel, elGeometry, elMorph, elNiter, elLmax, elOversample, elColormap,
989 elRunPause, elBenchmark, elReseed, elRecompile, elRevert, elEditorFile,
990 elMovieSpeed, elMovieRes, elMovieRotate, elMovieToggle,
991 ];
992 for (const el of locked) el.disabled = on;
993 elParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
994 elGeomParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
995 elMovie.textContent = on ? 'Cancel · 0%' : 'Export';
996}
998/**
999 * Recompute the run from t = 0 and download it as an MP4.
1000 *
1001 * The movie is not a recording of what already happened — it is the same
1002 * trajectory recomputed: same seed, same source, and the *current* parameters
1003 * and colormap throughout. Determinism makes this exact: after the replay the
1004 * state is where it was, so the one session is reused and the app resumes as
1005 * if nothing happened. Frames are composited from the live panels, so the
1006 * movie shows the spheres at the current camera orientation — and the replay
1007 * doubles as the progress display, since it is visible on screen.
1008 */
1009async function recordMovie(): Promise<void> {
1010 if (!session || movieBusy) return;
1011 if (session.steps === 0) {
1012 elMovie.textContent = 'run first';
1013 setTimeout(() => (elMovie.textContent = 'Export'), 1200);
1014 return;
1015 }
1016 movieBusy = true;
1017 movieCancel = false;
1018 const gen = generation;
1019 setMovieUi(true);
1020 let wasRunning = false;
1021 let total = 0;
1022 let done = 0;
1023 let seeded = false;
1024 let camBefore: ReturnType<SphereScene['cameraState']> | undefined;
1025 try {
1026 // An in-flight display-grid swap replaces the scenes whose canvases the
1027 // recorder captures, and resumes the run when it lands — let it finish.
1028 await viewChange;
1029 if (gen !== generation || !session) return;
1030 wasRunning = running;
1031 setRunning(false);
1032 while (pumping) await nextFrame(); // let an in-flight live frame drain
1033 if (gen !== generation || !session) return;
1034 total = session.steps;
1035 const speed = Number(elMovieSpeed.value) || 10;
1036 const sphere = Number(elMovieRes.value) || 768;
1037 const rotate = elMovieRotate.checked;
1038 if (rotate) camBefore = scenes[0]?.cameraState();
1039 // Render the scenes at exactly the chosen resolution for the recording —
1040 // independent of the window size — and restore afterwards.
1041 for (const s of scenes) s.captureSize(sphere);
1042 const durationS = Math.max(session.t / speed, 2 / MOVIE_FPS);
1043 const frames = Math.max(
1044 2,
1045 Math.min(Math.round(durationS * MOVIE_FPS) + 1, total + 1, MOVIE_MAX_FRAMES),
1046 );
1047 /** The step index captured as frame `i`; both endpoints land exactly. */
1048 const stepAt = (i: number): number => Math.round((i * total) / (frames - 1));
1050 const title =
1051 (presets.find((p) => p.key === elModel.value)?.label ?? model.label) +
1052 ` on ${geometry.label.toLowerCase()}` +
1053 (editedSource !== null || editedGeomSource !== null ? ' (edited)' : '');
1054 const subtitle = model.params
1055 .map((spec) => `${spec.label} ${fmtValue(params[spec.key])}`)
1056 .join(' · ');
1057 const rec = await MovieRecorder.create({
1058 panels: model.species.map((label, k) => ({ canvas: scenes[k].canvas, label })),
1059 title,
1060 subtitle,
1061 speed,
1062 fps: (frames - 1) / durationS,
1063 sphere,
1064 });
1066 let finished = false;
1067 try {
1068 // Reset the color-range smoothing as a re-seed does, so the shading
1069 // evolves in the movie the way it did live.
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 1070 await session.seed(seed);
1072 for (const r of ranges) {
1073 r.lo = NaN;
1074 r.hi = NaN;
1075 }
1076 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
1077 let lastVideoS = 0;
1078 for (let frame = 0; ; ) {
1079 await draw();
1080 if (gen !== generation) return;
1081 if (movieCancel) break;
1082 if (rotate) {
1083 // Advance the orbit by this frame's share of video time; siblings
1084 // follow scenes[0] through the usual camera sync.
1085 const videoS = session.t / speed;
1086 scenes[0]?.orbitBy(2 * Math.PI * MOVIE_ROTATE_RPS * (videoS - lastVideoS));
1087 lastVideoS = videoS;
1088 }
1089 for (const s of scenes) s.renderNow();
1090 await rec.addFrame(
1091 session.t,
1092 model.species.map((_, k) => ({ cmap, lo: ranges[k].lo, hi: ranges[k].hi })),
1093 );
1094 if (++frame >= frames) {
1095 finished = true;
1096 break;
1097 }
1098 const target = stepAt(frame);
1099 submitSteps(target - done);
1100 done = target;
1101 elMovie.textContent = `Cancel · ${Math.round((100 * done) / total)}%`;
1102 }
1103 if (finished) {
1104 const blob = await rec.finish();
1105 saveBlob(
1106 blob,
1107 `turing-surface-${model.key}-${geometry.key}-` +
1108 `t${session.t.toFixed(2)}-${speed}x.mp4`,
1109 );
1110 }
1111 } finally {
1112 if (!finished) rec.cancel();
1113 }
1114 } catch (e) {
1115 elErr.textContent = `movie: ${e instanceof Error ? e.message : e}`;
1116 } finally {
1117 // A cancelled replay stopped short of where the run was; step the
1118 // remainder — determinism makes this land exactly there.
1119 if (seeded && gen === generation && session) {
1120 while (done < total && gen === generation && session) {
1121 const n = Math.min(4096, total - done);
1122 submitSteps(n);
1123 done += n;
1124 elMovie.textContent = `restoring · ${Math.round((100 * done) / total)}%`;
1125 await session.sync();
1126 }
1127 await draw();
1128 updateStats();
1129 }
1130 if (gen === generation) {
1131 for (const s of scenes) s.restoreSize();
1132 }
1133 if (camBefore && gen === generation) {
1134 for (const s of scenes) s.setCameraState(camBefore);
1135 }
1136 movieBusy = false;
1137 setMovieUi(false);
1138 if (gen === generation) setRunning(wasRunning);
1139 }
1140}
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1142// ---------------------------------------------------------------- compare
1143/**
1144 * Comparing several solver settings at once.
1145 *
1146 * Deliberately a mode rather than a widening of the ordinary controls: the
1147 * single-run path above is untouched, and with the bar closed nothing about
1148 * using this page has changed. Opening it and pressing Compare tears down the
1149 * one session and hands the panels area to a CompareRun, which owns a session
1150 * per variant; pressing it again puts the single run back.
1151 *
1152 * The ceilings below are not arbitrary. Each variant compiles its whole
1153 * unrolled step with no pipeline cache between sessions (a solve iteration is
1154 * ~15 kernels per species), so the variant count is what you wait for; and
1155 * each panel is a WebGL context and a full mesh, so the panel count is what
1156 * the browser has to keep alive at once.
1157 */
1158const MAX_VARIANTS = 6;
1159const MAX_PANELS = 12;
1160/** dt divisors. Powers of two so that dtBase/K is exact in binary and every
1161 * variant lands on the same model time with no accumulated drift. */
1162const DT_DIVISORS = [1, 2, 4, 8];
1164/**
1165 * What the bar opens on: the default iteration count against the next step up,
1166 * at the default band. Two variants, so the first study is quick to compile,
1167 * and it asks the question the control exists for — is the default already
1168 * converged? A flat, low curve says yes; one that climbs says the answer is
1169 * still moving at niter 8 and the default is not enough for this shape.
1170 */
1171const cmpSelected = {
1172 niter: new Set<number>([DEFAULT_NITER, 2 * DEFAULT_NITER]),
1173 lmax: new Set<number>([63]),
1174 dt: new Set<number>([1]),
1175};
1177/** A row of toggle chips backed by a Set. At least one stays selected — an
1178 * empty axis has no meaning here, and silently falling back to a default
1179 * would hide which values are actually being run. */
1180function buildChips(host: HTMLElement, values: number[], selected: Set<number>, label: (v: number) => string): void {
1181 host.replaceChildren();
1182 for (const value of values) {
1183 const chip = document.createElement('button');
1184 chip.type = 'button';
1185 chip.className = 'chip';
1186 chip.textContent = label(value);
1187 const paint = (): void => chip.setAttribute('aria-pressed', String(selected.has(value)));
1188 paint();
1189 chip.addEventListener('click', () => {
1190 if (selected.has(value)) {
1191 if (selected.size === 1) return;
1192 selected.delete(value);
1193 } else {
1194 selected.add(value);
1195 }
1196 paint();
1197 refreshVariants();
1198 });
1199 host.append(chip);
1200 }
1201}
1203const cmpVariants = (): Variant[] =>
1204 crossProduct([...cmpSelected.niter], [...cmpSelected.lmax], [...cmpSelected.dt]);
1206/** The reference the user picked, clamped to the current variant list. */
1207let cmpRefKey = '';
1209/** Index of the reference in the current variant list, never negative. */
1210function compareRefIndex(): number {
1211 const i = cmpVariants().map(variantKey).indexOf(cmpRefKey);
1212 return i < 0 ? 0 : i;
1213}
1215function refreshVariants(): void {
1216 const variants = cmpVariants();
1217 const showDt = cmpSelected.dt.size > 1;
1218 const panels = variants.length * model.species.length;
1220 const prev = cmpRefKey;
1221 elCmpRef.replaceChildren();
1222 for (const v of variants) {
1223 const o = document.createElement('option');
1224 o.value = variantKey(v);
1225 o.textContent = variantLabel(v, showDt);
1226 elCmpRef.append(o);
1227 }
1228 const keys = variants.map(variantKey);
1229 cmpRefKey = keys.includes(prev) ? prev : keys[mostResolved(variants)];
1230 elCmpRef.value = cmpRefKey;
1232 const tooMany =
1233 variants.length > MAX_VARIANTS
1234 ? `${variants.length} variants — at most ${MAX_VARIANTS}`
1235 : panels > MAX_PANELS
1236 ? `${panels} panels — at most ${MAX_PANELS}`
1237 : '';
1238 elCmpCount.textContent = tooMany
1239 ? `too many: ${tooMany}`
1240 : `${variants.length} variants × ${model.species.length} species = ${panels} panels`;
1241 elCmpCount.style.color = tooMany ? '#b35900' : '';
1242 elCmpStart.disabled = tooMany !== '' && compareRun === null;
1243}
1245buildChips(
1246 elCmpNiter,
1247 [...elNiter.options].map((o) => Number(o.value)),
1248 cmpSelected.niter,
1249 String,
1250);
1251buildChips(
1252 elCmpLmax,
1253 [...elLmax.options].map((o) => Number(o.value)),
1254 cmpSelected.lmax,
1255 String,
1256);
1257buildChips(elCmpDt, DT_DIVISORS, cmpSelected.dt, (v) => (v === 1 ? 'dt' : `dt/${v}`));
1258refreshVariants();
1260elCmpRef.addEventListener('change', () => {
1261 cmpRefKey = elCmpRef.value;
1262 if (compareRun) void rebuildCompare();
1263});
1265elCompareToggle.addEventListener('click', () => {
1266 elCompareBar.hidden = !elCompareBar.hidden;
1267});
1269elCmpStart.addEventListener('click', () => {
1270 if (compareRun) void stopCompare();
1271 else void startCompare();
1272});
1274/** Controls the study supersedes or cannot honour while it is running. */
1275function setCompareUi(on: boolean): void {
1276 for (const el of [elNiter, elLmax, elOversample, elBenchmark, elMovieToggle]) {
1277 el.disabled = on;
1278 }
1279 elCmpNiter.querySelectorAll('button').forEach((b) => (b.disabled = on));
1280 elCmpLmax.querySelectorAll('button').forEach((b) => (b.disabled = on));
1281 elCmpDt.querySelectorAll('button').forEach((b) => (b.disabled = on));
1282 elCmpStart.textContent = on ? 'Stop comparing' : 'Compare';
1283 elCompareToggle.textContent = on ? 'Comparing' : 'Compare';
1284 if (on) elMovieBar.hidden = true;
1285}
1287async function startCompare(): Promise<void> {
1288 if (compareRun || !device) return;
1289 const variants = cmpVariants();
1290 if (variants.length > MAX_VARIANTS || variants.length * model.species.length > MAX_PANELS) {
1291 return;
1292 }
1293 // Take down the single run first: its pump, its scenes, its session. The
1294 // generation bump makes any readback already in flight drop its result.
1295 generation++;
1296 setRunning(false);
1297 while (pumping) await nextFrame();
1298 disposeView();
1299 session?.destroy();
1300 session = null;
1301 elBenchResult.textContent = '';
1302 elErr.textContent = '';
1303 setCompareUi(true);
1305 try {
1306 compareRun = await CompareRun.create({
1307 device,
1308 model,
1309 params,
1310 source: source(),
1311 geometry,
1312 geometryParams: geomParams,
1313 geometrySource: geomSource(),
1314 variants,
1315 reference: compareRefIndex(),
1316 seed,
1319 colormapName: () => elColormap.value,
1320 container: elPanels,
1321 onStatus: (html) => (elStats.innerHTML = html),
1322 });
1323 } catch (e) {
1324 compareRun = null;
1325 setCompareUi(false);
1326 refreshVariants();
1327 reportCompileError(e);
1328 await rebuild();
1329 return;
1330 }
1331 updateGeomNote();
1332 // The command describes the reference variant, which only exists now.
1333 updateCommand();
1334 elRunPause.textContent = 'Run';
1335}
1337async function stopCompare(): Promise<void> {
1338 if (!compareRun) return;
1339 compareRun.dispose();
1340 compareRun = null;
1341 setCompareUi(false);
1342 refreshVariants();
1343 elStats.textContent = '';
1344 await rebuild();
1345}
1347/** Rebuild the study in place — after a model, geometry, source or reference
1348 * change. Same teardown as stopping, without leaving the mode. */
1349async function rebuildCompare(): Promise<void> {
1350 if (!compareRun) return;
1351 compareRun.dispose();
1352 compareRun = null;
1353 setCompareUi(false);
1354 await startCompare();
1355}
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1357// ---------------------------------------------------------------- boot
1358async function boot(): Promise<void> {
1359 elModel.value = presets[0].key;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1360 // The iteration count is one default shared with the benchmark, like the
1361 // rest of the RunSpec's — take it from there rather than from the markup, so
1362 // the page and `npm run bench` cannot start out disagreeing about it.
1363 elNiter.value = String(DEFAULT_NITER);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1364 elGeometry.value = DEFAULT_GEOMETRY_KEY;
1365 elMorph.value = String(morph);
1366 applyGeometryChoice(DEFAULT_GEOMETRY_KEY);
1367 applyPreset(presets[0].key);
1368 try {
1369 device = await requestShtDevice();
1370 adapterName = await describeAdapter(device);
1371 } catch (e) {
1372 device = null;
1373 elErr.textContent =
1374 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 1375 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1377 }
1378 device.lost.then((info) => {
1379 if (info.reason !== 'destroyed') {
1380 elErr.textContent = `WebGPU device lost: ${info.message}`;
1381 }
1382 });
1383 await rebuild();
1384}
1386void boot();