/ proof-of-concept / steelsim31
Sign in
proof-of-concept / steelsim31
steelsim31 / src / sim / simulation.ts
669 lines · 25.7 KBBlameHistoryRaw
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), a rigid-body projection every RB_INTERVAL
6// steps, one retarded-time listener sum per audio sample, and one movie-frame
7// extraction at the end. Audio, frame, and statistics are read back after
8// every batch.
10import {
11 type PhysParams,
12 type Grid,
13 type Material,
14 makeGrid,
15 makeMaterial,
16 initialShape,
17 strikePoint,
18 pushPoint,
19 contactPatch,
20 nodeX,
21 nodeY,
22 nodeWeight,
23 nodeWeights,
24 HIST_LEN,
25 RB_INTERVAL,
26 RB_INTERVAL_HELD,
27 RHO_AIR,
28 SPEED_OF_SOUND,
29} from "../params.ts";
30import { maxAngularFrequency } from "./cpuRef.ts";
31import commonWgsl from "./shaders/common.wgsl?raw";
32import stepWgsl from "./shaders/step.wgsl?raw";
33import rigidWgsl from "./shaders/rigid.wgsl?raw";
34import audioWgsl from "./shaders/audio.wgsl?raw";
35import extractWgsl from "./shaders/extract.wgsl?raw";
37export type Phase = "push" | "hold" | "recording" | "done";
39export interface StickState {
40 x: number;
41 v: number;
42 force: number;
43 maxForce: number;
46export interface SimStatus {
47 phase: Phase;
48 /** Simulation time relative to the release or the impact [s]. */
49 t: number;
50 totalSteps: number;
51 ke: number;
52 wmax: number;
53 samples: number;
54 frames: number;
55 peakPressure: number;
56 delayClamped: boolean;
57 /** Drumstick state (strike excitation only). */
58 stick: StickState | null;
61const PARAMS_LEN = 36; // floats in the Params uniform
62const STEP_LEN = 12; // floats in the StepParams uniform
63const STATS_LEN = 16;
64const STICK_LEN = 8;
66export class Simulation {
67 readonly grid: Grid;
68 readonly mat: Material;
69 readonly dt: number;
70 /** Time steps per audio sample. */
71 readonly stepsPerSample: number;
72 readonly samplesPerBatch: number;
73 readonly stepsPerBatch: number;
74 readonly omegaMax: number;
76 /** Listener pressure [Pa] at fs, from the release or the impact. */
77 audio: Float32Array = new Float32Array(0);
78 samples = 0;
79 /** Movie frames of w [m]; frame k is at t = k / fps. */
80 frames: Float32Array<ArrayBuffer>[] = [];
82 phase: Phase = "push";
83 totalSteps = 0;
84 ke = 0;
85 wmax = 0;
86 peakPressure = 0;
87 delayClamped = false;
89 /** Steps since the start of the current clock (preparation or recording). */
90 private stepCount = 0;
91 private prepTime = 0;
92 private released = false;
93 private lastPrepFrame: Float32Array<ArrayBuffer> | null = null;
94 private stick: StickState | null = null;
96 private device: GPUDevice;
97 private params: PhysParams;
98 private paramsBuf: GPUBuffer;
99 private stepBuf: GPUBuffer;
100 private stepStride: number;
101 private stepData: ArrayBuffer;
102 private pos: GPUBuffer[];
103 private vel: GPUBuffer[];
104 private hist: GPUBuffer;
105 private react: GPUBuffer;
106 /** Stress-free initial shape (f32 per node), also used by the renderer. */
107 readonly w0: GPUBuffer;
108 /** Node weights (f32 per node, 0 outside the sheet), also used by the renderer. */
109 readonly nw: GPUBuffer;
110 private lw: GPUBuffer;
111 private audioOut: GPUBuffer;
112 private frameBuf: GPUBuffer;
113 private statsBuf: GPUBuffer;
114 private staging: GPUBuffer[];
115 private stagingIdx = 0;
116 private stagingSize: number;
117 /** Index of the buffer pair holding the current state. */
118 private cur = 0;
119 /** Plain f32 array of w for the live view. */
120 readonly displayW: GPUBuffer;
122 private stepPipe: GPUComputePipeline;
123 private sumsPipe: GPUComputePipeline;
124 private applyPipe: GPUComputePipeline;
125 private projectPipe: GPUComputePipeline;
126 private audioPipe: GPUComputePipeline;
127 private extractPipe: GPUComputePipeline;
128 private stepBG: GPUBindGroup[];
129 private rbBG: GPUBindGroup[];
130 private projectBG: GPUBindGroup;
131 private audioBG: GPUBindGroup;
132 private extractBG: GPUBindGroup[];
133 private displayBG: GPUBindGroup[];
135 constructor(device: GPUDevice, params: PhysParams) {
136 this.device = device;
137 this.params = { ...params };
138 const g = makeGrid(params);
139 const m = makeMaterial(params);
140 this.grid = g;
141 this.mat = m;
143 // Stable time step, rounded so that an integer number of steps make one audio sample.
144 this.omegaMax = maxAngularFrequency(g, m);
145 const dtMax = (0.85 * 2) / this.omegaMax;
146 this.stepsPerSample = Math.ceil(1 / (params.fs * dtMax));
147 this.dt = 1 / (params.fs * this.stepsPerSample);
148 this.samplesPerBatch = Math.max(1, Math.round(params.fs / params.fps));
149 this.stepsPerBatch = this.samplesPerBatch * this.stepsPerSample;
151 const n = g.n;
152 const f32 = (size: number, usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST) =>
153 device.createBuffer({ size: Math.max(16, size * 4), usage });
155 this.pos = [f32(4 * n), f32(4 * n)]; // [u | v | w | wLo]
156 this.vel = [f32(3 * n), f32(3 * n)]; // [du | dv | dw]
157 this.hist = f32(HIST_LEN * n);
158 this.react = f32(n + STICK_LEN);
159 this.w0 = f32(n);
160 device.queue.writeBuffer(this.w0, 0, initialShape(params, g));
161 this.nw = f32(n);
162 device.queue.writeBuffer(this.nw, 0, nodeWeights(g));
163 this.lw = f32(2 * n);
164 this.audioOut = f32(this.samplesPerBatch);
165 this.frameBuf = f32(n);
166 this.displayW = f32(n);
167 this.statsBuf = f32(STATS_LEN);
168 this.stagingSize = (this.samplesPerBatch + n + STATS_LEN + STICK_LEN) * 4;
169 this.staging = [0, 1].map(() =>
170 device.createBuffer({ size: this.stagingSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }),
171 );
173 // Mass and moments of inertia of the sheet.
174 let mass = 0, Ix = 0, Iy = 0;
175 for (let j = 0; j < g.ny; j++) {
176 for (let i = 0; i < g.nx; i++) {
177 const mk = m.rhoh * g.hx * g.hy * nodeWeight(g, i, j);
178 mass += mk;
179 Ix += mk * nodeY(g, j) ** 2;
180 Iy += mk * nodeX(g, i) ** 2;
181 }
182 }
183 // Drumstick contact patch and initial stick state (position 0, moving toward the sheet).
184 const cp = contactPatch(params, g);
185 const stick = new Float32Array(STICK_LEN);
186 stick[1] = -params.strikeVelocity; stick[5] = -params.strikeVelocity;
187 device.queue.writeBuffer(this.react, n * 4, stick);
189 const [px, py] = pushPoint(params);
190 const [sx, sy] = strikePoint(params);
191 const pf = new Float32Array(PARAMS_LEN);
192 const pu = new Uint32Array(pf.buffer);
193 pu[0] = g.nx; pu[1] = g.ny; pu[2] = n; pu[3] = HIST_LEN;
194 pf[4] = g.hx; pf[5] = g.hy; pf[6] = this.dt; pf[7] = m.nu;
195 pf[8] = m.D; pf[9] = m.C; pf[10] = m.G; pf[11] = m.rhoh;
196 pf[12] = px; pf[13] = py; pf[14] = params.fingerRadius ** 2; pf[15] = mass;
197 pf[16] = Ix; pf[17] = Iy; pf[18] = Ix + Iy; pf[19] = params.fs;
198 pf[20] = sx; pf[21] = sy; pf[22] = params.contactRadius ** 2; pf[23] = cp.mass;
199 pu[24] = cp.nearest; pf[25] = params.contactStiffness; pf[26] = params.contactExponent; pf[27] = params.stickMass;
200 pu[28] = cp.box[0]; pu[29] = cp.box[1]; pu[30] = cp.box[2]; pu[31] = cp.box[3];
201 pf[32] = cp.center[0]; pf[33] = cp.center[1];
202 this.paramsBuf = device.createBuffer({ size: pf.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
203 device.queue.writeBuffer(this.paramsBuf, 0, pf);
205 // Per-step parameters, one struct per step at a dynamic offset.
206 this.stepStride = Math.max(STEP_LEN * 4, device.limits.minUniformBufferOffsetAlignment);
207 this.stepData = new ArrayBuffer(this.stepStride * this.stepsPerBatch);
208 this.stepBuf = device.createBuffer({ size: this.stepData.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
210 // Pipelines.
211 const shader = (code: string) => device.createShaderModule({ code: commonWgsl + code });
212 const entry = (binding: number, type: GPUBufferBindingType, hasDynamicOffset = false): GPUBindGroupLayoutEntry => ({
213 binding,
214 visibility: GPUShaderStage.COMPUTE,
215 buffer: { type, hasDynamicOffset },
216 });
217 const pipeline = (module: GPUShaderModule, entryPoint: string, layout: GPUBindGroupLayout) =>
218 device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }), compute: { module, entryPoint } });
220 const stepLayout = device.createBindGroupLayout({
221 entries: [
222 entry(0, "uniform"),
223 entry(1, "uniform", true),
224 entry(2, "read-only-storage"),
225 entry(3, "read-only-storage"),
226 entry(4, "storage"),
227 entry(5, "storage"),
228 entry(6, "storage"),
229 entry(7, "storage"),
230 entry(8, "read-only-storage"),
231 entry(9, "read-only-storage"),
232 ],
233 });
234 this.stepPipe = pipeline(shader(stepWgsl), "main", stepLayout);
236 const rigidModule = shader(rigidWgsl);
237 const rbLayout = device.createBindGroupLayout({
238 entries: [entry(0, "uniform"), entry(1, "storage"), entry(2, "storage"), entry(3, "storage"), entry(9, "read-only-storage")],
239 });
240 this.sumsPipe = pipeline(rigidModule, "sums", rbLayout);
241 this.applyPipe = pipeline(rigidModule, "apply", rbLayout);
242 const projectLayout = device.createBindGroupLayout({
243 entries: [entry(0, "uniform"), entry(4, "storage"), entry(9, "read-only-storage")],
244 });
245 this.projectPipe = pipeline(rigidModule, "projectField", projectLayout);
247 const audioLayout = device.createBindGroupLayout({
248 entries: [entry(0, "uniform"), entry(1, "uniform", true), entry(2, "read-only-storage"), entry(3, "read-only-storage"), entry(4, "storage")],
249 });
250 this.audioPipe = pipeline(shader(audioWgsl), "main", audioLayout);
252 const extractLayout = device.createBindGroupLayout({
253 entries: [entry(0, "uniform"), entry(1, "read-only-storage"), entry(2, "storage")],
254 });
255 this.extractPipe = pipeline(shader(extractWgsl), "main", extractLayout);
257 // Bind groups. The state is double-buffered: bind group p reads pair p and writes pair 1 - p.
258 const sp = { buffer: this.stepBuf, size: STEP_LEN * 4 };
259 this.stepBG = [0, 1].map((p) =>
260 device.createBindGroup({
261 layout: stepLayout,
262 entries: [
263 { binding: 0, resource: { buffer: this.paramsBuf } },
264 { binding: 1, resource: sp },
265 { binding: 2, resource: { buffer: this.pos[p] } },
266 { binding: 3, resource: { buffer: this.vel[p] } },
267 { binding: 4, resource: { buffer: this.pos[1 - p] } },
268 { binding: 5, resource: { buffer: this.vel[1 - p] } },
269 { binding: 6, resource: { buffer: this.hist } },
270 { binding: 7, resource: { buffer: this.react } },
271 { binding: 8, resource: { buffer: this.w0 } },
272 { binding: 9, resource: { buffer: this.nw } },
273 ],
274 }),
275 );
276 this.rbBG = [0, 1].map((p) =>
277 device.createBindGroup({
278 layout: rbLayout,
279 entries: [
280 { binding: 0, resource: { buffer: this.paramsBuf } },
281 { binding: 1, resource: { buffer: this.pos[p] } },
282 { binding: 2, resource: { buffer: this.vel[p] } },
283 { binding: 3, resource: { buffer: this.statsBuf } },
284 { binding: 9, resource: { buffer: this.nw } },
285 ],
286 }),
287 );
288 this.projectBG = device.createBindGroup({
289 layout: projectLayout,
290 entries: [
291 { binding: 0, resource: { buffer: this.paramsBuf } },
292 { binding: 4, resource: { buffer: this.react } },
293 { binding: 9, resource: { buffer: this.nw } },
294 ],
295 });
296 this.audioBG = device.createBindGroup({
297 layout: audioLayout,
298 entries: [
299 { binding: 0, resource: { buffer: this.paramsBuf } },
300 { binding: 1, resource: sp },
301 { binding: 2, resource: { buffer: this.hist } },
302 { binding: 3, resource: { buffer: this.lw } },
303 { binding: 4, resource: { buffer: this.audioOut } },
304 ],
305 });
306 const extractBG = (p: number, out: GPUBuffer) =>
307 device.createBindGroup({
308 layout: extractLayout,
309 entries: [
310 { binding: 0, resource: { buffer: this.paramsBuf } },
311 { binding: 1, resource: { buffer: this.pos[p] } },
312 { binding: 2, resource: { buffer: out } },
313 ],
314 });
315 this.extractBG = [0, 1].map((p) => extractBG(p, this.frameBuf));
316 this.displayBG = [0, 1].map((p) => extractBG(p, this.displayW));
318 this.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
319 if (params.excitation === "strike") {
320 // No preparation: the sheet is at rest in its natural shape when the stick arrives.
321 this.phase = "recording";
322 this.released = true;
323 this.frames.push(new Float32Array(n));
324 }
325 }
327 /** Recomputes the per-node weights and delays of the listener sum. Takes effect on later samples. */
328 setListener(dist: number, azimuthDeg: number, elevationDeg: number): void {
329 const g = this.grid;
330 const az = (azimuthDeg * Math.PI) / 180;
331 const el = (elevationDeg * Math.PI) / 180;
332 const L = [dist * Math.sin(az) * Math.cos(el), dist * Math.sin(el), dist * Math.cos(az) * Math.cos(el)];
333 const data = new Float32Array(2 * g.n);
334 let dmin = Infinity;
335 for (let j = 0; j < g.ny; j++) {
336 for (let i = 0; i < g.nx; i++) {
337 const k = j * g.nx + i;
338 const rx = L[0] - nodeX(g, i), ry = L[1] - nodeY(g, j), rz = L[2];
339 const r = Math.sqrt(rx * rx + ry * ry + rz * rz);
340 const dA = g.hx * g.hy * nodeWeight(g, i, j);
341 data[2 * k] = (RHO_AIR * dA) / (2 * Math.PI * r);
342 data[2 * k + 1] = (r / SPEED_OF_SOUND) * this.params.fs;
343 dmin = Math.min(dmin, data[2 * k + 1]);
344 }
345 }
346 // Only the spread of delays across the sheet is kept; the common delay is dropped.
347 this.delayClamped = false;
348 for (let k = 0; k < g.n; k++) {
349 let d = data[2 * k + 1] - dmin;
350 if (d > HIST_LEN - 3) {
351 d = HIST_LEN - 3;
352 this.delayClamped = true;
353 }
354 data[2 * k + 1] = d;
355 }
356 this.device.queue.writeBuffer(this.lw, 0, data);
357 this.params.listenerDist = dist;
358 this.params.listenerAzimuth = azimuthDeg;
359 this.params.listenerElevation = elevationDeg;
360 }
362 setDamping(sigma0: number, sigma1: number, sigmaIn: number): void {
363 this.params.sigma0 = sigma0;
364 this.params.sigma1 = sigma1;
365 this.params.sigmaIn = sigmaIn;
366 }
368 setDuration(duration: number): void {
369 this.params.duration = duration;
370 if (this.canContinue()) this.phase = "recording";
371 }
373 /** True when a finished or stopped run can be extended. */
374 canContinue(): boolean {
375 return this.phase === "done" && this.released && this.samples < this.targetSamples();
376 }
378 /** Ends the run; the state, audio, and movie are kept. */
379 stop(): void {
380 this.phase = "done";
381 }
383 targetSamples(): number {
384 return Math.round(this.params.duration * this.params.fs);
385 }
387 status(): SimStatus {
388 const t = this.phase === "push" || this.phase === "hold" ? this.prepTime - this.prepDuration() : this.stepCount * this.dt;
389 return {
390 phase: this.phase,
391 t,
392 totalSteps: this.totalSteps,
393 ke: this.ke,
394 wmax: this.wmax,
395 samples: this.samples,
396 frames: this.frames.length,
397 peakPressure: this.peakPressure,
398 delayClamped: this.delayClamped,
399 stick: this.stick,
400 };
401 }
403 private prepDuration(): number {
404 return this.params.rampTime + this.params.holdMax;
405 }
407 /** Enqueues an extraction of the current w field into `displayW` (for the live view). */
408 encodeDisplay(encoder: GPUCommandEncoder): void {
409 const pass = encoder.beginComputePass();
410 pass.setPipeline(this.extractPipe);
411 pass.setBindGroup(0, this.displayBG[this.cur]);
412 pass.dispatchWorkgroups(Math.ceil(this.grid.n / 256));
413 pass.end();
414 }
416 /** The recorded listener pressure so far [Pa]. */
417 recordedAudio(): Float32Array {
418 return this.audio.subarray(0, this.samples);
419 }
421 private appendAudio(raw: Float32Array): void {
422 const need = this.samples + raw.length;
423 if (this.audio.length < need) {
424 const a = new Float32Array(Math.max(need, 2 * this.audio.length, this.params.fs));
425 a.set(this.audio.subarray(0, this.samples));
426 this.audio = a;
427 }
428 this.audio.set(raw, this.samples);
429 for (let k = 0; k < raw.length; k++) this.peakPressure = Math.max(this.peakPressure, Math.abs(raw[k]));
430 this.samples = need;
431 }
433 /** Writes the per-step uniform for step s of a batch. */
434 private writeStepParams(s: number, fields: { flags: number; slot: number; outIdx: number; pushW: number; pushV: number; sigma0: number; releaseGain: number }): void {
435 const o = (s * this.stepStride) / 4;
436 const u32 = new Uint32Array(this.stepData);
437 const i32 = new Int32Array(this.stepData);
438 const f32 = new Float32Array(this.stepData);
439 u32[o] = this.totalSteps + s;
440 u32[o + 1] = fields.flags;
441 i32[o + 2] = fields.slot;
442 i32[o + 3] = fields.outIdx;
443 f32[o + 4] = fields.pushW;
444 f32[o + 5] = fields.pushV;
445 f32[o + 6] = fields.sigma0;
446 f32[o + 7] = this.params.sigma1;
447 f32[o + 8] = fields.releaseGain;
448 f32[o + 9] = this.params.sigmaIn;
449 u32[o + 10] = (this.totalSteps + s) & 1;
450 u32[o + 11] = 0;
451 }
453 /** Runs one batch (one movie frame of time). Resolves when its results have been read back. */
454 async runBatch(): Promise<void> {
455 if (this.phase === "done") return;
456 const p = this.params;
457 const recording = this.phase === "recording";
458 const stepsLeft = recording ? (this.targetSamples() - this.samples) * this.stepsPerSample : this.stepsPerBatch;
459 const steps = Math.min(this.stepsPerBatch, Math.max(0, stepsLeft));
460 if (steps === 0) {
461 this.phase = "done";
462 return;
463 }
465 // Per-step parameters: the push ramp and settle damping before the release,
466 // the release ramp or the drumstick and the audio bookkeeping afterwards.
467 const audioSteps: number[] = [];
468 for (let s = 0; s < steps; s++) {
469 const local = this.stepCount + s;
470 let flags = 0, pushW = 0, pushV = 0, sigma0 = p.sigma0, slot = -1, outIdx = -1, releaseGain = 0;
471 if (!recording) {
472 const t = this.prepTime + (s + 1) * this.dt; // state after this step
473 const tau = Math.min(1, t / p.rampTime);
474 const smooth = tau * tau * (3 - 2 * tau);
475 const dsmooth = tau < 1 ? (6 * tau * (1 - tau)) / p.rampTime : 0;
476 flags = 1 | (tau >= 1 ? 2 : 0); // record the reaction once the ramp is complete
477 pushW = p.pushAmount * smooth;
478 pushV = p.pushAmount * dsmooth;
479 sigma0 = p.settleSigma0;
480 } else {
481 const t = local * this.dt;
482 if (p.excitation === "strike") {
483 flags = 8;
484 } else if (p.releaseTime > 0 && t < p.releaseTime) {
485 const tau = t / p.releaseTime;
486 releaseGain = 1 - tau * tau * (3 - 2 * tau);
487 flags = 4;
488 }
489 if (local % this.stepsPerSample === 0) {
490 slot = (local / this.stepsPerSample) % HIST_LEN;
491 outIdx = audioSteps.length;
492 audioSteps.push(s);
493 }
494 }
495 this.writeStepParams(s, { flags, slot, outIdx, pushW, pushV, sigma0, releaseGain });
496 }
497 this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, steps * this.stepStride);
499 // Encode the batch in chunks so that the GPU starts while JS is still encoding.
500 const CHUNK = 1024;
501 let enc = this.device.createCommandEncoder();
502 let pass = enc.beginComputePass();
503 const wgX = Math.ceil(this.grid.nx / 16), wgY = Math.ceil(this.grid.ny / 16);
504 const wgN = Math.ceil(this.grid.n / 256);
505 let cur = this.cur;
506 let audioPtr = 0;
507 // While held, the projection defines the inertia-relief holding and runs
508 // every RB_INTERVAL_HELD steps. While ringing it is optional; the sums
509 // still run once per batch for the statistics.
510 const project = !recording || p.rigidProjection;
511 const rbInterval = recording ? RB_INTERVAL : RB_INTERVAL_HELD;
512 let stepBound = false;
513 for (let s = 0; s < steps; s++) {
514 if (s > 0 && s % CHUNK === 0) {
515 pass.end();
516 this.device.queue.submit([enc.finish()]);
517 enc = this.device.createCommandEncoder();
518 pass = enc.beginComputePass();
519 stepBound = false;
520 }
521 if (!stepBound) {
522 pass.setPipeline(this.stepPipe);
523 stepBound = true;
524 }
525 pass.setBindGroup(0, this.stepBG[cur], [s * this.stepStride]);
526 pass.dispatchWorkgroups(wgX, wgY);
527 cur = 1 - cur;
528 const global = this.totalSteps + s + 1;
529 if ((project && global % rbInterval === 0) || s === steps - 1) {
530 pass.setPipeline(this.sumsPipe);
531 pass.setBindGroup(0, this.rbBG[cur]);
532 pass.dispatchWorkgroups(1);
533 if (project) {
534 pass.setPipeline(this.applyPipe);
535 pass.setBindGroup(0, this.rbBG[cur]);
536 pass.dispatchWorkgroups(wgN);
537 }
538 stepBound = false;
539 }
540 if (audioPtr < audioSteps.length && audioSteps[audioPtr] === s) {
541 pass.setPipeline(this.audioPipe);
542 pass.setBindGroup(0, this.audioBG, [s * this.stepStride]);
543 pass.dispatchWorkgroups(1);
544 audioPtr++;
545 stepBound = false;
546 }
547 }
548 pass.setPipeline(this.extractPipe);
549 pass.setBindGroup(0, this.extractBG[cur]);
550 pass.dispatchWorkgroups(wgN);
551 pass.end();
552 const staging = this.staging[this.stagingIdx];
553 this.stagingIdx = 1 - this.stagingIdx;
554 const nAudio = audioSteps.length;
555 const n = this.grid.n;
556 enc.copyBufferToBuffer(this.audioOut, 0, staging, 0, Math.max(4, nAudio * 4));
557 enc.copyBufferToBuffer(this.frameBuf, 0, staging, this.samplesPerBatch * 4, n * 4);
558 enc.copyBufferToBuffer(this.statsBuf, 0, staging, (this.samplesPerBatch + n) * 4, STATS_LEN * 4);
559 enc.copyBufferToBuffer(this.react, n * 4, staging, (this.samplesPerBatch + n + STATS_LEN) * 4, STICK_LEN * 4);
560 this.device.queue.submit([enc.finish()]);
561 this.cur = cur;
562 this.totalSteps += steps;
563 this.stepCount += steps;
564 if (!recording) this.prepTime += steps * this.dt;
566 await staging.mapAsync(GPUMapMode.READ);
567 const view = new Float32Array(staging.getMappedRange());
568 const stats = view.subarray(this.samplesPerBatch + n, this.samplesPerBatch + n + STATS_LEN);
569 this.ke = stats[12];
570 this.wmax = stats[13];
571 if (p.excitation === "strike") {
572 // The slot written by the last step is the one the next step reads.
573 const base = this.samplesPerBatch + n + STATS_LEN + 4 * (this.totalSteps & 1);
574 this.stick = { x: view[base], v: view[base + 1], force: view[base + 2], maxForce: view[base + 3] };
575 }
576 const frame = view.slice(this.samplesPerBatch, this.samplesPerBatch + n);
577 if (recording) {
578 this.appendAudio(view.slice(0, nAudio));
579 this.frames.push(frame);
580 } else {
581 this.lastPrepFrame = frame;
582 }
583 staging.unmap();
585 if (!recording) {
586 if (this.phase === "push" && this.prepTime >= p.rampTime) this.phase = "hold";
587 const settled = this.phase === "hold" && this.ke < p.settleKE;
588 if (settled || this.prepTime >= this.prepDuration()) this.release();
589 } else if (this.samples >= this.targetSamples()) {
590 this.phase = "done";
591 }
592 }
594 private release(): void {
595 this.phase = "recording";
596 this.released = true;
597 this.stepCount = 0;
598 this.totalSteps = 0;
599 // The finger force and the distributed holding reaction relax together:
600 // remove the rigid-body components of the recorded reaction.
601 const enc = this.device.createCommandEncoder();
602 const pass = enc.beginComputePass();
603 pass.setPipeline(this.projectPipe);
604 pass.setBindGroup(0, this.projectBG);
605 pass.dispatchWorkgroups(1);
606 pass.end();
607 this.device.queue.submit([enc.finish()]);
608 // Frame 0 is the held, settled shape.
609 if (this.lastPrepFrame) this.frames.push(this.lastPrepFrame);
610 }
612 // ---------- Testing support ----------
614 /**
615 * Advances the state by exactly one free step (no constraint, no drumstick,
616 * no rigid-body projection, no audio) with the current loss parameters.
617 */
618 async stepOnce(): Promise<void> {
619 this.writeStepParams(0, { flags: 0, slot: -1, outIdx: -1, pushW: 0, pushV: 0, sigma0: this.params.sigma0, releaseGain: 0 });
620 this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, this.stepStride);
621 const enc = this.device.createCommandEncoder();
622 const pass = enc.beginComputePass();
623 pass.setPipeline(this.stepPipe);
624 pass.setBindGroup(0, this.stepBG[this.cur], [0]);
625 pass.dispatchWorkgroups(Math.ceil(this.grid.nx / 16), Math.ceil(this.grid.ny / 16));
626 pass.end();
627 this.device.queue.submit([enc.finish()]);
628 this.cur = 1 - this.cur;
629 this.totalSteps++;
630 await this.device.queue.onSubmittedWorkDone();
631 }
633 /** Overwrites the state: pos as [u | v | w | wLo], vel as [du | dv | dw]. */
634 writeState(pos: Float32Array<ArrayBuffer>, vel: Float32Array<ArrayBuffer>): void {
635 const n = this.grid.n;
636 if (pos.length !== 4 * n || vel.length !== 3 * n) throw new Error("state arrays have the wrong length");
637 this.device.queue.writeBuffer(this.pos[this.cur], 0, pos);
638 this.device.queue.writeBuffer(this.vel[this.cur], 0, vel);
639 }
641 /** Reads back the full state as [u | v | w | wLo] and [du | dv | dw]. */
642 async readState(): Promise<{ pos: Float32Array; vel: Float32Array }> {
643 const n = this.grid.n;
644 const stg = this.device.createBuffer({ size: 7 * n * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
645 const enc = this.device.createCommandEncoder();
646 enc.copyBufferToBuffer(this.pos[this.cur], 0, stg, 0, 4 * n * 4);
647 enc.copyBufferToBuffer(this.vel[this.cur], 0, stg, 4 * n * 4, 3 * n * 4);
648 this.device.queue.submit([enc.finish()]);
649 await stg.mapAsync(GPUMapMode.READ);
650 const all = new Float32Array(stg.getMappedRange()).slice();
651 stg.unmap();
652 stg.destroy();
653 return { pos: all.subarray(0, 4 * n), vel: all.subarray(4 * n) };
654 }
656 destroy(): void {
657 for (const b of [...this.pos, ...this.vel, this.hist, this.react, this.w0, this.nw, this.lw, this.audioOut, this.frameBuf, this.displayW, this.statsBuf, this.paramsBuf, this.stepBuf, ...this.staging]) b.destroy();
658 }
660 get fs(): number {
661 return this.params.fs;
662 }
663 get fps(): number {
664 return this.params.fps;
665 }
666 get physParams(): PhysParams {
667 return this.params;
668 }
↑↓ move↵openescclose