concept-collection / dulcimer
dulcimer / src / mgpu / session.ts
252 lines · 7.2 KBBlameHistoryRaw
1/**
2 * One running simulation: the two grids, the body, the compiled .m, the
3 * timestep, the microphone.
4 *
5 * Everything that is not rendering. The app and the tests both go through
6 * this, so there is one place that decides how a pair of .m files becomes
7 * something running on the GPU — and nothing about it is browser-specific
8 * beyond needing a GPUDevice.
9 *
10 * The order of construction matters and is worth spelling out. The air grid
11 * fixes h; the scene fixes the fastest speed; together they fix dt (the CFL
12 * condition). Only then can the string grid be sized, because the string has
13 * no say in the timestep — the air dictates dt, and the string chooses the
14 * finest spacing that is stable at that dt for anything the sliders can ask
15 * (see makeStringGrid). Then the ops and the model compile against both
16 * grids.
17 */
18import {
19 makeAirGrid,
20 makeStringGrid,
21 stableDt,
22 type AirGrid,
23 type StringGrid,
24} from '../grid.ts';
25import { OpPlan } from './ops.ts';
26import { GpuModel, type ModelParams } from './model.ts';
27import { Scene } from '../scene/scene.ts';
28import { Recorder } from '../audio/recorder.ts';
29import { worstCase, type MModel, type Params } from './registry.ts';
30import type { MScene } from '../scene/registry.ts';
31import { C_AIR, CFL, DOMAIN_X } from '../units.ts';
33export interface ModelSessionOptions {
34 device: GPUDevice;
35 model: MModel;
36 params: Params;
37 /** Override the model source — the editor's working copy. */
38 source?: string;
39 scene: MScene;
40 sceneParams: Params;
41 /** Override the scene source — the editor's working copy. */
42 sceneSource?: string;
43 /** Air grid points along x; y and z get half each. */
44 nx: number;
45 /** Domain length along x, metres. */
46 Lx?: number;
47 /** String length, metres. */
48 Ls: number;
49 /** Grid fields one kernel may read, overriding what the device allows.
50 * Only for tests, which use it to exercise the planner's kernel splitting
51 * on a device that would never need it. */
52 operandBudget?: number;
55export class ModelSession {
56 readonly device: GPUDevice;
57 readonly model: MModel;
58 readonly air: AirGrid;
59 readonly string: StringGrid;
60 readonly gpu: GpuModel;
61 /** The microphone: the pressure at one point, every timestep. */
62 readonly recorder: Recorder;
64 /** Model time and step count since the last pluck. */
65 t = 0;
66 steps = 0;
68 #scene: Scene;
69 #sceneModel: MScene;
70 #params: Params;
71 #dt: number;
73 private constructor(init: {
74 device: GPUDevice;
75 model: MModel;
76 air: AirGrid;
77 string: StringGrid;
78 gpu: GpuModel;
79 recorder: Recorder;
80 scene: Scene;
81 sceneModel: MScene;
82 params: Params;
83 dt: number;
84 }) {
85 this.device = init.device;
86 this.model = init.model;
87 this.air = init.air;
88 this.string = init.string;
89 this.gpu = init.gpu;
90 this.recorder = init.recorder;
91 this.#scene = init.scene;
92 this.#sceneModel = init.sceneModel;
93 this.#params = init.params;
94 this.#dt = init.dt;
95 }
97 static async create(opts: ModelSessionOptions): Promise<ModelSession> {
98 const { device, model, params, scene, sceneParams } = opts;
99 const air = makeAirGrid(opts.nx, opts.Lx ?? DOMAIN_X);
101 // The scene first: the timestep depends on the fastest speed in it.
102 const built = Scene.create({
103 air,
104 Ls: opts.Ls,
105 source: opts.sceneSource ?? scene.source,
106 paramNames: scene.params.map((p) => p.key),
107 params: sceneParams,
108 });
110 const dt = stableDt(air.h, Math.max(built.cmax, C_AIR), CFL);
111 const string = makeStringGrid(opts.Ls, dt, worstCase(model));
113 const ops = new OpPlan(device, {
114 nx: air.nx,
115 ny: air.ny,
116 nz: air.nz,
117 h: air.h,
118 ns: string.ns,
119 hs: string.hs,
120 xs0: -string.Ls / 2,
121 Lx: air.Lx,
122 });
124 const gpu = await GpuModel.create({
125 device,
126 ops,
127 air,
128 string,
129 medium: built,
130 source: opts.source ?? model.source,
131 paramNames: model.params.map((p) => p.key),
132 state: model.state,
133 operandBudget: opts.operandBudget,
134 });
135 gpu.setDt(dt);
137 // The microphone listens to the host-owned pressure buffer, which is
138 // where both `init` and `step` leave their result.
139 const recorder = await Recorder.create({
140 device,
141 field: gpu.stateBuffer(model.pressure)!,
142 nx: air.nx,
143 ny: air.ny,
144 nz: air.nz,
145 });
147 const session = new ModelSession({
148 device, model, air, string, gpu, recorder,
149 scene: built, sceneModel: scene, params, dt,
150 });
151 gpu.setParams(params);
152 return session;
153 }
155 get scene(): Scene {
156 return this.#scene;
157 }
159 get sceneModel(): MScene {
160 return this.#sceneModel;
161 }
163 /** The air pressure field's name — what gets drawn and recorded. */
164 get pressureName(): string {
165 return this.model.pressure;
166 }
168 /** The string displacement field's name — what the string plot shows. */
169 get displacementName(): string {
170 return this.model.displacement;
171 }
173 get dt(): number {
174 return this.#dt;
175 }
177 /**
178 * Swap the body under a running model. It is data, not code, so this needs
179 * no recompile. The timestep is deliberately left alone: it was set from
180 * max(cmax, c_air) at build time, and a scene edit that *raises* the
181 * fastest speed above that needs a rebuild anyway (the caller compares
182 * `dtWanted` and rebuilds when they disagree).
183 */
184 setScene(sceneModel: MScene, params: Params, source?: string): void {
185 const built = Scene.create({
186 air: this.air,
187 Ls: this.string.Ls,
188 source: source ?? sceneModel.source,
189 paramNames: sceneModel.params.map((p) => p.key),
190 params,
191 });
192 this.#scene = built;
193 this.#sceneModel = sceneModel;
194 this.gpu.uploadMedium(built);
195 }
197 /** The timestep the current scene would ask for. Differs from `dt` only
198 * when a scene edit changed the fastest speed, which calls for a rebuild. */
199 get dtWanted(): number {
200 return stableDt(this.air.h, Math.max(this.#scene.cmax, C_AIR), CFL);
201 }
203 setParams(params: ModelParams): void {
204 this.#params = params;
205 this.gpu.setParams(params);
206 }
208 /** Run `init`: the string drawn into its pluck, silent air, t = 0. */
209 pluck(): void {
210 this.gpu.init();
211 this.recorder.clear();
212 this.t = 0;
213 this.steps = 0;
214 }
216 /** Put the microphone at the grid point nearest (x, y, z), metres. */
217 setMic(x: number, y: number, z: number): void {
218 const { Lx, Ly, Lz, h } = this.air;
219 this.recorder.setProbe(
220 (x + Lx / 2) / h - 0.5,
221 (y + Ly / 2) / h - 0.5,
222 (z + Lz / 2) / h - 0.5,
223 );
224 }
226 /** Advance `n` steps. Synchronous: records and submits, nothing read back.
227 * The microphone samples inside the same submission, once per step. */
228 step(n = 1): void {
229 this.gpu.step(n, (enc) => this.recorder.encode(enc));
230 this.t += n * this.#dt;
231 this.steps += n;
232 }
234 /** Wait for the submitted steps to finish, without reading anything back. */
235 sync(): Promise<undefined> {
236 return this.device.queue.onSubmittedWorkDone();
237 }
239 /** Read a named field back to the CPU. */
240 read(name: string): Promise<Float32Array> {
241 return this.gpu.read(name);
242 }
244 describe(): { init: string[]; step: string[] } {
245 return this.gpu.describe();
246 }
248 destroy(): void {
249 this.recorder.destroy();
250 this.gpu.destroy();
251 }