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