61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 1import { requestShtDevice, ShtPlan } from './sht/sht.ts';
2import { describeAdapter } from './solver/backend.ts';
3import { gridForLmax, makeRandn } from './solver/simulation.ts';
4import { presets, type Params } from './solver/models.ts';
5import { GpuModel } from './mgpu/model.ts';
6import { mModelByKey, type MModel } from './mgpu/registry.ts';
7import { ModelCompileError, formatFailure } from './mgpu/errors.ts';
8import { EXTERNAL_OPS } from './mgpu/externals.ts';
9import { CodeEditor } from './editor/codeEditor.ts';
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 10import {
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 11 formatCommand,
12 resolvePreset,
13 DEFAULT_STEPS,
14 DEFAULT_WARMUP,
15 type RunSpec,
16} from './bench/runSpec.ts';
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 17import {
18 buildTopology,
19 fillFieldValues,
20 fillColors,
21 type SphereMeshTopology,
22} from './render/sphereMesh.ts';
23import { SphereScene } from './render/SphereScene.ts';
24import { Colorbar } from './render/colorbar.ts';
25import { colormaps, colormapNames } from './render/colormaps.ts';
27const $ = <T extends HTMLElement>(id: string): T =>
28 document.getElementById(id) as T;
30const elModel = $<HTMLSelectElement>('model');
31const elLmax = $<HTMLSelectElement>('lmax');
32const elColormap = $<HTMLSelectElement>('colormap');
33const elRunPause = $<HTMLButtonElement>('runpause');
34const elReseed = $<HTMLButtonElement>('reseed');
35const elResetView = $<HTMLButtonElement>('resetview');
36const elParams = $('params');
37const elPanels = $('panels');
38const elStats = $('stats');
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 39const elCmd = $('cmd');
40const elCopyCmd = $<HTMLButtonElement>('copycmd');
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 41const elBlurb = $('blurb');
42const elErr = $('err');
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 43const elSource = $<HTMLTextAreaElement>('source');
44const elHighlight = $('highlight');
45const elCompiled = $('compiled');
46const elEditorTitle = $('editor-title');
47const elRecompile = $<HTMLButtonElement>('recompile');
48const elRevert = $<HTMLButtonElement>('revert');
50for (const p of presets) {
51 const o = document.createElement('option');
52 o.value = p.key;
53 o.textContent = p.label;
54 elModel.append(o);
55}
56for (const name of colormapNames) {
57 const o = document.createElement('option');
58 o.value = name;
59 o.textContent = name;
60 elColormap.append(o);
61}
62elColormap.value = 'jet';
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 64/** The model source, with MATLAB highlighting. The host-provided operations are
65 * marked so the boundary between the model and what it is given is visible. */
66const editor = new CodeEditor({
67 textarea: elSource,
68 overlay: elHighlight,
69 external: EXTERNAL_OPS,
70 onInput: (value) => {
71 editedSource = value;
72 elRecompile.textContent = 'Recompile *';
73 },
74});
76/** Timesteps submitted per rendered frame. Nothing is read back between them,
77 * so the batch costs one submit and one readback regardless of size. */
78const STEPS_PER_FRAME = 4;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 80// ---------------------------------------------------------------- state
81let device: GPUDevice | null = null;
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 82let sht: ShtPlan | null = null;
83let gpu: GpuModel | null = null;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 84let topo: SphereMeshTopology | null = null;
85let scenes: SphereScene[] = [];
86let colorbars: Colorbar[] = [];
87let valueBufs: Float32Array[] = [];
88let colorBufs: Float32Array[] = [];
89let ranges: { lo: number; hi: number }[] = [];
90let resizeObs: ResizeObserver | null = null;
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 92const initial = resolvePreset(presets[0].key);
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 93let model: MModel = mModelByKey(initial.model.key)!;
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 94let params: Params = initial.params;
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 95/** The .m as edited in the page; `null` while it matches the file. */
96let editedSource: string | null = null;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 97let seed = 1;
98let running = false;
99let adapterName = '';
100let pumping = false;
101let stepMs = 0;
103let stepCount = 0;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 104let generation = 0; // bumped on every rebuild to cancel stale pumps
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 106const source = (): string => editedSource ?? model.source;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 108// ---------------------------------------------------------------- UI wiring
109function buildParamInputs(): void {
110 elParams.replaceChildren();
111 for (const spec of model.params) {
112 const label = document.createElement('label');
113 label.textContent = `${spec.label} `;
114 const input = document.createElement('input');
115 input.type = 'number';
116 input.min = String(spec.min);
117 input.max = String(spec.max);
118 input.step = String(spec.step);
119 input.value = String(params[spec.key]);
120 input.addEventListener('change', () => {
121 const v = Number(input.value);
122 if (Number.isFinite(v)) params[spec.key] = v;
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 123 // Parameters are uniforms, not constants baked into the kernels, so a
124 // change costs an upload rather than a recompile.
125 gpu?.setParams(params);
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 126 updateCommand();
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 127 });
128 label.append(input);
129 elParams.append(label);
130 }
131}
133function applyPreset(presetKey: string): void {
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 134 const resolved = resolvePreset(presetKey);
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 135 const next = mModelByKey(resolved.model.key);
136 if (!next) {
137 elErr.textContent = `No .m model for '${resolved.model.key}'`;
138 return;
139 }
140 model = next;
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 141 params = resolved.params;
143 editor.value = model.source;
144 elEditorTitle.textContent =
145 `models/${model.key}.m — init() and step(), compiled to WebGPU`;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 146 buildParamInputs();
147 elBlurb.textContent = model.blurb;
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 148 updateCommand();
149}
151/** The run currently on screen, as the benchmark's RunSpec. */
152function currentSpec(): RunSpec {
153 return {
154 preset: elModel.value,
155 lmax: Number(elLmax.value),
158 steps: DEFAULT_STEPS,
159 warmup: DEFAULT_WARMUP,
160 params,
161 };
162}
164function updateCommand(): void {
165 elCmd.textContent = formatCommand(currentSpec());
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 166}
168elModel.addEventListener('change', () => {
169 applyPreset(elModel.value);
170 void rebuild();
171});
172elLmax.addEventListener('change', () => void rebuild());
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 173elColormap.addEventListener('change', () => void draw());
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 175function setRunning(next: boolean): void {
176 running = next;
177 elRunPause.textContent = running ? 'Pause' : 'Run';
178 if (running) void pump();
179}
181elRunPause.addEventListener('click', () => setRunning(!running));
182elReseed.addEventListener('click', () => {
183 seed = (Math.random() * 2 ** 31) >>> 0;
184 setRunning(false);
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 185 updateCommand();
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 186 void reseed();
187});
188elResetView.addEventListener('click', () => {
189 for (const s of scenes) s.resetCamera();
190});
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 192elRecompile.addEventListener('click', () => {
193 editedSource = editor.value;
194 void rebuild();
195});
196elRevert.addEventListener('click', () => {
197 editedSource = null;
198 editor.value = model.source;
199 void rebuild();
200});
202// The command reproduces this run's parameters on the desktop; keep it
203// selectable even where the clipboard API is unavailable.
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 204elCopyCmd.addEventListener('click', () => {
205 const text = elCmd.textContent ?? '';
206 const flash = (msg: string): void => {
207 elCopyCmd.textContent = msg;
208 setTimeout(() => (elCopyCmd.textContent = 'Copy'), 1200);
209 };
210 const selectCommand = (): void => {
211 const range = document.createRange();
212 range.selectNodeContents(elCmd);
213 const sel = getSelection();
214 sel?.removeAllRanges();
215 sel?.addRange(range);
216 flash('Selected');
217 };
218 if (!navigator.clipboard) return selectCommand();
219 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
220});
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 222// ---------------------------------------------------------------- setup
223function disposeView(): void {
224 for (const s of scenes) s.dispose();
225 scenes = [];
226 colorbars = [];
227 resizeObs?.disconnect();
228 resizeObs = null;
229 elPanels.replaceChildren();
230}
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 232/** Seeded perturbation, one normal deviate per grid point. */
233function makeNoise(npts: number): Float32Array {
234 const randn = makeRandn(seed);
235 const noise = new Float32Array(npts);
236 for (let i = 0; i < npts; i++) noise[i] = model.seedAmp * randn();
237 return noise;
238}
240/** Report a compile failure, and select the offending text in the editor. */
241function reportCompileError(e: unknown): void {
242 elErr.textContent = formatFailure(e, source());
243 elCompiled.textContent = '';
244 if (e instanceof ModelCompileError && e.start !== undefined) {
245 editor.select(e.start, e.end ?? e.start);
246 }
247}
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 249async function rebuild(): Promise<void> {
250 generation++;
251 const gen = generation;
252 setRunning(false);
253 disposeView();
255 gpu = null;
256 sht?.destroy();
257 sht = null;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 258 stepMs = 0;
260 stepCount = 0;
261 elErr.textContent = '';
15a77e2Add a desktop WebGPU benchmark and show its command in the appJeremy Magland 262 updateCommand();
265 const lmax = Number(elLmax.value);
266 const { nlat, nphi } = gridForLmax(lmax, model.pdeg);
267 const cfg = { lmax, mmax: lmax, nlat, nphi };
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 269 try {
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 270 sht = await ShtPlan.create(device, cfg);
271 gpu = await GpuModel.create({
272 device,
273 sht,
274 cfg,
275 source: source(),
276 paramNames: model.params.map((p) => p.key),
277 state: model.state,
278 view: model.species,
279 });
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 280 } catch (e) {
282 gpu?.destroy();
283 gpu = null;
284 sht?.destroy();
285 sht = null;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 286 return;
287 }
288 if (gen !== generation) return;
291 gpu.init(makeNoise(nlat * nphi));
293 const plan = gpu.describe();
294 elCompiled.textContent =
295 `one step compiled to ${plan.step.length} GPU operations:\n` +
296 plan.step.map((l) => ` ${l}`).join('\n');
297 elRecompile.textContent = 'Recompile';
299 // mesh + scenes
300 const phi = new Float64Array(nphi);
301 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 302 topo = buildTopology(sht.cosTheta, phi);
304 const sphereBg = getComputedStyle(document.documentElement)
305 .getPropertyValue('--sphere-bg')
306 .trim();
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 307 for (let k = 0; k < model.species.length; k++) {
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 308 const panel = document.createElement('div');
309 panel.className = 'panel';
310 const box = document.createElement('div');
311 box.className = 'sphere-box';
312 const tag = document.createElement('div');
313 tag.className = 'species-tag';
314 tag.textContent = model.species[k];
315 box.append(tag);
316 const side = document.createElement('div');
317 panel.append(box, side);
318 elPanels.append(panel);
320 const scene = new SphereScene(
321 box,
322 topo.numVertices,
323 topo.indices,
324 topo.sphereRef,
325 sphereBg || undefined,
326 );
327 scene.fitCamera();
328 scenes.push(scene);
329 colorbars.push(new Colorbar(side));
330 valueBufs[k] = new Float32Array(topo.numVertices);
331 colorBufs[k] = new Float32Array(topo.numVertices * 3);
332 ranges[k] = { lo: NaN, hi: NaN };
333 }
334 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
336 resizeObs = new ResizeObserver(() => {
337 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
338 boxes.forEach((box, i) => {
339 scenes[i]?.resize(box.clientWidth, box.clientHeight);
340 });
341 });
342 elPanels
343 .querySelectorAll<HTMLElement>('.sphere-box')
344 .forEach((box) => resizeObs!.observe(box));
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 347 updateStats();
348 void pump();
349}
351async function reseed(): Promise<void> {
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 352 if (!gpu || !sht) return;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 353 const gen = generation;
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 354 gpu.init(makeNoise(sht.cfg.nlat * sht.cfg.nphi));
355 simTime = 0;
356 stepCount = 0;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 357 if (gen !== generation) return;
358 for (const r of ranges) {
359 r.lo = NaN;
360 r.hi = NaN;
361 }
363 updateStats();
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 364}
366// ---------------------------------------------------------------- drawing
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 367async function draw(): Promise<void> {
368 if (!gpu || !topo) return;
369 const gen = generation;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 370 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 371 for (let k = 0; k < model.species.length; k++) {
372 // The one readback per frame — the loop is otherwise entirely on the GPU.
373 // A rebuild can land while this is in flight and destroy the buffer being
374 // mapped, which rejects the map; that result is stale anyway, so drop it.
375 let field: Float32Array;
376 try {
377 field = await gpu.read(model.species[k]);
378 } catch (e) {
379 if (gen !== generation) return;
380 throw e;
381 }
382 if (gen !== generation || !topo) return;
383 fillFieldValues(valueBufs[k], field, topo);
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 384 let lo = Infinity;
385 let hi = -Infinity;
386 for (const v of valueBufs[k]) {
387 if (v < lo) lo = v;
388 if (v > hi) hi = v;
389 }
390 // smooth the color range in both directions so the shading evolves
391 // gently as the pattern grows (out-of-range values clamp meanwhile)
392 const r = ranges[k];
393 if (!Number.isFinite(r.lo)) {
394 r.lo = lo;
395 r.hi = hi;
396 } else {
397 const a = 0.15;
398 r.lo += a * (lo - r.lo);
399 r.hi += a * (hi - r.hi);
400 }
401 if (r.hi - r.lo < 1e-9) {
402 const mid = (r.hi + r.lo) / 2;
403 r.lo = mid - 5e-10;
404 r.hi = mid + 5e-10;
405 }
406 fillColors(colorBufs[k], valueBufs[k], r.lo, r.hi, cmap);
407 scenes[k]?.updateColors(colorBufs[k]);
408 colorbars[k]?.update(cmap, r.lo, r.hi);
409 }
410}
412function updateStats(): void {
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 413 if (!gpu || !sht) return;
414 const { nlat, nphi } = sht.cfg;
415 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 416 const rate = stepMs > 0 ? `${(1000 / stepMs).toFixed(1)} steps/s` : '—';
417 elStats.innerHTML =
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 418 `<b>${kind}</b> · grid ${nlat}×${nphi} · nlm ${sht.nlm.toLocaleString()} · ` +
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 419 `${stepMs > 0 ? stepMs.toFixed(1) : '—'} ms/step · ${rate} · ` +
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 420 `t = <b>${simTime.toFixed(2)}</b> (${stepCount} steps)`;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 421}
423// ---------------------------------------------------------------- sim loop
424const nextFrame = () => new Promise<number>(requestAnimationFrame);
426async function pump(): Promise<void> {
427 if (pumping) return;
428 pumping = true;
429 const gen = generation;
430 try {
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 431 while (running && gpu && gen === generation) {
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 432 const t0 = performance.now();
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 433 gpu.step(STEPS_PER_FRAME);
434 // draw() awaits the readback, which also waits for the batch to finish,
435 // so this measures the real end-to-end cost per step.
436 await draw();
437 if (gen !== generation) break;
438 const dtMs = (performance.now() - t0) / STEPS_PER_FRAME;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 439 stepMs = stepMs === 0 ? dtMs : stepMs + 0.05 * (dtMs - stepMs);
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 440 simTime += STEPS_PER_FRAME * (params.dt ?? 0);
441 stepCount += STEPS_PER_FRAME;
442 updateStats();
443 await nextFrame();
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 444 }
445 if (gen === generation) {
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 447 updateStats();
448 }
449 } finally {
450 pumping = false;
451 }
452}
454// ---------------------------------------------------------------- boot
455async function boot(): Promise<void> {
456 elModel.value = presets[0].key;
457 applyPreset(presets[0].key);
458 try {
459 device = await requestShtDevice();
460 adapterName = await describeAdapter(device);
461 } catch (e) {
462 device = null;
463 elErr.textContent =
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 464 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
465 `This demo compiles the MATLAB solver to WebGPU compute shaders, so it ` +
466 `needs a WebGPU-capable browser (Chrome/Edge 113+).`;
467 return;
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 468 }
61e12f1Write the solver in MATLAB and compile it to WebGPUJeremy Magland 469 device.lost.then((info) => {
2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 470 if (info.reason !== 'destroyed') {
471 elErr.textContent = `WebGPU device lost: ${info.message}`;
472 }
473 });
474 await rebuild();
475}
477void boot();