Simplify UI text and built-in .m script comments
11 changed files+57−144
geometries/bumpy.mmodified+3−11View file
@@ -1,14 +1,6 @@
1-% Lobes around the equator, plus a gentle pear-shaped offset along z.
2-%
3-% The radial deformation sin(theta)^4 * cos(nlobe*phi) is the shape of a
4-% sectoral harmonic of order nlobe, concentrated at the equator and vanishing
5-% at both poles; the cos(theta) term breaks the north-south symmetry so the two
6-% ends differ.
7-%
8-% `nlobe` should be a whole number. A fraction makes cos(nlobe*phi) disagree
9-% with itself across the phi = 0 seam, which is not a closed surface; the
10-% analysis into coefficients will band-limit whatever results, but what comes
11-% back is not what this file says.
1+% Lobes around the equator, plus a pear-shaped offset along z.
2+% `nlobe` should be a whole number, or the surface does not close at the
3+% phi = 0 seam.
124
135 function [gx, gy, gz] = shape(theta, phi, amp, nlobe, pear)
146 st = sin(theta);
geometries/ellipsoid.mmodified+0−5View file
@@ -1,9 +1,4 @@
11 % A triaxial ellipsoid: the sphere with each axis scaled independently.
2-%
3-% Still degree-1 in the spherical harmonics — scaling a coordinate scales its
4-% coefficients — so the surface the solver sees is exactly the one written
5-% here, with nothing lost to band-limiting. The simplest geometry that is not
6-% the sphere, and the one whose curvature is easiest to reason about.
72
83 function [gx, gy, gz] = shape(theta, phi, ax, ay, az)
94 st = sin(theta);
geometries/peanut.mmodified+1−10View file
@@ -1,13 +1,4 @@
1-% A dumbbell: a radial profile that pinches at the equator, stretched along z.
2-%
3-% The radius is 1 - waist*sin(theta)^2, so the poles keep radius 1 and the
4-% equator narrows to 1 - waist. Multiplying it into the unit-sphere coordinates
5-% raises the degree to 3, which is still far below any lmax the app offers, so
6-% the band-limited surface is again the one written here.
7-%
8-% Two regions of positive curvature joined by a saddle — the first geometry in
9-% this list where the Laplace-Beltrami operator differs from the round one in a
10-% way that should visibly change where a pattern wants to put its spots.
1+% A dumbbell: radius 1 - waist*sin(theta)^2, stretched along z.
112
123 function [gx, gy, gz] = shape(theta, phi, waist, stretch)
134 st = sin(theta);
geometries/sphere.mmodified+5−12View file
@@ -1,16 +1,9 @@
1-% The unit sphere.
1+% The unit sphere — the reference case.
22 %
3-% A geometry file defines one function, `shape`, giving the Cartesian
4-% coordinates of the surface over the solver's (theta, phi) grid. Both inputs
5-% are npts x 1 grid fields, as are all three outputs, and every line is
6-% element-wise MATLAB compiled to a WebGPU kernel — the same backend the models
7-% go through.
8-%
9-% The host then analyses the result into spherical-harmonic coefficients, which
10-% is the form the geometry is carried in: band-limited at lmax, evaluable on
11-% any grid. For this file that costs nothing, because x, y and z ARE degree-1
12-% harmonics — which is what makes this the case where everything downstream
13-% reduces exactly to the round-sphere solver.
3+% A geometry file defines shape(theta, phi, ...) -> gx, gy, gz: the surface
4+% over the solver's grid (all npts x 1), compiled to WebGPU like the models.
5+% The host analyses the result into spherical-harmonic coefficients,
6+% band-limited at lmax.
147
158 function [gx, gy, gz] = shape(theta, phi)
169 st = sin(theta);
index.htmlmodified+16−24View file
@@ -183,36 +183,28 @@
183183 <h1>turing-surface</h1>
184184 <p class="warn">
185185 <b>⚠ Work in progress: the geometry is drawn, not solved on.</b>
186- The Laplace–Beltrami operator in the models is still the <em>round
187- sphere's</em> — the term that would carry the shape is a placeholder
188- that is identically zero. So on anything but the sphere, what you see
189- is the sphere's pattern painted onto that surface, not the pattern that
190- surface would actually grow. The spot size does not follow the
191- curvature, and it will not until that placeholder is filled in.
192- Everything else — the embedding, the mesh, the unrolled implicit solve
193- the correction plugs into — is real and in place.
186+ The solver still uses the round sphere's Laplace–Beltrami operator, so
187+ on other shapes you see the sphere's pattern painted onto that surface.
194188 </p>
195189 <p class="sub">
196- Reaction-diffusion on closed surfaces, solved live with spherical harmonics:
197- implicit spectral diffusion + explicit reaction (IMEX Euler), transforms on WebGPU via
190+ Reaction-diffusion on closed surfaces, solved live with spherical
191+ harmonics on WebGPU via
198192 <a href="https://github.com/concept-collection/shtns-webgpu">shtns-webgpu</a>.
199- The surface is an embedding of the sphere given by spherical-harmonic
200- coefficients for x, y and z. Both the solver and the shape are the
201- MATLAB below — <a href="https://numbl.org">numbl</a> parses and lowers
202- them in your browser, and each line becomes a WebGPU compute kernel.
203- Edit either and watch it change. Drag to rotate.
193+ Both the solver and the shape are the MATLAB below, compiled in your
194+ browser by <a href="https://numbl.org">numbl</a>. Edit either and watch
195+ it change. Drag to rotate.
204196 </p>
205197 <div class="controls">
206198 <label>preset
207199 <select id="model"></select>
208200 </label>
209- <label title="The surface, as a spherical-harmonic embedding. Rendered exactly; NOT yet in the Laplace-Beltrami operator, which is still the round-sphere one.">geometry
201+ <label title="The surface. Rendered, but not yet in the operator.">geometry
210202 <select id="geometry"></select>
211203 </label>
212- <label title="Blend between the unit sphere (0) and the surface (1). Display only — the solver's grid is the sphere's parametrization either way.">morph
204+ <label title="Blend between the sphere (0) and the surface (1). Display only.">morph
213205 <input type="range" id="morph" min="0" max="1" step="0.01" value="1" />
214206 </label>
215- <label title="Iterations of the implicit diffusion solve, unrolled into the compiled step. Changing it recompiles. The correction it iterates is zero until the geometry reaches the operator, so today every value gives the same answer.">solve iters
207+ <label title="Iterations of the implicit diffusion solve. Changing it recompiles.">solve iters
216208 <select id="niter">
217209 <option value="0">0</option>
218210 <option value="1" selected>1</option>
@@ -229,7 +221,7 @@
229221 <option value="255">255</option>
230222 </select>
231223 </label>
232- <label title="Display only — the solution is evaluated on a finer grid for rendering; the solver and its grid are unchanged. Auto oversamples coarse grids and leaves fine ones alone.">display oversampling
224+ <label title="Render on a finer grid. Display only.">display oversampling
233225 <select id="oversample">
234226 <option value="auto" selected>auto</option>
235227 <option value="1">off</option>
@@ -248,7 +240,7 @@
248240 <button id="movietoggle" title="Export the run as an MP4 movie">Export movie</button>
249241 </div>
250242 <div class="controls" id="moviebar" hidden>
251- <label title="Playback speed: simulation-time units per second of video — 1× plays one time unit per second">movie speed
243+ <label title="Simulation-time units per second of video">movie speed
252244 <select id="moviespeed">
253245 <option value="0.1">0.1×</option>
254246 <option value="0.5">0.5×</option>
@@ -259,7 +251,7 @@
259251 <option value="20">20×</option>
260252 </select>
261253 </label>
262- <label title="Rendered size of each sphere panel, in pixels — the video frame is the panels side by side plus the caption. Independent of the window size.">resolution
254+ <label title="Size of each sphere panel in the video, in pixels">resolution
263255 <select id="movieres">
264256 <option value="480">480</option>
265257 <option value="640">640</option>
@@ -268,10 +260,10 @@
268260 <option value="1440">1440</option>
269261 </select>
270262 </label>
271- <label title="Slowly orbit the camera during the movie — one revolution per 2 minutes of video, starting from the current view (which is restored afterwards)">
263+ <label title="Slowly orbit the camera during the movie">
272264 <input type="checkbox" id="movierotate" checked /> auto-rotate
273265 </label>
274- <button id="movie" title="Recompute the run from t = 0 and download it as an MP4 movie">Export</button>
266+ <button id="movie" title="Replay the run from t = 0 and download an MP4">Export</button>
275267 </div>
276268 <div class="controls" id="params"></div>
277269 <div class="controls" id="geomparams"></div>
@@ -306,7 +298,7 @@
306298 </div>
307299 <div class="cli">
308300 <div class="cli-head">
309- <span>The same run on the desktop, through the same .m and the same kernels</span>
301+ <span>The same run on the desktop</span>
310302 <button id="copycmd" type="button">Copy</button>
311303 </div>
312304 <code id="cmd"></code>
models/allencahn.mmodified+3−6View file
@@ -1,10 +1,8 @@
1-% Allen-Cahn on a closed surface. One species: interfaces form and then coarsen
2-% until one domain swallows the surface.
1+% Allen-Cahn on a closed surface: interfaces form, then coarsen.
32 %
43 % du/dt = eps2*lap_g(u) + u - u^3
54 %
6-% See models/schnakenberg.m for what the caller provides and for how the
7-% implicit solve is split between the round-sphere operator and the geometry.
5+% Same scheme as models/schnakenberg.m.
86
97 function [U, u] = init(noise)
108 U = analys(noise);
@@ -18,9 +16,8 @@ function [Un, u] = step(U, lam, gx, gy, gz, eps2, dt, niter)
1816 Un = Bu ./ (1 + (dt * eps2) * lam);
1917
2018 for k = 1:niter
21- % ---- placeholder: dlap = lap_g - lap_s (see models/schnakenberg.m) ----
19+ % Placeholder: dlap = lap_g - lap_s (see models/schnakenberg.m).
2220 dLu = 0 * Un;
23- % ----------------------------------------------------------------------
2421 Un = (Bu + (dt * eps2) * dLu) ./ (1 + (dt * eps2) * lam);
2522 end
2623 end
models/brusselator.mmodified+3−6View file
@@ -1,11 +1,9 @@
1-% Brusselator reaction-diffusion on a closed surface. Turing stripes and spots,
2-% from a smaller diffusivity contrast than Schnakenberg but a stiffer reaction.
1+% Brusselator reaction-diffusion on a closed surface.
32 %
43 % du/dt = D1*lap_g(u) + A - (B+1)*u + u^2*v
54 % dv/dt = D2*lap_g(v) + B*u - u^2*v
65 %
7-% See models/schnakenberg.m for what the caller provides and for how the
8-% implicit solve is split between the round-sphere operator and the geometry.
6+% Same scheme as models/schnakenberg.m.
97
108 function [U, V, u, v] = init(noise, A, B)
119 U = analys(A + noise);
@@ -26,10 +24,9 @@ function [Un, Vn, u, v] = step(U, V, lam, gx, gy, gz, A, B, D1, D2, dt, niter)
2624 Vn = Bv ./ (1 + (dt * D2) * lam);
2725
2826 for k = 1:niter
29- % ---- placeholder: dlap = lap_g - lap_s (see models/schnakenberg.m) ----
27+ % Placeholder: dlap = lap_g - lap_s (see models/schnakenberg.m).
3028 dLu = 0 * Un;
3129 dLv = 0 * Vn;
32- % ----------------------------------------------------------------------
3330 Un = (Bu + (dt * D1) * dLu) ./ (1 + (dt * D1) * lam);
3431 Vn = (Bv + (dt * D2) * dLv) ./ (1 + (dt * D2) * lam);
3532 end
models/schnakenberg.mmodified+10−36View file
@@ -3,17 +3,11 @@
33 % du/dt = D1*lap_g(u) + a - u + u^2*v
44 % dv/dt = D2*lap_g(v) + b - u^2*v
55 %
6-% lap_g is the Laplace-Beltrami operator of the surface, which the embedding
7-% (gx, gy, gz) determines. The reaction is explicit on the grid; diffusion is
8-% implicit, which on a general surface is no longer a divide — see `step`.
9-%
10-% Provided by the caller: synth/analys (the transforms), lam = l(l+1) per
11-% coefficient, gx/gy/gz (the surface, on the grid) and Gx/Gy/Gz (the same
12-% surface as spherical-harmonic coefficients), noise (the seeded perturbation),
13-% niter (iterations of the implicit solve), and the parameters. Grid fields are
14-% npts x 1; spectral fields are real 2 x nlm (row 1 real part, row 2
15-% imaginary), so no complex arithmetic is needed. Each function returns the new
16-% spectral state followed by the grid fields to display.
6+% Explicit reaction, implicit diffusion (IMEX Euler). The implicit solve
7+% splits lap_g = lap_s + dlap: the round-sphere part lap_s is diagonal in
8+% spherical-harmonic space (eigenvalues -lam), and the loop iterates the
9+% geometric correction dlap from that exact solve. Grid fields are npts x 1;
10+% spectral fields are real 2 x nlm.
1711
1812 function [U, V, u, v] = init(noise, a, b)
1913 us = a + b;
@@ -29,40 +23,20 @@ function [Un, Vn, u, v] = step(U, V, lam, gx, gy, gz, a, b, D1, D2, dt, niter)
2923 v = synth(V);
3024 uuv = u .* u .* v;
3125
32- % Explicit reaction. Bu, Bv are the right-hand side of the implicit
33- % diffusion solve (I - dt*D*lap_g) Unew = B.
26+ % Right-hand side of the implicit solve (I - dt*D*lap_g) Unew = B.
3427 Bu = U + dt * analys(a - u + uuv);
3528 Bv = V + dt * analys(b - uuv);
3629
37- % Split the surface Laplacian as lap_g = lap_s + dlap, where lap_s is the
38- % round-sphere one. lap_s is diagonal in spherical-harmonic space with
39- % eigenvalues -lam, so (I - dt*D*lap_s) inverts in a single divide and the
40- % whole geometry sits inside dlap. That turns the implicit solve into
41- %
42- % Unew = (Bu + dt*D1*dlap(Unew)) ./ (1 + dt*D1*lam)
43- %
44- % which the loop below iterates from the round-sphere answer: preconditioned
45- % Richardson, with the exactly invertible round-sphere operator as the
46- % preconditioner. It converges while dt*D*dlap stays small against
47- % (I - dt*D*lap_s), which is what would keep the cost to a few transforms.
30+ % Round-sphere solve, then iterate the geometric correction.
4831 Un = Bu ./ (1 + (dt * D1) * lam);
4932 Vn = Bv ./ (1 + (dt * D2) * lam);
5033
5134 for k = 1:niter
52- % ---- placeholder: dlap = lap_g - lap_s --------------------------------
53- % The geometry enters HERE and nowhere else. What belongs here is the
54- % Laplace-Beltrami operator of the embedding minus the round-sphere one,
55- % which needs the induced metric (from derivatives of gx, gy, gz) and
56- % surface derivatives of the field — transforms this project does not have
57- % yet. See "The geometry is not in the operator yet" in the README.
58- %
59- % Until then dlap is identically zero. Not a stand-in that happens to be
60- % small: it is exactly the round sphere, so this loop provably changes
61- % nothing, every iterate equals the line above, and the scheme is bit for
62- % bit the one turing-sphere runs. The surface is drawn, not solved on.
35+ % Placeholder: dlap = lap_g - lap_s is still identically zero, so this
36+ % is exactly the round-sphere solver and the geometry is only drawn.
37+ % See the README.
6338 dLu = 0 * Un;
6439 dLv = 0 * Vn;
65- % ----------------------------------------------------------------------
6640 Un = (Bu + (dt * D1) * dLu) ./ (1 + (dt * D1) * lam);
6741 Vn = (Bv + (dt * D2) * dLv) ./ (1 + (dt * D2) * lam);
6842 end
src/geom/registry.tsmodified+4−4View file
@@ -27,7 +27,7 @@ export interface MGeometry {
2727 const sphere: MGeometry = {
2828 key: 'sphere',
2929 label: 'Sphere',
30- blurb: 'The unit sphere. The reference case: identical to turing-sphere.',
30+ blurb: 'The unit sphere — the reference case.',
3131 params: [],
3232 source: sphereSource,
3333 };
@@ -35,7 +35,7 @@ const sphere: MGeometry = {
3535 const ellipsoid: MGeometry = {
3636 key: 'ellipsoid',
3737 label: 'Ellipsoid',
38- blurb: 'Each axis scaled independently. Exactly degree 1, so nothing is lost to band-limiting.',
38+ blurb: 'The sphere with each axis scaled independently.',
3939 params: [
4040 { key: 'ax', label: 'a', value: 1.5, min: 0.2, max: 3, step: 0.05 },
4141 { key: 'ay', label: 'b', value: 1, min: 0.2, max: 3, step: 0.05 },
@@ -47,7 +47,7 @@ const ellipsoid: MGeometry = {
4747 const peanut: MGeometry = {
4848 key: 'peanut',
4949 label: 'Peanut',
50- blurb: 'A dumbbell pinched at the equator: two positively curved ends joined by a saddle.',
50+ blurb: 'A dumbbell pinched at the equator.',
5151 params: [
5252 { key: 'waist', label: 'waist', value: 0.6, min: 0, max: 0.9, step: 0.05 },
5353 { key: 'stretch', label: 'stretch', value: 0.6, min: 0, max: 2, step: 0.05 },
@@ -58,7 +58,7 @@ const peanut: MGeometry = {
5858 const bumpy: MGeometry = {
5959 key: 'bumpy',
6060 label: 'Bumpy',
61- blurb: 'Equatorial lobes plus a pear-shaped offset — curvature that varies in both angles.',
61+ blurb: 'Equatorial lobes plus a pear-shaped offset.',
6262 params: [
6363 { key: 'amp', label: 'amp', value: 0.3, min: 0, max: 0.6, step: 0.02 },
6464 { key: 'nlobe', label: 'lobes', value: 5, min: 1, max: 12, step: 1 },
src/main.tsmodified+9−21View file
@@ -295,10 +295,10 @@ function showEditorFile(): void {
295295 editing = elEditorFile.value === 'geometry' ? 'geometry' : 'model';
296296 if (editing === 'geometry') {
297297 editor.value = geomSource();
298- elEditorTitle.textContent = `geometries/${geometry.key}.m — shape(), compiled to WebGPU`;
298+ elEditorTitle.textContent = `geometries/${geometry.key}.m`;
299299 } else {
300300 editor.value = source();
301- elEditorTitle.textContent = `models/${model.key}.m — init() and step(), compiled to WebGPU`;
301+ elEditorTitle.textContent = `models/${model.key}.m`;
302302 }
303303 }
304304
@@ -559,12 +559,8 @@ function updateGeomNote(): void {
559559 const isSphere = session.geometryModel.key === SPHERE_KEY;
560560 elGeomNote.innerHTML =
561561 `<b>${session.geometryModel.label}</b> — ${session.geometryModel.blurb} ` +
562- `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}. ` +
563- (isSphere
564- ? 'This is the round-sphere case, so the solver is exact here.'
565- : '<b>Rendered only:</b> the Laplace–Beltrami operator in the .m is still ' +
566- 'the round sphere\'s, so the pattern is the sphere\'s pattern painted ' +
567- 'onto this shape.');
562+ `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}.` +
563+ (isSphere ? '' : ' <b>Rendered only</b> — not yet in the operator.');
568564 }
569565
570566 /** Report a compile failure, and select the offending text in the editor. */
@@ -694,22 +690,16 @@ function updateStats(): void {
694690 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
695691 const solver =
696692 solverMs > 0
697- ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s, ` +
698- `batch of ${MEASURE_BURST}, no readback)`
699- : '—';
700- const frame =
701- frameMs > 0
702- ? `${frameMs.toFixed(1)} ms/frame (${STEPS_PER_FRAME} steps + readback + render)`
693+ ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s)`
703694 : '—';
695+ const frame = frameMs > 0 ? `${frameMs.toFixed(1)} ms/frame` : '—';
704696 const view = session.viewSht.cfg;
705697 const render =
706698 session.oversample > 1
707- ? ` (display ${view.nlat}×${view.nphi}, ${session.oversample}×)`
699+ ? ` (display ${view.nlat}×${view.nphi})`
708700 : '';
709701 elStats.innerHTML =
710702 `<b>${kind}</b> · grid ${nlat}×${nphi}${render} · nlm ${session.sht.nlm.toLocaleString()} · ` +
711- `${session.sht.fourierMode.toUpperCase()} · ${session.geometryModel.key} · ` +
712- `${session.niter} solve iter${session.niter === 1 ? '' : 's'} · ` +
713703 `solver ${solver} · ${frame} · ` +
714704 `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
715705 }
@@ -804,8 +794,7 @@ async function benchmark(): Promise<void> {
804794 elBenchResult.innerHTML =
805795 `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
806796 `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
807- `ramp ${(first / last).toFixed(2)}× (${first.toFixed(2)} → ${last.toFixed(2)}) · ` +
808- `${steps} steps in batches of ${BATCH} · ` +
797+ `ramp ${(first / last).toFixed(2)}× · ${steps} steps · ` +
809798 `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
810799 await draw();
811800 updateStats();
@@ -1006,8 +995,7 @@ async function boot(): Promise<void> {
1006995 device = null;
1007996 elErr.textContent =
1008997 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1009- `This demo compiles the MATLAB solver to WebGPU compute shaders, so it ` +
1010- `needs a WebGPU-capable browser (Chrome/Edge 113+).`;
998+ `Use a WebGPU-capable browser such as Chrome or Edge.`;
1011999 return;
10121000 }
10131001 device.lost.then((info) => {
src/mgpu/registry.tsmodified+3−9View file
@@ -50,9 +50,7 @@ const stateFor = (species: string[]): string[] => species.map((s) => s.toUpperCa
5050 const schnakenberg: MModel = {
5151 key: 'schnakenberg',
5252 label: 'Schnakenberg',
53- blurb:
54- 'Turing spots. The homogeneous state is stable to uniform perturbations ' +
55- 'but unstable to degrees 14 ≤ l ≤ 40, most strongly at l = 24.',
53+ blurb: 'Turing spots.',
5654 species: ['u', 'v'],
5755 state: stateFor(['u', 'v']),
5856 params: [
@@ -70,9 +68,7 @@ const schnakenberg: MModel = {
7068 const brusselator: MModel = {
7169 key: 'brusselator',
7270 label: 'Brusselator',
73- blurb:
74- 'Turing stripes and spots, from a smaller diffusivity contrast than ' +
75- 'Schnakenberg but with a stiffer reaction.',
71+ blurb: 'Turing stripes and spots.',
7672 species: ['u', 'v'],
7773 state: stateFor(['u', 'v']),
7874 params: [
@@ -90,9 +86,7 @@ const brusselator: MModel = {
9086 const allencahn: MModel = {
9187 key: 'allencahn',
9288 label: 'Allen–Cahn',
93- blurb:
94- 'A single species: interfaces form and then coarsen until one domain ' +
95- 'swallows the sphere.',
89+ blurb: 'One species: interfaces form, then coarsen.',
9690 species: ['u'],
9791 state: stateFor(['u']),
9892 params: [