// WebGPU time-stepping driver for the von Karman plate. // // The simulation advances in batches of one movie frame (1/fps seconds). A // batch encodes one compute dispatch per time step (with a per-step uniform // addressed by dynamic offset), an optional rigid-body projection every // RB_INTERVAL steps, one retarded-time listener sum over the batch's audio // samples, and one movie-frame extraction at the end. Audio, frame, and // statistics are read back after every batch. One batch is kept encoded // ahead of the one being read back, so the GPU does not wait for the // readback or for the encoding. The recording starts at the impact, with the // plate at rest in its stress-free shape. import { type SimParams, type Plate, type Grid, strikePoint, contactPatch, mountPatch, massProperties, thicknessRange, nodeX, nodeY, nodeWeight, nodeWeights, CFL, HIST_MARGIN, RB_INTERVAL, RHO_AIR, SPEED_OF_SOUND, } from "../params.ts"; import { maxAngularFrequency } from "./cpuRef.ts"; import commonWgsl from "./shaders/common.wgsl?raw"; import stepWgsl from "./shaders/step.wgsl?raw"; import rigidWgsl from "./shaders/rigid.wgsl?raw"; import audioWgsl from "./shaders/audio.wgsl?raw"; import extractWgsl from "./shaders/extract.wgsl?raw"; export type Phase = "recording" | "done"; export interface StickState { x: number; v: number; force: number; maxForce: number; } export interface SimStatus { phase: Phase; /** Simulation time since the impact [s]. */ t: number; totalSteps: number; ke: number; wmax: number; samples: number; frames: number; peakPressure: number; delayClamped: boolean; stick: StickState; } const PARAMS_LEN = 40; // floats in the Params uniform const STEP_LEN = 12; // floats in the StepParams uniform const STATS_LEN = 16; const STICK_LEN = 8; /** Nodes per workgroup along each axis in step.wgsl. */ const TILE = 12; /** A batch that has been encoded and submitted but not read back. */ interface Pending { staging: GPUBuffer; steps: number; nAudio: number; /** Which of the two stick-state slots the batch's last step wrote. */ stickParity: number; } export class Simulation { readonly plate: Plate; readonly grid: Grid; readonly dt: number; /** Time steps per audio sample. */ readonly stepsPerSample: number; readonly samplesPerBatch: number; readonly stepsPerBatch: number; readonly omegaMax: number; /** Ring buffer length of the listener sum [samples]. */ readonly histLen: number; /** Total mass of the plate [kg]. */ readonly mass: number; /** Listener pressure [Pa] at fs, from the impact. */ audio: Float32Array = new Float32Array(0); samples = 0; /** Movie frames of w [m]; frame k is at t = k / fps. */ frames: Float32Array[] = []; phase: Phase = "recording"; totalSteps = 0; ke = 0; wmax = 0; peakPressure = 0; delayClamped = false; private stick: StickState; private inFlight: Pending | null = null; private device: GPUDevice; private params: SimParams; private paramsBuf: GPUBuffer; private stepBuf: GPUBuffer; private stepStride: number; private stepData: ArrayBuffer; private pos: GPUBuffer[]; private vel: GPUBuffer[]; private hist: GPUBuffer; private stickBuf: GPUBuffer; /** Static per-node data for the kernels, packed as (w0, weight, thickness, 0) per node. */ private nd: GPUBuffer; /** Stress-free initial shape (f32 per node), for the renderer. */ readonly w0: GPUBuffer; /** Largest magnitude of the initial shape [m]. */ readonly w0Max: number; /** Node weights (f32 per node, 0 outside the plate), for the renderer. */ readonly nw: GPUBuffer; /** Thickness (f32 per node, 0 outside the plate), for the renderer. */ readonly hn: GPUBuffer; private lw: GPUBuffer; private audioOut: GPUBuffer; private frameBuf: GPUBuffer; private statsBuf: GPUBuffer; private staging: GPUBuffer[]; private stagingIdx = 0; private stagingSize: number; /** Index of the buffer pair holding the current state. */ private cur = 0; /** Plain f32 array of w for the live view. */ readonly displayW: GPUBuffer; private stepPipe: GPUComputePipeline; private sumsPipe: GPUComputePipeline; private applyPipe: GPUComputePipeline; private audioPipe: GPUComputePipeline; private extractPipe: GPUComputePipeline; private stepBG: GPUBindGroup[]; private rbBG: GPUBindGroup[]; private audioBG: GPUBindGroup; private extractBG: GPUBindGroup[]; private displayBG: GPUBindGroup[]; constructor(device: GPUDevice, plate: Plate, params: SimParams) { this.device = device; this.plate = plate; this.params = { ...params }; const g = plate.grid; const m = plate.mat; this.grid = g; // Stable time step, rounded so that an integer number of steps make one audio sample. const hr = thicknessRange(plate); this.omegaMax = maxAngularFrequency(g, m, hr.max, params.inPlaneMass); const dtMax = (CFL * 2) / this.omegaMax; this.stepsPerSample = Math.ceil(1 / (params.fs * dtMax)); this.dt = 1 / (params.fs * this.stepsPerSample); this.samplesPerBatch = Math.max(1, Math.round(params.fs / params.fps)); this.stepsPerBatch = this.samplesPerBatch * this.stepsPerSample; this.histLen = this.samplesPerBatch + HIST_MARGIN; const n = g.n; const f32 = (size: number, usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST) => device.createBuffer({ size: Math.max(16, size * 4), usage }); this.pos = [f32(4 * n), f32(4 * n)]; // [u | v | w | wLo] this.vel = [f32(3 * n), f32(3 * n)]; // [du | dv | dw] this.hist = f32(this.histLen * n); this.stickBuf = f32(STICK_LEN); this.w0 = f32(n); const shape = Float32Array.from(plate.w0); device.queue.writeBuffer(this.w0, 0, shape); this.w0Max = shape.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0); const weights = nodeWeights(g); this.nw = f32(n); device.queue.writeBuffer(this.nw, 0, weights); const thick = Float32Array.from(plate.h); this.hn = f32(n); device.queue.writeBuffer(this.hn, 0, thick); const packed = new Float32Array(4 * n); for (let k = 0; k < n; k++) { packed[4 * k] = shape[k]; packed[4 * k + 1] = weights[k]; packed[4 * k + 2] = thick[k]; } this.nd = f32(4 * n); device.queue.writeBuffer(this.nd, 0, packed); this.lw = f32(2 * n); this.audioOut = f32(this.samplesPerBatch); this.frameBuf = f32(n); this.displayW = f32(n); this.statsBuf = f32(STATS_LEN); this.stagingSize = (this.samplesPerBatch + n + STATS_LEN + STICK_LEN) * 4; this.staging = [0, 1].map(() => device.createBuffer({ size: this.stagingSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }), ); // Mass and moments of inertia of the plate, the contact patch, the mount patch, // and the initial stick state (position 0, moving toward the plate). const { mass, Ix, Iy } = massProperties(plate); this.mass = mass; const cp = contactPatch(params, plate); const mp = mountPatch(plate); const stick = new Float32Array(STICK_LEN); stick[1] = -params.strikeVelocity; stick[5] = -params.strikeVelocity; device.queue.writeBuffer(this.stickBuf, 0, stick); this.stick = { x: 0, v: -params.strikeVelocity, force: 0, maxForce: 0 }; const [sx, sy] = strikePoint(params, plate); const pf = new Float32Array(PARAMS_LEN); const pu = new Uint32Array(pf.buffer); pu[0] = g.nx; pu[1] = g.ny; pu[2] = n; pu[3] = this.histLen; pf[4] = g.hx; pf[5] = g.hy; pf[6] = this.dt; pf[7] = m.nu; 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; pf[12] = mass; pf[13] = Ix; pf[14] = Iy; pf[15] = Ix + Iy; pf[16] = params.fs; pf[17] = sx; pf[18] = sy; pf[19] = params.contactRadius ** 2; pf[20] = cp.mass; pu[21] = cp.nearest; pf[22] = params.contactStiffness; pf[23] = params.contactExponent; pf[24] = params.stickMass; pu[25] = cp.box[0]; pu[26] = cp.box[1]; pu[27] = cp.box[2]; pu[28] = cp.box[3]; pf[29] = cp.center[0]; pf[30] = cp.center[1]; pf[31] = params.inPlaneMass; pf[32] = 1 / params.inPlaneMass; if (mp && plate.mount) { pf[33] = plate.mount.x; pf[34] = plate.mount.y; pf[35] = plate.mount.radius ** 2; pf[36] = plate.mount.stiffness / mp.weight; pf[37] = plate.mount.damping / mp.weight; pu[38] = mp.nearest; pu[39] = 1; } this.paramsBuf = device.createBuffer({ size: pf.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); device.queue.writeBuffer(this.paramsBuf, 0, pf); // Per-step parameters, one struct per step at a dynamic offset. this.stepStride = Math.max(STEP_LEN * 4, device.limits.minUniformBufferOffsetAlignment); this.stepData = new ArrayBuffer(this.stepStride * this.stepsPerBatch); this.stepBuf = device.createBuffer({ size: this.stepData.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); // Pipelines. const shader = (code: string) => device.createShaderModule({ code: commonWgsl + code }); const entry = (binding: number, type: GPUBufferBindingType, hasDynamicOffset = false): GPUBindGroupLayoutEntry => ({ binding, visibility: GPUShaderStage.COMPUTE, buffer: { type, hasDynamicOffset }, }); const pipeline = (module: GPUShaderModule, entryPoint: string, layout: GPUBindGroupLayout) => device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }), compute: { module, entryPoint } }); const stepLayout = device.createBindGroupLayout({ entries: [ entry(0, "uniform"), entry(1, "uniform", true), entry(2, "read-only-storage"), entry(3, "read-only-storage"), entry(4, "storage"), entry(5, "storage"), entry(6, "storage"), entry(7, "storage"), entry(8, "read-only-storage"), ], }); this.stepPipe = pipeline(shader(stepWgsl), "main", stepLayout); const rigidModule = shader(rigidWgsl); const rbLayout = device.createBindGroupLayout({ entries: [entry(0, "uniform"), entry(1, "storage"), entry(2, "storage"), entry(3, "storage"), entry(8, "read-only-storage")], }); this.sumsPipe = pipeline(rigidModule, "sums", rbLayout); this.applyPipe = pipeline(rigidModule, "apply", rbLayout); const audioLayout = device.createBindGroupLayout({ entries: [entry(0, "uniform"), entry(1, "uniform", true), entry(2, "read-only-storage"), entry(3, "read-only-storage"), entry(4, "storage")], }); this.audioPipe = pipeline(shader(audioWgsl), "main", audioLayout); const extractLayout = device.createBindGroupLayout({ entries: [entry(0, "uniform"), entry(1, "read-only-storage"), entry(2, "storage")], }); this.extractPipe = pipeline(shader(extractWgsl), "main", extractLayout); // Bind groups. The state is double-buffered: bind group p reads pair p and writes pair 1 - p. const sp = { buffer: this.stepBuf, size: STEP_LEN * 4 }; this.stepBG = [0, 1].map((p) => device.createBindGroup({ layout: stepLayout, entries: [ { binding: 0, resource: { buffer: this.paramsBuf } }, { binding: 1, resource: sp }, { binding: 2, resource: { buffer: this.pos[p] } }, { binding: 3, resource: { buffer: this.vel[p] } }, { binding: 4, resource: { buffer: this.pos[1 - p] } }, { binding: 5, resource: { buffer: this.vel[1 - p] } }, { binding: 6, resource: { buffer: this.hist } }, { binding: 7, resource: { buffer: this.stickBuf } }, { binding: 8, resource: { buffer: this.nd } }, ], }), ); this.rbBG = [0, 1].map((p) => device.createBindGroup({ layout: rbLayout, entries: [ { binding: 0, resource: { buffer: this.paramsBuf } }, { binding: 1, resource: { buffer: this.pos[p] } }, { binding: 2, resource: { buffer: this.vel[p] } }, { binding: 3, resource: { buffer: this.statsBuf } }, { binding: 8, resource: { buffer: this.nd } }, ], }), ); this.audioBG = device.createBindGroup({ layout: audioLayout, entries: [ { binding: 0, resource: { buffer: this.paramsBuf } }, { binding: 1, resource: sp }, { binding: 2, resource: { buffer: this.hist } }, { binding: 3, resource: { buffer: this.lw } }, { binding: 4, resource: { buffer: this.audioOut } }, ], }); const extractBG = (p: number, out: GPUBuffer) => device.createBindGroup({ layout: extractLayout, entries: [ { binding: 0, resource: { buffer: this.paramsBuf } }, { binding: 1, resource: { buffer: this.pos[p] } }, { binding: 2, resource: { buffer: out } }, ], }); this.extractBG = [0, 1].map((p) => extractBG(p, this.frameBuf)); this.displayBG = [0, 1].map((p) => extractBG(p, this.displayW)); this.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation); // Frame 0: the plate at rest when the stick arrives. this.frames.push(new Float32Array(n)); } /** Recomputes the per-node weights and delays of the listener sum. Takes effect on later samples. */ setListener(dist: number, azimuthDeg: number, elevationDeg: number): void { const g = this.grid; const az = (azimuthDeg * Math.PI) / 180; const el = (elevationDeg * Math.PI) / 180; const L = [dist * Math.sin(az) * Math.cos(el), dist * Math.sin(el), dist * Math.cos(az) * Math.cos(el)]; const data = new Float32Array(2 * g.n); let dmin = Infinity; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { const k = j * g.nx + i; const rx = L[0] - nodeX(g, i), ry = L[1] - nodeY(g, j), rz = L[2]; const r = Math.sqrt(rx * rx + ry * ry + rz * rz); const dA = g.hx * g.hy * nodeWeight(g, i, j); data[2 * k] = (RHO_AIR * dA) / (2 * Math.PI * r); data[2 * k + 1] = (r / SPEED_OF_SOUND) * this.params.fs; dmin = Math.min(dmin, data[2 * k + 1]); } } // Only the spread of delays across the plate is kept; the common delay is dropped. this.delayClamped = false; for (let k = 0; k < g.n; k++) { let d = data[2 * k + 1] - dmin; if (d > HIST_MARGIN - 3) { d = HIST_MARGIN - 3; this.delayClamped = true; } data[2 * k + 1] = d; } this.device.queue.writeBuffer(this.lw, 0, data); this.params.listenerDist = dist; this.params.listenerAzimuth = azimuthDeg; this.params.listenerElevation = elevationDeg; } setDuration(duration: number): void { this.params.duration = duration; if (this.canContinue()) this.phase = "recording"; } /** True when a finished or stopped run can be extended. */ canContinue(): boolean { return this.phase === "done" && this.samples < this.targetSamples(); } /** Ends the run; the state, audio, and movie are kept. */ stop(): void { this.phase = "done"; } targetSamples(): number { return Math.round(this.params.duration * this.params.fs); } status(): SimStatus { // Report the time of the state that has been read back, not the one enqueued. const ahead = this.inFlight ? this.inFlight.steps * this.dt : 0; return { phase: this.phase, t: this.totalSteps * this.dt - ahead, totalSteps: this.totalSteps, ke: this.ke, wmax: this.wmax, samples: this.samples, frames: this.frames.length, peakPressure: this.peakPressure, delayClamped: this.delayClamped, stick: this.stick, }; } /** Enqueues an extraction of the current w field into `displayW` (for the live view). */ encodeDisplay(encoder: GPUCommandEncoder): void { const pass = encoder.beginComputePass(); pass.setPipeline(this.extractPipe); pass.setBindGroup(0, this.displayBG[this.cur]); pass.dispatchWorkgroups(Math.ceil(this.grid.n / 256)); pass.end(); } private stepWorkgroups(): [number, number] { return [Math.ceil(this.grid.nx / TILE), Math.ceil(this.grid.ny / TILE)]; } /** The recorded listener pressure so far [Pa]. */ recordedAudio(): Float32Array { return this.audio.subarray(0, this.samples); } private appendAudio(raw: Float32Array): void { const need = this.samples + raw.length; if (this.audio.length < need) { const a = new Float32Array(Math.max(need, 2 * this.audio.length, this.params.fs)); a.set(this.audio.subarray(0, this.samples)); this.audio = a; } this.audio.set(raw, this.samples); for (let k = 0; k < raw.length; k++) this.peakPressure = Math.max(this.peakPressure, Math.abs(raw[k])); this.samples = need; } /** Writes the per-step uniform for step s of a batch. */ private writeStepParams(s: number, fields: { flags: number; slot: number; outIdx: number }): void { const o = (s * this.stepStride) / 4; const u32 = new Uint32Array(this.stepData); const i32 = new Int32Array(this.stepData); const f32 = new Float32Array(this.stepData); const { sigma0, sigma1, sigmaIn } = this.plate.loss; u32[o] = this.totalSteps + s; u32[o + 1] = fields.flags; i32[o + 2] = fields.slot; i32[o + 3] = fields.outIdx; f32[o + 4] = sigma0; f32[o + 5] = sigma1; f32[o + 6] = sigmaIn; u32[o + 7] = (this.totalSteps + s) & 1; } /** * Runs one batch (one movie frame of time). Resolves when its results have * been read back. The following batch is encoded before this one is awaited. */ async runBatch(): Promise { if (this.phase === "done") return; const cur = this.inFlight ?? this.encodeBatch(); this.inFlight = null; if (!cur) { this.phase = "done"; return; } if (this.stepsLeft() > 0) this.inFlight = this.encodeBatch(); await this.collect(cur); } private stepsLeft(): number { return this.targetSamples() * this.stepsPerSample - this.totalSteps; } /** Encodes and submits one batch; null when no steps remain. */ private encodeBatch(): Pending | null { const p = this.params; const steps = Math.min(this.stepsPerBatch, Math.max(0, this.stepsLeft())); if (steps === 0) return null; // Per-step parameters: the drumstick and the audio bookkeeping. const audioSteps: number[] = []; for (let s = 0; s < steps; s++) { const local = this.totalSteps + s; let slot = -1, outIdx = -1; if (local % this.stepsPerSample === 0) { slot = (local / this.stepsPerSample) % this.histLen; outIdx = audioSteps.length; audioSteps.push(s); } this.writeStepParams(s, { flags: 1, slot, outIdx }); } this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, steps * this.stepStride); // Encode the batch in chunks so that the GPU starts while JS is still encoding. const CHUNK = 1024; let enc = this.device.createCommandEncoder(); let pass = enc.beginComputePass(); const [wgX, wgY] = this.stepWorkgroups(); const wgN = Math.ceil(this.grid.n / 256); let cur = this.cur; // The projection is optional and only meaningful for a free plate; the // sums still run once per batch for the statistics. const project = p.rigidProjection && !this.plate.mount; let stepBound = false; for (let s = 0; s < steps; s++) { if (s > 0 && s % CHUNK === 0) { pass.end(); this.device.queue.submit([enc.finish()]); enc = this.device.createCommandEncoder(); pass = enc.beginComputePass(); stepBound = false; } if (!stepBound) { pass.setPipeline(this.stepPipe); stepBound = true; } pass.setBindGroup(0, this.stepBG[cur], [s * this.stepStride]); pass.dispatchWorkgroups(wgX, wgY); cur = 1 - cur; const global = this.totalSteps + s + 1; if ((project && global % RB_INTERVAL === 0) || s === steps - 1) { pass.setPipeline(this.sumsPipe); pass.setBindGroup(0, this.rbBG[cur]); pass.dispatchWorkgroups(1); if (project) { pass.setPipeline(this.applyPipe); pass.setBindGroup(0, this.rbBG[cur]); pass.dispatchWorkgroups(wgN); } stepBound = false; } } // The listener sum for every audio sample of the batch, one workgroup each; // the ring buffer is long enough that no slot of the batch has been reused. const nAudio = audioSteps.length; if (nAudio > 0) { pass.setPipeline(this.audioPipe); pass.setBindGroup(0, this.audioBG, [audioSteps[0] * this.stepStride]); pass.dispatchWorkgroups(nAudio); } pass.setPipeline(this.extractPipe); pass.setBindGroup(0, this.extractBG[cur]); pass.dispatchWorkgroups(wgN); pass.end(); const staging = this.staging[this.stagingIdx]; this.stagingIdx = 1 - this.stagingIdx; const n = this.grid.n; enc.copyBufferToBuffer(this.audioOut, 0, staging, 0, Math.max(4, nAudio * 4)); enc.copyBufferToBuffer(this.frameBuf, 0, staging, this.samplesPerBatch * 4, n * 4); enc.copyBufferToBuffer(this.statsBuf, 0, staging, (this.samplesPerBatch + n) * 4, STATS_LEN * 4); enc.copyBufferToBuffer(this.stickBuf, 0, staging, (this.samplesPerBatch + n + STATS_LEN) * 4, STICK_LEN * 4); this.device.queue.submit([enc.finish()]); this.cur = cur; this.totalSteps += steps; return { staging, steps, nAudio, stickParity: this.totalSteps & 1 }; } /** Reads back a batch and applies its results. */ private async collect(b: Pending): Promise { const n = this.grid.n; await b.staging.mapAsync(GPUMapMode.READ); const view = new Float32Array(b.staging.getMappedRange()); const stats = view.subarray(this.samplesPerBatch + n, this.samplesPerBatch + n + STATS_LEN); this.ke = stats[12]; this.wmax = stats[13]; // The slot written by the last step is the one the next step reads. const base = this.samplesPerBatch + n + STATS_LEN + 4 * b.stickParity; this.stick = { x: view[base], v: view[base + 1], force: view[base + 2], maxForce: view[base + 3] }; this.appendAudio(view.slice(0, b.nAudio)); this.frames.push(view.slice(this.samplesPerBatch, this.samplesPerBatch + n)); b.staging.unmap(); if (this.samples >= this.targetSamples()) this.phase = "done"; } // ---------- Testing support ---------- /** * Advances the state by exactly one free step (no drumstick, no rigid-body * projection, no audio) with the plate's loss parameters and its mount. */ async stepOnce(): Promise { this.writeStepParams(0, { flags: 0, slot: -1, outIdx: -1 }); this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, this.stepStride); const enc = this.device.createCommandEncoder(); const pass = enc.beginComputePass(); pass.setPipeline(this.stepPipe); pass.setBindGroup(0, this.stepBG[this.cur], [0]); pass.dispatchWorkgroups(...this.stepWorkgroups()); pass.end(); this.device.queue.submit([enc.finish()]); this.cur = 1 - this.cur; this.totalSteps++; await this.device.queue.onSubmittedWorkDone(); } /** Overwrites the state: pos as [u | v | w | wLo], vel as [du | dv | dw]. */ writeState(pos: Float32Array, vel: Float32Array): void { const n = this.grid.n; if (pos.length !== 4 * n || vel.length !== 3 * n) throw new Error("state arrays have the wrong length"); this.device.queue.writeBuffer(this.pos[this.cur], 0, pos); this.device.queue.writeBuffer(this.vel[this.cur], 0, vel); } /** Reads back the full state as [u | v | w | wLo] and [du | dv | dw]. */ async readState(): Promise<{ pos: Float32Array; vel: Float32Array }> { const n = this.grid.n; const stg = this.device.createBuffer({ size: 7 * n * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); const enc = this.device.createCommandEncoder(); enc.copyBufferToBuffer(this.pos[this.cur], 0, stg, 0, 4 * n * 4); enc.copyBufferToBuffer(this.vel[this.cur], 0, stg, 4 * n * 4, 3 * n * 4); this.device.queue.submit([enc.finish()]); await stg.mapAsync(GPUMapMode.READ); const all = new Float32Array(stg.getMappedRange()).slice(); stg.unmap(); stg.destroy(); return { pos: all.subarray(0, 4 * n), vel: all.subarray(4 * n) }; } destroy(): void { this.inFlight = null; 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(); } get fs(): number { return this.params.fs; } get fps(): number { return this.params.fps; } get simParams(): SimParams { return this.params; } }