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