concept-collection / turing-sphere
Render on demand, display oversampling, and jump-free solver timing
Three display improvements, none touching the solver: - SphereScene renders only when something changed (new colors, camera motion, resize); the rAF loop keeps ticking only to drive damping. - Configurable display oversampling: the state is band-limited, so synthesizing it on a finer grid for rendering is exact interpolation. 'auto' picks the factor from the grid, and changing it swaps the render grid in place - state, camera pose and color ranges survive. - The periodic solver-rate burst snapshots and restores the spectral state, so measuring no longer advances the simulation by 32 extra steps and the pattern no longer lurches every 2 seconds.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 163ec450c3a2 parent b97baa7 Browse files
8 changed files+460−68
README.mdmodified+4−1View file
@@ -32,7 +32,10 @@ U_k = (U_k + dt*R_k) / (1 + dt*D_k*l(l+1))
3232
3333 You watch the patterns emerge in real time on orbitable 3D spheres (one per
3434 species, cameras synced), with pause/resume, re-seeding, live parameter editing,
35-and colormap selection.
35+and colormap selection. The display can oversample the solver — the state is
36+spectral, so evaluating it on a finer grid for rendering is exact
37+interpolation, not smoothing. This never touches the solver or its grid; by
38+default it turns on only when the solver grid is coarse.
3639
3740 Three models are included, one `.m` file each:
3841
index.htmlmodified+8−0View file
@@ -180,6 +180,14 @@
180180 <option value="255">255</option>
181181 </select>
182182 </label>
183+ <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
184+ <select id="oversample">
185+ <option value="auto" selected>auto</option>
186+ <option value="1">off</option>
187+ <option value="2">2×</option>
188+ <option value="4">4×</option>
189+ </select>
190+ </label>
183191 <label>colormap
184192 <select id="colormap"></select>
185193 </label>
src/main.tsmodified+125−56View file
@@ -1,4 +1,5 @@
11 import { requestShtDevice, describeAdapter } from './sht/sht.ts';
2+import { gridForLmax } from './sht/layout.ts';
23 import { ModelSession } from './mgpu/session.ts';
34 import { mModelByKey, presets, type MModel, type Params } from './mgpu/registry.ts';
45 import { ModelCompileError, formatFailure } from './mgpu/errors.ts';
@@ -26,6 +27,7 @@ const $ = <T extends HTMLElement>(id: string): T =>
2627
2728 const elModel = $<HTMLSelectElement>('model');
2829 const elLmax = $<HTMLSelectElement>('lmax');
30+const elOversample = $<HTMLSelectElement>('oversample');
2931 const elColormap = $<HTMLSelectElement>('colormap');
3032 const elRunPause = $<HTMLButtonElement>('runpause');
3133 const elBenchmark = $<HTMLButtonElement>('benchmark');
@@ -84,12 +86,30 @@ const STEPS_PER_FRAME = 4;
8486 * fixed cost of a few milliseconds. Spread over one frame's four steps it would
8587 * swamp them on a fast GPU and make the solver look far slower than it is. So the
8688 * rate is measured in an occasional larger batch, where the single sync is
87- * amortized the way the desktop benchmark amortizes its own. These are ordinary
88- * steps: the simulation advances by them like any others.
89+ * amortized the way the desktop benchmark amortizes its own. The state is
90+ * snapshotted and restored around the batch, so measuring never advances the
91+ * simulation — otherwise the pattern would visibly lurch forward at every
92+ * measurement.
8993 */
9094 const MEASURE_BURST = 32;
9195 const MEASURE_EVERY_MS = 2000;
9296
97+/**
98+ * 'auto' display oversampling targets this many render latitudes: the factor is
99+ * the smallest power of two (up to 4) that reaches it. A solver grid already
100+ * this fine gains nothing visually and is not oversampled.
101+ */
102+const AUTO_RENDER_NLAT = 256;
103+
104+/** The display oversampling factor the UI currently asks for. */
105+function resolveOversample(): number {
106+ if (elOversample.value !== 'auto') return Number(elOversample.value);
107+ const { nlat } = gridForLmax(Number(elLmax.value), model.pdeg);
108+ let os = 1;
109+ while (os < 4 && os * nlat < AUTO_RENDER_NLAT) os *= 2;
110+ return os;
111+}
112+
93113 // ---------------------------------------------------------------- state
94114 let device: GPUDevice | null = null;
95115 let session: ModelSession | null = null;
@@ -181,6 +201,12 @@ elModel.addEventListener('change', () => {
181201 void rebuild();
182202 });
183203 elLmax.addEventListener('change', () => void rebuild());
204+// Oversampling is display-only, so it swaps the render grid in place rather
205+// than rebuilding the run. Serialized: a rapid second change waits its turn.
206+let viewChange = Promise.resolve();
207+elOversample.addEventListener('change', () => {
208+ viewChange = viewChange.then(() => applyOversample());
209+});
184210 elColormap.addEventListener('change', () => void draw());
185211
186212 function setRunning(next: boolean): void {
@@ -241,6 +267,89 @@ function disposeView(): void {
241267 elPanels.replaceChildren();
242268 }
243269
270+/**
271+ * Build the mesh, scenes, colorbars and per-species buffers on the current
272+ * render grid. Call disposeView() first. The color ranges are kept if present,
273+ * so a display-only rebuild (an oversampling change) does not pop the shading;
274+ * a full rebuild clears `ranges` beforehand.
275+ */
276+function buildView(): void {
277+ if (!session) return;
278+ const view = session.viewSht;
279+ const { nphi } = view.cfg;
280+ const phi = new Float64Array(nphi);
281+ for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
282+ topo = buildTopology(view.cosTheta, phi);
283+
284+ const sphereBg = getComputedStyle(document.documentElement)
285+ .getPropertyValue('--sphere-bg')
286+ .trim();
287+ for (let k = 0; k < model.species.length; k++) {
288+ const panel = document.createElement('div');
289+ panel.className = 'panel';
290+ const box = document.createElement('div');
291+ box.className = 'sphere-box';
292+ const tag = document.createElement('div');
293+ tag.className = 'species-tag';
294+ tag.textContent = model.species[k];
295+ box.append(tag);
296+ const side = document.createElement('div');
297+ panel.append(box, side);
298+ elPanels.append(panel);
299+
300+ const scene = new SphereScene(
301+ box,
302+ topo.numVertices,
303+ topo.indices,
304+ topo.sphereRef,
305+ sphereBg || undefined,
306+ );
307+ scene.fitCamera();
308+ scenes.push(scene);
309+ colorbars.push(new Colorbar(side));
310+ valueBufs[k] = new Float32Array(topo.numVertices);
311+ colorBufs[k] = new Float32Array(topo.numVertices * 3);
312+ if (!ranges[k]) ranges[k] = { lo: NaN, hi: NaN };
313+ }
314+ for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
315+
316+ resizeObs = new ResizeObserver(() => {
317+ const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
318+ boxes.forEach((box, i) => {
319+ scenes[i]?.resize(box.clientWidth, box.clientHeight);
320+ });
321+ });
322+ elPanels
323+ .querySelectorAll<HTMLElement>('.sphere-box')
324+ .forEach((box) => resizeObs!.observe(box));
325+}
326+
327+/**
328+ * Apply the UI's oversampling choice to the running session. Display-only: the
329+ * session and its state survive; only the display plan, mesh and scenes are
330+ * rebuilt, keeping the camera pose and color ranges. The pump is drained first
331+ * so no readback is in flight on the plan being replaced.
332+ */
333+async function applyOversample(): Promise<void> {
334+ if (!session) return;
335+ const gen = generation;
336+ const os = resolveOversample();
337+ if (os === session.oversample) return;
338+ const wasRunning = running;
339+ setRunning(false);
340+ while (pumping) await nextFrame();
341+ if (gen !== generation || !session) return;
342+ await session.setOversample(os);
343+ if (gen !== generation || !session) return;
344+ const cam = scenes[0]?.cameraState();
345+ disposeView();
346+ buildView();
347+ if (cam) for (const s of scenes) s.setCameraState(cam);
348+ await draw();
349+ updateStats();
350+ if (wasRunning) setRunning(true);
351+}
352+
244353 /** Report a compile failure, and select the offending text in the editor. */
245354 function reportCompileError(e: unknown): void {
246355 elErr.textContent = formatFailure(e, source());
@@ -271,6 +380,7 @@ async function rebuild(): Promise<void> {
271380 params,
272381 lmax: Number(elLmax.value),
273382 source: source(),
383+ oversample: resolveOversample(),
274384 });
275385 } catch (e) {
276386 reportCompileError(e);
@@ -286,53 +396,8 @@ async function rebuild(): Promise<void> {
286396 plan.step.map((l) => ` ${l}`).join('\n');
287397 elRecompile.textContent = 'Recompile';
288398
289- // mesh + scenes
290- const { nphi } = session.cfg;
291- const phi = new Float64Array(nphi);
292- for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
293- topo = buildTopology(session.sht.cosTheta, phi);
294-
295- const sphereBg = getComputedStyle(document.documentElement)
296- .getPropertyValue('--sphere-bg')
297- .trim();
298- for (let k = 0; k < model.species.length; k++) {
299- const panel = document.createElement('div');
300- panel.className = 'panel';
301- const box = document.createElement('div');
302- box.className = 'sphere-box';
303- const tag = document.createElement('div');
304- tag.className = 'species-tag';
305- tag.textContent = model.species[k];
306- box.append(tag);
307- const side = document.createElement('div');
308- panel.append(box, side);
309- elPanels.append(panel);
310-
311- const scene = new SphereScene(
312- box,
313- topo.numVertices,
314- topo.indices,
315- topo.sphereRef,
316- sphereBg || undefined,
317- );
318- scene.fitCamera();
319- scenes.push(scene);
320- colorbars.push(new Colorbar(side));
321- valueBufs[k] = new Float32Array(topo.numVertices);
322- colorBufs[k] = new Float32Array(topo.numVertices * 3);
323- ranges[k] = { lo: NaN, hi: NaN };
324- }
325- for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
326-
327- resizeObs = new ResizeObserver(() => {
328- const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
329- boxes.forEach((box, i) => {
330- scenes[i]?.resize(box.clientWidth, box.clientHeight);
331- });
332- });
333- elPanels
334- .querySelectorAll<HTMLElement>('.sphere-box')
335- .forEach((box) => resizeObs!.observe(box));
399+ ranges = [];
400+ buildView();
336401
337402 await draw();
338403 updateStats();
@@ -363,7 +428,7 @@ async function draw(): Promise<void> {
363428 // mapped, which rejects the map; that result is stale anyway, so drop it.
364429 let field: Float32Array;
365430 try {
366- field = await session.read(model.species[k]);
431+ field = await session.readSpecies(k);
367432 } catch (e) {
368433 if (gen !== generation) return;
369434 throw e;
@@ -411,8 +476,13 @@ function updateStats(): void {
411476 frameMs > 0
412477 ? `${frameMs.toFixed(1)} ms/frame (${STEPS_PER_FRAME} steps + readback + render)`
413478 : '—';
479+ const view = session.viewSht.cfg;
480+ const render =
481+ session.oversample > 1
482+ ? ` (display ${view.nlat}×${view.nphi}, ${session.oversample}×)`
483+ : '';
414484 elStats.innerHTML =
415- `<b>${kind}</b> · grid ${nlat}×${nphi} · nlm ${session.sht.nlm.toLocaleString()} · ` +
485+ `<b>${kind}</b> · grid ${nlat}×${nphi}${render} · nlm ${session.sht.nlm.toLocaleString()} · ` +
416486 `${session.sht.fourierMode.toUpperCase()} · solver ${solver} · ${frame} · ` +
417487 `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
418488 }
@@ -428,13 +498,12 @@ async function pump(): Promise<void> {
428498 while (running && session && gen === generation) {
429499 // Occasionally, a burst purely to measure the solver rate: many steps,
430500 // one sync, nothing read back — directly comparable to the desktop
431- // benchmark's throughput number.
501+ // benchmark's throughput number. State-preserving: the display and
502+ // model time are unaffected.
432503 if (performance.now() - lastMeasure > MEASURE_EVERY_MS) {
433- const m0 = performance.now();
434- session.step(MEASURE_BURST);
435- await session.sync();
504+ const ms = await session.measure(MEASURE_BURST);
436505 if (gen !== generation) break;
437- solverMs = (performance.now() - m0) / MEASURE_BURST;
506+ solverMs = ms;
438507 lastMeasure = performance.now();
439508 }
440509
src/mgpu/model.tsmodified+73−9View file
@@ -67,8 +67,12 @@ export class GpuModel {
6767 #initPlan: ModelPlan;
6868 #stepPlan: ModelPlan;
6969 #readback: GPUBuffer;
70+ /** Scratch holding a copy of the whole spectral state; see snapshotState. */
71+ #stash: GPUBuffer;
7072 /** Which function wrote the state most recently; see `read`. */
7173 #lastRan: 'init' | 'step' = 'init';
74+ #stashedRan: 'init' | 'step' = 'init';
75+ #destroyed = false;
7276
7377 private constructor(init: {
7478 device: GPUDevice;
@@ -76,6 +80,7 @@ export class GpuModel {
7680 initPlan: ModelPlan;
7781 stepPlan: ModelPlan;
7882 readback: GPUBuffer;
83+ stash: GPUBuffer;
7984 paramNames: string[];
8085 state: string[];
8186 view: string[];
@@ -87,6 +92,7 @@ export class GpuModel {
8792 this.#initPlan = init.initPlan;
8893 this.#stepPlan = init.stepPlan;
8994 this.#readback = init.readback;
95+ this.#stash = init.stash;
9096 this.paramNames = init.paramNames;
9197 this.state = init.state;
9298 this.view = init.view;
@@ -144,9 +150,14 @@ export class GpuModel {
144150 size: 4 * Math.max(npts, 2 * nlm),
145151 usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
146152 });
153+ const stash = device.createBuffer({
154+ label: 'mgpu-state-stash',
155+ size: 4 * state.length * 2 * nlm,
156+ usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
157+ });
147158
148159 return new GpuModel({
149- device, host, initPlan, stepPlan, readback,
160+ device, host, initPlan, stepPlan, readback, stash,
150161 paramNames, state, view, npts, nlm,
151162 });
152163 }
@@ -174,6 +185,43 @@ export class GpuModel {
174185 this.#lastRan = 'init';
175186 }
176187
188+ /**
189+ * Copy the spectral state aside, so a batch of steps can run — to be timed —
190+ * and then be undone with restoreState, leaving the simulation exactly where
191+ * it was. Only the state is stashed: the grid view fields keep whatever the
192+ * batch last wrote until a subsequent step recomputes them, so step before
193+ * reading a view after a restore.
194+ */
195+ snapshotState(): void {
196+ this.#stashedRan = this.#lastRan;
197+ this.#copyState('save');
198+ }
199+
200+ restoreState(): void {
201+ this.#copyState('restore');
202+ this.#lastRan = this.#stashedRan;
203+ }
204+
205+ #copyState(dir: 'save' | 'restore'): void {
206+ // A restore can land after a rebuild destroyed the buffers mid-await;
207+ // there is nothing left to protect, so do not submit into destroyed state.
208+ if (this.#destroyed) return;
209+ const enc = this.#device.createCommandEncoder({ label: `mgpu-state-${dir}` });
210+ let offset = 0;
211+ for (const name of this.state) {
212+ const slot = this.#host.get(name);
213+ if (!slot) throw new Error(`state '${name}' has no host buffer`);
214+ const bytes = 4 * slot.count;
215+ if (dir === 'save') {
216+ enc.copyBufferToBuffer(slot.buffer, 0, this.#stash, offset, bytes);
217+ } else {
218+ enc.copyBufferToBuffer(this.#stash, offset, slot.buffer, 0, bytes);
219+ }
220+ offset += bytes;
221+ }
222+ this.#device.queue.submit([enc.finish()]);
223+ }
224+
177225 /**
178226 * Advance `steps` timesteps. Synchronous — this only records commands and
179227 * submits them; nothing is read back and nothing is awaited.
@@ -186,23 +234,37 @@ export class GpuModel {
186234 }
187235
188236 /**
189- * Read a named value back to the CPU. The only await in the whole loop.
190- *
191- * Grid fields like `u` are produced by both functions, into separate buffers
192- * (only the spectral state is shared), so this reads from whichever ran most
193- * recently — which is what makes the first frame show the initial state
194- * rather than an unwritten buffer.
237+ * The buffer currently holding a named value. Grid fields like `u` are
238+ * produced by both functions, into separate buffers (only the spectral state
239+ * is shared), so this resolves to whichever function ran most recently —
240+ * which is what makes the first frame show the initial state rather than an
241+ * unwritten buffer.
195242 */
196- async read(name: string): Promise<Float32Array> {
243+ #locate(name: string): { buffer: GPUBuffer; count: number } | null {
197244 const [first, second] =
198245 this.#lastRan === 'init'
199246 ? [this.#initPlan, this.#stepPlan]
200247 : [this.#stepPlan, this.#initPlan];
201248 const buffer = first.buffer(name) ?? second.buffer(name);
202249 const count = first.elementCount(name) ?? second.elementCount(name);
203- if (!buffer || count === undefined) {
250+ if (!buffer || count === undefined) return null;
251+ return { buffer, count };
252+ }
253+
254+ /** The GPU buffer a named value would be read from right now — for encoding
255+ * further GPU work against it (e.g. a display-grid synthesis of the state)
256+ * without a CPU round trip. */
257+ valueBuffer(name: string): GPUBuffer | null {
258+ return this.#locate(name)?.buffer ?? null;
259+ }
260+
261+ /** Read a named value back to the CPU. The only await in the whole loop. */
262+ async read(name: string): Promise<Float32Array> {
263+ const located = this.#locate(name);
264+ if (!located) {
204265 throw new Error(`read: the model has no value named '${name}'`);
205266 }
267+ const { buffer, count } = located;
206268 const enc = this.#device.createCommandEncoder({ label: `mgpu-read-${name}` });
207269 enc.copyBufferToBuffer(buffer, 0, this.#readback, 0, 4 * count);
208270 this.#device.queue.submit([enc.finish()]);
@@ -218,9 +280,11 @@ export class GpuModel {
218280 }
219281
220282 destroy(): void {
283+ this.#destroyed = true;
221284 this.#initPlan.destroy();
222285 this.#stepPlan.destroy();
223286 this.#host.destroy();
224287 this.#readback.destroy();
288+ this.#stash.destroy();
225289 }
226290 }
src/mgpu/session.tsmodified+99−2View file
@@ -19,6 +19,10 @@ export interface ModelSessionOptions {
1919 lmax: number;
2020 /** Override the model source — the editor's working copy. */
2121 source?: string;
22+ /** Linear render oversampling: read the species fields on a grid this many
23+ * times finer than the solver's in each direction (default 1). The state is
24+ * band-limited at lmax, so the finer evaluation is exact interpolation. */
25+ oversample?: number;
2226 }
2327
2428 export class ModelSession {
@@ -34,14 +38,19 @@ export class ModelSession {
3438 steps = 0;
3539
3640 #params: ModelParams;
41+ /** Display-only transforms on the oversampled grid; null at 1x. */
42+ #displaySht: ShtPlan | null;
43+ #oversample: number;
3744
3845 private constructor(init: {
3946 device: GPUDevice;
4047 model: MModel;
4148 cfg: ShtConfig;
4249 sht: ShtPlan;
50+ displaySht: ShtPlan | null;
4351 gpu: GpuModel;
4452 params: ModelParams;
53+ oversample: number;
4554 }) {
4655 this.device = init.device;
4756 this.model = init.model;
@@ -49,15 +58,36 @@ export class ModelSession {
4958 this.sht = init.sht;
5059 this.gpu = init.gpu;
5160 this.npts = init.cfg.nlat * init.cfg.nphi;
61+ this.#oversample = init.oversample;
5262 this.#params = init.params;
63+ this.#displaySht = init.displaySht;
64+ }
65+
66+ /** Linear render oversampling factor (1 = read on the solver grid). */
67+ get oversample(): number {
68+ return this.#oversample;
5369 }
5470
5571 static async create(opts: ModelSessionOptions): Promise<ModelSession> {
5672 const { device, model, params, lmax } = opts;
73+ const oversample = Math.max(1, Math.round(opts.oversample ?? 1));
5774 const { nlat, nphi } = gridForLmax(lmax, model.pdeg);
5875 const cfg = { lmax, mmax: lmax, nlat, nphi };
5976 const sht = await ShtPlan.create(device, cfg);
77+ let displaySht: ShtPlan | null = null;
6078 try {
79+ // The display plan shares nothing with the solver's beyond the
80+ // coefficients copied into it per readback; its grid is the solver's
81+ // scaled by the oversampling factor, so nphi stays a power of two (the
82+ // FFT path) for power-of-two factors.
83+ if (oversample > 1) {
84+ displaySht = await ShtPlan.create(device, {
85+ lmax,
86+ mmax: lmax,
87+ nlat: oversample * nlat,
88+ nphi: oversample * nphi,
89+ });
90+ }
6191 const gpu = await GpuModel.create({
6292 device,
6393 sht,
@@ -68,14 +98,47 @@ export class ModelSession {
6898 view: model.species,
6999 });
70100 gpu.setParams(params);
71- return new ModelSession({ device, model, cfg, sht, gpu, params });
101+ return new ModelSession({
102+ device, model, cfg, sht, displaySht, gpu, params, oversample,
103+ });
72104 } catch (e) {
73- // The transform plan owns GPU buffers; do not leak them on a compile error.
105+ // The transform plans own GPU buffers; do not leak them on a compile error.
106+ displaySht?.destroy();
74107 sht.destroy();
75108 throw e;
76109 }
77110 }
78111
112+ /** The plan whose grid `readSpecies` samples on — the display plan when
113+ * oversampling, otherwise the solver's. Its cosTheta/nphi define the mesh. */
114+ get viewSht(): ShtPlan {
115+ return this.#displaySht ?? this.sht;
116+ }
117+
118+ /**
119+ * Change the display oversampling in place. Display-only: the simulation
120+ * state, time and parameters are untouched, so the run continues seamlessly
121+ * on the new render grid. The caller must not have a readSpecies in flight —
122+ * its readback maps a buffer of the plan being destroyed.
123+ */
124+ async setOversample(oversample: number): Promise<void> {
125+ const os = Math.max(1, Math.round(oversample));
126+ if (os === this.#oversample) return;
127+ const next =
128+ os > 1
129+ ? await ShtPlan.create(this.device, {
130+ lmax: this.cfg.lmax,
131+ mmax: this.cfg.mmax,
132+ nlat: os * this.cfg.nlat,
133+ nphi: os * this.cfg.nphi,
134+ })
135+ : null;
136+ const old = this.#displaySht;
137+ this.#displaySht = next;
138+ this.#oversample = os;
139+ old?.destroy();
140+ }
141+
79142 /** Run `init` from a seeded perturbation, resetting model time. */
80143 seed(seed: number): void {
81144 this.gpu.init(seededNoise(this.npts, this.model.seedAmp, seed));
@@ -105,17 +168,51 @@ export class ModelSession {
105168 return this.device.queue.onSubmittedWorkDone();
106169 }
107170
171+ /**
172+ * Time a batch of `n` steps and return ms/step, leaving the simulation
173+ * exactly where it was: the spectral state is snapshotted before the batch
174+ * and restored after, and `t`/`steps` do not advance. One sync amortized
175+ * over the batch — the same measurement the desktop benchmark makes. The
176+ * grid view fields hold the batch's output until the next real step, so
177+ * step before reading them.
178+ */
179+ async measure(n: number): Promise<number> {
180+ this.gpu.snapshotState();
181+ const t0 = performance.now();
182+ this.gpu.step(n);
183+ await this.sync();
184+ const ms = (performance.now() - t0) / n;
185+ this.gpu.restoreState();
186+ return ms;
187+ }
188+
108189 /** Read a named value (a grid field or the spectral state). */
109190 read(name: string): Promise<Float32Array> {
110191 return this.gpu.read(name);
111192 }
112193
194+ /**
195+ * Read species `k` at render resolution (`viewSht`'s grid). Without
196+ * oversampling this is the grid field the .m returned. With oversampling the
197+ * spectral state is synthesized on the finer grid instead — the same field,
198+ * since the models define each species as synth of its state, evaluated
199+ * exactly on more points.
200+ */
201+ readSpecies(k: number): Promise<Float32Array> {
202+ if (!this.#displaySht) return this.read(this.model.species[k]);
203+ const state = this.model.state[k];
204+ const buf = this.gpu.valueBuffer(state);
205+ if (!buf) throw new Error(`readSpecies: no buffer for state '${state}'`);
206+ return this.#displaySht.synthFrom(buf);
207+ }
208+
113209 describe(): { init: string[]; step: string[] } {
114210 return this.gpu.describe();
115211 }
116212
117213 destroy(): void {
118214 this.gpu.destroy();
215+ this.#displaySht?.destroy();
119216 this.sht.destroy();
120217 }
121218 }
src/render/SphereScene.tsmodified+41−0View file
@@ -6,6 +6,10 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
66 * per-vertex positions and dynamic per-vertex colors, orbit controls, and
77 * optional camera synchronization with sibling scenes.
88 *
9+ * Rendering is on demand: the animation loop ticks every frame (it has to,
10+ * to drive OrbitControls damping), but only re-renders when the colors,
11+ * camera, or canvas size actually changed.
12+ *
913 * Adapted from figpack's SphereEmbedding view (figpack_experimental).
1014 */
1115 export class SphereScene {
@@ -21,6 +25,7 @@ export class SphereScene {
2125 target: THREE.Vector3;
2226 } | null = null;
2327 #syncing = false;
28+ #needsRender = true;
2429 #lastW = -1;
2530 #lastH = -1;
2631
@@ -78,6 +83,11 @@ export class SphereScene {
7883 this.#controls = new OrbitControls(this.#camera, this.#renderer.domElement);
7984 this.#controls.enableDamping = true;
8085 this.#controls.dampingFactor = 0.1;
86+ // Fires on user input and on every damping-tail update, so the flag stays
87+ // set until the camera has fully settled.
88+ this.#controls.addEventListener('change', () => {
89+ this.#needsRender = true;
90+ });
8191
8292 this.#animate();
8393 }
@@ -85,6 +95,8 @@ export class SphereScene {
8595 #animate = () => {
8696 this.#animationId = requestAnimationFrame(this.#animate);
8797 this.#controls.update();
98+ if (!this.#needsRender) return;
99+ this.#needsRender = false;
88100 this.#renderer.render(this.#scene, this.#camera);
89101 };
90102
@@ -92,6 +104,7 @@ export class SphereScene {
92104 const attr = this.#geometry.getAttribute('color') as THREE.BufferAttribute;
93105 (attr.array as Float32Array).set(colors);
94106 attr.needsUpdate = true;
107+ this.#needsRender = true;
95108 }
96109
97110 /** Mirror this scene's camera whenever the other scene's controls move. */
@@ -105,6 +118,7 @@ export class SphereScene {
105118 dst.#camera.updateProjectionMatrix();
106119 dst.#controls.target.copy(src.#controls.target);
107120 dst.#controls.update();
121+ dst.#needsRender = true;
108122 src.#syncing = false;
109123 });
110124 };
@@ -112,6 +126,28 @@ export class SphereScene {
112126 follow(other, this);
113127 }
114128
129+ /** Camera pose, for carrying the view across a scene rebuild. */
130+ cameraState(): { position: THREE.Vector3; target: THREE.Vector3; zoom: number } {
131+ return {
132+ position: this.#camera.position.clone(),
133+ target: this.#controls.target.clone(),
134+ zoom: this.#camera.zoom,
135+ };
136+ }
137+
138+ setCameraState(s: {
139+ position: THREE.Vector3;
140+ target: THREE.Vector3;
141+ zoom: number;
142+ }): void {
143+ this.#camera.position.copy(s.position);
144+ this.#camera.zoom = s.zoom;
145+ this.#camera.updateProjectionMatrix();
146+ this.#controls.target.copy(s.target);
147+ this.#controls.update();
148+ this.#needsRender = true;
149+ }
150+
115151 /** Position the camera to comfortably frame the geometry. */
116152 fitCamera(): void {
117153 this.#geometry.computeBoundingSphere();
@@ -129,6 +165,7 @@ export class SphereScene {
129165 this.#camera.far = radius * 100;
130166 this.#camera.updateProjectionMatrix();
131167 this.#controls.update();
168+ this.#needsRender = true;
132169 this.#defaultCameraState = {
133170 position: this.#camera.position.clone(),
134171 target: this.#controls.target.clone(),
@@ -140,6 +177,7 @@ export class SphereScene {
140177 this.#camera.position.copy(this.#defaultCameraState.position);
141178 this.#controls.target.copy(this.#defaultCameraState.target);
142179 this.#controls.update();
180+ this.#needsRender = true;
143181 } else {
144182 this.fitCamera();
145183 }
@@ -155,6 +193,9 @@ export class SphereScene {
155193 this.#camera.updateProjectionMatrix();
156194 // updateStyle=false: the canvas keeps its 100%/100% CSS sizing
157195 this.#renderer.setSize(width, height, false);
196+ // setSize clears the drawing buffer, so a re-render is required even
197+ // though nothing in the scene moved
198+ this.#needsRender = true;
158199 }
159200
160201 dispose(): void {
src/sht/sht.tsmodified+20−0View file
@@ -425,6 +425,26 @@ export class ShtPlan {
425425 return out;
426426 }
427427
428+ /**
429+ * Spectral -> spatial, with the coefficients read from a caller-owned GPU
430+ * buffer (interleaved [re, im], 8*nlm bytes, COPY_SRC) instead of uploaded
431+ * from the CPU. This is how a field already on the device — a model's
432+ * spectral state — is evaluated on this plan's grid, e.g. a finer display
433+ * grid than the one the coefficients were produced on.
434+ */
435+ async synthFrom(qlmSrc: GPUBuffer): Promise<Float32Array> {
436+ const { nlat, nphi } = this.cfg;
437+ const enc = this.device.createCommandEncoder({ label: 'sht-synth-from' });
438+ enc.copyBufferToBuffer(qlmSrc, 0, this.qlmIn, 0, 8 * this.nlm);
439+ this.encodeSynth(enc);
440+ enc.copyBufferToBuffer(this.spatBuf, 0, this.stageSpat, 0, 4 * nlat * nphi);
441+ this.device.queue.submit([enc.finish()]);
442+ await this.stageSpat.mapAsync(GPUMapMode.READ);
443+ const out = new Float32Array(this.stageSpat.getMappedRange().slice(0));
444+ this.stageSpat.unmap();
445+ return out;
446+ }
447+
428448 /** Spatial -> spectral. spat: length nlat*nphi. Returns interleaved qlm, length 2*nlm. */
429449 async analys(spat: Float32Array): Promise<Float32Array> {
430450 const { nlat, nphi } = this.cfg;
test/modelChecks.tsmodified+90−0View file
@@ -78,4 +78,94 @@ export async function modelChecks(
7878
7979 session.destroy();
8080 }
81+
82+ // The oversampled readback: readSpecies must be the state synthesized on the
83+ // display grid. Comparing against the display plan's own upload path
84+ // (read the state back, synth it from the CPU) exercises the GPU-to-GPU
85+ // coefficient copy against a known-good route through the same kernels.
86+ {
87+ const model = mModels.find((m) => m.key === 'allencahn')!;
88+ const session = await ModelSession.create({
89+ device,
90+ model,
91+ params: defaultParams(model),
92+ lmax: LMAX,
93+ oversample: 2,
94+ });
95+ session.seed(1);
96+ session.step(STEPS);
97+
98+ const fine = await session.readSpecies(0);
99+ const { nlat, nphi } = session.viewSht.cfg;
100+ check(
101+ 'oversample: species field is on the 2x display grid',
102+ nlat === 2 * session.cfg.nlat &&
103+ nphi === 2 * session.cfg.nphi &&
104+ fine.length === nlat * nphi,
105+ `render ${nlat}×${nphi}, ${fine.length} values`,
106+ );
107+
108+ const qlm = await session.read('U');
109+ const expected = await session.viewSht.synth(qlm);
110+ let maxDiff = 0;
111+ for (let i = 0; i < fine.length; i++) {
112+ const d = Math.abs(fine[i] - expected[i]);
113+ if (d > maxDiff) maxDiff = d;
114+ }
115+ check(
116+ 'oversample: readSpecies matches synth of the read-back state',
117+ maxDiff <= 1e-6,
118+ `max |diff| = ${maxDiff.toExponential(2)}`,
119+ );
120+
121+ // A timing burst must be invisible: the state is snapshotted and restored
122+ // around it, and model time does not advance.
123+ const tBefore = session.t;
124+ const stepsBefore = session.steps;
125+ const ms = await session.measure(8);
126+ const after = await session.read('U');
127+ let identical = qlm.length === after.length;
128+ if (identical) {
129+ for (let i = 0; i < qlm.length; i++) {
130+ if (qlm[i] !== after[i]) {
131+ identical = false;
132+ break;
133+ }
134+ }
135+ }
136+ check(
137+ 'measure: a timing burst leaves state, t and steps untouched',
138+ identical && session.t === tBefore && session.steps === stepsBefore,
139+ identical
140+ ? `state identical, t = ${session.t.toFixed(3)}, ${ms.toFixed(3)} ms/step`
141+ : 'state changed',
142+ );
143+
144+ // Changing the oversampling in place is display-only: the state survives
145+ // and the render grid drops back to the solver's.
146+ await session.setOversample(1);
147+ const qlmAfterSwap = await session.read('U');
148+ let stateSurvived = qlmAfterSwap.length === after.length;
149+ if (stateSurvived) {
150+ for (let i = 0; i < after.length; i++) {
151+ if (qlmAfterSwap[i] !== after[i]) {
152+ stateSurvived = false;
153+ break;
154+ }
155+ }
156+ }
157+ session.step(1); // recompute the view fields on the solver grid
158+ const coarse = await session.readSpecies(0);
159+ check(
160+ 'setOversample: swaps the render grid without touching the state',
161+ stateSurvived &&
162+ session.viewSht === session.sht &&
163+ coarse.length === session.cfg.nlat * session.cfg.nphi,
164+ stateSurvived
165+ ? `state survived, render back to ${session.cfg.nlat}×${session.cfg.nphi}`
166+ : 'state changed',
167+ );
168+
169+ session.destroy();
170+ }
81171 }