/ concept-collection / turing-sphere
Sign in
concept-collection / turing-sphere
363 lines · 10.9 KBBlameHistoryRaw
1import {
2 GpuBackend,
3 CpuBackend,
4 requestShtDevice,
5 type ShtBackend,
6} from './solver/backend.ts';
7import { Simulation, gridForLmax } from './solver/simulation.ts';
8import {
9 models,
10 presets,
11 defaultParams,
12 type ModelSpec,
13 type Params,
14} from './solver/models.ts';
15import {
16 buildTopology,
17 fillFieldValues,
18 fillColors,
19 type SphereMeshTopology,
20} from './render/sphereMesh.ts';
21import { SphereScene } from './render/SphereScene.ts';
22import { Colorbar } from './render/colorbar.ts';
23import { colormaps, colormapNames } from './render/colormaps.ts';
25const $ = <T extends HTMLElement>(id: string): T =>
26 document.getElementById(id) as T;
28const elModel = $<HTMLSelectElement>('model');
29const elLmax = $<HTMLSelectElement>('lmax');
30const elColormap = $<HTMLSelectElement>('colormap');
31const elBackend = $<HTMLSelectElement>('backend');
32const elRunPause = $<HTMLButtonElement>('runpause');
33const elReseed = $<HTMLButtonElement>('reseed');
34const elResetView = $<HTMLButtonElement>('resetview');
35const elParams = $('params');
36const elPanels = $('panels');
37const elStats = $('stats');
38const elBlurb = $('blurb');
39const elErr = $('err');
41for (const p of presets) {
42 const o = document.createElement('option');
43 o.value = p.key;
44 o.textContent = p.label;
45 elModel.append(o);
47for (const name of colormapNames) {
48 const o = document.createElement('option');
49 o.value = name;
50 o.textContent = name;
51 elColormap.append(o);
53elColormap.value = 'jet';
55// ---------------------------------------------------------------- state
56let device: GPUDevice | null = null;
57let backend: ShtBackend | null = null;
58let sim: Simulation | null = null;
59let topo: SphereMeshTopology | null = null;
60let scenes: SphereScene[] = [];
61let colorbars: Colorbar[] = [];
62let valueBufs: Float32Array[] = [];
63let colorBufs: Float32Array[] = [];
64let ranges: { lo: number; hi: number }[] = [];
65let resizeObs: ResizeObserver | null = null;
67let model: ModelSpec = models[0];
68let params: Params = defaultParams(model);
69let seed = 1;
70let running = false;
71let adapterName = '';
72let pumping = false;
73let stepMs = 0;
74let generation = 0; // bumped on every rebuild to cancel stale pumps
76// ---------------------------------------------------------------- UI wiring
77function buildParamInputs(): void {
78 elParams.replaceChildren();
79 for (const spec of model.params) {
80 const label = document.createElement('label');
81 label.textContent = `${spec.label} `;
82 const input = document.createElement('input');
83 input.type = 'number';
84 input.min = String(spec.min);
85 input.max = String(spec.max);
86 input.step = String(spec.step);
87 input.value = String(params[spec.key]);
88 input.addEventListener('change', () => {
89 const v = Number(input.value);
90 if (Number.isFinite(v)) params[spec.key] = v;
91 });
92 label.append(input);
93 elParams.append(label);
94 }
97function applyPreset(presetKey: string): void {
98 const preset = presets.find((p) => p.key === presetKey) ?? presets[0];
99 model = models.find((m) => m.key === preset.modelKey) ?? models[0];
100 params = { ...defaultParams(model), ...preset.params };
101 buildParamInputs();
102 elBlurb.textContent = model.blurb;
105elModel.addEventListener('change', () => {
106 applyPreset(elModel.value);
107 void rebuild();
108});
109elLmax.addEventListener('change', () => void rebuild());
110elBackend.addEventListener('change', () => void rebuild());
111elColormap.addEventListener('change', () => draw());
112function setRunning(next: boolean): void {
113 running = next;
114 elRunPause.textContent = running ? 'Pause' : 'Run';
115 if (running) void pump();
118elRunPause.addEventListener('click', () => setRunning(!running));
119elReseed.addEventListener('click', () => {
120 seed = (Math.random() * 2 ** 31) >>> 0;
121 setRunning(false);
122 void reseed();
123});
124elResetView.addEventListener('click', () => {
125 for (const s of scenes) s.resetCamera();
126});
128// ---------------------------------------------------------------- setup
129function disposeView(): void {
130 for (const s of scenes) s.dispose();
131 scenes = [];
132 colorbars = [];
133 resizeObs?.disconnect();
134 resizeObs = null;
135 elPanels.replaceChildren();
138async function rebuild(): Promise<void> {
139 generation++;
140 const gen = generation;
141 // a rebuild restarts from a fresh initial state, so pause like Re-seed does
142 setRunning(false);
143 disposeView();
144 backend?.destroy();
145 backend = null;
146 sim = null;
147 stepMs = 0;
149 const lmax = Number(elLmax.value);
150 const { nlat, nphi } = gridForLmax(lmax, model.pdeg);
151 const cfg = { lmax, mmax: lmax, nlat, nphi };
152 const wantGpu = elBackend.value === 'webgpu' && device !== null;
153 try {
154 backend = wantGpu
155 ? await GpuBackend.create(device!, cfg)
156 : new CpuBackend(cfg);
157 } catch (e) {
158 elErr.textContent = `Failed to create transform plan: ${e}`;
159 return;
160 }
161 if (gen !== generation) return;
162 elErr.textContent =
163 !wantGpu && lmax > 31
164 ? 'Heads up: the CPU backend is a direct-summation f64 reference — expect well under 10 steps/s at this lmax.'
165 : '';
167 sim = new Simulation(backend, model, params);
168 await sim.init(seed);
169 if (gen !== generation) return;
171 // mesh + scenes
172 const phi = new Float64Array(nphi);
173 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
174 topo = buildTopology(backend.cosTheta, phi);
176 const sphereBg = getComputedStyle(document.documentElement)
177 .getPropertyValue('--sphere-bg')
178 .trim();
179 for (let k = 0; k < sim.nspecies; k++) {
180 const panel = document.createElement('div');
181 panel.className = 'panel';
182 const box = document.createElement('div');
183 box.className = 'sphere-box';
184 const tag = document.createElement('div');
185 tag.className = 'species-tag';
186 tag.textContent = model.species[k];
187 box.append(tag);
188 const side = document.createElement('div');
189 panel.append(box, side);
190 elPanels.append(panel);
192 const scene = new SphereScene(
193 box,
194 topo.numVertices,
195 topo.indices,
196 topo.sphereRef,
197 sphereBg || undefined,
198 );
199 scene.fitCamera();
200 scenes.push(scene);
201 colorbars.push(new Colorbar(side));
202 valueBufs[k] = new Float32Array(topo.numVertices);
203 colorBufs[k] = new Float32Array(topo.numVertices * 3);
204 ranges[k] = { lo: NaN, hi: NaN };
205 }
206 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
208 resizeObs = new ResizeObserver(() => {
209 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
210 boxes.forEach((box, i) => {
211 scenes[i]?.resize(box.clientWidth, box.clientHeight);
212 });
213 });
214 elPanels
215 .querySelectorAll<HTMLElement>('.sphere-box')
216 .forEach((box) => resizeObs!.observe(box));
218 draw();
219 updateStats();
220 void pump();
223async function reseed(): Promise<void> {
224 if (!sim) return;
225 const gen = generation;
226 await sim.init(seed);
227 if (gen !== generation) return;
228 for (const r of ranges) {
229 r.lo = NaN;
230 r.hi = NaN;
231 }
232 draw();
235// ---------------------------------------------------------------- drawing
236function draw(): void {
237 if (!sim || !topo) return;
238 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
239 for (let k = 0; k < sim.nspecies; k++) {
240 fillFieldValues(valueBufs[k], sim.V[k], topo);
241 let lo = Infinity;
242 let hi = -Infinity;
243 for (const v of valueBufs[k]) {
244 if (v < lo) lo = v;
245 if (v > hi) hi = v;
246 }
247 // smooth the color range in both directions so the shading evolves
248 // gently as the pattern grows (out-of-range values clamp meanwhile)
249 const r = ranges[k];
250 if (!Number.isFinite(r.lo)) {
251 r.lo = lo;
252 r.hi = hi;
253 } else {
254 const a = 0.15;
255 r.lo += a * (lo - r.lo);
256 r.hi += a * (hi - r.hi);
257 }
258 if (r.hi - r.lo < 1e-9) {
259 const mid = (r.hi + r.lo) / 2;
260 r.lo = mid - 5e-10;
261 r.hi = mid + 5e-10;
262 }
263 fillColors(colorBufs[k], valueBufs[k], r.lo, r.hi, cmap);
264 scenes[k]?.updateColors(colorBufs[k]);
265 colorbars[k]?.update(cmap, r.lo, r.hi);
266 }
269function updateStats(): void {
270 if (!sim || !backend) return;
271 const { nlat, nphi } = backend.cfg;
272 const kind =
273 backend.kind === 'webgpu'
274 ? `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`
275 : 'CPU f64 (direct summation)';
276 const rate = stepMs > 0 ? `${(1000 / stepMs).toFixed(1)} steps/s` : '—';
277 elStats.innerHTML =
278 `<b>${kind}</b> · grid ${nlat}×${nphi} · nlm ${backend.nlm.toLocaleString()} · ` +
279 `${stepMs > 0 ? stepMs.toFixed(1) : '—'} ms/step · ${rate} · ` +
280 `t = <b>${sim.t.toFixed(2)}</b> (${sim.stepCount} steps)`;
283// ---------------------------------------------------------------- sim loop
284const nextFrame = () => new Promise<number>(requestAnimationFrame);
286async function pump(): Promise<void> {
287 if (pumping) return;
288 pumping = true;
289 const gen = generation;
290 let lastYield = performance.now();
291 try {
292 while (running && sim && gen === generation) {
293 const t0 = performance.now();
294 await sim.step();
295 const dtMs = performance.now() - t0;
296 stepMs = stepMs === 0 ? dtMs : stepMs + 0.05 * (dtMs - stepMs);
297 const now = performance.now();
298 if (now - lastYield > 25 || backend?.kind === 'cpu') {
299 draw();
300 updateStats();
301 await nextFrame();
302 lastYield = performance.now();
303 }
304 }
305 // final frame after pausing
306 if (gen === generation) {
307 draw();
308 updateStats();
309 }
310 } finally {
311 pumping = false;
312 }
315// ---------------------------------------------------------------- boot
316/** Best-effort human-readable adapter name, so it is clear which GPU (or
317 * software rasterizer) is actually running the transforms. */
318async function describeAdapter(dev: GPUDevice): Promise<string> {
319 const fmt = (info: GPUAdapterInfo | undefined): string => {
320 if (!info) return '';
321 const parts = [info.description, info.device, info.vendor].filter(
322 (s): s is string => !!s && s.length > 0,
323 );
324 const name = parts[0] ?? '';
325 return info.architecture && !name.includes(info.architecture)
326 ? `${name} (${info.architecture})`.trim()
327 : name;
328 };
329 const own = fmt((dev as GPUDevice & { adapterInfo?: GPUAdapterInfo }).adapterInfo);
330 if (own) return own;
331 try {
332 const adapter = await navigator.gpu.requestAdapter();
333 return fmt(adapter?.info);
334 } catch {
335 return '';
336 }
339async function boot(): Promise<void> {
340 elModel.value = presets[0].key;
341 applyPreset(presets[0].key);
342 try {
343 device = await requestShtDevice();
344 adapterName = await describeAdapter(device);
345 } catch (e) {
346 device = null;
347 elLmax.value = '31';
348 elBackend.value = 'cpu';
349 elBackend.options[0].disabled = true;
350 elErr.textContent =
351 `WebGPU is not available (${e instanceof Error ? e.message : e}); ` +
352 `falling back to the slow CPU transform at low resolution. ` +
353 `Use a WebGPU-capable browser (Chrome/Edge 113+) for the full experience.`;
354 }
355 device?.lost.then((info) => {
356 if (info.reason !== 'destroyed') {
357 elErr.textContent = `WebGPU device lost: ${info.message}`;
358 }
359 });
360 await rebuild();
363void boot();
moveopenescclose