/ proof-of-concept / steelsim21
Sign in
proof-of-concept / steelsim21
Finite finger release: ramp the recorded reaction force to zero over a release time
Jeremy Magland <jmagland@flatironinstitute.org> committed commit adc495d55f5f parent f8a8a6c Browse files
7 changed files+46−15
README.mdmodified+1−1View file
@@ -13,7 +13,7 @@ Requires Node 20 or later and a browser with WebGPU (Chrome or Edge 113+; Firefo
1313
1414 ## Using the simulator
1515
16-Press Start. The sheet is pushed at the bottom-right corner over the ramp time, held with heavy artificial damping until it settles, and then released; the recording of audio and movie frames starts at the release. The view shows the transverse displacement as a color map (2D) or a lit height field (3D, drag to orbit and wheel to zoom), the spectrogram shows the listener signal, and the audio can be played or downloaded as a 16-bit WAV at any time.
16+Press Start. The sheet is pushed at the bottom-right corner over the ramp time, held with heavy artificial damping until it settles, and then released by ramping the finger force to zero over the release time; the recording of audio and movie frames starts at the release. The view shows the transverse displacement as a color map (2D) or a lit height field (3D, drag to orbit and wheel to zoom), the spectrogram shows the listener signal, and the audio can be played or downloaded as a 16-bit WAV at any time.
1717
1818 - Plate, excitation, and numerics parameters take effect at the next Start. Damping, listener position, and the radiation model can be changed while a simulation runs.
1919 - After the simulation completes, drag the time slider to explore the movie; Play starts audio from the slider position and moves the movie and the spectrogram cursor with it. Live returns to the running view.
THEORY.mdmodified+1−1View file
@@ -37,7 +37,7 @@ The same projection defines what "holding" the sheet means during the push. The
3737
3838 ## 3. Excitation
3939
40-The simulation has three phases. During the push (default 0.1 s) the transverse displacement of the nodes within the finger radius follows a smooth ramp to the push amount, and heavy artificial damping ($\sigma_0 = 300\ \mathrm{s^{-1}}$) removes the waves that the ramp launches. During the hold the constraint stays in place until the non-rigid kinetic energy drops below a threshold or a maximum settle time elapses. At the release the constraint is removed instantaneously, the damping returns to its physical value, the clock is set to zero, and the recording of audio and movie frames begins. An instantaneous release is a step in the finger force and therefore excites all modes at once, in the same way that a plucked string is excited.
40+The simulation has three phases. During the push (default 0.1 s) the transverse displacement of the nodes within the finger radius follows a smooth ramp to the push amount, and heavy artificial damping ($\sigma_0 = 300\ \mathrm{s^{-1}}$) removes the waves that the ramp launches. During the hold the constraint stays in place until the non-rigid kinetic energy drops below a threshold or a maximum settle time elapses. At the release the clock is set to zero, the damping returns to its physical value, and the recording of audio and movie frames begins. The finger does not let go instantaneously: the reaction force that held each node of the patch is recorded at the end of the hold and then applied as an external force that falls smoothly to zero over the release time (20 ms by default). This matters for the sound. An instantaneous release is a step in the finger force, which excites all modes at once, and because the radiated pressure is proportional to the plate acceleration, the result is a nearly flat pressure spectrum, a cymbal-like click. A release over 20 ms confines the excitation to roughly the first hundred hertz, as a fingertip does. The release time can be set to zero to recover the instantaneous case.
4141
4242 ## 4. Spatial discretization
4343
src/main.tsmodified+1−0View file
@@ -41,6 +41,7 @@ const groups: { title: string; open: boolean; fields: FieldSpec[] }[] = [
4141 { key: "fingerRadius", label: "Finger radius", unit: "mm", scale: 1e-3, step: 1 },
4242 { key: "rampTime", label: "Push ramp", unit: "s", scale: 1, step: 0.01 },
4343 { key: "holdMax", label: "Max settle time", unit: "s", scale: 1, step: 0.1 },
44+ { key: "releaseTime", label: "Release time", unit: "ms", scale: 1e-3, step: 1 },
4445 { key: "settleSigma0", label: "Settle damping σ₀", unit: "1/s", scale: 1, step: 10 },
4546 { key: "holeInset", label: "Hole inset", unit: "mm", scale: 1e-3, step: 1 },
4647 ],
src/params.tsmodified+2−0View file
@@ -14,6 +14,7 @@ export interface PhysParams {
1414 fingerRadius: number; // radius of the pushed patch [m]
1515 rampTime: number; // duration of the push ramp [s]
1616 holdMax: number; // maximum settle time before release [s]
17+ releaseTime: number; // time over which the finger force falls to zero [s]
1718 settleSigma0: number; // artificial damping during push and settle [1/s]
1819 settleKE: number; // kinetic energy threshold that counts as settled [J]
1920 spacing: number; // requested grid spacing [m]
@@ -40,6 +41,7 @@ export const defaultParams: PhysParams = {
4041 fingerRadius: 0.01,
4142 rampTime: 0.1,
4243 holdMax: 0.5,
44+ releaseTime: 0.02,
4345 settleSigma0: 300,
4446 settleKE: 1e-8,
4547 spacing: 0.004,
src/sim/shaders/common.wgslmodified+5−1View file
@@ -26,13 +26,17 @@ struct Params {
2626 // Per-step parameters, addressed with a dynamic uniform offset.
2727 struct StepParams {
2828 step: u32,
29- flags: u32, // bit 0: push constraint active
29+ flags: u32, // bit 0: push constraint active; bit 1: record the constraint reaction; bit 2: apply the recorded reaction
3030 audioSlot: i32, // ring-buffer slot to record acceleration into (-1: none)
3131 audioOut: i32, // output sample index within the batch
3232 pushW: f32, // prescribed patch displacement [m]
3333 pushV: f32, // prescribed patch velocity [m/s]
3434 sigma0: f32,
3535 sigma1: f32,
36+ releaseGain: f32, // fraction of the recorded reaction still applied (finger letting go)
37+ pad0: f32,
38+ pad1: f32,
39+ pad2: f32,
3640 };
3741
3842 fn nodeX(i: i32) -> f32 { return (f32(i) - 0.5 * f32(P.nx - 1u)) * P.hx; }
src/sim/shaders/step.wgslmodified+12−1View file
@@ -13,6 +13,7 @@
1313 @group(0) @binding(4) var<storage, read_write> posOut: array<f32>;
1414 @group(0) @binding(5) var<storage, read_write> velOut: array<f32>;
1515 @group(0) @binding(6) var<storage, read_write> hist: array<f32>; // [slot * n + node] acceleration of w
16+@group(0) @binding(7) var<storage, read_write> react: array<f32>; // constraint reaction (acceleration of w) per node
1617
1718 const TILE = 16;
1819 const HALO = 2;
@@ -155,6 +156,9 @@ fn main(@builtin(global_invocation_id) gid: vec3u, @builtin(local_invocation_id)
155156 acc -= 2.0 * SP.sigma0 * vel;
156157 acc.z += 2.0 * SP.sigma1 * lap;
157158
159+ // Finger letting go: the reaction recorded at the end of the hold, ramped to zero.
160+ if ((SP.flags & 4u) != 0u) { acc.z += react[k] * SP.releaseGain; }
161+
158162 if (SP.audioSlot >= 0) { hist[u32(SP.audioSlot) * n + k] = acc.z; }
159163
160164 var vNew = vel + P.dt * acc;
@@ -168,7 +172,14 @@ fn main(@builtin(global_invocation_id) gid: vec3u, @builtin(local_invocation_id)
168172 pNew.z = t;
169173 if ((SP.flags & 1u) != 0u) {
170174 let dxp = nodeX(i) - P.pushX; let dyp = nodeY(j) - P.pushY;
171- if (dxp * dxp + dyp * dyp <= P.pushR2) { pNew.z = SP.pushW; vNew.z = SP.pushV; lo = 0.0; }
175+ let inPatch = dxp * dxp + dyp * dyp <= P.pushR2;
176+ if ((SP.flags & 2u) != 0u) {
177+ // Acceleration the constraint has to supply to hold this node.
178+ var r = 0.0;
179+ if (inPatch) { r = (SP.pushV - vNew.z) / P.dt; }
180+ react[k] = r;
181+ }
182+ if (inPatch) { pNew.z = SP.pushW; vNew.z = SP.pushV; lo = 0.0; }
172183 }
173184 posOut[k] = pNew.x; posOut[n + k] = pNew.y; posOut[2u * n + k] = pNew.z; posOut[3u * n + k] = lo;
174185 velOut[k] = vNew.x; velOut[n + k] = vNew.y; velOut[2u * n + k] = vNew.z;
src/sim/simulation.tsmodified+24−11View file
@@ -81,6 +81,7 @@ export class Simulation {
8181 private pos: GPUBuffer[];
8282 private vel: GPUBuffer[];
8383 private hist: GPUBuffer;
84+ private react: GPUBuffer;
8485 private lw: GPUBuffer;
8586 private audioOut: GPUBuffer;
8687 private frameBuf: GPUBuffer;
@@ -127,6 +128,7 @@ export class Simulation {
127128 this.pos = [f32(4 * n), f32(4 * n)]; // [u | v | w | wLo]
128129 this.vel = [f32(3 * n), f32(3 * n)]; // [du | dv | dw]
129130 this.hist = f32(HIST_LEN * n);
131+ this.react = f32(n);
130132 this.lw = f32(2 * n);
131133 this.audioOut = f32(this.samplesPerBatch);
132134 this.frameBuf = f32(n);
@@ -157,8 +159,8 @@ export class Simulation {
157159 this.paramsBuf = device.createBuffer({ size: pf.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
158160 device.queue.writeBuffer(this.paramsBuf, 0, pf);
159161
160- // Per-step parameters, one 32-byte struct per step at a dynamic offset.
161- this.stepStride = Math.max(32, device.limits.minUniformBufferOffsetAlignment);
162+ // Per-step parameters, one 48-byte struct per step at a dynamic offset.
163+ this.stepStride = Math.max(48, device.limits.minUniformBufferOffsetAlignment);
162164 this.stepData = new ArrayBuffer(this.stepStride * this.stepsPerBatch);
163165 this.stepBuf = device.createBuffer({ size: this.stepData.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
164166
@@ -181,6 +183,7 @@ export class Simulation {
181183 entry(4, "storage"),
182184 entry(5, "storage"),
183185 entry(6, "storage"),
186+ entry(7, "storage"),
184187 ],
185188 });
186189 this.stepPipe = mk(stepWgsl, "main", device.createPipelineLayout({ bindGroupLayouts: [stepLayout] }));
@@ -202,7 +205,7 @@ export class Simulation {
202205 this.audioPipe = mk(audioWgsl, "main", device.createPipelineLayout({ bindGroupLayouts: [audioLayout] }));
203206 this.extractPipe = mk(extractWgsl, "main");
204207
205- const sp = { buffer: this.stepBuf, size: 32 };
208+ const sp = { buffer: this.stepBuf, size: 48 };
206209 this.stepBG = [0, 1].map((p) =>
207210 device.createBindGroup({
208211 layout: stepLayout,
@@ -214,6 +217,7 @@ export class Simulation {
214217 { binding: 4, resource: { buffer: this.pos[1 - p] } },
215218 { binding: 5, resource: { buffer: this.vel[1 - p] } },
216219 { binding: 6, resource: { buffer: this.hist } },
220+ { binding: 7, resource: { buffer: this.react } },
217221 ],
218222 }),
219223 );
@@ -390,21 +394,29 @@ export class Simulation {
390394 const o = s * stride;
391395 const local = this.stepCount + s;
392396 u32[o] = this.totalSteps + s;
393- let flags = 0, pushW = 0, pushV = 0, sigma0 = p.sigma0, slot = -1, outIdx = -1;
397+ let flags = 0, pushW = 0, pushV = 0, sigma0 = p.sigma0, slot = -1, outIdx = -1, releaseGain = 0;
394398 if (!recording) {
395399 const t = this.prepTime + (s + 1) * this.dt; // state after this step
396400 const tau = Math.min(1, t / p.rampTime);
397401 const smooth = tau * tau * (3 - 2 * tau);
398402 const dsmooth = tau < 1 ? (6 * tau * (1 - tau)) / p.rampTime : 0;
399- flags = 1;
403+ flags = 1 | (tau >= 1 ? 2 : 0); // record the reaction once the ramp is complete
400404 pushW = p.pushAmount * smooth;
401405 pushV = p.pushAmount * dsmooth;
402406 sigma0 = p.settleSigma0;
403- } else if (local % this.stepsPerSample === 0) {
404- const sampleIdx = local / this.stepsPerSample;
405- slot = sampleIdx % HIST_LEN;
406- outIdx = audioStepsInBatch.length;
407- audioStepsInBatch.push(s);
407+ } else {
408+ const t = local * this.dt;
409+ if (p.releaseTime > 0 && t < p.releaseTime) {
410+ const tau = t / p.releaseTime;
411+ releaseGain = 1 - tau * tau * (3 - 2 * tau);
412+ flags = 4;
413+ }
414+ if (local % this.stepsPerSample === 0) {
415+ const sampleIdx = local / this.stepsPerSample;
416+ slot = sampleIdx % HIST_LEN;
417+ outIdx = audioStepsInBatch.length;
418+ audioStepsInBatch.push(s);
419+ }
408420 }
409421 u32[o + 1] = flags;
410422 i32[o + 2] = slot;
@@ -413,6 +425,7 @@ export class Simulation {
413425 f32[o + 5] = pushV;
414426 f32[o + 6] = sigma0;
415427 f32[o + 7] = p.sigma1;
428+ f32[o + 8] = releaseGain;
416429 }
417430 this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, steps * this.stepStride);
418431
@@ -523,7 +536,7 @@ export class Simulation {
523536 }
524537
525538 destroy(): void {
526- for (const b of [...this.pos, ...this.vel, this.hist, this.lw, this.audioOut, this.frameBuf, this.displayW, this.statsBuf, this.paramsBuf, this.stepBuf, ...this.staging]) b.destroy();
539+ 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();
527540 }
528541
529542 get fs(): number {
↑↓ move↵openescclose