1// WebGPU time-stepping driver for the von Karman plate.
2//
3// The simulation advances in batches of one movie frame (1/fps seconds). A
4// batch encodes one compute dispatch per time step (with a per-step uniform
5// addressed by dynamic offset), an optional rigid-body projection every
6// RB_INTERVAL steps, one retarded-time listener sum over the batch's audio
7// samples, and one movie-frame extraction at the end. Audio, frame, and
8// statistics are read back after every batch. One batch is kept encoded
9// ahead of the one being read back, so the GPU does not wait for the
10// readback or for the encoding. The recording starts at the impact, with the
11// plate at rest in its stress-free shape.
13import {
14 type SimParams,
15 type Plate,
16 type Grid,
17 strikePoint,
18 contactPatch,
19 mountPatch,
20 massProperties,
21 thicknessRange,
22 nodeX,
23 nodeY,
24 listenerPoint,
25 nodeWeight,
26 nodeWeights,
27 CFL,
28 HIST_MARGIN,
29 RB_INTERVAL,
30 RHO_AIR,
31 SPEED_OF_SOUND,
32} from "../params.ts";
33import { maxAngularFrequency } from "./cpuRef.ts";
34import commonWgsl from "./shaders/common.wgsl?raw";
35import stepWgsl from "./shaders/step.wgsl?raw";
36import rigidWgsl from "./shaders/rigid.wgsl?raw";
37import audioWgsl from "./shaders/audio.wgsl?raw";
38import extractWgsl from "./shaders/extract.wgsl?raw";
40export type Phase = "recording" | "done";
42export interface StickState {
43 x: number;
44 v: number;
45 force: number;
46 maxForce: number;
47 /** True once the contact has been released for longer than the shader's STICK_RELEASE; the stick then no longer touches the plate. */
48 withdrawn: boolean;
49}
51export interface SimStatus {
52 phase: Phase;
53 /** Simulation time since the impact [s]. */
54 t: number;
55 totalSteps: number;
56 ke: number;
57 wmax: number;
58 samples: number;
59 frames: number;
60 peakPressure: number;
61 delayClamped: boolean;
62 stick: StickState;
63}
65const PARAMS_LEN = 40; // floats in the Params uniform
66const STEP_LEN = 12; // floats in the StepParams uniform
67const STATS_LEN = 16;
68const STICK_LEN = 16; // two 8-float slots of stick state
69/** Nodes per workgroup along each axis in step.wgsl. */
70const TILE = 12;
72/** A batch that has been encoded and submitted but not read back. */
73interface Pending {
74 staging: GPUBuffer;
75 steps: number;
76 nAudio: number;
77 /** Which of the two stick-state slots the batch's last step wrote. */
78 stickParity: number;
79}
81export class Simulation {
82 readonly plate: Plate;
83 readonly grid: Grid;
84 readonly dt: number;
85 /** Time steps per audio sample. */
86 readonly stepsPerSample: number;
87 readonly samplesPerBatch: number;
88 readonly stepsPerBatch: number;
89 readonly omegaMax: number;
90 /** Ring buffer length of the listener sum [samples]. */
91 readonly histLen: number;
92 /** Total mass of the plate [kg]. */
93 readonly mass: number;
95 /** Listener pressure [Pa] at fs, from the impact. */
96 audio: Float32Array = new Float32Array(0);
97 samples = 0;
98 /** Movie frames of w [m]; frame k is at t = k / fps. */
99 frames: Float32Array<ArrayBuffer>[] = [];
101 phase: Phase = "recording";
102 totalSteps = 0;
103 ke = 0;
104 wmax = 0;
105 peakPressure = 0;
106 delayClamped = false;
108 private stick: StickState;
109 private inFlight: Pending | null = null;
111 private device: GPUDevice;
112 private params: SimParams;
113 private paramsBuf: GPUBuffer;
114 private stepBuf: GPUBuffer;
115 private stepStride: number;
116 private stepData: ArrayBuffer;
117 private pos: GPUBuffer[];
118 private vel: GPUBuffer[];
119 private hist: GPUBuffer;
120 private stickBuf: GPUBuffer;
121 /** Static per-node data for the kernels, packed as (w0, weight, thickness, 0) per node. */
122 private nd: GPUBuffer;
123 /** Stress-free initial shape (f32 per node), for the renderer. */
124 readonly w0: GPUBuffer;
125 /** Largest magnitude of the initial shape [m]. */
126 readonly w0Max: number;
127 /** Node weights (f32 per node, 0 outside the plate), for the renderer. */
128 readonly nw: GPUBuffer;
129 /** Thickness (f32 per node, 0 outside the plate), for the renderer. */
130 readonly hn: GPUBuffer;
131 private lw: GPUBuffer;
132 private audioOut: GPUBuffer;
133 private frameBuf: GPUBuffer;
134 private statsBuf: GPUBuffer;
135 private staging: GPUBuffer[];
136 private stagingIdx = 0;
137 private stagingSize: number;
138 /** Index of the buffer pair holding the current state. */
139 private cur = 0;
140 /** Plain f32 array of w for the live view. */
141 readonly displayW: GPUBuffer;
143 private stepPipe: GPUComputePipeline;
144 private sumsPipe: GPUComputePipeline;
145 private applyPipe: GPUComputePipeline;
146 private audioPipe: GPUComputePipeline;
147 private extractPipe: GPUComputePipeline;
148 private stepBG: GPUBindGroup[];
149 private rbBG: GPUBindGroup[];
150 private audioBG: GPUBindGroup;
151 private extractBG: GPUBindGroup[];
152 private displayBG: GPUBindGroup[];
154 constructor(device: GPUDevice, plate: Plate, params: SimParams) {
155 this.device = device;
156 this.plate = plate;
157 this.params = { ...params };
158 const g = plate.grid;
159 const m = plate.mat;
160 this.grid = g;
162 // Stable time step, rounded so that an integer number of steps make one audio sample.
163 const hr = thicknessRange(plate);
164 this.omegaMax = maxAngularFrequency(g, m, hr.max, params.inPlaneMass);
165 const dtMax = (CFL * 2) / this.omegaMax;
166 this.stepsPerSample = Math.ceil(1 / (params.fs * dtMax));
167 this.dt = 1 / (params.fs * this.stepsPerSample);
168 this.samplesPerBatch = Math.max(1, Math.round(params.fs / params.fps));
169 this.stepsPerBatch = this.samplesPerBatch * this.stepsPerSample;
170 this.histLen = this.samplesPerBatch + HIST_MARGIN;
172 const n = g.n;
173 const f32 = (size: number, usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST) =>
174 device.createBuffer({ size: Math.max(16, size * 4), usage });
176 this.pos = [f32(4 * n), f32(4 * n)]; // [u | v | w | wLo]
177 this.vel = [f32(3 * n), f32(3 * n)]; // [du | dv | dw]
178 this.hist = f32(this.histLen * n);
179 this.stickBuf = f32(STICK_LEN);
180 this.w0 = f32(n);
181 const shape = Float32Array.from(plate.w0);
182 device.queue.writeBuffer(this.w0, 0, shape);
183 this.w0Max = shape.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
184 const weights = nodeWeights(g);
185 this.nw = f32(n);
186 device.queue.writeBuffer(this.nw, 0, weights);
187 const thick = Float32Array.from(plate.h);
188 this.hn = f32(n);
189 device.queue.writeBuffer(this.hn, 0, thick);
190 const packed = new Float32Array(4 * n);
191 for (let k = 0; k < n; k++) { packed[4 * k] = shape[k]; packed[4 * k + 1] = weights[k]; packed[4 * k + 2] = thick[k]; }
192 this.nd = f32(4 * n);
193 device.queue.writeBuffer(this.nd, 0, packed);
194 this.lw = f32(2 * n);
195 this.audioOut = f32(this.samplesPerBatch);
196 this.frameBuf = f32(n);
197 this.displayW = f32(n);
198 this.statsBuf = f32(STATS_LEN);
199 this.stagingSize = (this.samplesPerBatch + n + STATS_LEN + STICK_LEN) * 4;
200 this.staging = [0, 1].map(() =>
201 device.createBuffer({ size: this.stagingSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }),
202 );
204 // Mass and moments of inertia of the plate, the contact patch, the mount patch,
205 // and the initial stick state (position 0, moving toward the plate).
206 const { mass, Ix, Iy } = massProperties(plate);
207 this.mass = mass;
208 const cp = contactPatch(params, plate);
209 const mp = mountPatch(plate);
210 const stick = new Float32Array(STICK_LEN);
211 stick[1] = -params.strikeVelocity; stick[9] = -params.strikeVelocity;
212 device.queue.writeBuffer(this.stickBuf, 0, stick);
213 this.stick = { x: 0, v: -params.strikeVelocity, force: 0, maxForce: 0, withdrawn: false };
215 const [sx, sy] = strikePoint(params, plate);
216 const pf = new Float32Array(PARAMS_LEN);
217 const pu = new Uint32Array(pf.buffer);
218 pu[0] = g.nx; pu[1] = g.ny; pu[2] = n; pu[3] = this.histLen;
219 pf[4] = g.hx; pf[5] = g.hy; pf[6] = this.dt; pf[7] = m.nu;
220 pf[8] = m.E / (12 * (1 - m.nu * m.nu)); pf[9] = m.E / (1 - m.nu * m.nu); pf[10] = m.E / (2 * (1 + m.nu)); pf[11] = m.rho;
221 pf[12] = mass; pf[13] = Ix; pf[14] = Iy; pf[15] = Ix + Iy;
222 pf[16] = params.fs; pf[17] = sx; pf[18] = sy; pf[19] = params.contactRadius ** 2;
223 pf[20] = cp.mass; pu[21] = cp.nearest; pf[22] = params.contactStiffness; pf[23] = params.contactExponent;
224 pf[24] = params.stickMass; pu[25] = cp.box[0]; pu[26] = cp.box[1]; pu[27] = cp.box[2];
225 pu[28] = cp.box[3]; pf[29] = cp.center[0]; pf[30] = cp.center[1]; pf[31] = params.inPlaneMass;
226 pf[32] = 1 / params.inPlaneMass;
227 if (mp && plate.mount) {
228 pf[33] = plate.mount.x; pf[34] = plate.mount.y; pf[35] = plate.mount.radius ** 2;
229 pf[36] = plate.mount.stiffness / mp.weight; pf[37] = plate.mount.damping / mp.weight;
230 pu[38] = mp.nearest; pu[39] = 1;
231 }
232 this.paramsBuf = device.createBuffer({ size: pf.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
233 device.queue.writeBuffer(this.paramsBuf, 0, pf);
235 // Per-step parameters, one struct per step at a dynamic offset.
236 this.stepStride = Math.max(STEP_LEN * 4, device.limits.minUniformBufferOffsetAlignment);
237 this.stepData = new ArrayBuffer(this.stepStride * this.stepsPerBatch);
238 this.stepBuf = device.createBuffer({ size: this.stepData.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
240 // Pipelines.
241 const shader = (code: string) => device.createShaderModule({ code: commonWgsl + code });
242 const entry = (binding: number, type: GPUBufferBindingType, hasDynamicOffset = false): GPUBindGroupLayoutEntry => ({
243 binding,
244 visibility: GPUShaderStage.COMPUTE,
245 buffer: { type, hasDynamicOffset },
246 });
247 const pipeline = (module: GPUShaderModule, entryPoint: string, layout: GPUBindGroupLayout) =>
248 device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }), compute: { module, entryPoint } });
250 const stepLayout = device.createBindGroupLayout({
251 entries: [
252 entry(0, "uniform"),
253 entry(1, "uniform", true),
254 entry(2, "read-only-storage"),
255 entry(3, "read-only-storage"),
256 entry(4, "storage"),
257 entry(5, "storage"),
258 entry(6, "storage"),
259 entry(7, "storage"),
260 entry(8, "read-only-storage"),
261 ],
262 });
263 this.stepPipe = pipeline(shader(stepWgsl), "main", stepLayout);
265 const rigidModule = shader(rigidWgsl);
266 const rbLayout = device.createBindGroupLayout({
267 entries: [entry(0, "uniform"), entry(1, "storage"), entry(2, "storage"), entry(3, "storage"), entry(8, "read-only-storage")],
268 });
269 this.sumsPipe = pipeline(rigidModule, "sums", rbLayout);
270 this.applyPipe = pipeline(rigidModule, "apply", rbLayout);
272 const audioLayout = device.createBindGroupLayout({
273 entries: [entry(0, "uniform"), entry(1, "uniform", true), entry(2, "read-only-storage"), entry(3, "read-only-storage"), entry(4, "storage")],
274 });
275 this.audioPipe = pipeline(shader(audioWgsl), "main", audioLayout);
277 const extractLayout = device.createBindGroupLayout({
278 entries: [entry(0, "uniform"), entry(1, "read-only-storage"), entry(2, "storage")],
279 });
280 this.extractPipe = pipeline(shader(extractWgsl), "main", extractLayout);
282 // Bind groups. The state is double-buffered: bind group p reads pair p and writes pair 1 - p.
283 const sp = { buffer: this.stepBuf, size: STEP_LEN * 4 };
284 this.stepBG = [0, 1].map((p) =>
285 device.createBindGroup({
286 layout: stepLayout,
287 entries: [
288 { binding: 0, resource: { buffer: this.paramsBuf } },
289 { binding: 1, resource: sp },
290 { binding: 2, resource: { buffer: this.pos[p] } },
291 { binding: 3, resource: { buffer: this.vel[p] } },
292 { binding: 4, resource: { buffer: this.pos[1 - p] } },
293 { binding: 5, resource: { buffer: this.vel[1 - p] } },
294 { binding: 6, resource: { buffer: this.hist } },
295 { binding: 7, resource: { buffer: this.stickBuf } },
296 { binding: 8, resource: { buffer: this.nd } },
297 ],
298 }),
299 );
300 this.rbBG = [0, 1].map((p) =>
301 device.createBindGroup({
302 layout: rbLayout,
303 entries: [
304 { binding: 0, resource: { buffer: this.paramsBuf } },
305 { binding: 1, resource: { buffer: this.pos[p] } },
306 { binding: 2, resource: { buffer: this.vel[p] } },
307 { binding: 3, resource: { buffer: this.statsBuf } },
308 { binding: 8, resource: { buffer: this.nd } },
309 ],
310 }),
311 );
312 this.audioBG = device.createBindGroup({
313 layout: audioLayout,
314 entries: [
315 { binding: 0, resource: { buffer: this.paramsBuf } },
316 { binding: 1, resource: sp },
317 { binding: 2, resource: { buffer: this.hist } },
318 { binding: 3, resource: { buffer: this.lw } },
319 { binding: 4, resource: { buffer: this.audioOut } },
320 ],
321 });
322 const extractBG = (p: number, out: GPUBuffer) =>
323 device.createBindGroup({
324 layout: extractLayout,
325 entries: [
326 { binding: 0, resource: { buffer: this.paramsBuf } },
327 { binding: 1, resource: { buffer: this.pos[p] } },
328 { binding: 2, resource: { buffer: out } },
329 ],
330 });
331 this.extractBG = [0, 1].map((p) => extractBG(p, this.frameBuf));
332 this.displayBG = [0, 1].map((p) => extractBG(p, this.displayW));
334 this.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
335 // Frame 0: the plate at rest when the stick arrives.
336 this.frames.push(new Float32Array(n));
337 }
339 /** Recomputes the per-node weights and delays of the listener sum. Takes effect on later samples. */
340 setListener(dist: number, azimuthDeg: number, elevationDeg: number): void {
341 const g = this.grid;
342 const L = listenerPoint(dist, azimuthDeg, elevationDeg);
343 const data = new Float32Array(2 * g.n);
344 let dmin = Infinity;
345 for (let j = 0; j < g.ny; j++) {
346 for (let i = 0; i < g.nx; i++) {
347 const k = j * g.nx + i;
348 const rx = L[0] - nodeX(g, i), ry = L[1] - nodeY(g, j), rz = L[2];
349 const r = Math.sqrt(rx * rx + ry * ry + rz * rz);
350 const dA = g.hx * g.hy * nodeWeight(g, i, j);
351 data[2 * k] = (RHO_AIR * dA) / (2 * Math.PI * r);
352 data[2 * k + 1] = (r / SPEED_OF_SOUND) * this.params.fs;
353 dmin = Math.min(dmin, data[2 * k + 1]);
354 }
355 }
356 // Only the spread of delays across the plate is kept; the common delay is dropped.
357 this.delayClamped = false;
358 for (let k = 0; k < g.n; k++) {
359 let d = data[2 * k + 1] - dmin;
360 if (d > HIST_MARGIN - 3) {
361 d = HIST_MARGIN - 3;
362 this.delayClamped = true;
363 }
364 data[2 * k + 1] = d;
365 }
366 this.device.queue.writeBuffer(this.lw, 0, data);
367 this.params.listenerDist = dist;
368 this.params.listenerAzimuth = azimuthDeg;
369 this.params.listenerElevation = elevationDeg;
370 }
372 setDuration(duration: number): void {
373 this.params.duration = duration;
374 if (this.canContinue()) this.phase = "recording";
375 }
377 /** True when a finished or stopped run can be extended. */
378 canContinue(): boolean {
379 return this.phase === "done" && this.samples < this.targetSamples();
380 }
382 /** Ends the run; the state, audio, and movie are kept. */
383 stop(): void {
384 this.phase = "done";
385 }
387 targetSamples(): number {
388 return Math.round(this.params.duration * this.params.fs);
389 }
391 status(): SimStatus {
392 // Report the time of the state that has been read back, not the one enqueued.
393 const ahead = this.inFlight ? this.inFlight.steps * this.dt : 0;
394 return {
395 phase: this.phase,
396 t: this.totalSteps * this.dt - ahead,
397 totalSteps: this.totalSteps,
398 ke: this.ke,
399 wmax: this.wmax,
400 samples: this.samples,
401 frames: this.frames.length,
402 peakPressure: this.peakPressure,
403 delayClamped: this.delayClamped,
404 stick: this.stick,
405 };
406 }
408 /** Enqueues an extraction of the current w field into `displayW` (for the live view). */
409 encodeDisplay(encoder: GPUCommandEncoder): void {
410 const pass = encoder.beginComputePass();
411 pass.setPipeline(this.extractPipe);
412 pass.setBindGroup(0, this.displayBG[this.cur]);
413 pass.dispatchWorkgroups(Math.ceil(this.grid.n / 256));
414 pass.end();
415 }
417 private stepWorkgroups(): [number, number] {
418 return [Math.ceil(this.grid.nx / TILE), Math.ceil(this.grid.ny / TILE)];
419 }
421 /** The recorded listener pressure so far [Pa]. */
422 recordedAudio(): Float32Array {
423 return this.audio.subarray(0, this.samples);
424 }
426 private appendAudio(raw: Float32Array): void {
427 const need = this.samples + raw.length;
428 if (this.audio.length < need) {
429 const a = new Float32Array(Math.max(need, 2 * this.audio.length, this.params.fs));
430 a.set(this.audio.subarray(0, this.samples));
431 this.audio = a;
432 }
433 this.audio.set(raw, this.samples);
434 for (let k = 0; k < raw.length; k++) this.peakPressure = Math.max(this.peakPressure, Math.abs(raw[k]));
435 this.samples = need;
436 }
438 /** Writes the per-step uniform for step s of a batch. */
439 private writeStepParams(s: number, fields: { flags: number; slot: number; outIdx: number }): void {
440 const o = (s * this.stepStride) / 4;
441 const u32 = new Uint32Array(this.stepData);
442 const i32 = new Int32Array(this.stepData);
443 const f32 = new Float32Array(this.stepData);
444 const { sigma0, sigma1, sigmaIn } = this.plate.loss;
445 u32[o] = this.totalSteps + s;
446 u32[o + 1] = fields.flags;
447 i32[o + 2] = fields.slot;
448 i32[o + 3] = fields.outIdx;
449 f32[o + 4] = sigma0;
450 f32[o + 5] = sigma1;
451 f32[o + 6] = sigmaIn;
452 u32[o + 7] = (this.totalSteps + s) & 1;
453 }
455 /**
456 * Runs one batch (one movie frame of time). Resolves when its results have
457 * been read back. The following batch is encoded before this one is awaited.
458 */
459 async runBatch(): Promise<void> {
460 if (this.phase === "done") return;
461 const cur = this.inFlight ?? this.encodeBatch();
462 this.inFlight = null;
463 if (!cur) {
464 this.phase = "done";
465 return;
466 }
467 if (this.stepsLeft() > 0) this.inFlight = this.encodeBatch();
468 await this.collect(cur);
469 }
471 private stepsLeft(): number {
472 return this.targetSamples() * this.stepsPerSample - this.totalSteps;
473 }
475 /** Encodes and submits one batch; null when no steps remain. */
476 private encodeBatch(): Pending | null {
477 const p = this.params;
478 const steps = Math.min(this.stepsPerBatch, Math.max(0, this.stepsLeft()));
479 if (steps === 0) return null;
481 // Per-step parameters: the drumstick and the audio bookkeeping.
482 const audioSteps: number[] = [];
483 for (let s = 0; s < steps; s++) {
484 const local = this.totalSteps + s;
485 let slot = -1, outIdx = -1;
486 if (local % this.stepsPerSample === 0) {
487 slot = (local / this.stepsPerSample) % this.histLen;
488 outIdx = audioSteps.length;
489 audioSteps.push(s);
490 }
491 this.writeStepParams(s, { flags: 1, slot, outIdx });
492 }
493 this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, steps * this.stepStride);
495 // Encode the batch in chunks so that the GPU starts while JS is still encoding.
496 const CHUNK = 1024;
497 let enc = this.device.createCommandEncoder();
498 let pass = enc.beginComputePass();
499 const [wgX, wgY] = this.stepWorkgroups();
500 const wgN = Math.ceil(this.grid.n / 256);
501 let cur = this.cur;
502 // The projection is optional and only meaningful for a free plate; the
503 // sums still run once per batch for the statistics.
504 const project = p.rigidProjection && !this.plate.mount;
505 let stepBound = false;
506 for (let s = 0; s < steps; s++) {
507 if (s > 0 && s % CHUNK === 0) {
508 pass.end();
509 this.device.queue.submit([enc.finish()]);
510 enc = this.device.createCommandEncoder();
511 pass = enc.beginComputePass();
512 stepBound = false;
513 }
514 if (!stepBound) {
515 pass.setPipeline(this.stepPipe);
516 stepBound = true;
517 }
518 pass.setBindGroup(0, this.stepBG[cur], [s * this.stepStride]);
519 pass.dispatchWorkgroups(wgX, wgY);
520 cur = 1 - cur;
521 const global = this.totalSteps + s + 1;
522 if ((project && global % RB_INTERVAL === 0) || s === steps - 1) {
523 pass.setPipeline(this.sumsPipe);
524 pass.setBindGroup(0, this.rbBG[cur]);
525 pass.dispatchWorkgroups(1);
526 if (project) {
527 pass.setPipeline(this.applyPipe);
528 pass.setBindGroup(0, this.rbBG[cur]);
529 pass.dispatchWorkgroups(wgN);
530 }
531 stepBound = false;
532 }
533 }
534 // The listener sum for every audio sample of the batch, one workgroup each;
535 // the ring buffer is long enough that no slot of the batch has been reused.
536 const nAudio = audioSteps.length;
537 if (nAudio > 0) {
538 pass.setPipeline(this.audioPipe);
539 pass.setBindGroup(0, this.audioBG, [audioSteps[0] * this.stepStride]);
540 pass.dispatchWorkgroups(nAudio);
541 }
542 pass.setPipeline(this.extractPipe);
543 pass.setBindGroup(0, this.extractBG[cur]);
544 pass.dispatchWorkgroups(wgN);
545 pass.end();
546 const staging = this.staging[this.stagingIdx];
547 this.stagingIdx = 1 - this.stagingIdx;
548 const n = this.grid.n;
549 enc.copyBufferToBuffer(this.audioOut, 0, staging, 0, Math.max(4, nAudio * 4));
550 enc.copyBufferToBuffer(this.frameBuf, 0, staging, this.samplesPerBatch * 4, n * 4);
551 enc.copyBufferToBuffer(this.statsBuf, 0, staging, (this.samplesPerBatch + n) * 4, STATS_LEN * 4);
552 enc.copyBufferToBuffer(this.stickBuf, 0, staging, (this.samplesPerBatch + n + STATS_LEN) * 4, STICK_LEN * 4);
553 this.device.queue.submit([enc.finish()]);
554 this.cur = cur;
555 this.totalSteps += steps;
556 return { staging, steps, nAudio, stickParity: this.totalSteps & 1 };
557 }
559 /** Reads back a batch and applies its results. */
560 private async collect(b: Pending): Promise<void> {
561 const n = this.grid.n;
562 await b.staging.mapAsync(GPUMapMode.READ);
563 const view = new Float32Array(b.staging.getMappedRange());
564 const stats = view.subarray(this.samplesPerBatch + n, this.samplesPerBatch + n + STATS_LEN);
565 this.ke = stats[12];
566 this.wmax = stats[13];
567 // The slot written by the last step is the one the next step reads.
568 const base = this.samplesPerBatch + n + STATS_LEN + 8 * b.stickParity;
569 this.stick = { x: view[base], v: view[base + 1], force: view[base + 2], maxForce: view[base + 3], withdrawn: view[base + 5] !== 0 };
570 this.appendAudio(view.slice(0, b.nAudio));
571 this.frames.push(view.slice(this.samplesPerBatch, this.samplesPerBatch + n));
572 b.staging.unmap();
573 if (this.samples >= this.targetSamples()) this.phase = "done";
574 }
576 // ---------- Testing support ----------
578 /**
579 * Advances the state by exactly one free step (no drumstick, no rigid-body
580 * projection, no audio) with the plate's loss parameters and its mount.
581 */
582 async stepOnce(): Promise<void> {
583 this.writeStepParams(0, { flags: 0, slot: -1, outIdx: -1 });
584 this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, this.stepStride);
585 const enc = this.device.createCommandEncoder();
586 const pass = enc.beginComputePass();
587 pass.setPipeline(this.stepPipe);
588 pass.setBindGroup(0, this.stepBG[this.cur], [0]);
589 pass.dispatchWorkgroups(...this.stepWorkgroups());
590 pass.end();
591 this.device.queue.submit([enc.finish()]);
592 this.cur = 1 - this.cur;
593 this.totalSteps++;
594 await this.device.queue.onSubmittedWorkDone();
595 }
597 /** Overwrites the state: pos as [u | v | w | wLo], vel as [du | dv | dw]. */
598 writeState(pos: Float32Array<ArrayBuffer>, vel: Float32Array<ArrayBuffer>): void {
599 const n = this.grid.n;
600 if (pos.length !== 4 * n || vel.length !== 3 * n) throw new Error("state arrays have the wrong length");
601 this.device.queue.writeBuffer(this.pos[this.cur], 0, pos);
602 this.device.queue.writeBuffer(this.vel[this.cur], 0, vel);
603 }
605 /** Reads back the full state as [u | v | w | wLo] and [du | dv | dw]. */
606 async readState(): Promise<{ pos: Float32Array; vel: Float32Array }> {
607 const n = this.grid.n;
608 const stg = this.device.createBuffer({ size: 7 * n * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
609 const enc = this.device.createCommandEncoder();
610 enc.copyBufferToBuffer(this.pos[this.cur], 0, stg, 0, 4 * n * 4);
611 enc.copyBufferToBuffer(this.vel[this.cur], 0, stg, 4 * n * 4, 3 * n * 4);
612 this.device.queue.submit([enc.finish()]);
613 await stg.mapAsync(GPUMapMode.READ);
614 const all = new Float32Array(stg.getMappedRange()).slice();
615 stg.unmap();
616 stg.destroy();
617 return { pos: all.subarray(0, 4 * n), vel: all.subarray(4 * n) };
618 }
620 destroy(): void {
621 this.inFlight = null;
622 for (const b of [...this.pos, ...this.vel, this.hist, this.stickBuf, this.nd, this.w0, this.nw, this.hn, this.lw, this.audioOut, this.frameBuf, this.displayW, this.statsBuf, this.paramsBuf, this.stepBuf, ...this.staging]) b.destroy();
623 }
625 get fs(): number {
626 return this.params.fs;
627 }
628 get fps(): number {
629 return this.params.fps;
630 }
631 get simParams(): SimParams {
632 return this.params;
633 }
634}