/ proof-of-concept / cymbalsim
Sign in
proof-of-concept / cymbalsim
cymbalsim / src / sim / simulation.ts
461 lines · 19.9 KBCodeBlameHistory
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 1// WebGPU time-stepping driver of the full (geometrically nonlinear)
2// simulation. The run advances in batches of one movie frame (1/fps s). A
3// batch encodes one compute dispatch per time step (with a per-step uniform
4// addressed by dynamic offset), one retarded-time listener sum over the
5// batch's audio samples, and one extraction of the transverse displacement
6// at the end. Audio, frame, and stick state are read back after every batch;
7// one batch is kept encoded ahead of the one being read back so that the
8// GPU never waits for JavaScript. The recording starts at the impact, with
9// the plate at rest in its stress-free shape.
11import {
12 type Plate,
13 type SimParams,
14 type StandRates,
15 CFL,
16 HIST_MARGIN,
17 massProperties,
18 standRates,
19 strikePatch,
20 strikePoint,
21 thicknessRange,
22 nodeWeights,
23 listenerTerms,
24} from "../params.ts";
25import { type FullLoss, maxAngularFrequency } from "../model/reference.ts";
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 26import { FELT_ARMS, feltStepConstants } from "../felt.ts";
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 27import commonWgsl from "./shaders/common.wgsl?raw";
28import stepWgsl from "./shaders/step.wgsl?raw";
29import audioWgsl from "./shaders/audio.wgsl?raw";
30import extractWgsl from "./shaders/extract.wgsl?raw";
32export type Phase = "recording" | "done";
34export interface StickState {
35 x: number;
36 v: number;
37 force: number;
38 maxForce: number;
39 withdrawn: boolean;
42export interface SimStatus {
43 phase: Phase;
44 /** Simulation time since the impact of the state read back so far [s]. */
45 t: number;
46 totalSteps: number;
47 samples: number;
48 frames: number;
49 peakPressure: number;
50 /** Largest transverse displacement seen in the frames [m]. */
51 wmax: number;
52 delayClamped: boolean;
53 stick: StickState;
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 56const PARAMS_LEN = 40 + 9 * 4; // Params: 40 scalars and the 9 vec4 of the felt's chain
58const STICK_LEN = 16;
59/** Nodes per workgroup along each axis in step.wgsl. */
60export const TILE = 12;
62interface Pending {
63 staging: GPUBuffer;
64 steps: number;
65 nAudio: number;
66 stickParity: number;
69export class Simulation {
70 readonly plate: Plate;
71 readonly dt: number;
72 readonly stepsPerSample: number;
73 readonly samplesPerBatch: number;
74 readonly stepsPerBatch: number;
75 readonly omegaMax: number;
76 readonly histLen: number;
77 readonly mass: number;
78 readonly stand: StandRates | null;
80 audio: Float32Array = new Float32Array(0);
81 samples = 0;
82 /** Movie frames of w [m]; frame k is at t = k / fps. */
83 frames: Float32Array[] = [];
84 phase: Phase = "recording";
85 totalSteps = 0;
86 peakPressure = 0;
87 wmax = 0;
88 delayClamped = false;
90 private stick: StickState;
91 private inFlight: Pending | null = null;
92 private device: GPUDevice;
93 private params: SimParams;
94 private loss: FullLoss;
95 private paramsBuf: GPUBuffer;
96 private stepBuf: GPUBuffer;
97 private stepStride: number;
98 private stepData: ArrayBuffer;
99 private pos: GPUBuffer[];
100 private vel: GPUBuffer[];
101 private hist: GPUBuffer;
102 private stickBuf: GPUBuffer;
103 private nd: GPUBuffer;
106 private audioOut: GPUBuffer;
107 private frameBuf: GPUBuffer;
108 private staging: GPUBuffer[];
109 private stagingIdx = 0;
110 private cur = 0;
111 private stepPipe: GPUComputePipeline;
112 private audioPipe: GPUComputePipeline;
113 private extractPipe: GPUComputePipeline;
114 private stepBG: GPUBindGroup[];
115 private audioBG: GPUBindGroup;
116 private extractBG: GPUBindGroup[];
118 constructor(device: GPUDevice, plate: Plate, params: SimParams, loss: FullLoss) {
119 this.device = device;
120 this.plate = plate;
121 this.params = { ...params };
122 this.loss = loss;
123 const g = plate.grid, m = plate.mat;
125 // Stable time step, rounded so that an integer number of steps make one audio sample.
126 this.omegaMax = maxAngularFrequency(g, m, thicknessRange(plate).max);
127 const dtMax = (CFL * 2) / this.omegaMax;
128 this.stepsPerSample = Math.ceil(1 / (params.fs * dtMax));
129 this.dt = 1 / (params.fs * this.stepsPerSample);
130 this.samplesPerBatch = Math.max(1, Math.round(params.fs / params.fps));
131 this.stepsPerBatch = this.samplesPerBatch * this.stepsPerSample;
132 this.histLen = this.samplesPerBatch + HIST_MARGIN;
134 const n = g.n;
135 const f32 = (size: number, usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST) =>
136 device.createBuffer({ size: Math.max(16, size * 4), usage });
137 this.pos = [f32(4 * n), f32(4 * n)];
138 this.vel = [f32(3 * n), f32(3 * n)];
139 this.hist = f32(this.histLen * n);
140 this.stickBuf = f32(STICK_LEN);
141 this.stand = standRates(plate);
142 const weights = nodeWeights(g);
143 const packed = new Float32Array(4 * n);
144 for (let k = 0; k < n; k++) {
145 packed[4 * k] = plate.w0[k]; packed[4 * k + 1] = weights[k]; packed[4 * k + 2] = plate.h[k];
146 packed[4 * k + 3] = this.stand ? this.stand.kwBottom[k] : 0;
147 }
148 this.nd = f32(4 * n);
149 device.queue.writeBuffer(this.nd, 0, packed);
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 150 const nArms = this.stand ? this.stand.chain.tau.length : 0;
151 if (nArms > FELT_ARMS) throw new Error("the felt's chain has more arms than the kernel holds");
152 this.padQ = f32(3 * nArms * n);
154 this.audioOut = f32(this.samplesPerBatch);
155 this.frameBuf = f32(n);
156 const stagingSize = (this.samplesPerBatch + n + STICK_LEN) * 4;
157 this.staging = [0, 1].map(() => device.createBuffer({ size: stagingSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }));
159 const { mass, Ix, Iy } = massProperties(plate);
160 this.mass = mass;
161 const cp = strikePatch(plate, params);
162 let i0 = g.nx, i1 = 0, j0 = g.ny, j1 = 0;
163 for (const k of cp.nodes) {
164 const i = k % g.nx, j = Math.floor(k / g.nx);
165 i0 = Math.min(i0, i); i1 = Math.max(i1, i); j0 = Math.min(j0, j); j1 = Math.max(j1, j);
166 }
167 const stick = new Float32Array(STICK_LEN);
168 stick[1] = -params.strikeVelocity; stick[9] = -params.strikeVelocity;
169 device.queue.writeBuffer(this.stickBuf, 0, stick);
170 this.stick = { x: 0, v: -params.strikeVelocity, force: 0, maxForce: 0, withdrawn: false };
172 const sp = strikePoint(plate, params);
173 const pf = new Float32Array(PARAMS_LEN);
174 const pu = new Uint32Array(pf.buffer);
175 pu[0] = g.nx; pu[1] = g.ny; pu[2] = n; pu[3] = this.histLen;
176 pf[4] = g.hx; pf[5] = g.hy; pf[6] = this.dt; pf[7] = m.nu;
177 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;
178 pf[12] = mass; pf[13] = Ix; pf[14] = Iy; pf[15] = params.fs;
179 pf[16] = sp.x; pf[17] = sp.y; pf[18] = params.contactRadius ** 2; pf[19] = cp.mass;
180 pu[20] = cp.nearest; pf[21] = params.contactStiffness; pf[22] = params.contactExponent; pf[23] = params.stickMass;
181 pu[24] = i0; pu[25] = i1; pu[26] = j0; pu[27] = j1;
182 pf[28] = cp.cx; pf[29] = cp.cy;
183 const st = this.stand;
184 pu[30] = st && st.nodes.length > 0 ? 1 : 0;
185 pu[31] = params.padLiftoff ? 1 : 0;
186 pf[32] = st ? st.preloadTop : 0; pf[33] = st ? st.preloadBottom : 0;
190 if (st) {
191 const { decay, growth } = feltStepConstants(st.chain, this.dt);
192 for (let a = 0; a < nArms; a++) { pf[40 + a] = decay[a]; pf[52 + a] = growth[a]; pf[64 + a] = st.chain.k[a]; }
193 }
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 194 this.paramsBuf = device.createBuffer({ size: pf.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
195 device.queue.writeBuffer(this.paramsBuf, 0, pf);
197 this.stepStride = Math.max(STEP_LEN * 4, device.limits.minUniformBufferOffsetAlignment);
198 this.stepData = new ArrayBuffer(this.stepStride * this.stepsPerBatch);
199 this.stepBuf = device.createBuffer({ size: this.stepData.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
201 const shader = (code: string) => device.createShaderModule({ code: commonWgsl + code });
202 const entry = (binding: number, type: GPUBufferBindingType, hasDynamicOffset = false): GPUBindGroupLayoutEntry => ({
203 binding, visibility: GPUShaderStage.COMPUTE, buffer: { type, hasDynamicOffset },
204 });
205 const pipeline = (module: GPUShaderModule, layout: GPUBindGroupLayout) =>
206 device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }), compute: { module, entryPoint: "main" } });
207 const stepLayout = device.createBindGroupLayout({
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 208 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"), entry(9, "storage")],
210 this.stepPipe = pipeline(shader(stepWgsl), stepLayout);
211 const audioLayout = device.createBindGroupLayout({
212 entries: [entry(0, "uniform"), entry(1, "uniform", true), entry(2, "read-only-storage"), entry(3, "read-only-storage"), entry(4, "storage"), entry(8, "read-only-storage")],
213 });
214 this.audioPipe = pipeline(shader(audioWgsl), audioLayout);
215 const extractLayout = device.createBindGroupLayout({ entries: [entry(0, "uniform"), entry(1, "read-only-storage"), entry(2, "storage"), entry(8, "read-only-storage")] });
216 this.extractPipe = pipeline(shader(extractWgsl), extractLayout);
218 const spb = { buffer: this.stepBuf, size: STEP_LEN * 4 };
219 this.stepBG = [0, 1].map((p) =>
220 device.createBindGroup({
221 layout: stepLayout,
222 entries: [
223 { binding: 0, resource: { buffer: this.paramsBuf } },
224 { binding: 1, resource: spb },
225 { binding: 2, resource: { buffer: this.pos[p] } },
226 { binding: 3, resource: { buffer: this.vel[p] } },
227 { binding: 4, resource: { buffer: this.pos[1 - p] } },
228 { binding: 5, resource: { buffer: this.vel[1 - p] } },
229 { binding: 6, resource: { buffer: this.hist } },
230 { binding: 7, resource: { buffer: this.stickBuf } },
231 { binding: 8, resource: { buffer: this.nd } },
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 232 { binding: 9, resource: { buffer: this.padQ } },
234 }),
235 );
236 this.audioBG = device.createBindGroup({
237 layout: audioLayout,
238 entries: [
239 { binding: 0, resource: { buffer: this.paramsBuf } },
240 { binding: 1, resource: spb },
241 { binding: 2, resource: { buffer: this.hist } },
242 { binding: 3, resource: { buffer: this.lw } },
243 { binding: 4, resource: { buffer: this.audioOut } },
244 { binding: 8, resource: { buffer: this.nd } },
245 ],
246 });
247 this.extractBG = [0, 1].map((p) =>
248 device.createBindGroup({
249 layout: extractLayout,
250 entries: [
251 { binding: 0, resource: { buffer: this.paramsBuf } },
252 { binding: 1, resource: { buffer: this.pos[p] } },
253 { binding: 2, resource: { buffer: this.frameBuf } },
254 { binding: 8, resource: { buffer: this.nd } },
255 ],
256 }),
257 );
258 this.setListener(params);
259 this.frames.push(new Float32Array(n));
260 }
262 /** Recomputes the per-node weights and delays of the listener sum; takes effect on later samples. */
263 setListener(p: SimParams): void {
264 const g = this.plate.grid;
265 this.params.listenerDist = p.listenerDist; this.params.listenerAngle = p.listenerAngle; this.params.listenerAzimuth = p.listenerAzimuth;
266 const { weight, delay } = listenerTerms(this.plate, this.params);
267 const data = new Float32Array(2 * g.n);
268 let dmin = Infinity;
269 for (let k = 0; k < g.n; k++) if (weight[k] > 0) dmin = Math.min(dmin, delay[k]);
270 this.delayClamped = false;
271 for (let k = 0; k < g.n; k++) {
272 data[2 * k] = weight[k];
273 let d = weight[k] > 0 ? (delay[k] - dmin) * this.params.fs : 0;
274 if (d > HIST_MARGIN - 3) { d = HIST_MARGIN - 3; this.delayClamped = true; }
275 data[2 * k + 1] = d;
276 }
277 this.device.queue.writeBuffer(this.lw, 0, data);
278 }
280 setDuration(duration: number): void {
281 this.params.duration = duration;
282 if (this.canContinue()) this.phase = "recording";
283 }
284 canContinue(): boolean {
285 return this.phase === "done" && this.samples < this.targetSamples();
286 }
287 stop(): void {
288 this.phase = "done";
289 }
290 targetSamples(): number {
291 return Math.round(this.params.duration * this.params.fs);
292 }
293 status(): SimStatus {
294 const ahead = this.inFlight ? this.inFlight.steps * this.dt : 0;
295 return { phase: this.phase, t: this.totalSteps * this.dt - ahead, totalSteps: this.totalSteps, samples: this.samples, frames: this.frames.length, peakPressure: this.peakPressure, wmax: this.wmax, delayClamped: this.delayClamped, stick: this.stick };
296 }
297 recordedAudio(): Float32Array {
298 return this.audio.subarray(0, this.samples);
299 }
300 private stepWorkgroups(): [number, number] {
301 return [Math.ceil(this.plate.grid.nx / TILE), Math.ceil(this.plate.grid.ny / TILE)];
302 }
303 private appendAudio(raw: Float32Array): void {
304 const need = this.samples + raw.length;
305 if (this.audio.length < need) {
306 const a = new Float32Array(Math.max(need, 2 * this.audio.length, this.params.fs));
307 a.set(this.audio.subarray(0, this.samples));
308 this.audio = a;
309 }
310 this.audio.set(raw, this.samples);
311 for (let k = 0; k < raw.length; k++) this.peakPressure = Math.max(this.peakPressure, Math.abs(raw[k]));
312 this.samples = need;
313 }
314 private writeStepParams(s: number, fields: { flags: number; slot: number; outIdx: number }): void {
315 const o = (s * this.stepStride) / 4;
316 const u32 = new Uint32Array(this.stepData), i32 = new Int32Array(this.stepData), f32 = new Float32Array(this.stepData);
317 u32[o] = this.totalSteps + s;
318 u32[o + 1] = fields.flags;
319 i32[o + 2] = fields.slot;
320 i32[o + 3] = fields.outIdx;
321 f32[o + 4] = this.loss.sigma0;
322 f32[o + 5] = this.loss.sigma1;
323 f32[o + 6] = this.loss.sigmaIn;
324 u32[o + 7] = (this.totalSteps + s) & 1;
325 }
327 /** Runs one batch (one movie frame of time); resolves when its results have been read back. */
328 async runBatch(): Promise<void> {
329 if (this.phase === "done") return;
330 const cur = this.inFlight ?? this.encodeBatch();
331 this.inFlight = null;
332 if (!cur) { this.phase = "done"; return; }
333 if (this.stepsLeft() > 0) this.inFlight = this.encodeBatch();
334 await this.collect(cur);
335 }
336 private stepsLeft(): number {
337 return this.targetSamples() * this.stepsPerSample - this.totalSteps;
338 }
339 private encodeBatch(): Pending | null {
340 const steps = Math.min(this.stepsPerBatch, Math.max(0, this.stepsLeft()));
341 if (steps === 0) return null;
342 const audioSteps: number[] = [];
343 for (let s = 0; s < steps; s++) {
344 const local = this.totalSteps + s;
345 let slot = -1, outIdx = -1;
346 if (local % this.stepsPerSample === 0) {
347 slot = (local / this.stepsPerSample) % this.histLen;
348 outIdx = audioSteps.length;
349 audioSteps.push(s);
350 }
351 this.writeStepParams(s, { flags: 1, slot, outIdx });
352 }
353 this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, steps * this.stepStride);
354 const CHUNK = 1024;
355 let enc = this.device.createCommandEncoder();
356 let pass = enc.beginComputePass();
357 const [wgX, wgY] = this.stepWorkgroups();
358 const n = this.plate.grid.n;
359 let cur = this.cur;
360 pass.setPipeline(this.stepPipe);
361 for (let s = 0; s < steps; s++) {
362 if (s > 0 && s % CHUNK === 0) {
363 pass.end();
364 this.device.queue.submit([enc.finish()]);
365 enc = this.device.createCommandEncoder();
366 pass = enc.beginComputePass();
367 pass.setPipeline(this.stepPipe);
368 }
369 pass.setBindGroup(0, this.stepBG[cur], [s * this.stepStride]);
370 pass.dispatchWorkgroups(wgX, wgY);
371 cur = 1 - cur;
372 }
373 const nAudio = audioSteps.length;
374 if (nAudio > 0) {
375 pass.setPipeline(this.audioPipe);
376 pass.setBindGroup(0, this.audioBG, [audioSteps[0] * this.stepStride]);
377 pass.dispatchWorkgroups(nAudio);
378 }
379 pass.setPipeline(this.extractPipe);
380 pass.setBindGroup(0, this.extractBG[cur]);
381 pass.dispatchWorkgroups(Math.ceil(n / 256));
382 pass.end();
383 const staging = this.staging[this.stagingIdx];
384 this.stagingIdx = 1 - this.stagingIdx;
385 enc.copyBufferToBuffer(this.audioOut, 0, staging, 0, Math.max(4, nAudio * 4));
386 enc.copyBufferToBuffer(this.frameBuf, 0, staging, this.samplesPerBatch * 4, n * 4);
387 enc.copyBufferToBuffer(this.stickBuf, 0, staging, (this.samplesPerBatch + n) * 4, STICK_LEN * 4);
388 this.device.queue.submit([enc.finish()]);
389 this.cur = cur;
390 this.totalSteps += steps;
391 return { staging, steps, nAudio, stickParity: this.totalSteps & 1 };
392 }
393 private async collect(b: Pending): Promise<void> {
394 const n = this.plate.grid.n;
395 await b.staging.mapAsync(GPUMapMode.READ);
396 const view = new Float32Array(b.staging.getMappedRange());
397 const base = this.samplesPerBatch + n + 8 * b.stickParity;
398 this.stick = { x: view[base], v: view[base + 1], force: view[base + 2], maxForce: view[base + 3], withdrawn: view[base + 5] !== 0 };
399 this.appendAudio(view.slice(0, b.nAudio));
400 const frame = view.slice(this.samplesPerBatch, this.samplesPerBatch + n);
401 for (let k = 0; k < n; k++) this.wmax = Math.max(this.wmax, Math.abs(frame[k]));
402 this.frames.push(frame);
403 b.staging.unmap();
404 if (this.samples >= this.targetSamples()) this.phase = "done";
405 }
407 // ---------- Testing support ----------
409 /** Advances the state by exactly one free step (no drumstick, no audio) with the losses and the stand. */
410 async stepOnce(): Promise<void> {
411 this.writeStepParams(0, { flags: 0, slot: -1, outIdx: -1 });
412 this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, this.stepStride);
413 const enc = this.device.createCommandEncoder();
414 const pass = enc.beginComputePass();
415 pass.setPipeline(this.stepPipe);
416 pass.setBindGroup(0, this.stepBG[this.cur], [0]);
417 pass.dispatchWorkgroups(...this.stepWorkgroups());
418 pass.end();
419 this.device.queue.submit([enc.finish()]);
420 this.cur = 1 - this.cur;
421 this.totalSteps++;
422 await this.device.queue.onSubmittedWorkDone();
423 }
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 424 /** Length of the felt's state: 3 fields times the arms of the chain times the nodes (0 on a free plate). */
425 get padStateLength(): number {
426 return 3 * (this.stand ? this.stand.chain.tau.length : 0) * this.plate.grid.n;
427 }
428 /** Overwrites the state: pos as [u | v | w | wLo], vel as [du | dv | dw], and the extensions of the felt's arms (relaxed when omitted). */
429 writeState(pos: Float32Array<ArrayBuffer>, vel: Float32Array<ArrayBuffer>, pad?: Float32Array<ArrayBuffer>): void {
431 if (pos.length !== 4 * n || vel.length !== 3 * n) throw new Error("state arrays have the wrong length");
432 this.device.queue.writeBuffer(this.pos[this.cur], 0, pos);
433 this.device.queue.writeBuffer(this.vel[this.cur], 0, vel);
435 if (pad !== undefined && pad.length !== np) throw new Error("the felt state has the wrong length");
436 if (np > 0) this.device.queue.writeBuffer(this.padQ, 0, pad ?? new Float32Array(np));
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 438 /** Reads back the full state as [u | v | w | wLo], [du | dv | dw], and the felt's arm extensions. */
439 async readState(): Promise<{ pos: Float32Array; vel: Float32Array; pad: Float32Array }> {
440 const n = this.plate.grid.n, np = this.padStateLength;
441 const stg = this.device.createBuffer({ size: (7 * n + np) * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 442 const enc = this.device.createCommandEncoder();
443 enc.copyBufferToBuffer(this.pos[this.cur], 0, stg, 0, 4 * n * 4);
444 enc.copyBufferToBuffer(this.vel[this.cur], 0, stg, 4 * n * 4, 3 * n * 4);
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 445 if (np > 0) enc.copyBufferToBuffer(this.padQ, 0, stg, 7 * n * 4, np * 4);
447 await stg.mapAsync(GPUMapMode.READ);
448 const all = new Float32Array(stg.getMappedRange()).slice();
449 stg.unmap();
450 stg.destroy();
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 451 return { pos: all.subarray(0, 4 * n), vel: all.subarray(4 * n, 7 * n), pad: all.subarray(7 * n) };
454 destroy(): void {
455 this.inFlight = null;
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 456 for (const b of [...this.pos, ...this.vel, this.hist, this.stickBuf, this.nd, this.padQ, this.lw, this.audioOut, this.frameBuf, this.paramsBuf, this.stepBuf, ...this.staging]) b.destroy();
458 get fs(): number { return this.params.fs; }
459 get fps(): number { return this.params.fps; }
460 get simParams(): SimParams { return this.params; }
↑↓ move↵openescclose