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