35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 1import { requestShtDevice, describeAdapter } from './sht/sht.ts';
163ec45Render on demand, display oversampling, and jump-free solver timingJeremy Magland 2import { gridForLmax } from './sht/layout.ts';
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 3import { ModelSession } from './mgpu/session.ts';
4import { mModelByKey, presets, type MModel, type Params } from './mgpu/registry.ts';
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 5import { ModelCompileError, formatFailure } from './mgpu/errors.ts';
6import { EXTERNAL_OPS } from './mgpu/externals.ts';
7import { CodeEditor } from './editor/codeEditor.ts';
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 8import {
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 9 formatCommand,
10 resolvePreset,
11 DEFAULT_STEPS,
12 DEFAULT_WARMUP,
13 type RunSpec,
14} from './bench/runSpec.ts';
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 15import {
16 buildTopology,
17 fillFieldValues,
18 fillColors,
19 type SphereMeshTopology,
20} from './render/sphereMesh.ts';
21import { SphereScene } from './render/SphereScene.ts';
f168295Movie export: recompute the run from t = 0 into a captioned MP4Jeremy Magland 22import { Colorbar, fmtValue } from './render/colorbar.ts';
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 23import { colormaps, colormapNames } from './render/colormaps.ts';
f168295Movie export: recompute the run from t = 0 into a captioned MP4Jeremy Magland 24import { MovieRecorder } from './render/movie.ts';
26const $ = <T extends HTMLElement>(id: string): T =>
27 document.getElementById(id) as T;
29const elModel = $<HTMLSelectElement>('model');
30const elLmax = $<HTMLSelectElement>('lmax');
163ec45Render on demand, display oversampling, and jump-free solver timingJeremy Magland 31const elOversample = $<HTMLSelectElement>('oversample');
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 32const elColormap = $<HTMLSelectElement>('colormap');
33const elRunPause = $<HTMLButtonElement>('runpause');
3221abdAdd a Benchmark button that measures the solver and the GPU's clock rampJeremy Magland 34const elBenchmark = $<HTMLButtonElement>('benchmark');
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 35const elReseed = $<HTMLButtonElement>('reseed');
36const elResetView = $<HTMLButtonElement>('resetview');
f168295Movie export: recompute the run from t = 0 into a captioned MP4Jeremy Magland 37const elMovieToggle = $<HTMLButtonElement>('movietoggle');
38const elMovieBar = $('moviebar');
39const elMovieSpeed = $<HTMLSelectElement>('moviespeed');
40const elMovieRotate = $<HTMLInputElement>('movierotate');
41const elMovie = $<HTMLButtonElement>('movie');
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 42const elParams = $('params');
43const elPanels = $('panels');
44const elStats = $('stats');
3221abdAdd a Benchmark button that measures the solver and the GPU's clock rampJeremy Magland 45const elBenchResult = $('benchresult');
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 46const elCmd = $('cmd');
47const elCopyCmd = $<HTMLButtonElement>('copycmd');
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 48const elBlurb = $('blurb');
49const elErr = $('err');
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 50const elSource = $<HTMLTextAreaElement>('source');
51const elHighlight = $('highlight');
52const elCompiled = $('compiled');
53const elEditorTitle = $('editor-title');
54const elRecompile = $<HTMLButtonElement>('recompile');
55const elRevert = $<HTMLButtonElement>('revert');
57for (const p of presets) {
58 const o = document.createElement('option');
59 o.value = p.key;
60 o.textContent = p.label;
61 elModel.append(o);
62}
63for (const name of colormapNames) {
64 const o = document.createElement('option');
65 o.value = name;
66 o.textContent = name;
67 elColormap.append(o);
68}
69elColormap.value = 'jet';
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 71/** The model source, with MATLAB highlighting. The host-provided operations are
72 * marked so the boundary between the model and what it is given is visible. */
73const editor = new CodeEditor({
74 textarea: elSource,
75 overlay: elHighlight,
76 external: EXTERNAL_OPS,
77 onInput: (value) => {
78 editedSource = value;
79 elRecompile.textContent = 'Recompile *';
80 },
81});
83/** Timesteps submitted per rendered frame. Nothing is read back between them,
84 * so the batch costs one submit and one readback regardless of size. */
85const STEPS_PER_FRAME = 4;
88 * Steps in a solver-timing burst, and how often to run one.
89 *
90 * Timing the solver needs a `queue.onSubmittedWorkDone()` to know the work
91 * finished, and in a browser that is an IPC round trip into the GPU process — a
92 * fixed cost of a few milliseconds. Spread over one frame's four steps it would
93 * swamp them on a fast GPU and make the solver look far slower than it is. So the
94 * rate is measured in an occasional larger batch, where the single sync is
163ec45Render on demand, display oversampling, and jump-free solver timingJeremy Magland 95 * amortized the way the desktop benchmark amortizes its own. The state is
96 * snapshotted and restored around the batch, so measuring never advances the
97 * simulation — otherwise the pattern would visibly lurch forward at every
98 * measurement.
100const MEASURE_BURST = 32;
101const MEASURE_EVERY_MS = 2000;
104 * 'auto' display oversampling targets this many render latitudes: the factor is
105 * the smallest power of two (up to 4) that reaches it. A solver grid already
106 * this fine gains nothing visually and is not oversampled.
107 */
108const AUTO_RENDER_NLAT = 256;
110/** The display oversampling factor the UI currently asks for. */
111function resolveOversample(): number {
112 if (elOversample.value !== 'auto') return Number(elOversample.value);
113 const { nlat } = gridForLmax(Number(elLmax.value), model.pdeg);
114 let os = 1;
115 while (os < 4 && os * nlat < AUTO_RENDER_NLAT) os *= 2;
116 return os;
117}
120 * Movie frame rate, and a cap on frames per movie. Playback speed comes from
121 * the UI, in simulation-time units per second of video; the movie's length is
122 * the run's t at that speed, and the frame count follows from it — capped by
123 * the run's own step count (a step is at most one frame) and by
124 * MOVIE_MAX_FRAMES to bound encode time and file size. Frame timestamps are
125 * derived from simulation time, so a capped movie keeps its duration and
126 * speed exactly, at a lower effective frame rate.
127 */
128const MOVIE_FPS = 30;
129const MOVIE_MAX_FRAMES = 3600;
131/** Movie auto-rotation: camera revolutions per second of video. Measured in
132 * video time, so the orbit pace on screen is the same at every export speed. */
133const MOVIE_ROTATE_RPS = 1 / 120;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 135// ---------------------------------------------------------------- state
136let device: GPUDevice | null = null;
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 137let session: ModelSession | null = null;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 138let topo: SphereMeshTopology | null = null;
139let scenes: SphereScene[] = [];
140let colorbars: Colorbar[] = [];
141let valueBufs: Float32Array[] = [];
142let colorBufs: Float32Array[] = [];
143let ranges: { lo: number; hi: number }[] = [];
144let resizeObs: ResizeObserver | null = null;
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 146const initial = resolvePreset(presets[0].key);
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 147let model: MModel = mModelByKey(initial.model.key)!;
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 148let params: Params = initial.params;
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 149/** The .m as edited in the page; `null` while it matches the file. */
150let editedSource: string | null = null;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 151let seed = 1;
152let running = false;
153let adapterName = '';
154let pumping = false;
f168295Movie export: recompute the run from t = 0 into a captioned MP4Jeremy Magland 155let movieBusy = false;
156let movieCancel = false;
0f3abbdSeparate solver time from frame time, and add a cross-environment checkJeremy Magland 157let solverMs = 0;
158let frameMs = 0;
cde22eaStop the per-frame sync from inflating the app's solver numberJeremy Magland 159let lastMeasure = 0;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 160let generation = 0; // bumped on every rebuild to cancel stale pumps
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 162const source = (): string => editedSource ?? model.source;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 164// ---------------------------------------------------------------- UI wiring
165function buildParamInputs(): void {
166 elParams.replaceChildren();
167 for (const spec of model.params) {
168 const label = document.createElement('label');
169 label.textContent = `${spec.label} `;
170 const input = document.createElement('input');
171 input.type = 'number';
172 input.min = String(spec.min);
173 input.max = String(spec.max);
174 input.step = String(spec.step);
175 input.value = String(params[spec.key]);
176 input.addEventListener('change', () => {
177 const v = Number(input.value);
178 if (Number.isFinite(v)) params[spec.key] = v;
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 179 // Parameters are uniforms, not constants baked into the kernels, so a
180 // change costs an upload rather than a recompile.
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 181 session?.setParams(params);
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 182 updateCommand();
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 183 });
184 label.append(input);
185 elParams.append(label);
186 }
187}
189function applyPreset(presetKey: string): void {
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 190 const resolved = resolvePreset(presetKey);
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 191 const next = mModelByKey(resolved.model.key);
192 if (!next) {
193 elErr.textContent = `No .m model for '${resolved.model.key}'`;
194 return;
195 }
196 model = next;
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 197 params = resolved.params;
199 editor.value = model.source;
200 elEditorTitle.textContent =
201 `models/${model.key}.m — init() and step(), compiled to WebGPU`;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 202 buildParamInputs();
203 elBlurb.textContent = model.blurb;
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 204 updateCommand();
205}
207/** The run currently on screen, as the benchmark's RunSpec. */
208function currentSpec(): RunSpec {
209 return {
210 preset: elModel.value,
211 lmax: Number(elLmax.value),
212 seed,
213 steps: DEFAULT_STEPS,
214 warmup: DEFAULT_WARMUP,
215 params,
216 };
217}
219function updateCommand(): void {
220 elCmd.textContent = formatCommand(currentSpec());
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 221}
223elModel.addEventListener('change', () => {
224 applyPreset(elModel.value);
225 void rebuild();
226});
227elLmax.addEventListener('change', () => void rebuild());
163ec45Render on demand, display oversampling, and jump-free solver timingJeremy Magland 228// Oversampling is display-only, so it swaps the render grid in place rather
229// than rebuilding the run. Serialized: a rapid second change waits its turn.
230let viewChange = Promise.resolve();
231elOversample.addEventListener('change', () => {
232 viewChange = viewChange.then(() => applyOversample());
233});
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 234elColormap.addEventListener('change', () => void draw());
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 236function setRunning(next: boolean): void {
237 running = next;
238 elRunPause.textContent = running ? 'Pause' : 'Run';
239 if (running) void pump();
240}
242elRunPause.addEventListener('click', () => setRunning(!running));
3221abdAdd a Benchmark button that measures the solver and the GPU's clock rampJeremy Magland 243elBenchmark.addEventListener('click', () => void benchmark());
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 244elReseed.addEventListener('click', () => {
245 seed = (Math.random() * 2 ** 31) >>> 0;
246 setRunning(false);
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 247 updateCommand();
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 248 void reseed();
249});
250elResetView.addEventListener('click', () => {
251 for (const s of scenes) s.resetCamera();
252});
f168295Movie export: recompute the run from t = 0 into a captioned MP4Jeremy Magland 253elMovieToggle.addEventListener('click', () => {
254 elMovieBar.hidden = !elMovieBar.hidden;
255});
256elMovie.addEventListener('click', () => {
257 if (movieBusy) movieCancel = true;
258 else void recordMovie();
259});
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 261elRecompile.addEventListener('click', () => {
262 editedSource = editor.value;
263 void rebuild();
264});
265elRevert.addEventListener('click', () => {
266 editedSource = null;
267 editor.value = model.source;
268 void rebuild();
269});
271// The command reproduces this run's parameters on the desktop; keep it
272// selectable even where the clipboard API is unavailable.
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 273elCopyCmd.addEventListener('click', () => {
274 const text = elCmd.textContent ?? '';
275 const flash = (msg: string): void => {
276 elCopyCmd.textContent = msg;
277 setTimeout(() => (elCopyCmd.textContent = 'Copy'), 1200);
278 };
279 const selectCommand = (): void => {
280 const range = document.createRange();
281 range.selectNodeContents(elCmd);
282 const sel = getSelection();
283 sel?.removeAllRanges();
284 sel?.addRange(range);
285 flash('Selected');
286 };
287 if (!navigator.clipboard) return selectCommand();
288 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
289});
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 291// ---------------------------------------------------------------- setup
292function disposeView(): void {
293 for (const s of scenes) s.dispose();
294 scenes = [];
295 colorbars = [];
296 resizeObs?.disconnect();
297 resizeObs = null;
298 elPanels.replaceChildren();
299}
302 * Build the mesh, scenes, colorbars and per-species buffers on the current
303 * render grid. Call disposeView() first. The color ranges are kept if present,
304 * so a display-only rebuild (an oversampling change) does not pop the shading;
305 * a full rebuild clears `ranges` beforehand.
306 */
307function buildView(): void {
308 if (!session) return;
309 const view = session.viewSht;
310 const { nphi } = view.cfg;
311 const phi = new Float64Array(nphi);
312 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
313 topo = buildTopology(view.cosTheta, phi);
315 const sphereBg = getComputedStyle(document.documentElement)
316 .getPropertyValue('--sphere-bg')
317 .trim();
318 for (let k = 0; k < model.species.length; k++) {
319 const panel = document.createElement('div');
320 panel.className = 'panel';
321 const box = document.createElement('div');
322 box.className = 'sphere-box';
323 const tag = document.createElement('div');
324 tag.className = 'species-tag';
325 tag.textContent = model.species[k];
326 box.append(tag);
327 const side = document.createElement('div');
328 panel.append(box, side);
329 elPanels.append(panel);
331 const scene = new SphereScene(
332 box,
333 topo.numVertices,
334 topo.indices,
335 topo.sphereRef,
336 sphereBg || undefined,
337 );
338 scene.fitCamera();
339 scenes.push(scene);
340 colorbars.push(new Colorbar(side));
341 valueBufs[k] = new Float32Array(topo.numVertices);
342 colorBufs[k] = new Float32Array(topo.numVertices * 3);
343 if (!ranges[k]) ranges[k] = { lo: NaN, hi: NaN };
344 }
345 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
347 resizeObs = new ResizeObserver(() => {
348 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
349 boxes.forEach((box, i) => {
350 scenes[i]?.resize(box.clientWidth, box.clientHeight);
351 });
352 });
353 elPanels
354 .querySelectorAll<HTMLElement>('.sphere-box')
355 .forEach((box) => resizeObs!.observe(box));
356}
358/**
359 * Apply the UI's oversampling choice to the running session. Display-only: the
360 * session and its state survive; only the display plan, mesh and scenes are
361 * rebuilt, keeping the camera pose and color ranges. The pump is drained first
362 * so no readback is in flight on the plan being replaced.
363 */
364async function applyOversample(): Promise<void> {
365 if (!session) return;
366 const gen = generation;
367 const os = resolveOversample();
368 if (os === session.oversample) return;
369 const wasRunning = running;
370 setRunning(false);
371 while (pumping) await nextFrame();
372 if (gen !== generation || !session) return;
373 await session.setOversample(os);
374 if (gen !== generation || !session) return;
375 const cam = scenes[0]?.cameraState();
376 disposeView();
377 buildView();
378 if (cam) for (const s of scenes) s.setCameraState(cam);
379 await draw();
380 updateStats();
381 if (wasRunning) setRunning(true);
382}
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 384/** Report a compile failure, and select the offending text in the editor. */
385function reportCompileError(e: unknown): void {
386 elErr.textContent = formatFailure(e, source());
387 elCompiled.textContent = '';
388 if (e instanceof ModelCompileError && e.start !== undefined) {
389 editor.select(e.start, e.end ?? e.start);
390 }
391}
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 393async function rebuild(): Promise<void> {
394 generation++;
395 const gen = generation;
396 setRunning(false);
397 disposeView();
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 398 session?.destroy();
399 session = null;
0f3abbdSeparate solver time from frame time, and add a cross-environment checkJeremy Magland 400 solverMs = 0;
401 frameMs = 0;
cde22eaStop the per-frame sync from inflating the app's solver numberJeremy Magland 402 lastMeasure = 0;
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 403 elErr.textContent = '';
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 404 updateCommand();
407 try {
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 408 session = await ModelSession.create({
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 410 model,
411 params,
412 lmax: Number(elLmax.value),
163ec45Render on demand, display oversampling, and jump-free solver timingJeremy Magland 414 oversample: resolveOversample(),
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 416 } catch (e) {
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 418 return;
419 }
420 if (gen !== generation) return;
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 422 session.seed(seed);
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 424 const plan = session.describe();
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 425 elCompiled.textContent =
426 `one step compiled to ${plan.step.length} GPU operations:\n` +
427 plan.step.map((l) => ` ${l}`).join('\n');
428 elRecompile.textContent = 'Recompile';
163ec45Render on demand, display oversampling, and jump-free solver timingJeremy Magland 430 ranges = [];
431 buildView();
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 434 updateStats();
435 void pump();
436}
438async function reseed(): Promise<void> {
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 439 if (!session) return;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 440 const gen = generation;
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 441 session.seed(seed);
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 442 if (gen !== generation) return;
443 for (const r of ranges) {
444 r.lo = NaN;
445 r.hi = NaN;
446 }
448 updateStats();
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 449}
451// ---------------------------------------------------------------- drawing
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 452async function draw(): Promise<void> {
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 453 if (!session || !topo) return;
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 454 const gen = generation;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 455 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 456 for (let k = 0; k < model.species.length; k++) {
457 // The one readback per frame — the loop is otherwise entirely on the GPU.
458 // A rebuild can land while this is in flight and destroy the buffer being
459 // mapped, which rejects the map; that result is stale anyway, so drop it.
460 let field: Float32Array;
461 try {
163ec45Render on demand, display oversampling, and jump-free solver timingJeremy Magland 462 field = await session.readSpecies(k);
464 if (gen !== generation) return;
465 throw e;
466 }
467 if (gen !== generation || !topo) return;
468 fillFieldValues(valueBufs[k], field, topo);
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 469 let lo = Infinity;
470 let hi = -Infinity;
471 for (const v of valueBufs[k]) {
472 if (v < lo) lo = v;
473 if (v > hi) hi = v;
474 }
475 // smooth the color range in both directions so the shading evolves
476 // gently as the pattern grows (out-of-range values clamp meanwhile)
477 const r = ranges[k];
478 if (!Number.isFinite(r.lo)) {
479 r.lo = lo;
480 r.hi = hi;
481 } else {
482 const a = 0.15;
483 r.lo += a * (lo - r.lo);
484 r.hi += a * (hi - r.hi);
485 }
486 if (r.hi - r.lo < 1e-9) {
487 const mid = (r.hi + r.lo) / 2;
488 r.lo = mid - 5e-10;
489 r.hi = mid + 5e-10;
490 }
491 fillColors(colorBufs[k], valueBufs[k], r.lo, r.hi, cmap);
492 scenes[k]?.updateColors(colorBufs[k]);
493 colorbars[k]?.update(cmap, r.lo, r.hi);
494 }
495}
497function updateStats(): void {
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 498 if (!session) return;
499 const { nlat, nphi } = session.cfg;
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 500 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
0f3abbdSeparate solver time from frame time, and add a cross-environment checkJeremy Magland 501 const solver =
502 solverMs > 0
cde22eaStop the per-frame sync from inflating the app's solver numberJeremy Magland 503 ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s, ` +
504 `batch of ${MEASURE_BURST}, no readback)`
0f3abbdSeparate solver time from frame time, and add a cross-environment checkJeremy Magland 505 : '—';
506 const frame =
507 frameMs > 0
cde22eaStop the per-frame sync from inflating the app's solver numberJeremy Magland 508 ? `${frameMs.toFixed(1)} ms/frame (${STEPS_PER_FRAME} steps + readback + render)`
0f3abbdSeparate solver time from frame time, and add a cross-environment checkJeremy Magland 509 : '—';
163ec45Render on demand, display oversampling, and jump-free solver timingJeremy Magland 510 const view = session.viewSht.cfg;
511 const render =
512 session.oversample > 1
513 ? ` (display ${view.nlat}×${view.nphi}, ${session.oversample}×)`
514 : '';
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 515 elStats.innerHTML =
163ec45Render on demand, display oversampling, and jump-free solver timingJeremy Magland 516 `<b>${kind}</b> · grid ${nlat}×${nphi}${render} · nlm ${session.sht.nlm.toLocaleString()} · ` +
0f3abbdSeparate solver time from frame time, and add a cross-environment checkJeremy Magland 517 `${session.sht.fourierMode.toUpperCase()} · solver ${solver} · ${frame} · ` +
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 518 `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 519}
521// ---------------------------------------------------------------- sim loop
522const nextFrame = () => new Promise<number>(requestAnimationFrame);
524async function pump(): Promise<void> {
525 if (pumping) return;
526 pumping = true;
527 const gen = generation;
528 try {
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 529 while (running && session && gen === generation) {
cde22eaStop the per-frame sync from inflating the app's solver numberJeremy Magland 530 // Occasionally, a burst purely to measure the solver rate: many steps,
531 // one sync, nothing read back — directly comparable to the desktop
163ec45Render on demand, display oversampling, and jump-free solver timingJeremy Magland 532 // benchmark's throughput number. State-preserving: the display and
533 // model time are unaffected.
cde22eaStop the per-frame sync from inflating the app's solver numberJeremy Magland 534 if (performance.now() - lastMeasure > MEASURE_EVERY_MS) {
163ec45Render on demand, display oversampling, and jump-free solver timingJeremy Magland 535 const ms = await session.measure(MEASURE_BURST);
cde22eaStop the per-frame sync from inflating the app's solver numberJeremy Magland 536 if (gen !== generation) break;
163ec45Render on demand, display oversampling, and jump-free solver timingJeremy Magland 537 solverMs = ms;
cde22eaStop the per-frame sync from inflating the app's solver numberJeremy Magland 538 lastMeasure = performance.now();
539 }
541 // The frame itself. No explicit sync here — draw()'s readback already
542 // waits for the steps, so asking twice would only add a round trip.
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 543 const t0 = performance.now();
35d91faDelete the TypeScript solver; the .m models are the only implementationJeremy Magland 544 session.step(STEPS_PER_FRAME);
546 if (gen !== generation) break;
cde22eaStop the per-frame sync from inflating the app's solver numberJeremy Magland 547 frameMs = frameMs === 0
548 ? performance.now() - t0
549 : frameMs + 0.05 * (performance.now() - t0 - frameMs);
551 await nextFrame();
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 552 }
553 if (gen === generation) {
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 555 updateStats();
556 }
557 } finally {
558 pumping = false;
559 }
560}
3221abdAdd a Benchmark button that measures the solver and the GPU's clock rampJeremy Magland 562/**
563 * Sustained solver benchmark, in the page.
564 *
565 * The same measurement `npm run bench` makes: batches of steps submitted
566 * together, waited for, never read back, with no rendering and no animation
567 * pacing in between. That makes it directly comparable to the terminal number,
568 * which is the only way to tell a genuinely slower browser GPU stack apart from
569 * the costs the app adds on top.
570 *
571 * It also reports the ramp — the first third of the run against the last. GPUs
572 * downclock when idle, and an animation-paced loop leaves them idle most of every
573 * frame, so a large ramp means the app's steady-state number is limited by clocks
574 * rather than by the work.
575 *
576 * These are ordinary steps: the simulation advances by them.
577 */
578async function benchmark(): Promise<void> {
f168295Movie export: recompute the run from t = 0 into a captioned MP4Jeremy Magland 579 if (!session || movieBusy) return;
3221abdAdd a Benchmark button that measures the solver and the GPU's clock rampJeremy Magland 580 setRunning(false);
581 const BATCH = 32;
582 const DURATION_MS = 2000;
583 elBenchResult.textContent = 'benchmarking…';
f168295Movie export: recompute the run from t = 0 into a captioned MP4Jeremy Magland 584 // A movie started mid-benchmark would replay while this loop still steps.
585 elMovie.disabled = true;
586 try {
587 await nextFrame();
589 const gen = generation;
590 const perStep: number[] = [];
591 const t0 = performance.now();
592 while (performance.now() - t0 < DURATION_MS) {
593 const b0 = performance.now();
594 session.step(BATCH);
595 await session.sync();
596 if (gen !== generation) return;
597 perStep.push((performance.now() - b0) / BATCH);
598 }
f168295Movie export: recompute the run from t = 0 into a captioned MP4Jeremy Magland 600 const mean = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
601 const all = mean(perStep);
602 const best = Math.min(...perStep);
603 const third = Math.max(1, Math.floor(perStep.length / 3));
604 const first = mean(perStep.slice(0, third));
605 const last = mean(perStep.slice(-third));
606 const steps = perStep.length * BATCH;
608 elBenchResult.innerHTML =
609 `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
610 `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
611 `ramp ${(first / last).toFixed(2)}× (${first.toFixed(2)} → ${last.toFixed(2)}) · ` +
612 `${steps} steps in batches of ${BATCH} · ` +
613 `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
614 await draw();
615 updateStats();
616 } finally {
617 elMovie.disabled = false;
f168295Movie export: recompute the run from t = 0 into a captioned MP4Jeremy Magland 621// ---------------------------------------------------------------- movie
622function saveBlob(blob: Blob, filename: string): void {
623 const url = URL.createObjectURL(blob);
624 const a = document.createElement('a');
625 a.href = url;
626 a.download = filename;
627 a.click();
628 setTimeout(() => URL.revokeObjectURL(url), 10_000);
629}
631/** Submit `n` steps in bounded command buffers — a single buffer encoding
632 * many thousands of steps can exhaust the encoder. */
633function submitSteps(n: number): void {
634 while (n > 0 && session) {
635 const chunk = Math.min(512, n);
636 session.step(chunk);
637 n -= chunk;
638 }
639}
641/** While recording, lock everything that could change the run mid-replay;
642 * the Movie button itself becomes the cancel button. */
643function setMovieUi(on: boolean): void {
644 const locked = [
645 elModel, elLmax, elOversample, elColormap, elRunPause, elBenchmark,
646 elReseed, elRecompile, elRevert, elMovieSpeed, elMovieRotate, elMovieToggle,
647 ];
648 for (const el of locked) el.disabled = on;
649 elParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
650 elMovie.textContent = on ? 'Cancel · 0%' : 'Export';
651}
653/**
654 * Recompute the run from t = 0 and download it as an MP4.
655 *
656 * The movie is not a recording of what already happened — it is the same
657 * trajectory recomputed: same seed, same source, and the *current* parameters
658 * and colormap throughout. Determinism makes this exact: after the replay the
659 * state is where it was, so the one session is reused and the app resumes as
660 * if nothing happened. Frames are composited from the live panels, so the
661 * movie shows the spheres at the current camera orientation — and the replay
662 * doubles as the progress display, since it is visible on screen.
663 */
664async function recordMovie(): Promise<void> {
665 if (!session || movieBusy) return;
666 if (session.steps === 0) {
667 elMovie.textContent = 'run first';
668 setTimeout(() => (elMovie.textContent = 'Export'), 1200);
669 return;
670 }
671 movieBusy = true;
672 movieCancel = false;
673 const gen = generation;
674 setMovieUi(true);
675 let wasRunning = false;
676 let total = 0;
677 let done = 0;
678 let seeded = false;
679 let camBefore: ReturnType<SphereScene['cameraState']> | undefined;
680 try {
681 // An in-flight display-grid swap replaces the scenes whose canvases the
682 // recorder captures, and resumes the run when it lands — let it finish.
683 await viewChange;
684 if (gen !== generation || !session) return;
685 wasRunning = running;
686 setRunning(false);
687 while (pumping) await nextFrame(); // let an in-flight live frame drain
688 if (gen !== generation || !session) return;
689 total = session.steps;
690 const speed = Number(elMovieSpeed.value) || 10;
691 const rotate = elMovieRotate.checked;
692 if (rotate) camBefore = scenes[0]?.cameraState();
693 const durationS = Math.max(session.t / speed, 2 / MOVIE_FPS);
694 const frames = Math.max(
695 2,
696 Math.min(Math.round(durationS * MOVIE_FPS) + 1, total + 1, MOVIE_MAX_FRAMES),
697 );
698 /** The step index captured as frame `i`; both endpoints land exactly. */
699 const stepAt = (i: number): number => Math.round((i * total) / (frames - 1));
701 const title =
702 (presets.find((p) => p.key === elModel.value)?.label ?? model.label) +
703 (editedSource !== null ? ' (edited)' : '');
704 const subtitle = model.params
705 .map((spec) => `${spec.label} ${fmtValue(params[spec.key])}`)
706 .join(' · ');
707 const rec = await MovieRecorder.create({
708 panels: model.species.map((label, k) => ({ canvas: scenes[k].canvas, label })),
709 title,
710 subtitle,
711 speed,
712 fps: (frames - 1) / durationS,
713 });
715 let finished = false;
716 try {
717 // Reset the color-range smoothing as a re-seed does, so the shading
718 // evolves in the movie the way it did live.
719 session.seed(seed);
720 seeded = true;
721 for (const r of ranges) {
722 r.lo = NaN;
723 r.hi = NaN;
724 }
725 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
726 let lastVideoS = 0;
727 for (let frame = 0; ; ) {
728 await draw();
729 if (gen !== generation) return;
730 if (movieCancel) break;
731 if (rotate) {
732 // Advance the orbit by this frame's share of video time; siblings
733 // follow scenes[0] through the usual camera sync.
734 const videoS = session.t / speed;
735 scenes[0]?.orbitBy(2 * Math.PI * MOVIE_ROTATE_RPS * (videoS - lastVideoS));
736 lastVideoS = videoS;
737 }
738 for (const s of scenes) s.renderNow();
739 await rec.addFrame(
740 session.t,
741 model.species.map((_, k) => ({ cmap, lo: ranges[k].lo, hi: ranges[k].hi })),
742 );
743 if (++frame >= frames) {
744 finished = true;
745 break;
746 }
747 const target = stepAt(frame);
748 submitSteps(target - done);
749 done = target;
750 elMovie.textContent = `Cancel · ${Math.round((100 * done) / total)}%`;
751 }
752 if (finished) {
753 const blob = await rec.finish();
754 saveBlob(
755 blob,
756 `turing-sphere-${model.key}-t${session.t.toFixed(2)}-${speed}x.mp4`,
757 );
758 }
759 } finally {
760 if (!finished) rec.cancel();
761 }
762 } catch (e) {
763 elErr.textContent = `movie: ${e instanceof Error ? e.message : e}`;
764 } finally {
765 // A cancelled replay stopped short of where the run was; step the
766 // remainder — determinism makes this land exactly there.
767 if (seeded && gen === generation && session) {
768 while (done < total && gen === generation && session) {
769 const n = Math.min(4096, total - done);
770 submitSteps(n);
771 done += n;
772 elMovie.textContent = `restoring · ${Math.round((100 * done) / total)}%`;
773 await session.sync();
774 }
775 await draw();
776 updateStats();
777 }
778 if (camBefore && gen === generation) {
779 for (const s of scenes) s.setCameraState(camBefore);
780 }
781 movieBusy = false;
782 setMovieUi(false);
783 if (gen === generation) setRunning(wasRunning);
784 }
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 787// ---------------------------------------------------------------- boot
788async function boot(): Promise<void> {
789 elModel.value = presets[0].key;
790 applyPreset(presets[0].key);
791 try {
792 device = await requestShtDevice();
793 adapterName = await describeAdapter(device);
794 } catch (e) {
795 device = null;
796 elErr.textContent =
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 797 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
798 `This demo compiles the MATLAB solver to WebGPU compute shaders, so it ` +
799 `needs a WebGPU-capable browser (Chrome/Edge 113+).`;
800 return;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 801 }
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 802 device.lost.then((info) => {
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 803 if (info.reason !== 'destroyed') {
804 elErr.textContent = `WebGPU device lost: ${info.message}`;
805 }
806 });
807 await rebuild();
808}
810void boot();