f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 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 batch
4// encodes one compute dispatch per time step (with a per-step uniform addressed
5// by dynamic offset), a rigid-body projection every RB_INTERVAL steps, one
6// retarded-time audio sum per audio sample, and one movie-frame extraction at
7// the end. Audio, frame, and statistics are read back after every batch.
9import {
10 type PhysParams,
11 type Grid,
12 type Material,
13 makeGrid,
14 makeMaterial,
15 nodeX,
16 nodeY,
17 trapWeight,
18 HIST_LEN,
19 RB_INTERVAL,
20 RB_INTERVAL_HELD,
21 RHO_AIR,
22 SPEED_OF_SOUND,
23} from "../params.ts";
24import { maxAngularFrequency } from "./cpuRef.ts";
25import commonWgsl from "./shaders/common.wgsl?raw";
26import stepWgsl from "./shaders/step.wgsl?raw";
27import rigidWgsl from "./shaders/rigid.wgsl?raw";
28import audioWgsl from "./shaders/audio.wgsl?raw";
29import extractWgsl from "./shaders/extract.wgsl?raw";
31export type Phase = "push" | "hold" | "recording" | "done";
33export interface SimStatus {
34 phase: Phase;
35 /** Simulation time relative to the release [s]. */
36 t: number;
37 totalSteps: number;
38 ke: number;
39 wmax: number;
40 samples: number;
41 frames: number;
42 peakPressure: number;
43 delayClamped: boolean;
44}
46const STATS_LEN = 16;
48export class Simulation {
49 readonly grid: Grid;
50 readonly mat: Material;
51 readonly dt: number;
52 /** Time steps per audio sample. */
53 readonly stepsPerSample: number;
54 readonly samplesPerBatch: number;
55 readonly stepsPerBatch: number;
56 readonly omegaMax: number;
58 /** Raw listener pressure [Pa] at fs, from the release. */
59 audio: Float32Array = new Float32Array(0);
60 samples = 0;
61 /** Movie frames of w [m]; frame k is at t = k / fps. */
62 frames: Float32Array<ArrayBuffer>[] = [];
64 phase: Phase = "push";
65 /** Steps since the start of the current phase clock (prep or recording). */
66 private stepCount = 0;
67 private prepTime = 0;
68 totalSteps = 0;
69 ke = 0;
70 wmax = 0;
71 peakPressure = 0;
72 delayClamped = false;
73 private hpState = { y: 0, x: 0 };
75 private device: GPUDevice;
76 private params: PhysParams;
77 private paramsBuf: GPUBuffer;
78 private stepBuf: GPUBuffer;
79 private stepStride: number;
80 private stepData: ArrayBuffer;
81 private pos: GPUBuffer[];
82 private vel: GPUBuffer[];
83 private hist: GPUBuffer;
adc495dFinite finger release: ramp the recorded reaction force to zero over a release timeJeremy Magland 84 private react: GPUBuffer;
86 private audioOut: GPUBuffer;
87 private frameBuf: GPUBuffer;
88 private statsBuf: GPUBuffer;
89 private staging: GPUBuffer[];
90 private stagingIdx = 0;
91 /** Index of the buffer pair holding the current state. */
92 private cur = 0;
93 /** The `displayW` buffer is a plain f32 array of w for the renderer. */
94 readonly displayW: GPUBuffer;
96 private stepPipe: GPUComputePipeline;
97 private sumsPipe: GPUComputePipeline;
98 private applyPipe: GPUComputePipeline;
b9a5a7eSelf-equilibrated release force and loudness normalizationJeremy Magland 99 private projectPipe: GPUComputePipeline;
100 private projectBG: GPUBindGroup;
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 101 private audioPipe: GPUComputePipeline;
102 private extractPipe: GPUComputePipeline;
103 private stepBG: GPUBindGroup[];
104 private rbBG: GPUBindGroup[];
105 private audioBG: GPUBindGroup;
106 private extractBG: GPUBindGroup[];
107 private displayBG: GPUBindGroup[];
108 private stagingSize: number;
110 constructor(device: GPUDevice, params: PhysParams) {
111 this.device = device;
112 this.params = { ...params };
113 const g = makeGrid(params);
114 const m = makeMaterial(params);
115 this.grid = g;
116 this.mat = m;
118 // Stable time step, aligned so that an integer number of steps make one audio sample.
119 this.omegaMax = maxAngularFrequency(g, m);
120 const dtMax = (0.85 * 2) / this.omegaMax;
121 this.stepsPerSample = Math.ceil(1 / (params.fs * dtMax));
122 this.dt = 1 / (params.fs * this.stepsPerSample);
123 this.samplesPerBatch = Math.max(1, Math.round(params.fs / params.fps));
124 this.stepsPerBatch = this.samplesPerBatch * this.stepsPerSample;
126 const n = g.n;
127 const f32 = (size: number, usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST) =>
128 device.createBuffer({ size: Math.max(16, size * 4), usage });
130 this.pos = [f32(4 * n), f32(4 * n)]; // [u | v | w | wLo]
131 this.vel = [f32(3 * n), f32(3 * n)]; // [du | dv | dw]
132 this.hist = f32(HIST_LEN * n);
adc495dFinite finger release: ramp the recorded reaction force to zero over a release timeJeremy Magland 133 this.react = f32(n);
135 this.audioOut = f32(this.samplesPerBatch);
136 this.frameBuf = f32(n);
137 this.displayW = f32(n);
138 this.statsBuf = f32(STATS_LEN);
139 this.stagingSize = (this.samplesPerBatch + n + STATS_LEN) * 4;
140 this.staging = [0, 1].map(() =>
141 device.createBuffer({ size: this.stagingSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }),
142 );
144 // Constant parameters.
145 let mass = 0, Ix = 0, Iy = 0;
146 for (let j = 0; j < g.ny; j++) {
147 for (let i = 0; i < g.nx; i++) {
148 const mk = m.rhoh * g.hx * g.hy * trapWeight(g, i, j);
149 mass += mk;
150 Ix += mk * nodeY(g, j) ** 2;
151 Iy += mk * nodeX(g, i) ** 2;
152 }
153 }
154 const pf = new Float32Array(20);
155 const pu = new Uint32Array(pf.buffer);
156 pu[0] = g.nx; pu[1] = g.ny; pu[2] = n; pu[3] = HIST_LEN;
157 pf[4] = g.hx; pf[5] = g.hy; pf[6] = this.dt; pf[7] = m.nu;
158 pf[8] = m.D; pf[9] = m.C; pf[10] = m.G; pf[11] = m.rhoh;
159 pf[12] = params.Lx / 2; pf[13] = -params.Ly / 2; pf[14] = params.fingerRadius ** 2; pf[15] = mass;
160 pf[16] = Ix; pf[17] = Iy; pf[18] = Ix + Iy; pf[19] = 0;
161 this.paramsBuf = device.createBuffer({ size: pf.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
162 device.queue.writeBuffer(this.paramsBuf, 0, pf);
adc495dFinite finger release: ramp the recorded reaction force to zero over a release timeJeremy Magland 164 // Per-step parameters, one 48-byte struct per step at a dynamic offset.
165 this.stepStride = Math.max(48, device.limits.minUniformBufferOffsetAlignment);
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 166 this.stepData = new ArrayBuffer(this.stepStride * this.stepsPerBatch);
167 this.stepBuf = device.createBuffer({ size: this.stepData.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
169 const mk = (code: string, entry: string, layout: GPUPipelineLayout | "auto" = "auto") =>
170 device.createComputePipeline({
171 layout,
172 compute: { module: device.createShaderModule({ code: commonWgsl + code }), entryPoint: entry },
173 });
174 const entry = (binding: number, type: GPUBufferBindingType, hasDynamicOffset = false): GPUBindGroupLayoutEntry => ({
175 binding,
176 visibility: GPUShaderStage.COMPUTE,
177 buffer: { type, hasDynamicOffset },
178 });
179 const stepLayout = device.createBindGroupLayout({
180 entries: [
181 entry(0, "uniform"),
182 entry(1, "uniform", true),
183 entry(2, "read-only-storage"),
184 entry(3, "read-only-storage"),
185 entry(4, "storage"),
186 entry(5, "storage"),
187 entry(6, "storage"),
adc495dFinite finger release: ramp the recorded reaction force to zero over a release timeJeremy Magland 188 entry(7, "storage"),
190 });
191 this.stepPipe = mk(stepWgsl, "main", device.createPipelineLayout({ bindGroupLayouts: [stepLayout] }));
192 const audioLayout = device.createBindGroupLayout({
193 entries: [entry(0, "uniform"), entry(1, "uniform", true), entry(2, "read-only-storage"), entry(3, "read-only-storage"), entry(4, "storage")],
194 });
195 const rigidModule = device.createShaderModule({ code: commonWgsl + rigidWgsl });
196 const rbLayout = device.createBindGroupLayout({
197 entries: [
198 { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
199 { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
200 { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
201 { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
202 ],
203 });
204 const rbPipeLayout = device.createPipelineLayout({ bindGroupLayouts: [rbLayout] });
205 this.sumsPipe = device.createComputePipeline({ layout: rbPipeLayout, compute: { module: rigidModule, entryPoint: "sums" } });
206 this.applyPipe = device.createComputePipeline({ layout: rbPipeLayout, compute: { module: rigidModule, entryPoint: "apply" } });
b9a5a7eSelf-equilibrated release force and loudness normalizationJeremy Magland 207 const projectLayout = device.createBindGroupLayout({
208 entries: [
209 { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
210 { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
211 ],
212 });
213 this.projectPipe = device.createComputePipeline({
214 layout: device.createPipelineLayout({ bindGroupLayouts: [projectLayout] }),
215 compute: { module: rigidModule, entryPoint: "projectField" },
216 });
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 217 this.audioPipe = mk(audioWgsl, "main", device.createPipelineLayout({ bindGroupLayouts: [audioLayout] }));
218 this.extractPipe = mk(extractWgsl, "main");
adc495dFinite finger release: ramp the recorded reaction force to zero over a release timeJeremy Magland 220 const sp = { buffer: this.stepBuf, size: 48 };
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 221 this.stepBG = [0, 1].map((p) =>
222 device.createBindGroup({
223 layout: stepLayout,
224 entries: [
225 { binding: 0, resource: { buffer: this.paramsBuf } },
226 { binding: 1, resource: sp },
227 { binding: 2, resource: { buffer: this.pos[p] } },
228 { binding: 3, resource: { buffer: this.vel[p] } },
229 { binding: 4, resource: { buffer: this.pos[1 - p] } },
230 { binding: 5, resource: { buffer: this.vel[1 - p] } },
231 { binding: 6, resource: { buffer: this.hist } },
adc495dFinite finger release: ramp the recorded reaction force to zero over a release timeJeremy Magland 232 { binding: 7, resource: { buffer: this.react } },
234 }),
235 );
236 this.rbBG = [0, 1].map((p) =>
237 device.createBindGroup({
238 layout: rbLayout,
239 entries: [
240 { binding: 0, resource: { buffer: this.paramsBuf } },
241 { binding: 1, resource: { buffer: this.pos[p] } },
242 { binding: 2, resource: { buffer: this.vel[p] } },
243 { binding: 3, resource: { buffer: this.statsBuf } },
244 ],
245 }),
246 );
b9a5a7eSelf-equilibrated release force and loudness normalizationJeremy Magland 247 this.projectBG = device.createBindGroup({
248 layout: projectLayout,
249 entries: [
250 { binding: 0, resource: { buffer: this.paramsBuf } },
251 { binding: 4, resource: { buffer: this.react } },
252 ],
253 });
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 254 this.audioBG = device.createBindGroup({
255 layout: audioLayout,
256 entries: [
257 { binding: 0, resource: { buffer: this.paramsBuf } },
258 { binding: 1, resource: sp },
259 { binding: 2, resource: { buffer: this.hist } },
260 { binding: 3, resource: { buffer: this.lw } },
261 { binding: 4, resource: { buffer: this.audioOut } },
262 ],
263 });
264 const extractBG = (p: number, out: GPUBuffer) =>
265 device.createBindGroup({
266 layout: this.extractPipe.getBindGroupLayout(0),
267 entries: [
268 { binding: 0, resource: { buffer: this.paramsBuf } },
269 { binding: 1, resource: { buffer: this.pos[p] } },
270 { binding: 2, resource: { buffer: out } },
271 ],
272 });
273 this.extractBG = [0, 1].map((p) => extractBG(p, this.frameBuf));
274 this.displayBG = [0, 1].map((p) => extractBG(p, this.displayW));
276 this.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation, params.radiation);
277 }
279 /** Recomputes the per-node weights and delays of the listener sum. Takes effect on later samples. */
280 setListener(dist: number, azimuthDeg: number, elevationDeg: number, radiation: "rayleigh" | "dipole"): void {
281 const g = this.grid;
282 const az = (azimuthDeg * Math.PI) / 180;
283 const el = (elevationDeg * Math.PI) / 180;
284 const L = [dist * Math.sin(az) * Math.cos(el), dist * Math.sin(el), dist * Math.cos(az) * Math.cos(el)];
285 const data = new Float32Array(2 * g.n);
286 let dmin = Infinity;
287 for (let j = 0; j < g.ny; j++) {
288 for (let i = 0; i < g.nx; i++) {
289 const k = j * g.nx + i;
290 const rx = L[0] - nodeX(g, i), ry = L[1] - nodeY(g, j), rz = L[2];
291 const r = Math.sqrt(rx * rx + ry * ry + rz * rz);
292 const dA = g.hx * g.hy * trapWeight(g, i, j);
293 let wgt = (RHO_AIR * dA) / (2 * Math.PI * r);
294 if (radiation === "dipole") wgt *= Math.abs(rz) / r;
295 data[2 * k] = wgt;
296 data[2 * k + 1] = (r / SPEED_OF_SOUND) * this.params.fs;
297 dmin = Math.min(dmin, data[2 * k + 1]);
298 }
299 }
300 this.delayClamped = false;
301 for (let k = 0; k < g.n; k++) {
302 let d = data[2 * k + 1] - dmin;
303 if (d > HIST_LEN - 2) {
304 d = HIST_LEN - 2;
305 this.delayClamped = true;
306 }
307 data[2 * k + 1] = d;
308 }
309 this.device.queue.writeBuffer(this.lw, 0, data);
310 this.params.listenerDist = dist;
311 this.params.listenerAzimuth = azimuthDeg;
312 this.params.listenerElevation = elevationDeg;
313 this.params.radiation = radiation;
314 }
316 setDamping(sigma0: number, sigma1: number): void {
317 this.params.sigma0 = sigma0;
318 this.params.sigma1 = sigma1;
319 }
321 setDuration(duration: number): void {
322 this.params.duration = duration;
323 if (this.phase === "done" && this.samples < this.targetSamples()) this.phase = "recording";
324 }
326 targetSamples(): number {
327 return Math.round(this.params.duration * this.params.fs);
328 }
330 status(): SimStatus {
331 const t = this.phase === "push" || this.phase === "hold" ? this.prepTime - this.prepDuration() : this.stepCount * this.dt;
332 return {
333 phase: this.phase,
334 t,
335 totalSteps: this.totalSteps,
336 ke: this.ke,
337 wmax: this.wmax,
338 samples: this.samples,
339 frames: this.frames.length,
340 peakPressure: this.peakPressure,
341 delayClamped: this.delayClamped,
342 };
343 }
345 private prepDuration(): number {
346 return this.params.rampTime + this.params.holdMax;
347 }
349 /** Enqueues an extraction of the current w field into `displayW` (for the live view). */
350 encodeDisplay(encoder: GPUCommandEncoder): void {
351 const pass = encoder.beginComputePass();
352 pass.setPipeline(this.extractPipe);
353 pass.setBindGroup(0, this.displayBG[this.cur]);
354 pass.dispatchWorkgroups(Math.ceil(this.grid.n / 256));
355 pass.end();
356 }
358 /** Listener audio with the radiation post-processing applied [Pa]. */
359 processedAudio(): Float32Array {
360 return this.processed.subarray(0, this.samples);
361 }
362 private processed: Float32Array = new Float32Array(0);
364 private appendAudio(raw: Float32Array): void {
365 const need = this.samples + raw.length;
366 if (this.audio.length < need) {
367 const grow = Math.max(need, 2 * this.audio.length, this.params.fs);
368 const a = new Float32Array(grow);
369 a.set(this.audio.subarray(0, this.samples));
370 this.audio = a;
371 const b = new Float32Array(grow);
372 b.set(this.processed.subarray(0, this.samples));
373 this.processed = b;
374 }
375 this.audio.set(raw, this.samples);
376 // Dipole option: first-order high-pass at the baffle cutoff c / (2 sqrt(Lx Ly)).
377 const dipole = this.params.radiation === "dipole";
378 const fc = SPEED_OF_SOUND / (2 * Math.sqrt(this.params.Lx * this.params.Ly));
379 const alpha = 1 / (1 + (2 * Math.PI * fc) / this.params.fs);
380 for (let k = 0; k < raw.length; k++) {
381 const x = raw[k];
382 let y = x;
383 if (dipole) {
384 y = alpha * (this.hpState.y + x - this.hpState.x);
385 }
386 this.hpState = { y, x };
387 this.processed[this.samples + k] = y;
388 this.peakPressure = Math.max(this.peakPressure, Math.abs(y));
389 }
390 this.samples = need;
391 }
393 /** Runs one batch (one movie frame of time). Resolves when its results have been read back. */
394 async runBatch(): Promise<void> {
395 if (this.phase === "done") return;
396 const p = this.params;
397 const recording = this.phase === "recording";
398 const nSteps = this.stepsPerBatch;
399 const stepsLeft = recording ? (this.targetSamples() - this.samples) * this.stepsPerSample : nSteps;
400 const steps = Math.min(nSteps, Math.max(0, stepsLeft));
401 if (steps === 0) {
402 this.phase = "done";
403 return;
404 }
406 // Fill the per-step parameters.
407 const u32 = new Uint32Array(this.stepData);
408 const i32 = new Int32Array(this.stepData);
409 const f32 = new Float32Array(this.stepData);
410 const stride = this.stepStride / 4;
411 const audioStepsInBatch: number[] = [];
412 for (let s = 0; s < steps; s++) {
413 const o = s * stride;
414 const local = this.stepCount + s;
415 u32[o] = this.totalSteps + s;
adc495dFinite finger release: ramp the recorded reaction force to zero over a release timeJeremy Magland 416 let flags = 0, pushW = 0, pushV = 0, sigma0 = p.sigma0, slot = -1, outIdx = -1, releaseGain = 0;
418 const t = this.prepTime + (s + 1) * this.dt; // state after this step
419 const tau = Math.min(1, t / p.rampTime);
420 const smooth = tau * tau * (3 - 2 * tau);
421 const dsmooth = tau < 1 ? (6 * tau * (1 - tau)) / p.rampTime : 0;
adc495dFinite finger release: ramp the recorded reaction force to zero over a release timeJeremy Magland 422 flags = 1 | (tau >= 1 ? 2 : 0); // record the reaction once the ramp is complete
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 423 pushW = p.pushAmount * smooth;
424 pushV = p.pushAmount * dsmooth;
425 sigma0 = p.settleSigma0;
adc495dFinite finger release: ramp the recorded reaction force to zero over a release timeJeremy Magland 426 } else {
427 const t = local * this.dt;
428 if (p.releaseTime > 0 && t < p.releaseTime) {
429 const tau = t / p.releaseTime;
430 releaseGain = 1 - tau * tau * (3 - 2 * tau);
431 flags = 4;
432 }
433 if (local % this.stepsPerSample === 0) {
434 const sampleIdx = local / this.stepsPerSample;
435 slot = sampleIdx % HIST_LEN;
436 outIdx = audioStepsInBatch.length;
437 audioStepsInBatch.push(s);
438 }
440 u32[o + 1] = flags;
441 i32[o + 2] = slot;
442 i32[o + 3] = outIdx;
443 f32[o + 4] = pushW;
444 f32[o + 5] = pushV;
445 f32[o + 6] = sigma0;
446 f32[o + 7] = p.sigma1;
adc495dFinite finger release: ramp the recorded reaction force to zero over a release timeJeremy Magland 447 f32[o + 8] = releaseGain;
449 this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, steps * this.stepStride);
451 // Encode the batch in chunks so that the GPU starts while JS is still encoding.
452 const CHUNK = 1024;
453 let enc = this.device.createCommandEncoder();
454 let pass = enc.beginComputePass();
455 const wgX = Math.ceil(this.grid.nx / 16), wgY = Math.ceil(this.grid.ny / 16);
456 const wgN = Math.ceil(this.grid.n / 256);
457 let cur = this.cur;
458 let audioPtr = 0;
459 const rbInterval = recording ? RB_INTERVAL : RB_INTERVAL_HELD;
460 let stepBound = false;
461 for (let s = 0; s < steps; s++) {
462 if (s > 0 && s % CHUNK === 0) {
463 pass.end();
464 this.device.queue.submit([enc.finish()]);
465 enc = this.device.createCommandEncoder();
466 pass = enc.beginComputePass();
467 stepBound = false;
468 }
469 if (!stepBound) {
470 pass.setPipeline(this.stepPipe);
471 stepBound = true;
472 }
473 pass.setBindGroup(0, this.stepBG[cur], [s * this.stepStride]);
474 pass.dispatchWorkgroups(wgX, wgY);
475 cur = 1 - cur;
476 const global = this.totalSteps + s + 1;
477 if (global % rbInterval === 0 || s === steps - 1) {
478 pass.setPipeline(this.sumsPipe);
479 pass.setBindGroup(0, this.rbBG[cur]);
480 pass.dispatchWorkgroups(1);
481 pass.setPipeline(this.applyPipe);
482 pass.setBindGroup(0, this.rbBG[cur]);
483 pass.dispatchWorkgroups(wgN);
484 stepBound = false;
485 }
486 if (audioPtr < audioStepsInBatch.length && audioStepsInBatch[audioPtr] === s) {
487 pass.setPipeline(this.audioPipe);
488 pass.setBindGroup(0, this.audioBG, [s * this.stepStride]);
489 pass.dispatchWorkgroups(1);
490 audioPtr++;
491 stepBound = false;
492 }
493 }
494 pass.setPipeline(this.extractPipe);
495 pass.setBindGroup(0, this.extractBG[cur]);
496 pass.dispatchWorkgroups(wgN);
497 pass.end();
498 const staging = this.staging[this.stagingIdx];
499 this.stagingIdx = 1 - this.stagingIdx;
500 const nAudio = audioStepsInBatch.length;
501 enc.copyBufferToBuffer(this.audioOut, 0, staging, 0, Math.max(4, nAudio * 4));
502 enc.copyBufferToBuffer(this.frameBuf, 0, staging, this.samplesPerBatch * 4, this.grid.n * 4);
503 enc.copyBufferToBuffer(this.statsBuf, 0, staging, (this.samplesPerBatch + this.grid.n) * 4, STATS_LEN * 4);
504 this.device.queue.submit([enc.finish()]);
505 this.cur = cur;
506 this.totalSteps += steps;
507 this.stepCount += steps;
508 if (!recording) this.prepTime += steps * this.dt;
510 await staging.mapAsync(GPUMapMode.READ);
511 const view = new Float32Array(staging.getMappedRange());
512 const stats = view.subarray(this.samplesPerBatch + this.grid.n, this.samplesPerBatch + this.grid.n + STATS_LEN);
513 this.ke = stats[12];
514 this.wmax = stats[13];
515 if (recording) {
516 this.appendAudio(view.slice(0, nAudio));
517 this.frames.push(view.slice(this.samplesPerBatch, this.samplesPerBatch + this.grid.n));
518 } else {
519 this.lastPrepFrame = view.slice(this.samplesPerBatch, this.samplesPerBatch + this.grid.n);
520 }
521 staging.unmap();
523 if (!recording) {
524 if (this.phase === "push" && this.prepTime >= p.rampTime) this.phase = "hold";
525 const settled = this.phase === "hold" && this.ke < p.settleKE;
526 if (settled || this.prepTime >= this.prepDuration()) this.release();
527 } else if (this.samples >= this.targetSamples()) {
528 this.phase = "done";
529 }
530 }
531 private lastPrepFrame: Float32Array<ArrayBuffer> | null = null;
533 private release(): void {
534 this.phase = "recording";
535 this.stepCount = 0;
536 this.totalSteps = 0;
b9a5a7eSelf-equilibrated release force and loudness normalizationJeremy Magland 537 // The finger force and the distributed holding reaction relax together:
538 // remove the rigid-body components of the recorded reaction.
539 const enc = this.device.createCommandEncoder();
540 const pass = enc.beginComputePass();
541 pass.setPipeline(this.projectPipe);
542 pass.setBindGroup(0, this.projectBG);
543 pass.dispatchWorkgroups(1);
544 pass.end();
545 this.device.queue.submit([enc.finish()]);
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 546 // Frame 0 is the held, settled shape.
547 if (this.lastPrepFrame) this.frames.push(this.lastPrepFrame);
548 }
550 /** Reads back the full state (debugging and tests). */
551 async readState(): Promise<{ pos: Float32Array; vel: Float32Array }> {
552 const n = this.grid.n;
553 const stg = this.device.createBuffer({ size: 7 * n * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
554 const enc = this.device.createCommandEncoder();
555 enc.copyBufferToBuffer(this.pos[this.cur], 0, stg, 0, 4 * n * 4);
556 enc.copyBufferToBuffer(this.vel[this.cur], 0, stg, 4 * n * 4, 3 * n * 4);
557 this.device.queue.submit([enc.finish()]);
558 await stg.mapAsync(GPUMapMode.READ);
559 const all = new Float32Array(stg.getMappedRange()).slice();
560 stg.unmap();
561 stg.destroy();
562 // Returned as [u | v | w | wLo] and [du | dv | dw].
563 return { pos: all.subarray(0, 4 * n), vel: all.subarray(4 * n) };
564 }
566 destroy(): void {
adc495dFinite finger release: ramp the recorded reaction force to zero over a release timeJeremy Magland 567 for (const b of [...this.pos, ...this.vel, this.hist, this.react, this.lw, this.audioOut, this.frameBuf, this.displayW, this.statsBuf, this.paramsBuf, this.stepBuf, ...this.staging]) b.destroy();
570 get fs(): number {
571 return this.params.fs;
572 }
573 get fps(): number {
574 return this.params.fps;
575 }
576 get physParams(): PhysParams {
577 return this.params;
578 }
579}