/** * The app: problem controls, the solve pipeline (near quadrature, optional * self-test, room problem, energy balance, field on three planes), and the * views. The operator is kept between solves when only the wall admittance * or the source changes, since its near weights depend only on k and the * surface. */ import { roundedCube, type Surface } from './surface.ts'; import { ALPHA, energyBalance, green, inside, maskedPoints, relError, roomRhs, selftestPoints, selftestRhs, slicePoints, type CVec, } from './problem.ts'; import { adapterDescription, Gpu } from './gpu/device.ts'; import { Operator } from './gpu/operator.ts'; import { colorLimits, DB_MAX, DB_MIN, type Field, type Mode, type SliceField } from './view/field.ts'; import { SliceView } from './view/slices.ts'; import { RoomView } from './view/room3d.ts'; import { drawColorbar, DIVERGING, SEQUENTIAL } from './view/colormaps.ts'; import { drawConvergence } from './view/convergence.ts'; const $ = (id: string): T => { const el = document.getElementById(id); if (!el) throw new Error(`no #${id}`); return el as T; }; const A = 1.0; const EXTERIOR_SOURCE = [1.7, 0.6, -0.5]; interface Params { k: number; beta: number; m: number; norder: number; p: number; x0: [number, number, number]; eps: number; ngrid: number; selftest: boolean; } const PRESETS: Record> = { quick: { k: 6, beta: 0.2, m: 2, norder: 6 }, small: { k: 10, beta: 0.2, m: 4, norder: 6 }, medium: { k: 10, beta: 0.2, m: 6, norder: 8 }, large: { k: 40, beta: 0.05, m: 10, norder: 8 }, }; const DEFAULTS: Params = { k: 10, beta: 0.2, m: 4, norder: 6, p: 8, x0: [-0.35, 0.2, 0.1], eps: 1e-5, ngrid: 101, selftest: false }; const ui = { preset: $('preset'), k: $('k'), beta: $('beta'), m: $('m'), norder: $('norder'), p: $('p'), x0: [$('x0'), $('y0'), $('z0')], eps: $('eps'), ngrid: $('ngrid'), selftest: $('selftest'), solve: $('solve'), cancel: $('cancel'), derived: $('derived'), progress: $('progress'), status: $('status'), err: $('err'), mode: $('mode'), animate: $('animate'), showWalls: $('showWalls'), showPlanes: [$('showXY'), $('showXZ'), $('showYZ')], results: $('results'), conv: $('conv'), empty: $('empty'), cbar: $('cbar'), cbarHi: $('cbarHi'), cbarLo: $('cbarLo'), hover: $('hover'), device: $('device'), }; const fmtBytes = (b: number) => (b >= 2 ** 30 ? `${(b / 2 ** 30).toFixed(2)} GB` : `${(b / 2 ** 20).toFixed(0)} MB`); const fmtS = (s: number) => (s < 10 ? `${s.toFixed(2)} s` : `${s.toFixed(1)} s`); // ------------------------------------------------------------------ params function readParams(): Params { return { k: Number(ui.k.value), beta: Number(ui.beta.value), m: Math.round(Number(ui.m.value)), norder: Number(ui.norder.value), p: Number(ui.p.value), x0: ui.x0.map((e) => Number(e.value)) as [number, number, number], eps: Number(ui.eps.value), ngrid: Number(ui.ngrid.value), selftest: ui.selftest.checked, }; } function writeParams(p: Params) { ui.k.value = String(p.k); ui.beta.value = String(p.beta); ui.m.value = String(p.m); ui.norder.value = String(p.norder); ui.p.value = String(p.p); p.x0.forEach((v, i) => (ui.x0[i].value = String(+v.toFixed(3)))); ui.eps.value = String(p.eps); if (![...ui.eps.options].some((o) => o.value === ui.eps.value)) ui.eps.value = '1e-5'; ui.ngrid.value = String(p.ngrid); ui.selftest.checked = p.selftest; matchPreset(); } function matchPreset() { const p = readParams(); const name = Object.entries(PRESETS).find(([, v]) => v.k === p.k && v.beta === p.beta && v.m === p.m && v.norder === p.norder)?.[0]; ui.preset.value = name ?? 'custom'; } function validate(p: Params): string | null { if (!(p.k > 0 && p.k <= 80)) return 'the wavenumber must be between 0 and 80'; if (!(p.beta > 0)) return 'the wall admittance must be positive (absorbing walls)'; if (!(p.m >= 1 && p.m <= 16)) return 'patches per face edge must be between 1 and 16'; if (!inside(p.x0, A, p.p, 0.04)) return 'the source must be inside the room, a little away from the walls'; return null; } // ------------------------------------------------------------------ derived let deviceLimit = Infinity; function describe(p: Params): { html: string; blocking: boolean } { const npts = 6 * p.m * p.m * (p.norder + 1) ** 2; const wl = (2 * Math.PI) / p.k; const perWl = ((p.norder + 1) * p.m * wl) / (2 * A); const rfac = p.norder <= 6 ? 2.0 : 1.25; // near patches per node, measured on the named cases: about 9 at rfac 2, 5.5 at rfac 1.25, capped by the patch count const perNode = Math.min(6 * p.m * p.m, rfac >= 2 ? 9.5 : 5.8); const wbytes = npts * perNode * 6 * (p.norder + 1) ** 2 * 8; const parts = [ `${npts.toLocaleString()} points`, `room ${((2 * A) / wl).toFixed(1)} wavelengths wide`, `${perWl.toFixed(1)} nodes per wavelength`, `near weights ≈ ${fmtBytes(wbytes)}`, ]; let blocking = false; if (wbytes > 1.3 * deviceLimit) { parts.push(`probably more than this GPU allows (${fmtBytes(deviceLimit)})`); blocking = false; // an estimate only; let the operator decide } if (perWl < 5) parts.push('under-resolved: expect large errors'); const err = validate(p); if (err) { parts.push(`${err}`); blocking = true; } return { html: parts.join(' · '), blocking }; } function updateDerived() { const d = describe(readParams()); ui.derived.innerHTML = d.html; if (!running) ui.solve.disabled = d.blocking; } // ------------------------------------------------------------------ status let running = false; let cancelled = false; class Cancelled extends Error {} function setStatus(text: string, fraction?: number) { ui.status.textContent = text; if (fraction === undefined) ui.progress.removeAttribute('value'); else ui.progress.value = fraction; } function checkCancel() { if (cancelled) throw new Cancelled('cancelled'); } // ------------------------------------------------------------------ results type Row = [string, string, string?]; function showResults(rows: Row[]) { const tb = document.createElement('tbody'); for (const [a, b, cls] of rows) { const tr = document.createElement('tr'); const td1 = document.createElement('td'); td1.textContent = a; const td2 = document.createElement('td'); td2.textContent = b; if (cls) td2.className = cls; tr.append(td1, td2); tb.append(tr); } ui.results.replaceChildren(tb); } const quality = (e: number, tol: number) => (e < 10 * tol ? '' : e < 1e-2 ? 'warn' : 'bad'); // ------------------------------------------------------------------ main async function start() { if (!navigator.gpu) throw new Error('this browser has no WebGPU. Chrome and Edge 113+, Safari 26+, and Firefox 141+ on Windows have it; on Linux, Chrome may need chrome://flags/#enable-unsafe-webgpu.'); const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }); if (!adapter) throw new Error('WebGPU found no adapter on this machine.'); const lim = adapter.limits; const device = await adapter.requestDevice({ requiredLimits: { maxStorageBufferBindingSize: lim.maxStorageBufferBindingSize, maxBufferSize: lim.maxBufferSize }, }); device.lost.then((info) => { if (info.reason !== 'destroyed') { ui.err.textContent = `The GPU device was lost (${info.message}). This can happen when one dispatch runs too long; reload the page to continue.`; } }); deviceLimit = Math.min(device.limits.maxStorageBufferBindingSize, device.limits.maxBufferSize); const software = /swiftshader/i.test(adapterDescription(adapter)); ui.device.textContent = `WebGPU adapter: ${adapterDescription(adapter)}; largest buffer ${fmtBytes(deviceLimit)}.` + (software ? ' This is a software adapter, so only the quick case is practical.' : ''); const gpu = new Gpu(device, adapter); const slices = new SliceView( $('slices'), (x) => { const p = readParams(); if (!inside(x, A, p.p, 0.04)) return; x.forEach((v, i) => (ui.x0[i].value = String(+v.toFixed(3)))); updateDerived(); if (!running) void solve(); }, (t) => (ui.hover.textContent = t), ); const room = new RoomView(device, $('room')); let op: Operator | null = null; let opKey = ''; let surf: Surface | null = null; let field: Field | null = null; let conv: { label: string; res: number[]; color: string }[] = []; let lastEps = 1e-5; const redrawConv = () => drawConvergence(ui.conv, conv, lastEps); const updateColorbar = () => { const mode = ui.mode.value as Mode; drawColorbar(ui.cbar, mode === 'pressure' ? DIVERGING : SEQUENTIAL); if (!field) { ui.cbarHi.textContent = ui.cbarLo.textContent = ''; } else if (mode === 'pressure') { ui.cbarHi.textContent = `+${field.umax.toPrecision(2)}`; ui.cbarLo.textContent = `−${field.umax.toPrecision(2)}`; } else { ui.cbarHi.textContent = `+${DB_MAX} dB`; ui.cbarLo.textContent = `${DB_MIN} dB`; } }; async function solve() { const p = readParams(); const bad = validate(p); if (bad) { ui.err.textContent = bad; return; } running = true; cancelled = false; ui.solve.disabled = true; ui.cancel.disabled = false; ui.err.textContent = ''; lastEps = p.eps; const rows: Row[] = []; // the previous solution no longer matches the controls, so take it down field = null; slices.clear(); room.clear(); ui.empty.textContent = 'Solving…'; ui.empty.style.display = ''; showResults([['solving…', '']]); updateColorbar(); conv = []; redrawConv(); try { const t0 = performance.now(); const key = `${p.k}|${p.m}|${p.norder}|${p.p}`; const lam = -p.beta; let setupS = 0; if (!op || key !== opKey) { op?.destroy(); op = null; opKey = ''; setStatus('building the surface…'); await new Promise((r) => setTimeout(r, 0)); surf = roundedCube(A, p.p, p.m, p.norder); const o = new Operator(gpu, surf, p.k, ALPHA, lam, 0, { progress: (stage, f) => { checkCancel(); setStatus(`${stage}: ${(100 * f).toFixed(0)}%`, f); }, }); setStatus('near quadrature…', 0); const info = await o.setup(); op = o; opKey = key; setupS = info.setupSeconds; } const o = op!; const s = surf!; o.lam = lam; const info = o.info; rows.push(['points', s.npts.toLocaleString()]); rows.push(['near pairs · quadtree leaves', `${info.npairs.toLocaleString()} · ${info.nleaves.toLocaleString()}`]); rows.push(['near weights', fmtBytes(info.weightsBytes)]); rows.push(['near quadrature (setup)', setupS ? fmtS(setupS) : `reused (${fmtS(info.setupSeconds)} earlier)`]); const npts = s.npts; const xs = s.srcvals.subarray(0, 3 * npts); const nrm = s.srcvals.subarray(9 * npts, 12 * npts); conv = []; if (p.selftest) { const series = { label: 'self-test', res: [] as number[], color: '#8b949e' }; conv.push(series); const sol = await o.solve(selftestRhs(xs, nrm, EXTERIOR_SOURCE, p.k, lam), p.eps, 400, (it, r) => { checkCancel(); series.res.push(r); redrawConv(); setStatus(`self-test GMRES: iteration ${it}, residual ${r.toExponential(1)}`); }); setStatus('self-test: evaluating…'); const xt = selftestPoints(50, A); const ut = await o.eval(xt, sol.x, sol.phi1, 'self-test field'); const ub = await o.evalOnSurface(sol.x, sol.phi1); const e1 = relError(ut, green(xt, EXTERIOR_SOURCE, p.k)); const e2 = relError(ub, green(xs, EXTERIOR_SOURCE, p.k)); rows.push(['self-test error, interior · walls', `${e1.toExponential(1)} · ${e2.toExponential(1)}`, quality(Math.max(e1, e2), p.eps)]); } const series = { label: 'room problem', res: [] as number[], color: '#2f81f7' }; conv.push(series); const sol = await o.solve(roomRhs(xs, nrm, p.x0, p.k, lam), p.eps, 400, (it, r) => { checkCancel(); series.res.push(r); redrawConv(); setStatus(`GMRES: iteration ${it}, residual ${r.toExponential(1)}`); }); rows.push(['GMRES iterations · residual', `${sol.niter} · ${sol.rres.toExponential(1)}`]); rows.push(['solve', `${fmtS(sol.seconds)} (${((1000 * sol.seconds) / sol.niter).toFixed(0)} ms per iteration)`]); // energy balance setStatus('energy balance…'); const v0 = await o.eval(Float64Array.from(p.x0), sol.x, sol.phi1, 'field at the source'); const vw = await o.evalOnSurface(sol.x, sol.phi1); const gw = green(xs, p.x0, p.k); const uWall = new Float64Array(2 * npts); for (let i = 0; i < 2 * npts; i++) uWall[i] = gw[i] + vw[i]; const eb = energyBalance(p.k, lam, [v0[0], v0[1]], s.wts, uWall); rows.push(['power: source · walls', `${eb.pSrc.toFixed(6)} · ${eb.pWall.toFixed(6)}`]); rows.push(['energy mismatch', eb.mismatch.toExponential(1), quality(eb.mismatch, p.eps)]); // the field on the three planes through the source const tf = performance.now(); const { grid, slices: sl } = slicePoints(p.ngrid, p.x0, A, p.p); const sfs: SliceField[] = []; for (const [si, slice] of sl.entries()) { const { pts, index } = maskedPoints(slice.pts, slice.mask); const v = await o.eval(pts, sol.x, sol.phi1, `field on the ${slice.name}-plane (${si + 1} of 3)`); const g = green(pts, p.x0, p.k); const u = new Float64Array(2 * slice.mask.length).fill(NaN); for (let q = 0; q < index.length; q++) { u[2 * index[q]] = g[2 * q] + v[2 * q]; u[2 * index[q] + 1] = g[2 * q + 1] + v[2 * q + 1]; } sfs.push({ slice, u }); } const nfield = sl.reduce((acc, x) => acc + x.mask.reduce((a, b) => a + b, 0), 0); rows.push(['field on the planes', `${fmtS((performance.now() - tf) / 1000)} (${nfield.toLocaleString()} points)`]); rows.push(['total', fmtS((performance.now() - t0) / 1000)]); const lims = colorLimits(sfs, p.x0, p.k); field = { k: p.k, beta: p.beta, x0: [...p.x0], a: A, p: p.p, ngrid: p.ngrid, grid, slices: sfs, uWall, ...lims }; slices.setField(field); room.setField(s, field); ui.empty.style.display = 'none'; updateColorbar(); showResults(rows); redrawConv(); setStatus(`done in ${fmtS((performance.now() - t0) / 1000)}`, 1); } catch (e) { ui.empty.textContent = 'No solution: press Solve to compute the field.'; if (e instanceof Cancelled) { setStatus('cancelled', 0); // a cancelled setup leaves no usable operator if (op && !op.info) { op.destroy(); op = null; opKey = ''; } } else { console.error(e instanceof Error ? `${e.message}\n${e.stack}` : String(e)); ui.err.textContent = e instanceof Error ? e.message : String(e); setStatus('failed', 0); op?.destroy(); op = null; opKey = ''; } if (rows.length) showResults(rows); } finally { running = false; ui.cancel.disabled = true; updateDerived(); } } ui.solve.addEventListener('click', () => void solve()); ui.cancel.addEventListener('click', () => { cancelled = true; setStatus('cancelling…'); }); ui.preset.addEventListener('change', () => { const pr = PRESETS[ui.preset.value]; if (pr) writeParams({ ...readParams(), ...pr }); updateDerived(); }); for (const el of [ui.k, ui.beta, ui.m, ui.norder, ui.p, ...ui.x0, ui.eps, ui.ngrid, ui.selftest]) { el.addEventListener('input', () => { matchPreset(); updateDerived(); }); } ui.mode.addEventListener('change', () => { updateColorbar(); room.invalidate(); }); for (const el of [ui.showWalls, ...ui.showPlanes]) el.addEventListener('change', () => room.invalidate()); ui.x0.forEach((el) => el.addEventListener('keydown', (e) => e.key === 'Enter' && !running && void solve())); // the frame loop: one period of the pressure every 2 s let phase = 0; let last = performance.now(); const frame = (t: number) => { const dt = (t - last) / 1000; last = t; const animating = ui.animate.checked && ui.mode.value === 'pressure' && field !== null; if (animating) phase = (phase + dt * Math.PI) % (2 * Math.PI); const mode = ui.mode.value as Mode; const ph = mode === 'pressure' ? phase : 0; if (field && (animating || slicesDirty)) { slices.draw(mode, ph); slicesDirty = false; } // ?noroom skips the 3D view: in headless Chrome, presenting to a WebGPU canvas breaks later async calls if (!noRoom) room.draw(mode, ph, { walls: ui.showWalls.checked, planes: ui.showPlanes.map((c) => c.checked) as [boolean, boolean, boolean] }, animating); requestAnimationFrame(frame); }; let slicesDirty = true; const noRoom = location.search.includes('noroom'); ui.mode.addEventListener('change', () => (slicesDirty = true)); ui.animate.addEventListener('change', () => (slicesDirty = true)); requestAnimationFrame(frame); writeParams({ ...DEFAULTS, x0: [...DEFAULTS.x0] }); updateDerived(); updateColorbar(); redrawConv(); setStatus(software ? 'ready (software adapter: choose the quick case)' : 'ready', 0); if (software) { writeParams({ ...readParams(), ...PRESETS.quick }); updateDerived(); } (window as unknown as { __app__: unknown }).__app__ = { solve, get field() { return field; } }; } start().catch((e) => { console.error(e); ui.err.textContent = e instanceof Error ? e.message : String(e); setStatus('unavailable', 0); }); export type { CVec };