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';
c90d0e2Check reference files in the browser's compare modeJeremy Magland 42import { loadReferenceFile } from './compare/referenceFile.ts';
43import type { ReferenceCase } from './compare/referenceCase.ts';
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 44
45const $ = <T extends HTMLElement>(id: string): T =>
46 document.getElementById(id) as T;
48const elModel = $<HTMLSelectElement>('model');
49const elGeometry = $<HTMLSelectElement>('geometry');
50const elMorph = $<HTMLInputElement>('morph');
51const elNiter = $<HTMLSelectElement>('niter');
52const elLmax = $<HTMLSelectElement>('lmax');
53const elOversample = $<HTMLSelectElement>('oversample');
54const elColormap = $<HTMLSelectElement>('colormap');
55const elRunPause = $<HTMLButtonElement>('runpause');
ca37955Adding a way to reset simulation from same random IC.Owen Melia 56const elRestart = $<HTMLButtonElement>('restart');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 57const elBenchmark = $<HTMLButtonElement>('benchmark');
58const elReseed = $<HTMLButtonElement>('reseed');
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 59const elLam3 = $<HTMLInputElement>('lam3');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 60const elResetView = $<HTMLButtonElement>('resetview');
61const elMovieToggle = $<HTMLButtonElement>('movietoggle');
62const elMovieBar = $('moviebar');
63const elMovieSpeed = $<HTMLSelectElement>('moviespeed');
64const elMovieRes = $<HTMLSelectElement>('movieres');
65const elMovieRotate = $<HTMLInputElement>('movierotate');
66const elMovie = $<HTMLButtonElement>('movie');
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 67const elModeSimulate = $<HTMLButtonElement>('mode-simulate');
68const elModeEffort = $<HTMLButtonElement>('mode-effort');
69const elModeVsUpload = $<HTMLButtonElement>('mode-vs-upload');
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 70const elCompareBar = $('comparebar');
71const elCmpNiter = $('cmp-niter');
72const elCmpLmax = $('cmp-lmax');
73const elCmpDt = $('cmp-dt');
74const elCmpRef = $<HTMLSelectElement>('cmp-ref');
c90d0e2Check reference files in the browser's compare modeJeremy Magland 75const elCmpFile = $<HTMLInputElement>('cmp-file');
76const elCmpFileInfo = $('cmp-fileinfo');
77const elCmpFileClear = $<HTMLButtonElement>('cmp-fileclear');
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 78const elCmpStart = $<HTMLButtonElement>('cmp-start');
79const elCmpCount = $('cmp-count');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 80const elParams = $('params');
81const elGeomParams = $('geomparams');
82const elGeomNote = $('geomnote');
83const elPanels = $('panels');
84const elStats = $('stats');
85const elBenchResult = $('benchresult');
86const elCmd = $('cmd');
87const elCopyCmd = $<HTMLButtonElement>('copycmd');
88const elBlurb = $('blurb');
89const elErr = $('err');
90const elSource = $<HTMLTextAreaElement>('source');
91const elHighlight = $('highlight');
92const elCompiled = $('compiled');
93const elEditorTitle = $('editor-title');
94const elEditorFile = $<HTMLSelectElement>('editor-file');
95const elRecompile = $<HTMLButtonElement>('recompile');
96const elRevert = $<HTMLButtonElement>('revert');
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 98/** The named groups the control area is organized into (index.html's
99 * `.ctrl-group[data-group]` wrappers). Each mode shows a declared subset of
100 * these — see MODE_GROUPS and applyModeVisibility below. */
101const GROUP_NAMES = [
102 'surface', 'surface-params', 'solver', 'display',
103 'playback', 'benchmark', 'seed', 'movie',
104] as const;
105type GroupName = (typeof GROUP_NAMES)[number];
106const groupEls: Record<GroupName, HTMLElement> = Object.fromEntries(
107 GROUP_NAMES.map((name) => [
108 name,
109 document.querySelector(`.ctrl-group[data-group="${name}"]`) as HTMLElement,
110 ]),
111) as Record<GroupName, HTMLElement>;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 113for (const p of presets) {
114 const o = document.createElement('option');
115 o.value = p.key;
116 o.textContent = p.label;
117 elModel.append(o);
118}
119for (const g of mGeometries) {
120 const o = document.createElement('option');
121 o.value = g.key;
122 o.textContent = g.label;
123 elGeometry.append(o);
124}
125for (const [value, label] of [['model', 'the solver'], ['geometry', 'the surface']]) {
126 const o = document.createElement('option');
127 o.value = value;
128 o.textContent = label;
129 elEditorFile.append(o);
130}
131for (const name of colormapNames) {
132 const o = document.createElement('option');
133 o.value = name;
134 o.textContent = name;
135 elColormap.append(o);
136}
137elColormap.value = 'jet';
139/** Whichever .m is open: the solver or the surface. Both are MATLAB, compiled
140 * by the same backend, so one editor serves both. The host-provided operations
141 * are marked so the boundary between the file and what it is given is
142 * visible. */
143const editor = new CodeEditor({
144 textarea: elSource,
145 overlay: elHighlight,
146 external: EXTERNAL_OPS,
147 onInput: (value) => {
148 if (editing === 'geometry') editedGeomSource = value;
149 else editedSource = value;
150 elRecompile.textContent = 'Recompile *';
151 },
152});
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 154/**
155 * Timesteps submitted per rendered frame, at most. Nothing is read back
156 * between them, so the batch costs one submit and one readback regardless of
157 * size — but a compute pass is still real GPU work, and a browser's GPU
158 * process enforces a watchdog timeout a headless desktop run does not: a
159 * submission with enough dispatches in it can trip "device lost" outright,
160 * on weak-enough hardware, well before it would ever show up as merely slow.
161 * The `for k = 1:niter` correction loop makes a step's dispatch count scale
162 * with niter (each iteration is ~15 dispatches per species — see
163 * models/schnakenberg.m), so a fixed per-frame step count that was safe when
164 * every model's step was a handful of dispatches is not safe once niter is
165 * large. `stepsPerFrame`/`measureBurst` below scale it down — never up, so
166 * the common case does not change — to keep one submission's total dispatch
167 * count under DISPATCH_BUDGET regardless of how expensive the compiled step
168 * is.
169 */
170const STEPS_PER_FRAME_BASE = 4;
171/** See STEPS_PER_FRAME_BASE. Recomputed per rebuild in `rebuild()`. */
172let stepsPerFrame = STEPS_PER_FRAME_BASE;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 173
174/**
175 * Steps in a solver-timing burst, and how often to run one.
176 *
177 * Timing the solver needs a `queue.onSubmittedWorkDone()` to know the work
178 * finished, and in a browser that is an IPC round trip into the GPU process — a
179 * fixed cost of a few milliseconds. Spread over one frame's four steps it would
180 * swamp them on a fast GPU and make the solver look far slower than it is. So the
181 * rate is measured in an occasional larger batch, where the single sync is
182 * amortized the way the desktop benchmark amortizes its own. The state is
183 * snapshotted and restored around the batch, so measuring never advances the
184 * simulation — otherwise the pattern would visibly lurch forward at every
185 * measurement.
186 */
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 187const MEASURE_BURST_BASE = 32;
188/** See STEPS_PER_FRAME_BASE — the measurement burst is one submission too,
189 * and a bigger one: 32 steps is the single largest batch this app ever
190 * submits, so it is the first thing to cross DISPATCH_BUDGET as niter grows. */
191let measureBurst = MEASURE_BURST_BASE;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 192const MEASURE_EVERY_MS = 2000;
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 194/**
195 * Upper bound on dispatches in one submission — the frame batch and the
196 * measurement burst are both scaled down to stay under this, never up, so
197 * a cheap model's pacing is unchanged. Chosen well under what this project's
198 * own desktop benchmark measures as trivially fast (single-digit ms even at
199 * niter=8's ~450 dispatches/step), because the risk here is not GPU time on
200 * capable hardware — it is a browser's GPU-process watchdog on weak
201 * (integrated-graphics) hardware, which a headless desktop run never
202 * exercises and this project has no way to benchmark directly.
203 */
204const DISPATCH_BUDGET = 1000;
207 * 'auto' display oversampling targets this many render latitudes: the factor is
208 * the smallest power of two (up to 4) that reaches it. A solver grid already
209 * this fine gains nothing visually and is not oversampled.
210 */
211const AUTO_RENDER_NLAT = 256;
213/** The display oversampling factor the UI currently asks for. */
214function resolveOversample(): number {
215 if (elOversample.value !== 'auto') return Number(elOversample.value);
216 const { nlat } = gridForLmax(Number(elLmax.value), model.pdeg);
217 let os = 1;
218 while (os < 4 && os * nlat < AUTO_RENDER_NLAT) os *= 2;
219 return os;
220}
222/**
223 * Movie frame rate, and a cap on frames per movie. Playback speed comes from
224 * the UI, in simulation-time units per second of video; the movie's length is
225 * the run's t at that speed, and the frame count follows from it — capped by
226 * the run's own step count (a step is at most one frame) and by
227 * MOVIE_MAX_FRAMES to bound encode time and file size. Frame timestamps are
228 * derived from simulation time, so a capped movie keeps its duration and
229 * speed exactly, at a lower effective frame rate.
230 */
231const MOVIE_FPS = 30;
232const MOVIE_MAX_FRAMES = 3600;
234/** Movie auto-rotation: camera revolutions per second of video. Measured in
235 * video time, so the orbit pace on screen is the same at every export speed. */
236const MOVIE_ROTATE_RPS = 1 / 120;
238// ---------------------------------------------------------------- state
239let device: GPUDevice | null = null;
240let session: ModelSession | null = null;
241let topo: SphereMeshTopology | null = null;
242let scenes: SphereScene[] = [];
243let colorbars: Colorbar[] = [];
244let valueBufs: Float32Array[] = [];
245let colorBufs: Float32Array[] = [];
246let ranges: { lo: number; hi: number }[] = [];
247let resizeObs: ResizeObserver | null = null;
249const initial = resolvePreset(presets[0].key);
250let model: MModel = mModelByKey(initial.model.key)!;
251let params: Params = initial.params;
252let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
253let geomParams: Params = defaultGeometryParams(geometry);
254/** Which file the editor is showing. */
255let editing: 'model' | 'geometry' = 'model';
256/** Each .m as edited in the page; `null` while it matches the file. */
257let editedSource: string | null = null;
258let editedGeomSource: string | null = null;
259/** Sphere (0) to surface (1). Display only; does not touch the solver. */
260let morph = 1;
261let seed = 1;
262let running = false;
263let adapterName = '';
264let pumping = false;
265let movieBusy = false;
266let movieCancel = false;
267let solverMs = 0;
268let frameMs = 0;
269let lastMeasure = 0;
270let generation = 0; // bumped on every rebuild to cancel stale pumps
271/** Surface coordinates on the render grid, interleaved xyz; null before the
272 * first build. Kept so the morph slider can re-fill positions without
273 * re-synthesizing. */
274let coords: Float32Array | null = null;
275let posBuf: Float32Array | null = null;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 276/** The convergence study, when one is running; null in ordinary single-run
277 * mode. While it is non-null there is no `session`: the study owns one per
278 * variant, and the panels area is its grid. */
279let compareRun: CompareRun | null = null;
ca37955Adding a way to reset simulation from same random IC.Owen Melia 280/** `session`'s spectral state as of the last (re-)seed — what "Restart"
281 * rewinds to. Captured fresh each time a new field is actually established
282 * (rebuild/reseed), not just once, so Restart reflects the run's current
283 * starting point rather than permanently the very first draw. */
284let initialState: Record<string, Float32Array> | null = null;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 285
286const source = (): string => editedSource ?? model.source;
287const geomSource = (): string => editedGeomSource ?? geometry.source;
289// ---------------------------------------------------------------- UI wiring
290function buildParamInputs(): void {
291 elParams.replaceChildren();
293 const tag = document.createElement('label');
294 tag.textContent = 'model parameters';
295 elParams.append(tag);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 296 for (const spec of model.params) {
297 const label = document.createElement('label');
298 label.textContent = `${spec.label} `;
299 const input = document.createElement('input');
300 input.type = 'number';
301 input.min = String(spec.min);
302 input.max = String(spec.max);
303 input.step = String(spec.step);
304 input.value = String(params[spec.key]);
305 input.addEventListener('change', () => {
306 const v = Number(input.value);
307 if (Number.isFinite(v)) params[spec.key] = v;
308 // Parameters are uniforms, not constants baked into the kernels, so a
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 309 // change costs an upload rather than a recompile. In compare mode `dt`
310 // is the *base* timestep each variant's divisor divides, so the study
311 // re-derives every variant's dt from it.
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 312 session?.setParams(params);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 313 compareRun?.setParams(params);
315 });
316 label.append(input);
317 elParams.append(label);
318 }
319}
321/**
322 * The shape's own parameters. Unlike the model's, these are NOT uniforms: the
323 * surface is evaluated once at build time and reduced to coefficients, so
324 * moving one rebuilds the geometry (and with it the mesh), though not the
325 * simulation's compiled step.
326 */
327function buildGeomParamInputs(): void {
328 elGeomParams.replaceChildren();
329 if (geometry.params.length === 0) return;
330 const tag = document.createElement('label');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 332 elGeomParams.append(tag);
333 for (const spec of geometry.params) {
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 334 // A random seed picks a draw and means nothing on its own, so it gets a
335 // button to the next one rather than a box to type a number into. The
336 // shape changes; the simulation running on it does not restart.
337 if (spec.reseed) {
338 const button = document.createElement('button');
339 button.textContent = 'Re-seed shape';
340 button.title =
341 `Draw another ${geometry.label.toLowerCase()} — a new random surface, ` +
342 `leaving the pattern running on it alone.`;
343 button.addEventListener('click', () => {
344 const span = spec.max - spec.min;
345 let next = geomParams[spec.key];
346 // Never hand back the shape that is already on screen.
347 while (next === geomParams[spec.key]) {
348 next = spec.min + Math.floor(Math.random() * (span + 1));
349 }
350 geomParams[spec.key] = next;
351 viewChange = viewChange.then(() => applyGeometry());
352 });
353 elGeomParams.append(button);
354 continue;
355 }
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 356 const label = document.createElement('label');
357 label.textContent = `${spec.label} `;
358 const input = document.createElement('input');
359 input.type = 'number';
360 input.min = String(spec.min);
361 input.max = String(spec.max);
362 input.step = String(spec.step);
363 input.value = String(geomParams[spec.key]);
364 input.addEventListener('change', () => {
365 const v = Number(input.value);
366 if (Number.isFinite(v)) geomParams[spec.key] = v;
367 viewChange = viewChange.then(() => applyGeometry());
368 });
369 label.append(input);
370 elGeomParams.append(label);
371 }
372}
374function applyPreset(presetKey: string): void {
375 const resolved = resolvePreset(presetKey);
376 const next = mModelByKey(resolved.model.key);
377 if (!next) {
378 elErr.textContent = `No .m model for '${resolved.model.key}'`;
379 return;
380 }
381 model = next;
382 params = resolved.params;
383 editedSource = null;
384 buildParamInputs();
385 elBlurb.textContent = model.blurb;
386 showEditorFile();
387 updateCommand();
388}
390function applyGeometryChoice(key: string): void {
391 const next = mGeometryByKey(key);
392 if (!next) {
393 elErr.textContent = `No .m geometry for '${key}'`;
394 return;
395 }
396 geometry = next;
397 geomParams = defaultGeometryParams(geometry);
398 editedGeomSource = null;
399 buildGeomParamInputs();
400 showEditorFile();
401}
403/** Load the chosen file into the editor, keeping any unsaved edit to it. */
404function showEditorFile(): void {
405 editing = elEditorFile.value === 'geometry' ? 'geometry' : 'model';
406 if (editing === 'geometry') {
407 editor.value = geomSource();
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 408 elEditorTitle.textContent = `geometries/${geometry.key}.m`;
410 editor.value = source();
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 411 elEditorTitle.textContent = `models/${model.key}.m`;
413}
416 * The run currently on screen, as the benchmark's RunSpec. While a study is
417 * running there is no single run, so this describes its *reference* variant —
418 * the one the other rows are measured against, and the only one of them whose
419 * numbers mean anything on their own.
420 */
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 421function currentSpec(): RunSpec {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 422 const ref = compareRun?.variants[compareRefIndex()];
423 const dt = ref ? { dt: (params.dt ?? 0) / ref.dtDiv } : null;
425 preset: elModel.value,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 426 lmax: ref ? ref.lmax : Number(elLmax.value),
428 steps: DEFAULT_STEPS,
429 warmup: DEFAULT_WARMUP,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 430 params: dt ? { ...params, ...dt } : params,
432 geometryParams: geomParams,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 433 niter: ref ? ref.niter : Number(elNiter.value),
435}
437function updateCommand(): void {
c90d0e2Check reference files in the browser's compare modeJeremy Magland 438 // A study against a reference file replays the file, so its desktop
439 // equivalent is the ref checker, not the benchmark.
440 if (compareRun?.refFile) {
441 elCmd.textContent = `npm run ref -- --in ${compareRun.refFile.label}`;
442 return;
443 }
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 444 elCmd.textContent = formatCommand(currentSpec());
445}
447elModel.addEventListener('change', () => {
448 applyPreset(elModel.value);
449 void rebuild();
450});
451elLmax.addEventListener('change', () => void rebuild());
452// The solve iteration count is unrolled into the compiled step, so unlike a
453// parameter it cannot be changed without recompiling.
454elNiter.addEventListener('change', () => void rebuild());
455// Oversampling and geometry are display-or-data changes, not code ones, so
456// they swap things in place rather than rebuilding the run. Serialized through
457// one chain: a rapid second change waits its turn.
458let viewChange = Promise.resolve();
459elOversample.addEventListener('change', () => {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 460 // The study picks its own display grid — one grid common to every variant is
461 // what makes their fields comparable — so this control is inert (and
462 // disabled) while one is running.
463 if (compareRun) return;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 464 viewChange = viewChange.then(() => applyOversample());
465});
466elGeometry.addEventListener('change', () => {
467 applyGeometryChoice(elGeometry.value);
468 viewChange = viewChange.then(() => applyGeometry());
469});
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 470// The seed field's wavelength: a uniform plus a host-side redraw, so it
471// reseeds the run in place rather than recompiling it. Too small a value asks
472// for more Fourier modes than the table holds, which `drawModes` refuses —
473// report that like any other failure instead of leaving the run half-seeded.
474elLam3.addEventListener('change', () => {
475 const v = Number(elLam3.value);
476 if (!Number.isFinite(v) || v <= 0) return;
477 // Changing the wavelength redraws the field, which restarts the run — so
478 // pause first, exactly as the Re-seed button does. Without it the reseed's
479 // readback races the pump's own, and the two collide on the staging buffer.
480 setRunning(false);
481 viewChange = viewChange.then(async () => {
beac00aMerge main into random-fieldsJeremy Magland 482 // A study seeds every variant from one field at one wavelength, so this is
483 // the same control there — set on each variant, redrawn by the one reseed.
484 const target = compareRun ?? session;
485 if (!target) return;
486 const previous = target.lam3;
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 489 await reseed();
490 elErr.textContent = '';
491 } catch (e) {
492 // Too fine a wavelength asks for more Fourier modes than the table
493 // holds. Put the working value back rather than leaving the run seeded
494 // from a field that was never drawn.
495 elErr.textContent = e instanceof Error ? e.message : String(e);
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 497 elLam3.value = String(previous);
498 await reseed();
499 }
500 });
501});
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 502// Morph is pure rendering: no readback, no GPU work, just the vertex buffer.
503elMorph.addEventListener('input', () => {
504 morph = Number(elMorph.value);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 505 if (compareRun) compareRun.setMorph(morph);
506 else applyMorph();
507});
508elColormap.addEventListener('change', () => {
509 if (compareRun) void compareRun.draw();
510 else void draw();
512elEditorFile.addEventListener('change', () => showEditorFile());
514function setRunning(next: boolean): void {
515 running = next;
516 elRunPause.textContent = running ? 'Pause' : 'Run';
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 517 if (compareRun) {
518 compareRun.setRunning(next);
519 return;
520 }
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 521 if (running) void pump();
522}
524elRunPause.addEventListener('click', () => setRunning(!running));
525elBenchmark.addEventListener('click', () => void benchmark());
526elReseed.addEventListener('click', () => {
527 seed = (Math.random() * 2 ** 31) >>> 0;
528 setRunning(false);
529 updateCommand();
530 void reseed();
531});
ca37955Adding a way to reset simulation from same random IC.Owen Melia 532elRestart.addEventListener('click', () => {
533 setRunning(false);
534 void restart();
535});
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 536elResetView.addEventListener('click', () => {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 537 compareRun?.resetView();
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 538 for (const s of scenes) s.resetCamera();
539});
540elMovieToggle.addEventListener('click', () => {
541 elMovieBar.hidden = !elMovieBar.hidden;
542});
543elMovie.addEventListener('click', () => {
544 if (movieBusy) movieCancel = true;
545 else void recordMovie();
546});
548elRecompile.addEventListener('click', () => {
549 if (editing === 'geometry') editedGeomSource = editor.value;
550 else editedSource = editor.value;
551 void rebuild();
552});
553elRevert.addEventListener('click', () => {
554 if (editing === 'geometry') editedGeomSource = null;
555 else editedSource = null;
556 showEditorFile();
557 void rebuild();
558});
560// The command reproduces this run's parameters on the desktop; keep it
561// selectable even where the clipboard API is unavailable.
562elCopyCmd.addEventListener('click', () => {
563 const text = elCmd.textContent ?? '';
564 const flash = (msg: string): void => {
565 elCopyCmd.textContent = msg;
566 setTimeout(() => (elCopyCmd.textContent = 'Copy'), 1200);
567 };
568 const selectCommand = (): void => {
569 const range = document.createRange();
570 range.selectNodeContents(elCmd);
571 const sel = getSelection();
572 sel?.removeAllRanges();
573 sel?.addRange(range);
574 flash('Selected');
575 };
576 if (!navigator.clipboard) return selectCommand();
577 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
578});
580// ---------------------------------------------------------------- setup
581function disposeView(): void {
582 for (const s of scenes) s.dispose();
583 scenes = [];
584 colorbars = [];
585 topo = null;
586 coords = null;
587 posBuf = null;
588 resizeObs?.disconnect();
589 resizeObs = null;
590 elPanels.replaceChildren();
591}
593/**
594 * Build the mesh, scenes, colorbars and per-species buffers on the current
595 * render grid, from surface coordinates already synthesized there. Call
596 * disposeView() first. The color ranges are kept if present, so a display-only
597 * rebuild (an oversampling change) does not pop the shading; a full rebuild
598 * clears `ranges` beforehand.
599 */
600function buildView(surface: Float32Array): void {
601 if (!session) return;
602 const view = session.viewSht;
603 const { nphi } = view.cfg;
604 const phi = new Float64Array(nphi);
605 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
606 topo = buildTopology(view.cosTheta, phi);
607 coords = surface;
608 posBuf = new Float32Array(topo.numVertices * 3);
609 fillPositions(posBuf, coords, topo, morph);
611 const sphereBg = getComputedStyle(document.documentElement)
612 .getPropertyValue('--sphere-bg')
613 .trim();
614 for (let k = 0; k < model.species.length; k++) {
615 const panel = document.createElement('div');
616 panel.className = 'panel';
617 const box = document.createElement('div');
618 box.className = 'sphere-box';
619 const tag = document.createElement('div');
620 tag.className = 'species-tag';
621 tag.textContent = model.species[k];
622 box.append(tag);
623 const side = document.createElement('div');
624 panel.append(box, side);
625 elPanels.append(panel);
627 const scene = new SphereScene(
628 box,
629 topo.numVertices,
630 topo.indices,
631 // Each scene owns its position buffer: three.js uploads from it, and the
632 // morph rewrites all of them from the one shared `coords`.
633 Float32Array.from(posBuf),
634 sphereBg || undefined,
635 );
636 scene.fitCamera();
637 scenes.push(scene);
638 colorbars.push(new Colorbar(side));
639 valueBufs[k] = new Float32Array(topo.numVertices);
640 colorBufs[k] = new Float32Array(topo.numVertices * 3);
641 if (!ranges[k]) ranges[k] = { lo: NaN, hi: NaN };
642 }
643 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
645 resizeObs = new ResizeObserver(() => {
646 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
647 boxes.forEach((box, i) => {
648 scenes[i]?.resize(box.clientWidth, box.clientHeight);
649 });
650 });
651 elPanels
652 .querySelectorAll<HTMLElement>('.sphere-box')
653 .forEach((box) => resizeObs!.observe(box));
654}
656/**
657 * Apply the UI's oversampling choice to the running session. Display-only: the
658 * session and its state survive; only the display plan, mesh and scenes are
659 * rebuilt, keeping the camera pose and color ranges. The pump is drained first
660 * so no readback is in flight on the plan being replaced.
661 */
662async function applyOversample(): Promise<void> {
663 if (!session) return;
664 const gen = generation;
665 const os = resolveOversample();
666 if (os === session.oversample) return;
667 const wasRunning = running;
668 setRunning(false);
669 while (pumping) await nextFrame();
670 if (gen !== generation || !session) return;
671 await session.setOversample(os);
672 if (gen !== generation || !session) return;
673 const surface = await session.renderPositions();
674 if (gen !== generation || !session) return;
675 const cam = scenes[0]?.cameraState();
676 disposeView();
677 buildView(surface);
678 if (cam) for (const s of scenes) s.setCameraState(cam);
679 await draw();
680 updateStats();
681 if (wasRunning) setRunning(true);
682}
684/**
685 * Re-evaluate the surface and swap it in. Data, not code: the compiled step is
686 * untouched and the simulation keeps its state and its model time, so a shape
687 * can be changed mid-run. Only the mesh is rebuilt.
688 */
689async function applyGeometry(): Promise<void> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 690 // The in-place swap below is a single session's trick. Each variant carries
691 // the surface band-limited at its own lmax, and the study's meshes are built
692 // from those, so a shape change goes through the full rebuild instead.
693 if (compareRun) return rebuildCompare();
695 const gen = generation;
696 const wasRunning = running;
697 setRunning(false);
698 while (pumping) await nextFrame();
699 if (gen !== generation || !session) return;
700 try {
701 await session.setGeometry(geometry, geomParams, geomSource());
702 } catch (e) {
703 reportCompileError(e);
704 return;
705 }
706 if (gen !== generation || !session) return;
707 const surface = await session.renderPositions();
708 if (gen !== generation || !session) return;
709 const cam = scenes[0]?.cameraState();
710 disposeView();
711 buildView(surface);
712 if (cam) for (const s of scenes) s.setCameraState(cam);
713 elErr.textContent = '';
714 await draw();
715 updateGeomNote();
716 updateStats();
717 if (wasRunning) setRunning(true);
718}
720/** Re-place the vertices for the current morph. No GPU work and no readback —
721 * the surface is already on the CPU, so this is a buffer fill per panel. */
722function applyMorph(): void {
723 if (!topo || !coords || !posBuf) return;
724 fillPositions(posBuf, coords, topo, morph);
725 for (const s of scenes) s.updatePositions(posBuf);
726}
f132fe4Drop the work-in-progress bannerDan Fortunato 728/** What the surface is, and how far it departs from the sphere. */
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 729function updateGeomNote(): void {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 730 // In compare mode each variant carries the surface band-limited at its own
731 // lmax; the reference's is the one quoted, as everywhere else.
732 const s = session ?? compareRun?.referenceSession ?? null;
733 if (!s) {
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 734 elGeomNote.textContent = '';
735 return;
736 }
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 737 const { lo, hi } = s.geometry.radiusRange();
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 739 `<b>${s.geometryModel.label}</b> — ${s.geometryModel.blurb} ` +
f132fe4Drop the work-in-progress bannerDan Fortunato 740 `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}.`;
743/** Report a compile failure, and select the offending text in the editor. */
744function reportCompileError(e: unknown): void {
745 elErr.textContent = formatFailure(e, source());
746 elCompiled.textContent = '';
747 if (e instanceof ModelCompileError && e.start !== undefined) {
748 editor.select(e.start, e.end ?? e.start);
749 }
750}
752async function rebuild(): Promise<void> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 753 // A study is several runs, so "rebuild the run" means rebuild all of them.
754 // Everything that recompiles — a model or preset change, an edit to either
755 // .m, a revert — arrives here, and none of it needs to know which mode is up.
756 if (compareRun) return rebuildCompare();
758 const gen = generation;
759 setRunning(false);
760 disposeView();
761 session?.destroy();
762 session = null;
763 solverMs = 0;
764 frameMs = 0;
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 765 // Not 0: with a large niter's dispatch count not yet known (that needs the
766 // compiled plan below), the first measurement burst should wait for the
767 // ordinary per-frame batch — already sized to this model — to prove itself
768 // first, rather than firing a possibly-oversized burst before a single
769 // frame has run.
770 lastMeasure = performance.now();
772 updateCommand();
773 if (!device) return;
775 try {
776 session = await ModelSession.create({
777 device,
778 model,
779 params,
780 lmax: Number(elLmax.value),
781 source: source(),
782 oversample: resolveOversample(),
783 geometry,
784 geometryParams: geomParams,
785 geometrySource: geomSource(),
786 niter: Number(elNiter.value),
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 787 lam3: Number(elLam3.value),
789 } catch (e) {
790 reportCompileError(e);
791 return;
792 }
793 if (gen !== generation) return;
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 795 await session.seed(seed);
ca37955Adding a way to reset simulation from same random IC.Owen Melia 796 if (gen !== generation) return;
797 initialState = await session.readState();
798 if (gen !== generation) return;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 799
800 const plan = session.describe();
801 elCompiled.textContent =
802 `one step compiled to ${plan.step.length} GPU operations:\n` +
803 plan.step.map((l) => ` ${l}`).join('\n');
804 elRecompile.textContent = 'Recompile';
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 806 // Scale the frame batch and the measurement burst down — never up — so
807 // neither submission's total dispatch count exceeds DISPATCH_BUDGET, no
808 // matter how expensive niter has made one step. See STEPS_PER_FRAME_BASE.
809 const opsPerStep = Math.max(1, plan.step.length);
810 stepsPerFrame = Math.max(1, Math.min(STEPS_PER_FRAME_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
811 measureBurst = Math.max(1, Math.min(MEASURE_BURST_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 813 const surface = await session.renderPositions();
814 if (gen !== generation) return;
816 ranges = [];
817 buildView(surface);
819 await draw();
820 updateGeomNote();
821 updateStats();
822 void pump();
823}
825async function reseed(): Promise<void> {
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 826 // One new perturbation for the whole study, band-limited at its coarsest
827 // variant and evaluated on each grid — see src/compare/sharedStart.ts.
828 if (compareRun) return compareRun.reseed(seed);
830 const gen = generation;
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 831 await session.seed(seed);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 832 if (gen !== generation) return;
ca37955Adding a way to reset simulation from same random IC.Owen Melia 833 initialState = await session.readState();
834 if (gen !== generation) return;
835 for (const r of ranges) {
836 r.lo = NaN;
837 r.hi = NaN;
838 }
839 await draw();
840 updateStats();
841}
843/** Rewind to the field this run is currently starting from — the last
844 * (re-)seed, not necessarily the very first one — without drawing a new
845 * one. Unlike reseed(), the seed value and lam3 are untouched, so nothing
846 * the CLI command line encodes changes. */
847async function restart(): Promise<void> {
848 if (compareRun) return compareRun.restart();
849 if (!session || !initialState) return;
850 session.loadState(initialState);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 851 for (const r of ranges) {
852 r.lo = NaN;
853 r.hi = NaN;
854 }
855 await draw();
856 updateStats();
857}
859// ---------------------------------------------------------------- drawing
860async function draw(): Promise<void> {
861 if (!session || !topo) return;
862 const gen = generation;
863 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
864 for (let k = 0; k < model.species.length; k++) {
865 // The one readback per frame — the loop is otherwise entirely on the GPU.
866 // A rebuild can land while this is in flight and destroy the buffer being
867 // mapped, which rejects the map; that result is stale anyway, so drop it.
868 let field: Float32Array;
869 try {
870 field = await session.readSpecies(k);
871 } catch (e) {
872 if (gen !== generation) return;
873 throw e;
874 }
875 if (gen !== generation || !topo) return;
876 fillFieldValues(valueBufs[k], field, topo);
877 let lo = Infinity;
878 let hi = -Infinity;
879 for (const v of valueBufs[k]) {
880 if (v < lo) lo = v;
881 if (v > hi) hi = v;
882 }
883 // smooth the color range in both directions so the shading evolves
884 // gently as the pattern grows (out-of-range values clamp meanwhile)
885 const r = ranges[k];
886 if (!Number.isFinite(r.lo)) {
887 r.lo = lo;
888 r.hi = hi;
889 } else {
890 const a = 0.15;
891 r.lo += a * (lo - r.lo);
892 r.hi += a * (hi - r.hi);
893 }
ef3ae33Do not stretch the colormap across a constant field's roundoffJeremy Magland 894 // A field that is uniform to fp32 precision — Schnakenberg's v at t = 0 is
895 // exactly constant — would otherwise have the colormap stretched across its
896 // roundoff and be drawn as vivid noise. See floorRange.
897 const shown = floorRange(r.lo, r.hi);
898 fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, cmap);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 899 scenes[k]?.updateColors(colorBufs[k]);
ef3ae33Do not stretch the colormap across a constant field's roundoffJeremy Magland 900 colorbars[k]?.update(cmap, shown.lo, shown.hi);
902}
904function updateStats(): void {
905 if (!session) return;
906 const { nlat, nphi } = session.cfg;
907 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
908 const solver =
909 solverMs > 0
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 910 ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s)`
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 912 const frame = frameMs > 0 ? `${frameMs.toFixed(1)} ms/frame` : '—';
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 913 const view = session.viewSht.cfg;
914 const render =
915 session.oversample > 1
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 916 ? ` (display ${view.nlat}×${view.nphi})`
918 elStats.innerHTML =
919 `<b>${kind}</b> · grid ${nlat}×${nphi}${render} · nlm ${session.sht.nlm.toLocaleString()} · ` +
920 `solver ${solver} · ${frame} · ` +
921 `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
922}
924// ---------------------------------------------------------------- sim loop
925const nextFrame = () => new Promise<number>(requestAnimationFrame);
927async function pump(): Promise<void> {
928 if (pumping) return;
929 pumping = true;
930 const gen = generation;
931 try {
932 while (running && session && gen === generation) {
933 // Occasionally, a burst purely to measure the solver rate: many steps,
934 // one sync, nothing read back — directly comparable to the desktop
935 // benchmark's throughput number. State-preserving: the display and
936 // model time are unaffected.
937 if (performance.now() - lastMeasure > MEASURE_EVERY_MS) {
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 938 const ms = await session.measure(measureBurst);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 939 if (gen !== generation) break;
940 solverMs = ms;
941 lastMeasure = performance.now();
942 }
944 // The frame itself. No explicit sync here — draw()'s readback already
945 // waits for the steps, so asking twice would only add a round trip.
946 const t0 = performance.now();
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 947 session.step(stepsPerFrame);
949 if (gen !== generation) break;
950 frameMs = frameMs === 0
951 ? performance.now() - t0
952 : frameMs + 0.05 * (performance.now() - t0 - frameMs);
953 updateStats();
954 await nextFrame();
955 }
956 if (gen === generation) {
957 await draw();
958 updateStats();
959 }
960 } finally {
961 pumping = false;
962 }
963}
965/**
966 * Sustained solver benchmark, in the page.
967 *
968 * The same measurement `npm run bench` makes: batches of steps submitted
969 * together, waited for, never read back, with no rendering and no animation
970 * pacing in between. That makes it directly comparable to the terminal number,
971 * which is the only way to tell a genuinely slower browser GPU stack apart from
972 * the costs the app adds on top.
973 *
974 * It also reports the ramp — the first third of the run against the last. GPUs
975 * downclock when idle, and an animation-paced loop leaves them idle most of every
976 * frame, so a large ramp means the app's steady-state number is limited by clocks
977 * rather than by the work.
978 *
979 * These are ordinary steps: the simulation advances by them.
980 */
981async function benchmark(): Promise<void> {
982 if (!session || movieBusy) return;
983 setRunning(false);
8144287WIP: added code for Laplace-Beltrami operator evaluation on smooth genus-0 surfaceOwen Melia 984 // Same base size and the same DISPATCH_BUDGET scaling as the automatic
985 // measurement burst (see STEPS_PER_FRAME_BASE) — this is a user-triggered
986 // 32-step submission, exactly the shape of thing that risks a browser's
987 // GPU-process watchdog on weak hardware once niter makes a step expensive.
988 const BATCH = measureBurst;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 989 const DURATION_MS = 2000;
990 elBenchResult.textContent = 'benchmarking…';
991 // A movie started mid-benchmark would replay while this loop still steps.
992 elMovie.disabled = true;
993 try {
994 await nextFrame();
996 const gen = generation;
997 const perStep: number[] = [];
998 const t0 = performance.now();
999 while (performance.now() - t0 < DURATION_MS) {
1000 const b0 = performance.now();
1001 session.step(BATCH);
1002 await session.sync();
1003 if (gen !== generation) return;
1004 perStep.push((performance.now() - b0) / BATCH);
1005 }
1007 const mean = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
1008 const all = mean(perStep);
1009 const best = Math.min(...perStep);
1010 const third = Math.max(1, Math.floor(perStep.length / 3));
1011 const first = mean(perStep.slice(0, third));
1012 const last = mean(perStep.slice(-third));
1013 const steps = perStep.length * BATCH;
1015 elBenchResult.innerHTML =
1016 `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
1017 `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 1018 `ramp ${(first / last).toFixed(2)}× · ${steps} steps · ` +
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1019 `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
1020 await draw();
1021 updateStats();
1022 } finally {
1023 elMovie.disabled = false;
1024 }
1025}
1027// ---------------------------------------------------------------- movie
1028function saveBlob(blob: Blob, filename: string): void {
1029 const url = URL.createObjectURL(blob);
1030 const a = document.createElement('a');
1031 a.href = url;
1032 a.download = filename;
1033 a.click();
1034 setTimeout(() => URL.revokeObjectURL(url), 10_000);
1035}
1037/** Submit `n` steps in bounded command buffers — a single buffer encoding
1038 * many thousands of steps can exhaust the encoder. */
1039function submitSteps(n: number): void {
1040 while (n > 0 && session) {
1041 const chunk = Math.min(512, n);
1042 session.step(chunk);
1043 n -= chunk;
1044 }
1045}
1047/** While recording, lock everything that could change the run mid-replay;
1048 * the Movie button itself becomes the cancel button. */
1049function setMovieUi(on: boolean): void {
1050 const locked = [
1051 elModel, elGeometry, elMorph, elNiter, elLmax, elOversample, elColormap,
ca37955Adding a way to reset simulation from same random IC.Owen Melia 1052 elRunPause, elRestart, elBenchmark, elReseed, elRecompile, elRevert, elEditorFile,
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1053 elMovieSpeed, elMovieRes, elMovieRotate, elMovieToggle,
1054 ];
1055 for (const el of locked) el.disabled = on;
1056 elParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
1057 elGeomParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
1058 elMovie.textContent = on ? 'Cancel · 0%' : 'Export';
1059}
1061/**
1062 * Recompute the run from t = 0 and download it as an MP4.
1063 *
1064 * The movie is not a recording of what already happened — it is the same
1065 * trajectory recomputed: same seed, same source, and the *current* parameters
1066 * and colormap throughout. Determinism makes this exact: after the replay the
1067 * state is where it was, so the one session is reused and the app resumes as
1068 * if nothing happened. Frames are composited from the live panels, so the
1069 * movie shows the spheres at the current camera orientation — and the replay
1070 * doubles as the progress display, since it is visible on screen.
1071 */
1072async function recordMovie(): Promise<void> {
1073 if (!session || movieBusy) return;
1074 if (session.steps === 0) {
1075 elMovie.textContent = 'run first';
1076 setTimeout(() => (elMovie.textContent = 'Export'), 1200);
1077 return;
1078 }
1079 movieBusy = true;
1080 movieCancel = false;
1081 const gen = generation;
1082 setMovieUi(true);
1083 let wasRunning = false;
1084 let total = 0;
1085 let done = 0;
1086 let seeded = false;
1087 let camBefore: ReturnType<SphereScene['cameraState']> | undefined;
1088 try {
1089 // An in-flight display-grid swap replaces the scenes whose canvases the
1090 // recorder captures, and resumes the run when it lands — let it finish.
1091 await viewChange;
1092 if (gen !== generation || !session) return;
1093 wasRunning = running;
1094 setRunning(false);
1095 while (pumping) await nextFrame(); // let an in-flight live frame drain
1096 if (gen !== generation || !session) return;
1097 total = session.steps;
1098 const speed = Number(elMovieSpeed.value) || 10;
1099 const sphere = Number(elMovieRes.value) || 768;
1100 const rotate = elMovieRotate.checked;
1101 if (rotate) camBefore = scenes[0]?.cameraState();
1102 // Render the scenes at exactly the chosen resolution for the recording —
1103 // independent of the window size — and restore afterwards.
1104 for (const s of scenes) s.captureSize(sphere);
1105 const durationS = Math.max(session.t / speed, 2 / MOVIE_FPS);
1106 const frames = Math.max(
1107 2,
1108 Math.min(Math.round(durationS * MOVIE_FPS) + 1, total + 1, MOVIE_MAX_FRAMES),
1109 );
1110 /** The step index captured as frame `i`; both endpoints land exactly. */
1111 const stepAt = (i: number): number => Math.round((i * total) / (frames - 1));
1113 const title =
1114 (presets.find((p) => p.key === elModel.value)?.label ?? model.label) +
1115 ` on ${geometry.label.toLowerCase()}` +
1116 (editedSource !== null || editedGeomSource !== null ? ' (edited)' : '');
1117 const subtitle = model.params
1118 .map((spec) => `${spec.label} ${fmtValue(params[spec.key])}`)
1119 .join(' · ');
1120 const rec = await MovieRecorder.create({
1121 panels: model.species.map((label, k) => ({ canvas: scenes[k].canvas, label })),
1122 title,
1123 subtitle,
1124 speed,
1125 fps: (frames - 1) / durationS,
1126 sphere,
1127 });
1129 let finished = false;
1130 try {
1131 // Reset the color-range smoothing as a re-seed does, so the shading
1132 // evolves in the movie the way it did live.
0ae15cfSeed runs from smooth random fields, and add the blob geometryDan Fortunato 1133 await session.seed(seed);
1135 for (const r of ranges) {
1136 r.lo = NaN;
1137 r.hi = NaN;
1138 }
1139 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
1140 let lastVideoS = 0;
1141 for (let frame = 0; ; ) {
1142 await draw();
1143 if (gen !== generation) return;
1144 if (movieCancel) break;
1145 if (rotate) {
1146 // Advance the orbit by this frame's share of video time; siblings
1147 // follow scenes[0] through the usual camera sync.
1148 const videoS = session.t / speed;
1149 scenes[0]?.orbitBy(2 * Math.PI * MOVIE_ROTATE_RPS * (videoS - lastVideoS));
1150 lastVideoS = videoS;
1151 }
1152 for (const s of scenes) s.renderNow();
1153 await rec.addFrame(
1154 session.t,
1155 model.species.map((_, k) => ({ cmap, lo: ranges[k].lo, hi: ranges[k].hi })),
1156 );
1157 if (++frame >= frames) {
1158 finished = true;
1159 break;
1160 }
1161 const target = stepAt(frame);
1162 submitSteps(target - done);
1163 done = target;
1164 elMovie.textContent = `Cancel · ${Math.round((100 * done) / total)}%`;
1165 }
1166 if (finished) {
1167 const blob = await rec.finish();
1168 saveBlob(
1169 blob,
1170 `turing-surface-${model.key}-${geometry.key}-` +
1171 `t${session.t.toFixed(2)}-${speed}x.mp4`,
1172 );
1173 }
1174 } finally {
1175 if (!finished) rec.cancel();
1176 }
1177 } catch (e) {
1178 elErr.textContent = `movie: ${e instanceof Error ? e.message : e}`;
1179 } finally {
1180 // A cancelled replay stopped short of where the run was; step the
1181 // remainder — determinism makes this land exactly there.
1182 if (seeded && gen === generation && session) {
1183 while (done < total && gen === generation && session) {
1184 const n = Math.min(4096, total - done);
1185 submitSteps(n);
1186 done += n;
1187 elMovie.textContent = `restoring · ${Math.round((100 * done) / total)}%`;
1188 await session.sync();
1189 }
1190 await draw();
1191 updateStats();
1192 }
1193 if (gen === generation) {
1194 for (const s of scenes) s.restoreSize();
1195 }
1196 if (camBefore && gen === generation) {
1197 for (const s of scenes) s.setCameraState(camBefore);
1198 }
1199 movieBusy = false;
1200 setMovieUi(false);
1201 if (gen === generation) setRunning(wasRunning);
1202 }
1203}
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1205// ---------------------------------------------------------------- compare
1206/**
1207 * Comparing several solver settings at once.
1208 *
1209 * Deliberately a mode rather than a widening of the ordinary controls: the
1210 * single-run path above is untouched, and with the bar closed nothing about
1211 * using this page has changed. Opening it and pressing Compare tears down the
1212 * one session and hands the panels area to a CompareRun, which owns a session
1213 * per variant; pressing it again puts the single run back.
1214 *
1215 * The ceilings below are not arbitrary. Each variant compiles its whole
1216 * unrolled step with no pipeline cache between sessions (a solve iteration is
1217 * ~15 kernels per species), so the variant count is what you wait for; and
1218 * each panel is a WebGL context and a full mesh, so the panel count is what
1219 * the browser has to keep alive at once.
1220 */
1221const MAX_VARIANTS = 6;
1222const MAX_PANELS = 12;
1223/** dt divisors. Powers of two so that dtBase/K is exact in binary and every
1224 * variant lands on the same model time with no accumulated drift. */
1225const DT_DIVISORS = [1, 2, 4, 8];
1227/**
1228 * What the bar opens on: the default iteration count against the next step up,
1229 * at the default band. Two variants, so the first study is quick to compile,
1230 * and it asks the question the control exists for — is the default already
1231 * converged? A flat, low curve says yes; one that climbs says the answer is
1232 * still moving at niter 8 and the default is not enough for this shape.
1233 */
1234const cmpSelected = {
1235 niter: new Set<number>([DEFAULT_NITER, 2 * DEFAULT_NITER]),
1236 lmax: new Set<number>([63]),
1237 dt: new Set<number>([1]),
1238};
1240/** A row of toggle chips backed by a Set. At least one stays selected — an
1241 * empty axis has no meaning here, and silently falling back to a default
1242 * would hide which values are actually being run. */
1243function buildChips(host: HTMLElement, values: number[], selected: Set<number>, label: (v: number) => string): void {
1244 host.replaceChildren();
1245 for (const value of values) {
1246 const chip = document.createElement('button');
1247 chip.type = 'button';
1248 chip.className = 'chip';
1249 chip.textContent = label(value);
1250 const paint = (): void => chip.setAttribute('aria-pressed', String(selected.has(value)));
1251 paint();
1252 chip.addEventListener('click', () => {
1253 if (selected.has(value)) {
1254 if (selected.size === 1) return;
1255 selected.delete(value);
1256 } else {
1257 selected.add(value);
1258 }
1259 paint();
1260 refreshVariants();
1261 });
1262 host.append(chip);
1263 }
1264}
1266const cmpVariants = (): Variant[] =>
1267 crossProduct([...cmpSelected.niter], [...cmpSelected.lmax], [...cmpSelected.dt]);
1270 * A loaded reference file, or null. While one is loaded the study checks the
1271 * variants against it instead of against each other: the file defines the
1272 * whole problem (model, parameters, geometry, initial state, end time), so
1273 * the page's own model and geometry choices do not enter the study at all —
1274 * only the solver knobs above do.
1275 */
1276let refCase: ReferenceCase | null = null;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1278/** The reference the user picked, clamped to the current variant list. */
1279let cmpRefKey = '';
1281/** Index of the reference in the current variant list, never negative. */
1282function compareRefIndex(): number {
1283 const i = cmpVariants().map(variantKey).indexOf(cmpRefKey);
1284 return i < 0 ? 0 : i;
1285}
1287function refreshVariants(): void {
1288 const variants = cmpVariants();
1289 const showDt = cmpSelected.dt.size > 1;
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1290 // With a file loaded the study's model is the file's, and its final state
1291 // is one more row of panels.
1292 const cmpModel = refCase?.model ?? model;
1293 const rowCount = variants.length + (refCase ? 1 : 0);
1294 const panels = rowCount * cmpModel.species.length;
1296 const prev = cmpRefKey;
1297 elCmpRef.replaceChildren();
1299 // The file is the reference; the pick among variants means nothing here.
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1300 const o = document.createElement('option');
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1301 o.textContent = `the file's final state`;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1302 elCmpRef.append(o);
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1303 elCmpRef.disabled = true;
1304 } else {
1305 elCmpRef.disabled = false;
1306 for (const v of variants) {
1307 const o = document.createElement('option');
1308 o.value = variantKey(v);
1309 o.textContent = variantLabel(v, showDt);
1310 elCmpRef.append(o);
1311 }
1312 const keys = variants.map(variantKey);
1313 cmpRefKey = keys.includes(prev) ? prev : keys[mostResolved(variants)];
1314 elCmpRef.value = cmpRefKey;
1317 const tooMany =
1318 variants.length > MAX_VARIANTS
1319 ? `${variants.length} variants — at most ${MAX_VARIANTS}`
1320 : panels > MAX_PANELS
1321 ? `${panels} panels — at most ${MAX_PANELS}`
1322 : '';
1323 elCmpCount.textContent = tooMany
1324 ? `too many: ${tooMany}`
3210e7dOpen the reference comparison in one clickJeremy Magland 1325 : `${variants.length} variant${variants.length === 1 ? '' : 's'}` +
1326 `${refCase ? ' + the file' : ''} × ` +
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1327 `${cmpModel.species.length} species = ${panels} panels`;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1328 elCmpCount.style.color = tooMany ? '#b35900' : '';
1329 elCmpStart.disabled = tooMany !== '' && compareRun === null;
1330}
1333 * The niter chips on offer. A loaded reference file adds its own recorded
1334 * iteration count if the standard list lacks it, so the file's settings are
1335 * always selectable; clearing the file drops any selection outside the
1336 * standard list again.
1337 */
1338function rebuildNiterChips(): void {
1339 const all = [...elNiter.options].map((o) => Number(o.value));
1340 let values = all;
1341 if (refCase && !all.includes(refCase.niter)) {
1342 values = [...all, refCase.niter].sort((a, b) => a - b);
1343 }
1344 if (!refCase) {
1345 for (const v of [...cmpSelected.niter]) if (!values.includes(v)) cmpSelected.niter.delete(v);
1346 if (cmpSelected.niter.size === 0) cmpSelected.niter.add(DEFAULT_NITER);
1347 }
1348 buildChips(elCmpNiter, values, cmpSelected.niter, String);
1349}
1352 * The lmax chips on offer. A loaded reference file floors them at its own
1353 * band: a variant below it could not even hold the file's initial state
1354 * (prolongation only widens), so those values are not offered rather than
1355 * offered and refused.
1356 */
1357function rebuildLmaxChips(): void {
1358 const all = [...elLmax.options].map((o) => Number(o.value));
1359 let values = all;
1360 if (refCase) {
1361 const floor = refCase.lmax;
1362 values = all.filter((v) => v >= floor);
1363 if (!values.includes(floor)) values = [floor, ...values];
1364 for (const v of [...cmpSelected.lmax]) if (!values.includes(v)) cmpSelected.lmax.delete(v);
1365 if (cmpSelected.lmax.size === 0) cmpSelected.lmax.add(floor);
1366 }
1367 buildChips(elCmpLmax, values, cmpSelected.lmax, String);
1368}
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1372buildChips(elCmpDt, DT_DIVISORS, cmpSelected.dt, (v) => (v === 1 ? 'dt' : `dt/${v}`));
1373refreshVariants();
1375elCmpRef.addEventListener('change', () => {
1376 cmpRefKey = elCmpRef.value;
1377 if (compareRun) void rebuildCompare();
1378});
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1380/** Reflect the loaded (or cleared) reference file in the compare bar. */
1381function applyRefUi(): void {
1382 elCmpFileInfo.hidden = elCmpFileClear.hidden = refCase === null;
1383 if (refCase) {
1384 const rc = refCase;
1385 const geomParamText = rc.geometry.params
1386 .map((p) => `${p.key}=${rc.geometryParams[p.key]}`)
1387 .join(' ');
1388 const name = document.createElement('b');
1389 name.textContent = rc.label;
1390 const info = document.createElement('span');
1391 info.textContent =
1392 ` — ${rc.model.label} on ${rc.geometry.label.toLowerCase()}` +
1393 (geomParamText ? ` (${geomParamText})` : '') +
1394 `, lmax ${rc.lmax}, T = ${(rc.steps * (rc.params.dt ?? 0)).toFixed(2)}` +
1395 ` (${rc.steps} × dt ${rc.params.dt})`;
1396 elCmpFileInfo.replaceChildren(name, info);
1397 }
1400 refreshVariants();
1401}
1404 * The four top-level modes and which control groups each shows (see
1405 * GROUP_NAMES/groupEls above; `.ctrl-group` wrappers in index.html).
1406 * `currentMode` tracks which configuration is on screen — the compare bar
1407 * being open, and in which flavor — not whether a study has actually been
1408 * started inside it. That match matters: without it, opening the bar
1409 * (which already shows the right groups) leaves its top-row button
1410 * unhighlighted until a study happens to start, which is inconsistent with
1411 * `vs-upload`'s one-click flow and reads as broken.
1412 */
1413type Mode = 'simulate' | 'compute-effort' | 'vs-sphere' | 'vs-upload';
1414let currentMode: Mode = 'simulate';
1416const MODE_GROUPS: Record<Mode, readonly GroupName[]> = {
1417 simulate: ['surface', 'surface-params', 'solver', 'display', 'playback', 'benchmark', 'seed', 'movie'],
1418 'compute-effort': ['surface', 'surface-params', 'display', 'playback', 'seed'],
1419 'vs-sphere': [], // unreachable — the button is disabled, no listener ever calls setMode with this
ca37955Adding a way to reset simulation from same random IC.Owen Melia 1420 // No `seed` here: nothing in that group does anything useful against a
1421 // loaded file (lam3 is silently absorbed, and Restart already covers what
1422 // Re-seed would otherwise be doing — reloading the file's fixed initial
1423 // state) — see CompareRun.restart().
1424 'vs-upload': ['display', 'playback'],
1427function setModeButtons(mode: Mode): void {
1428 elModeSimulate.setAttribute('aria-pressed', String(mode === 'simulate'));
1429 elModeEffort.setAttribute('aria-pressed', String(mode === 'compute-effort'));
1430 elModeVsUpload.setAttribute('aria-pressed', String(mode === 'vs-upload'));
1431}
1433/** Show exactly the groups `mode` declares; hide the rest. */
1434function applyModeVisibility(mode: Mode): void {
1435 currentMode = mode;
1436 const shown = new Set<GroupName>(MODE_GROUPS[mode]);
1437 for (const name of GROUP_NAMES) groupEls[name].hidden = !shown.has(name);
1438 setModeButtons(mode);
1439}
1441/**
1442 * Enter `mode`: groups, top-row buttons, and the compare bar's own
1443 * visibility (open for the two compare flavors, closed for Simulate).
1444 * Doesn't touch `compareRun`/`refCase` or start/stop a study — callers
1445 * decide that; this only decides what's on screen, and it decides it
1446 * immediately, so the button you clicked lights up right away rather than
1447 * waiting on a study that may not exist yet (or may never start, if the
1448 * bar's own Compare is never pressed).
1449 */
1450function enterMode(mode: Mode): void {
1451 applyModeVisibility(mode);
1452 elCompareBar.hidden = mode === 'simulate';
1453}
1455/** Entering a mode from the top row. */
1456function setMode(mode: Mode): void {
1457 if (mode === 'vs-sphere') return; // unreachable — button is disabled
1458 if (mode === 'simulate') {
1459 if (compareRun) void stopCompare();
1460 enterMode('simulate');
1461 return;
1462 }
1463 if (mode === 'compute-effort') {
c8e230aFixing some bugs in the UIOwen Melia 1464 // Tear down whatever study is running first (mirrors Simulate above) —
1465 // stopCompare's synchronous prefix disposes it and nulls `compareRun`
1466 // before its first `await`, so `refCase` is safe to drop right after.
1467 if (compareRun) void stopCompare();
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 1468 if (refCase) {
1469 refCase = null;
1470 applyRefUi();
1471 }
1472 enterMode('compute-effort');
1473 return;
1474 }
1475 // vs-upload: opens the file picker; entering the mode itself happens once
1476 // a file is actually chosen (elCmpFile's change handler below) — not here,
1477 // since cancelling the dialog must leave the current mode untouched.
1478 elCmpFile.click();
1479}
1481elModeSimulate.addEventListener('click', () => setMode('simulate'));
1482elModeEffort.addEventListener('click', () => setMode('compute-effort'));
1483elModeVsUpload.addEventListener('click', () => setMode('vs-upload'));
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1485elCmpFile.addEventListener('change', () => {
1486 const file = elCmpFile.files?.[0];
1487 // Cleared so picking the same file again still fires a change event.
1488 elCmpFile.value = '';
1489 if (!file) return;
1490 void (async () => {
1491 try {
1492 refCase = await loadReferenceFile(file);
1493 elErr.textContent = '';
1494 } catch (e) {
1495 refCase = null;
1496 elErr.textContent = `reference file ${file.name}: ${e instanceof Error ? e.message : e}`;
1498 return;
3210e7dOpen the reference comparison in one clickJeremy Magland 1500 // One click, one study: the file's own settings become the single
1501 // variant — its recorded niter, its band, its dt undivided — and the
1502 // comparison opens on them, paused at the initial state so what runs is
c8e230aFixing some bugs in the UIOwen Melia 1503 // the user's choice. (Widening it is: teardown the comparison, pick more
1504 // chips, compile it again — the file stays loaded.)
1506 cmpSelected.niter.add(refCase.niter);
1507 cmpSelected.lmax.clear();
1508 cmpSelected.lmax.add(refCase.lmax);
1509 cmpSelected.dt.clear();
1510 cmpSelected.dt.add(1);
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 1512 enterMode('vs-upload');
1514 // A study is already up (this one loaded over it): same teardown as
1515 // rebuildCompare, then the new file's study takes its place.
1516 compareRun.dispose();
1517 compareRun = null;
1518 setCompareUi(false);
1519 }
1520 await startCompare();
1522});
1523elCmpFileClear.addEventListener('click', () => {
1524 refCase = null;
1525 applyRefUi();
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 1526 // The bar stays open — this only drops back to the plain chip comparison.
1527 // Only reachable while idle (elCmpFileClear is disabled during a study).
1528 enterMode('compute-effort');
1531elCmpStart.addEventListener('click', () => {
1532 if (compareRun) void stopCompare();
1533 else void startCompare();
1534});
1537 * Controls the study supersedes or cannot honour while it is running.
1538 * Mode/group/button state is not this function's job — that's set the
1539 * moment a mode is entered (enterMode, above), independent of whether a
1540 * study inside it has actually started or stopped.
1541 */
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1542function setCompareUi(on: boolean): void {
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 1543 // A study picks its own display grid, so oversample stays individually
1544 // disabled inside the still-visible display group; and clearing a loaded
1545 // file out from under a running study would leave it checking against one
1546 // that no longer exists.
1547 elOversample.disabled = on;
1548 elCmpFileClear.disabled = on;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1549 elCmpNiter.querySelectorAll('button').forEach((b) => (b.disabled = on));
1550 elCmpLmax.querySelectorAll('button').forEach((b) => (b.disabled = on));
1551 elCmpDt.querySelectorAll('button').forEach((b) => (b.disabled = on));
c8e230aFixing some bugs in the UIOwen Melia 1552 elCmpStart.textContent = on ? 'Teardown comparison' : 'Compile comparison';
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 1553 // The movie bar's own hidden flag is independent of the movie *group's* —
1554 // force it closed so it doesn't reappear open once the group is shown
1555 // again on returning to Simulate.
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1556 if (on) elMovieBar.hidden = true;
1557}
1559async function startCompare(): Promise<void> {
1560 if (compareRun || !device) return;
3210e7dOpen the reference comparison in one clickJeremy Magland 1561 // Snapshotted for the whole study: `refCase` only changes with no study up
1562 // (clearing is disabled during one, and loading tears it down first).
1564 const cmpModel = rc?.model ?? model;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1565 const variants = cmpVariants();
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1566 const rowCount = variants.length + (rc ? 1 : 0);
1567 if (variants.length > MAX_VARIANTS || rowCount * cmpModel.species.length > MAX_PANELS) {
1569 }
1570 // Take down the single run first: its pump, its scenes, its session. The
1571 // generation bump makes any readback already in flight drop its result.
1572 generation++;
1573 setRunning(false);
1574 while (pumping) await nextFrame();
1575 disposeView();
1576 session?.destroy();
1577 session = null;
1578 elBenchResult.textContent = '';
1579 elErr.textContent = '';
1580 setCompareUi(true);
1582 try {
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1583 // Against a reference file, the problem is the file's — its model,
1584 // parameters and geometry, from the registry sources (the editor's
1585 // working copies describe the page's run, not the file's).
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1586 compareRun = await CompareRun.create({
1587 device,
1589 params: rc ? rc.params : params,
1590 source: rc ? rc.model.source : source(),
1591 geometry: rc ? rc.geometry : geometry,
1592 geometryParams: rc ? rc.geometryParams : geomParams,
1593 geometrySource: rc ? rc.geometry.source : geomSource(),
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1595 reference: rc ? 0 : compareRefIndex(),
1596 refFile: rc ?? undefined,
1597 onFinished: () => setRunning(false),
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1599 lam3: rc ? undefined : Number(elLam3.value),
1601 colormapName: () => elColormap.value,
1602 container: elPanels,
1603 onStatus: (html) => (elStats.innerHTML = html),
1604 });
1605 } catch (e) {
1606 compareRun = null;
1607 setCompareUi(false);
1608 refreshVariants();
1609 reportCompileError(e);
1610 await rebuild();
1611 return;
1612 }
1613 updateGeomNote();
1614 // The command describes the reference variant, which only exists now.
1615 updateCommand();
1616 elRunPause.textContent = 'Run';
1617}
1619async function stopCompare(): Promise<void> {
1620 if (!compareRun) return;
1621 compareRun.dispose();
1622 compareRun = null;
1623 setCompareUi(false);
1624 refreshVariants();
1625 elStats.textContent = '';
1626 await rebuild();
1627}
1629/** Rebuild the study in place — after a model, geometry, source or reference
1630 * change. Same teardown as stopping, without leaving the mode. */
1631async function rebuildCompare(): Promise<void> {
1632 if (!compareRun) return;
1633 compareRun.dispose();
1634 compareRun = null;
1635 setCompareUi(false);
1636 await startCompare();
1637}
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1639// ---------------------------------------------------------------- boot
1640async function boot(): Promise<void> {
9389d73WIP: First draft at new interface with multiple comparison modesOwen Melia 1641 enterMode('simulate');
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1642 elModel.value = presets[0].key;
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1643 // The iteration count is one default shared with the benchmark, like the
1644 // rest of the RunSpec's — take it from there rather than from the markup, so
1645 // the page and `npm run bench` cannot start out disagreeing about it.
1646 elNiter.value = String(DEFAULT_NITER);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1647 elGeometry.value = DEFAULT_GEOMETRY_KEY;
1648 elMorph.value = String(morph);
1649 applyGeometryChoice(DEFAULT_GEOMETRY_KEY);
1650 applyPreset(presets[0].key);
1651 try {
1652 device = await requestShtDevice();
1653 adapterName = await describeAdapter(device);
1654 } catch (e) {
1655 device = null;
1656 elErr.textContent =
1657 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
62e6cc4Simplify UI text and built-in .m script commentsJeremy Magland 1658 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1660 }
1661 device.lost.then((info) => {
1662 if (info.reason !== 'destroyed') {
1663 elErr.textContent = `WebGPU device lost: ${info.message}`;
1664 }
1665 });
1666 await rebuild();
1667}
1669void boot();