1/**
2 * One running scene: the air grid, every object's grid, the generated (or
3 * hand-edited) .m files compiled to GPU plans, the 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 SceneSpec becomes something
7 * running on the GPU — and nothing about it is browser-specific beyond
8 * needing a GPUDevice.
9 *
10 * The order of construction matters and is worth spelling out. The air grid
11 * fixes h; with the speed of air that fixes a provisional timestep (the CFL
12 * condition); the generator then sizes every object's grid at that dt and
13 * instantiates the ops. The scene .m is evaluated next — normally it keeps
14 * the medium at c0 and the provisional dt stands, but an edited scene that
15 * puts a faster medium somewhere lowers dt, and the object grids are resized
16 * once at the final value. Objects have no say in the timestep; each chooses
17 * the finest node spacing that is stable at the air's dt for anything its
18 * sliders can reach (see src/grids.ts), which is why moving a slider never
19 * recompiles.
20 */
21import { makeAirGrid, stableDt, type AirGrid, type ObjectGrid } from '../grids.ts';
22import { OpPlan } from '../mgpu/ops.ts';
23import { GpuModel, type ModelParams } from '../mgpu/model.ts';
24import { Scene } from '../scene/scene.ts';
25import { Recorder } from '../audio/recorder.ts';
26import { C_AIR, CFL, DOMAIN_X } from '../units.ts';
27import { generate, suffixedParams, type GeneratedScene } from './codegen.ts';
28import { templateOf } from './objects.ts';
29import { BODY_TEMPLATES } from './bodies.ts';
30import { defaultsOf, type Params, type SceneSpec } from './types.ts';
32export interface StudioSessionOptions {
33 device: GPUDevice;
34 spec: SceneSpec;
35 /** Model parameter values by suffixed key; defaults fill the gaps. */
36 params?: Params;
37 /** Scene parameter values (placement + body) by key; defaults fill gaps. */
38 sceneParams?: Params;
39 /** Override the generated sources — the Lab editor's working copies. */
40 modelSource?: string;
41 sceneSource?: string;
42 /** Air grid points along x; y and z get half each. */
43 nx: number;
44 /** Domain length along x, metres. */
45 Lx?: number;
46 /** Grid fields one kernel may read, overriding what the device allows.
47 * Only for tests. */
48 operandBudget?: number;
49}
51/** Model + scene parameter defaults for a spec, suffixed, spec values kept. */
52export function specParams(spec: SceneSpec): { params: Params; sceneParams: Params } {
53 const params: Params = {};
54 const sceneParams: Params = {};
55 for (const o of spec.objects) {
56 const t = templateOf(o);
57 Object.assign(params, suffixedParams(t.paramSpecs, o.params, o.id));
58 Object.assign(sceneParams, suffixedParams(t.placeSpecs, o.place, o.id));
59 }
60 Object.assign(
61 sceneParams,
62 defaultsOf(BODY_TEMPLATES[spec.body.type].paramSpecs),
63 spec.body.params,
64 );
65 return { params, sceneParams };
66}
68export class StudioSession {
69 readonly device: GPUDevice;
70 readonly spec: SceneSpec;
71 readonly air: AirGrid;
72 readonly gpu: GpuModel;
73 readonly generated: GeneratedScene;
74 /** The sources actually compiled (generated, or the editor's overrides). */
75 readonly modelSource: string;
76 readonly sceneSource: string;
77 /** The microphone: the pressure at one point, every timestep. */
78 readonly recorder: Recorder;
80 /** Model time and step count since the last excite. */
81 t = 0;
82 steps = 0;
84 #scene: Scene;
85 #params: Params;
86 #sceneParams: Params;
87 #dt: number;
89 private constructor(init: {
90 device: GPUDevice;
91 spec: SceneSpec;
92 air: AirGrid;
93 gpu: GpuModel;
94 generated: GeneratedScene;
95 modelSource: string;
96 sceneSource: string;
97 recorder: Recorder;
98 scene: Scene;
99 params: Params;
100 sceneParams: Params;
101 dt: number;
102 }) {
103 this.device = init.device;
104 this.spec = init.spec;
105 this.air = init.air;
106 this.gpu = init.gpu;
107 this.generated = init.generated;
108 this.modelSource = init.modelSource;
109 this.sceneSource = init.sceneSource;
110 this.recorder = init.recorder;
111 this.#scene = init.scene;
112 this.#params = init.params;
113 this.#sceneParams = init.sceneParams;
114 this.#dt = init.dt;
115 }
117 static async create(opts: StudioSessionOptions): Promise<StudioSession> {
118 const { device, spec } = opts;
119 const air = makeAirGrid(opts.nx, opts.Lx ?? DOMAIN_X);
121 const defaults = specParams(spec);
122 const params = { ...defaults.params, ...opts.params };
123 const sceneParams = { ...defaults.sceneParams, ...opts.sceneParams };
125 // A provisional timestep from the speed of air; the scene is evaluated
126 // against it, and only an edited scene with a faster medium changes it.
127 let dt = stableDt(air.h, C_AIR, CFL);
128 let generated = generate(spec, air, dt);
129 const sceneSource = opts.sceneSource ?? generated.sceneSource;
131 const buildScene = (): Scene =>
132 Scene.create({
133 air,
134 source: sceneSource,
135 outputs: generated.sceneOutputs,
136 consts: generated.sceneConsts,
137 paramNames: generated.sceneParamNames,
138 params: sceneParams,
139 });
140 let scene = buildScene();
141 const dtFinal = stableDt(air.h, Math.max(scene.cmax, C_AIR), CFL);
142 if (dtFinal < dt) {
143 // A faster medium tightens the CFL bound; resize the object grids once
144 // at the final timestep. The scene's fields do not depend on dt.
145 dt = dtFinal;
146 generated = generate(spec, air, dt);
147 }
149 const modelSource = opts.modelSource ?? generated.modelSource;
150 const ops = new OpPlan(device, generated.ops);
152 // The scene's evaluated fields replace the generator's placeholders.
153 const fields = { ...generated.modelFields, ...scene.fields };
155 const gpu = await GpuModel.create({
156 device,
157 ops,
158 consts: generated.modelConsts,
159 fields,
160 source: modelSource,
161 paramNames: generated.modelParamNames,
162 state: generated.state,
163 operandBudget: opts.operandBudget,
164 });
165 gpu.setDt(dt);
167 // The microphone listens to the host-owned pressure buffer, which is
168 // where both `init` and `step` leave their result.
169 const recorder = await Recorder.create({
170 device,
171 field: gpu.stateBuffer('p')!,
172 nx: air.nx,
173 ny: air.ny,
174 nz: air.nz,
175 });
177 const session = new StudioSession({
178 device, spec, air, gpu, generated, modelSource, sceneSource,
179 recorder, scene, params, sceneParams, dt,
180 });
181 gpu.setParams(params);
182 return session;
183 }
185 get scene(): Scene {
186 return this.#scene;
187 }
189 get dt(): number {
190 return this.#dt;
191 }
193 get params(): Params {
194 return this.#params;
195 }
197 get sceneParams(): Params {
198 return this.#sceneParams;
199 }
201 /** Each object's grid, by id. */
202 get grids(): Record<string, ObjectGrid> {
203 return this.generated.grids;
204 }
206 /**
207 * Re-evaluate the scene under a running model — a body edit, an object
208 * moved. It is data, not code, so this needs no recompile. The timestep is
209 * deliberately left alone: it was set from max(cmax, c_air) at build time,
210 * and a scene edit that *raises* the fastest speed above that needs a
211 * rebuild anyway (the caller compares `dtWanted` and rebuilds when they
212 * disagree).
213 */
214 setScene(sceneParams: Params, source?: string): void {
215 const scene = Scene.create({
216 air: this.air,
217 source: source ?? this.sceneSource,
218 outputs: this.generated.sceneOutputs,
219 consts: this.generated.sceneConsts,
220 paramNames: this.generated.sceneParamNames,
221 params: sceneParams,
222 });
223 this.#scene = scene;
224 this.#sceneParams = sceneParams;
225 this.gpu.uploadFields(scene.fields);
226 }
228 /** The timestep the current scene would ask for. Differs from `dt` only
229 * when a scene edit changed the fastest speed, which calls for a rebuild. */
230 get dtWanted(): number {
231 return stableDt(this.air.h, Math.max(this.#scene.cmax, C_AIR), CFL);
232 }
234 setParams(params: ModelParams): void {
235 this.#params = params;
236 this.gpu.setParams(params);
237 }
239 /** Run `init`: every object drawn into its excitation, silent air, t = 0. */
240 excite(): void {
241 this.gpu.init();
242 this.recorder.clear();
243 this.t = 0;
244 this.steps = 0;
245 }
247 /** Put the microphone at the grid point nearest (x, y, z), metres. */
248 setMic(x: number, y: number, z: number): void {
249 const { Lx, Ly, Lz, h } = this.air;
250 this.recorder.setProbe(
251 (x + Lx / 2) / h - 0.5,
252 (y + Ly / 2) / h - 0.5,
253 (z + Lz / 2) / h - 0.5,
254 );
255 }
257 /** Advance `n` steps. Synchronous: records and submits, nothing read back.
258 * The microphone samples inside the same submission, once per step. */
259 step(n = 1): void {
260 this.gpu.step(n, (enc) => this.recorder.encode(enc));
261 this.t += n * this.#dt;
262 this.steps += n;
263 }
265 /** Wait for the submitted steps to finish, without reading anything back. */
266 sync(): Promise<undefined> {
267 return this.device.queue.onSubmittedWorkDone();
268 }
270 /** Read a named field back to the CPU. */
271 read(name: string): Promise<Float32Array> {
272 return this.gpu.read(name);
273 }
275 describe(): { init: string[]; step: string[] } {
276 return this.gpu.describe();
277 }
279 destroy(): void {
280 this.recorder.destroy();
281 this.gpu.destroy();
282 }
283}