3Reaction–diffusion systems (Turing patterns) on **closed surfaces given by
4spherical-harmonic embeddings**, solved live in the browser with a spectral
5method whose transforms run on the GPU via WebGPU.
7This is the sibling of
8[turing-sphere](https://github.com/concept-collection/turing-sphere), which
9solves the same systems on the round sphere. Everything there is here; what is
10added is a *surface*.
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 12The geometry is in the operator: the models evaluate the surface
13Laplace–Beltrami operator `lap_g` inside the implicit solve, in a **flux form
14that costs 6 spherical-harmonic transforms per species per iteration** where
15the textbook Cartesian-gradient form needs 12. See
16[The geometry in the operator](#the-geometry-in-the-operator) and
17[docs/reduced-transforms.md](docs/reduced-transforms.md).
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 18
19## What a surface is here
21A geometry is an embedding of the sphere into R³: three scalar fields x, y, z
22over the (θ, φ) parametrization, each carried as spherical-harmonic
23coefficients. The unit sphere is the case where all three are pure degree-1
24harmonics.
26You write one down as MATLAB, in [`geometries/`](geometries/):
28```matlab
29function [gx, gy, gz] = shape(theta, phi, waist, stretch)
30 st = sin(theta);
31 r = 1 - waist * (st .^ 2);
32 gx = r .* (st .* cos(phi));
33 gy = r .* (st .* sin(phi));
34 gz = (1 + stretch) * (r .* cos(theta));
35end
36```
38That is ordinary element-wise MATLAB and goes through the same compiler and the
39same WGSL backend the models do. It is evaluated once on the solver's grid, and
40then **analysed into coefficients**, which is the form everything downstream
41uses. Two things follow from going through the coefficients rather than keeping
42the pointwise values:
44- **It is exactly band-limited at lmax.** The surface has as many derivatives as
45 the scheme needs and no aliased content the solver cannot see. What the solver
46 and the renderer both use is the *synthesis* of the coefficients, so for a
47 shape with sharp features the surface being solved on is not quite the one
48 that was written down — which is the honest thing for a spectral method to do.
49- **It can be evaluated on any grid.** The renderer draws the surface on the
50 (possibly finer) display grid by synthesizing the same coefficients there.
51 That is exact interpolation, not subdivision — the same argument that lets the
52 species fields be oversampled, and it is checked directly in the tests.
54Four geometries ship: [sphere](geometries/sphere.m) (the reference case),
55[ellipsoid](geometries/ellipsoid.m), [peanut](geometries/peanut.m) — a dumbbell
56whose waist is a saddle — and [bumpy](geometries/bumpy.m). Each is editable in
57the page, with its own parameters. Changing a shape does not recompile the
58solver and does not disturb the run: the geometry is data whose shape in the
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 59bindings depends only on the grid, so a swap is sixteen buffer writes and the
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 60pattern carries straight on.
62A **morph** slider blends the drawn surface back to the unit sphere. The
63parametrization is the sphere's either way, so sweeping it shows which point
64went where.
66## The scheme, and where the geometry enters
68It solves the N-species system
70```
71d(u_k)/dt = D_k*lap_g(u_k) + f_k(t, u_1, ..., u_N), k = 1, ..., N
72```
74where `lap_g` is the Laplace–Beltrami operator of the surface. On the round
75sphere `lap_g` is diagonal in spherical-harmonic space with eigenvalues
76`-l(l+1)`, which is what makes turing-sphere's implicit diffusion a single
77divide. On a general surface it is not diagonal, and not even constant-
78coefficient, so that divide has to become a solve.
80The models split the operator:
82```
83lap_g = lap_s + dlap
84```
86with `lap_s` the round-sphere one. `(I - dt*D*lap_s)` is still exactly
87invertible, so the implicit step
89```
90(I - dt*D*lap_g) Unew = B
91```
93rearranges into a fixed point that keeps the whole geometry on the right-hand
94side,
96```
97Unew = (B + dt*D*dlap(Unew)) ./ (1 + dt*D*lam)
98```
100and the loop iterates it from the round-sphere answer. That is preconditioned
101Richardson, with the operator we can invert exactly as the preconditioner; it
102converges while `dt*D*dlap` stays small against `(I - dt*D*lap_s)`, which is
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 103what keeps the cost to a few transforms per step rather than a full elliptic
104solve (see [docs/richardson-iteration.md](docs/richardson-iteration.md)). One
105species of [`models/schnakenberg.m`](models/schnakenberg.m)'s solve loop:
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 106
107```matlab
e4d6a3bPrecondition with the operator's symbol; project the correction onto the bandDan Fortunato 108lamJ = lam ./ jhat; % mean-J preconditioner eigenvalues (below)
109...
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 110for k = 1:niter
111 Fu = Un .* filt; % zero the top 2 degrees before differentiating
e4d6a3bPrecondition with the operator's symbol; project the correction onto the bandDan Fortunato 112 vtu = dthetac(Fu);
113 vpu = dphic(Fu);
114 [Ftu, Fpu] = synth(vtu, vpu); % sin(theta)*dtheta(u), dphi(u) -- smooth on
115 % the sphere, one batched dispatch
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 116 Pu = p1 .* Ftu + p2 .* Fpu; % the two fluxes, also smooth: the precomputed
117 Qu = p2 .* Ftu + q2 .* Fpu; % weights carry every 1/sin(theta) there is
e4d6a3bPrecondition with the operator's symbol; project the correction onto the bandDan Fortunato 118 [PAu, QAu] = analys(Pu, Qu);
119 Pcu = PAu .* filt;
120 Qcu = QAu .* filt;
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 121 scu = dthetac(Pcu) + dphic(Qcu); % divergence, in coefficient space
122 lapu = r .* synth(scu); % = lap_g(u) on the grid
e4d6a3bPrecondition with the operator's symbol; project the correction onto the bandDan Fortunato 123 dLu = (analys(lapu) + lamJ .* Un) .* filt; % dlap, projected onto the band
124 Un = (Bu + (dt * D1) * dLu) ./ (1 + (dt * D1) * lamJ);
126```
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 128On the sphere `dlap` is mathematically zero — `p1 = q2 = 1`, `p2 = 0`,
e4d6a3bPrecondition with the operator's symbol; project the correction onto the bandDan Fortunato 129`r = 1/sin²θ`, `jhat = 1`, and the composition collapses to `lap_s` — so the
130sphere case reproduces turing-sphere to fp32 round-off, and the tests assert
131the state stays put across 0, 1 and 4 iterations.
133**The preconditioner folds in the symbol of the operator.** `jhat` is the
134host's minimax scale `2/(μmin + μmax)` over the eigenvalues `μ(x)` of the
135operator's principal symbol — the inverse squared principal stretches of
136the embedding, direction included, read straight off the flux-metric
137arrays (`S = (1/J)·[[p1,p2],[p2,q2]]`). Preconditioning with `lam/jhat`
138then contracts every mode *and every direction* at rate
139`(μmax − μmin)/(μmax + μmin) < 1` on any surface, where the plain `lam`
140diverges wherever `μ > 2` — peanut reaches `μ = 6.2`. A det-based mean of
141the area factor (μ's geometric mean, exact only for conformal surfaces) is
142not enough: it under-corrects anisotropic stretching and leaves directional
143high-degree bands with amplification > 1, which surfaced as patterns going
144high-frequency and diverging as `niter` or `lmax` grew. The answer never
145depends on `jhat` — the `lamJ` term added inside `dLu` is the term divided
146back out — only the convergence rate does.
148**The correction is projected onto the band** (`.* filt` on `dLu`,
149matching algos.tex Algorithm 5's zeroing of the top coefficients). Without
150it the top two degrees iterate toward the *undiffused* `Bu` — each solve
151iteration strips a bit more of their implicit diffusion, at species-
152dependent rates, which manufactures a spurious Turing band at the band
153edge: visible on the round sphere as top-degree energy growing ~3%/step at
154`lmax 127, niter 8`. With both fixes the whole niter × geometry sweep
155converges, the spectral centroid of the pattern is resolution-independent
156(l ≈ 26 at lmax 63 and 127 alike), and `jhat: 1` is kept as the divergent
157control in the tests.
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 158
159### The geometry in the operator
161`dlap = lap_g - lap_s` is applied to the current iterate at every solve
162iteration, so its transform count is what the whole step's cost scales with.
163Two formulations ship:
1651. **The flux form** (above, all three models): `lap_g u` as the weighted
166 divergence of two weighted fluxes of the sin-scaled derivatives. The
167 weights `p1, p2, q2, r` are grid arrays precomputed once per surface from
168 the embedding's θ/φ tangents
169 ([`src/geom/metric.ts`](src/geom/metric.ts)), chosen so that **every field
170 that gets analysed is a smooth function on the sphere** — the property
171 that makes spherical-harmonic analysis meaningful, and the entire
172 difficulty near the poles. Cost: **6 transforms** per species per
173 iteration (3 syntheses + 3 analyses; `dthetac`/`dphic` are O(nlm)
174 coefficient shuffles, not transforms). The derivation, the smoothness
175 argument and the fp32 error analysis are in
176 [docs/reduced-transforms.md](docs/reduced-transforms.md).
1772. **The Cartesian-gradient form** (Algorithm 4 of `docs/algos.pdf`), kept as
178 a live reference in
179 [`models/schnakenberg_alg4.m`](models/schnakenberg_alg4.m) and selectable
180 in the app: the surface gradient carried as three ambient components
181 through the inverse metric quantities `Vt*/Vp*`. Cost: **12 transforms**
182 per species per iteration. The tests hold both forms to the same answer on
183 a curved surface, and both metric formulations are precomputed and
184 uploaded for every geometry, so either kind of model runs.
186The θ-derivative machinery both forms need — the α± recurrence
187(`sin θ ∂θ Y_l^m = α⁺Y_{l+1}^m + α⁻Y_{l-1}^m`) as a coefficient-space shuffle
188feeding the existing scalar synthesis — lives in
189[`src/sht/deriv.ts`](src/sht/deriv.ts); no Legendre-derivative tables are
190required.
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 191
192### `for` loops, unrolled
194A plan is a fixed list of GPU operations with no branching, which is what makes
195a timestep pure command recording — one submit, no CPU in the loop. A counted
196loop still fits: the planner
197([`src/mgpu/plan.ts`](src/mgpu/plan.ts)) unrolls it, planning the body once per
198iteration.
200Nothing else had to change for that, because numbl gives a variable one cName
201for every assignment to it: the buffer an iteration writes is the buffer the
202next one reads, which is exactly a loop-carried value. The loop variable gets no
203buffer at all — it is bound as a derived scalar to that iteration's literal, so
204a kernel reading `k` folds the number in.
206Two consequences worth stating:
208- **The bounds must be known when the model compiles.** `niter` is supplied as a
209 fixed scalar rather than a tunable one, so changing it recompiles — unlike a
210 parameter, which is a uniform. A runtime bound is refused at compile time with
211 a source position, not silently mis-compiled, and there is a test for that.
212- **Fusion survives.** numbl's inline pass recurses into loop bodies, so a line
213 inside the loop is still one kernel. It runs there with no protected names,
214 though, which means an assignment whose only visible use is later in the same
215 body can be elided — correct for a body-local temp, wrong if something outside
216 the loop wanted it. [`src/mgpu/compile.ts`](src/mgpu/compile.ts) snapshots what
217 each loop body assigns before the pass and refuses the ones that escape, so
218 that case is a compile error rather than a stale read.
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 220Unrolling is exactly linear in the trip count: 19 GPU ops per species per
221iteration (6 transforms, 4 coefficient shuffles, 9 kernels), asserted in the
222tests.
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 223
224## MATLAB, compiled to WebGPU
226Unchanged from turing-sphere, and it now compiles the geometry files too. numbl
227parses and lowers each function for the concrete argument types of the current
228grid; its inline pass folds single-use temps back into their consumer, so one
229line of MATLAB becomes one expression tree; and this repo emits one WGSL compute
230kernel per element-wise statement
231([`src/mgpu/wgsl.ts`](src/mgpu/wgsl.ts)). `synth` / `analys` are external
232operations whose type rules numbl learns from a `.mtoc2.js` workspace file, and
233which the backend maps onto the spherical-harmonic pipelines. Anything it cannot
234express is refused at compile time with a source position.
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 236The Schnakenberg step compiles to 51 GPU operations at one solve iteration:
23716 transforms, 8 coefficient-space shuffles, 25 generated kernels, and 2
238buffer copies feeding the new state back.
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 239
a4fee9cBatch independent transforms through one Legendre dispatchDan Fortunato 240**Transforms batch.** The expensive part of every Legendre stage is
241generating the associated Legendre values on the fly by recurrence — work
242that depends only on the grid, not on the field. `synth`/`analys` therefore
243take multiple fields, and a grouped call runs as one batched dispatch: one
244walk of the recurrence, one accumulator lane per field —
246```matlab
247[Ftu, Fpu, Ftv, Fpv] = synth(vtu, vpu, vtv, vpv); % one Legendre dispatch
248```
250The grouping is a promise of independence, never of a lane width: the
251planner ([`src/mgpu/plan.ts`](src/mgpu/plan.ts), `materializeTransforms`)
252chunks each group into whatever the device supports — one ×4 batch under the
253default WebGPU limits, or scalar dispatches with `SHT_BATCH=0` for A/B — so
254the same source runs anywhere. Ungrouped transforms that happen to sit on
255consecutive independent lines are batched the same way. Per-lane arithmetic
256is identical to the scalar kernels', so batched and scalar plans produce
257bit-identical states, asserted in the tests along with compile-time refusal
258of a group that drops one of its outputs. All 16 transforms of the step
259above land in batches, worth ~25% of the whole step (0.88 vs 1.14 ms/step at
260lmax 127, 2 iterations, on bumpy).
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 262Two consequences carried over:
264- **The step is synchronous.** WebGPU's encode path is synchronous and every
265 pipeline is built once at compile time, so a timestep is pure command
266 recording; the only `await` in the loop is the single readback per rendered
267 frame.
268- **Parameters are uniforms, not constants.** Moving a slider rewrites a small
269 buffer instead of triggering a recompile. Editing the MATLAB recompiles;
270 changing `dt` does not. `niter` is the deliberate exception, above.
272## Provenance
274- **turing-sphere**, which this is a fork of: the solver, the transforms
275 backend, the compilation path, the benchmarks and the analytic tests.
276- **Transforms:** [shtns-webgpu](https://github.com/concept-collection/shtns-webgpu) —
277 fp32 spherical harmonic transforms in WGSL compute shaders, modeled on
278 [SHTNS](https://nschaeff.bitbucket.io/shtns/). Vendored under
279 [`src/sht/`](src/sht/) (CECILL-2.1), including the f64 CPU reference transform
280 used for testing.
281- **Rendering:** three.js meshes with per-vertex colormaps, adapted from the
282 `SphereEmbedding` view in
283 [figpack](https://github.com/flatironinstitute/figpack)'s experimental
284 extension package ([`src/render/`](src/render/)). That view displays a
285 time-varying embedded geometry with fields on it, which is the same picture
286 this draws — including its sphere/surface morph, which turing-sphere had
287 dropped as having nothing to morph to.
289turing-sphere additionally carries a comparison against a native build of
290upstream SHTNS ([`bench/shtns/`](https://github.com/concept-collection/turing-sphere/tree/main/bench/shtns)).
291That is not duplicated here: the transforms are the same code, and its C-side
292transcription of the model would have to be maintained against a step this
293project intends to change.
295Because the algorithm is compiled to compute shaders, **WebGPU is required** —
296there is no CPU fallback (the f64 CPU transform remains, for tests).
298## Numerics
300- Grid: Gauss–Legendre × equispaced-φ, dealiased for the cubic reactions with
301 the `(pdeg+1)` rule: `nlat ≥ ((pdeg+1)·lmax+1)/2`, `nphi ≥ (pdeg+1)·lmax+1`
302 (rounded up to a power of two for the GPU FFT path). At the default lmax 63
303 that is a 128×256 grid.
304- Spectral layout: SHTNS conventions — orthonormal + Condon–Shortley, complex
305 coefficients for m ≥ 0, m-major ordering.
306- fp32 transforms introduce ~1e-6 relative error per step; for pattern formation
307 from 1e-2 seeded noise this is inconsequential. The geometry goes through one
308 analysis/synthesis round trip and picks up the same round-off: the unit sphere
309 comes back with radius 1 to ~2e-5 under Dawn, ~4e-4 under SwiftShader.
310- The shipped geometries are all degree ≤ 5, far below any lmax the app offers,
311 so band-limiting removes nothing from them. A shape you write yourself may not
312 be so lucky — see the note in [`geometries/bumpy.m`](geometries/bumpy.m).
314## Desktop vs browser
316[`scripts/bench.ts`](scripts/bench.ts) runs the same thing the app runs — same
317`.m`, same generated WGSL, same transforms — from Node on desktop WebGPU (Google
318Dawn), and the app prints the command line that reproduces whatever it is
319currently simulating:
321```
322npm run bench -- --preset schnak-spots --geometry ellipsoid --lmax 63 --niter 1 \
323 --steps 2000 --seed 1 --a 0.1 --b 0.9 --D1 0.0004 --D2 0.008 --dt 0.05 \
324 --gax 1.5 --gay 1 --gaz 0.6
325```
327Copy it from under the stats line and compare the `ms/step` it reports with the
328app's. Both sides go through the one shared
329[`src/bench/runSpec.ts`](src/bench/runSpec.ts) — the app formats a run into that
330command, the benchmark parses it back — so there is no second copy of the
331defaults for the two runs to drift apart on. Geometry parameters take a `g`
332prefix (`--gwaist`) so a shape parameter can never collide with a model one.
334The app reports **two** numbers and only the first is comparable to the
335benchmark: `solver` is the batch of steps alone, waited for but not read back;
336`ms/frame` additionally carries a GPU→CPU readback per species, the
337colormapping, and the vertex upload. Those per-frame costs are fixed and do not
338shrink when the GPU gets faster, so on a quick GPU a frame can easily cost ten
339times the steps inside it. That is expected and is not the solver being slower
340in the browser.
342To attribute the gap rather than guess at it:
344```
345node scripts/compare-perf.mjs [--lmax 63] [--steps 300]
346```
348measures the same solver work in both — batched, nothing read back, no rendering
349on either side — and reports each with its CPU-encoding share, the Fourier
350stage, and the adapter. It stops you first if the two are not even the same
351device, which is a common cause of "the browser is much slower". Both sides
352resolve the geometry and the iteration count from the same constants, because
353the iteration count is unrolled into the step and a mismatch would compare two
354different amounts of work.
356The app's **Benchmark** button runs the same measurement in the page, plus the
357**ramp** — the first third of the run against the last. GPUs downclock when
358idle and an animation-paced loop leaves them idle most of every frame, so a
359large ramp means the steady-state number is limited by clocks rather than work.
361### Is it really the same computation?
363```
364node scripts/compare-env.mjs [--lmax 31] [--steps 200] [--preset schnak-spots]
365```
367runs one identical spec on the desktop and in a real browser and compares the
368final spectral state. The pipeline is deterministic given (model source,
369geometry, parameters, lmax, niter, seed, steps), so the two should agree to fp32
370round-off — not bit for bit, since GPUs differ in fused-multiply-add and other
371latitude fp32 allows. It also reports which Fourier stage each side chose, since
372FFT and DFT are genuinely different algorithms that round differently.
374Desktop WebGPU comes from the `webgpu` package (prebuilt Dawn, ~70 MB), an
375optional dependency so that an unsupported platform fails the install of that
376package alone. Its binaries need glibc 2.29+. Other flags: `--steps`,
377`--warmup`, `--batch`, `--json`, `--help`; `DAWN_FLAGS='backend=vulkan'`
378(`;`-separated) passes Dawn options through.
380## Tests
382There is no second implementation of the solver to diff against, so the `.m`
383path is checked against **closed-form answers** and against **exact structural
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 384properties**. Five modules, run in both environments:
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 385
386[`test/analyticChecks.ts`](test/analyticChecks.ts) — cases whose evolution is
387known exactly, run through the whole real pipeline. All three are statements
388about the round sphere, so all three build on the sphere geometry:
390- **A** — a linear reaction leaves every mode independent, growing by exactly
391 `(1 + dt*c) / (1 + dt*D*l(l+1))` per step. Pins the transform round trip, the
392 eigenvalue mapping, the IMEX update and the state feedback at once. ~2e-7 over
393 20 steps.
394- **B** — a nonlinear reaction on a uniform field stays uniform, so each step is
395 exactly the scalar ODE map. 1.5e-8 over 25 steps.
396- **C** — a 1e-6 perturbation of the Schnakenberg fixed point follows the
397 linearized 2×2 IMEX recurrence, and `(l=24, m=7)` is confirmed unstable.
398 Looser (~4e-3) because fp32 keeps about four digits of a perturbation that
399 small.
401[`test/geometryChecks.ts`](test/geometryChecks.ts) — the surface and the loop:
403- every geometry compiles and closes; the sphere has radius 1 everywhere and is
404 **exactly degree 1** in the harmonics, which is what makes the reference case
405 exact rather than merely accurate;
406- the peanut matches its own closed-form radial profile at every grid point, and
407 **the same coefficients give the same surface on a 2× grid** — the 2× Gauss
408 latitudes share no point with the 1× ones, so agreeing there is agreeing
409 everywhere, which is what "rendered exactly, not subdivided" means;
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 410- unrolling is **exactly linear** in the trip count, and on the sphere — where
411 the geometric correction is mathematically zero — the state after 20 steps
412 stays within fp32 round-off of the 0-iteration one at 1 and 4 iterations;
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 413- a runtime loop bound is refused at compile time;
414- swapping the surface mid-run leaves the spectral state untouched.
591a4f5Reduce the Laplace-Beltrami matvec to 6 transforms per species per iterationDan Fortunato 416[`test/fluxChecks.ts`](test/fluxChecks.ts) — the six-transform flux-form
417Laplace-Beltrami scheme
418([docs/reduced-transforms.md](docs/reduced-transforms.md)):
420- on the sphere, the precomputed weights match their closed form and the
421 analysed fluxes are **exactly band-limited** (beyond-band tails at f64
422 round-off, ~1e-13), while the deliberately non-smooth control
423 `Q̃/sin θ` keeps a fat tail (~1e-2) — the discrimination the whole scheme
424 rests on;
425- on a non-axisymmetric surface, the flux tails match the Cartesian gradient
426 component's, the doc's §7.1 criterion;
427- the compiled op sequences add **6 transforms per species per iteration
428 against Algorithm 4's 12**, and a real simulation driven by each stays
429 within fp32 accumulation of the other.
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 431[`test/modelChecks.ts`](test/modelChecks.ts) compiles every model the app offers
432and asserts **how many kernels it compiles to**, split into the base step and
433what one solve iteration adds. That is a fusion guard: if numbl's inline pass
434stops folding, the results stay correct while every operator becomes its own
435dispatch, which is invisible in the numbers.
437[`test/transformChecks.ts`](test/transformChecks.ts) compares the WGSL transforms
a4fee9cBatch independent transforms through one Legendre dispatchDan Fortunato 438against shtns-webgpu's f64 CPU twin, and holds every compiled batch width to
439the scalar transforms lane by lane; a model run with `SHT_BATCH=0` must
440reproduce the batched run's state exactly.
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 441
442- `npm run test:node` — under Dawn on the desktop, via `vite-node`. Needs a GPU;
443 `--skip-without-gpu` lets a machine without one say so and move on (which is
444 what CI does, since the browser suite covers the same modules).
445- `npm run test:gpu` — builds and drives headless Chrome, on SwiftShader in CI.
446 Also runs the soak. A few geometry tolerances are set by SwiftShader's fp32,
447 which is about an order of magnitude looser than Dawn's.
449Other commands:
451- `npm run bench -- --help` — the desktop benchmark.
452- `npm run bench:sht -- --help` — the transforms alone, no solver.
453- `npx vite-node scripts/diagnose-sht.ts` — when the transform tests fail on a
454 GPU, say *which* stage is wrong.
455- `npx vite-node scripts/diagnose-leg.ts [--m 0]` — read the Legendre recurrence
456 out of the production shader term by term.
457- `npx vite-node scripts/longrun-node.ts [lmax]` — run to t = 100 and confirm the
458 pattern saturates rather than decaying or diverging.
459- `node scripts/soak.mjs [steps] [lmax]` — drive the demo for many steps,
460 sampling JS heap and catching crashes.
461- `node scripts/screenshot.mjs out.png [light|dark] [minSteps]` — screenshot the
462 demo after a number of steps.
463- `node scripts/check-live.mjs [url]` — smoke-check a deployed URL.
464- `test.html?soak=<steps>&lmax=<n>` — solver-only soak with no rendering.
466## Development
468```
469npm install
470npm run dev # local dev server
471npm run build # type-check + production build to dist/
472```
474### The numbl dependency
476numbl is a local `file:../../numbl` dependency, so a sibling checkout of
477[numbl](https://github.com/flatironinstitute/numbl) is required. We use its
478compiler internals — parser, lowerer, IR, inline pass — which its package
479`exports` map does not publish, so they are reached through the `numbl-src` path
480alias in [`vite.config.ts`](vite.config.ts).
482The exact surface we depend on is written down in
483[`src/mgpu/numbl.d.ts`](src/mgpu/numbl.d.ts) and TypeScript checks against
484*that*, not against numbl's sources. This keeps this project's compiler settings
485independent of numbl's, and means a change to one of those shapes upstream
486breaks the build here with a clear diff rather than deep inside numbl's tree.
487The `For` IR node is spelled out there, since the planner now walks it.
489CI clones numbl to the sibling path that the `file:` dependency expects, pinned
490to a commit, with `--ignore-scripts` (npm runs a linked package's `prepare`
491script, and numbl's is husky). numbl's own `node_modules` are not needed: the
492slice we import is self-contained TypeScript.
494The `scripts/*.ts` entry points that touch the compiler go through `vite-node`,
495so they resolve imports exactly as the browser build does. Plain `node` cannot:
496numbl's sources import each other as `./foo.js` while the files are `.ts`.
498Deployed to GitHub Pages by `.github/workflows/deploy.yml` on push to `main`.
500## License
502CECILL-2.1 (inherited from SHTNS via shtns-webgpu, whose sources are vendored).