Modal2D · Guide

Practical recipes, keyboard reference, troubleshooting, and the mental models you need to read the code.

Companion to features.html (what's here) and roadmap.html (what's next).

01 Recipes

Concrete walkthroughs. Each one assumes you've started the viewer and are in Play mode. Step counts in the headers are rough — your hands move faster than reading does.

First instrument: tune 8 notes
~ 5 min
  1. Toolbar: switch from Play to Create.
  2. Pick a shape from the Component combo. Start with Box-2s — well-behaved, gives clean fundamentals.
  3. Drag Oct: to pick the octave you want (e.g. C4 for middle C).
  4. Click the C note button — that's the first target.
  5. Click Auto to make the tuner auto-strike between iterations.
  6. Click Fine-W. The strike button glows red — strike the shape once. The tuner then iterates on its own.
  7. When Fine-W converges, it auto-advances to D, then E, F, G, A, B, C′.
  8. When all 8 notes are green, click Save, then switch to Test to play your instrument.
Fine-W binary-searches the half-width until the fundamental matches the target. Auto automates strike + advance so you can do all 8 notes hands-free. HF acceleration (on by default) typically converges each note in 2–3 solves.
Explore: see what notes a shape produces
~ 3 min
  1. Switch to Discover. The chromatic grid appears in the right panel.
  2. Set Lo / Hi octave drag-ints to span the range you care about (default C0–C9).
  3. Drag W / H / Shell sliders. Strike the shape after each change.
  4. If the detected freq lands within threshold cents of a chromatic note, the cell auto-fills.
  5. For bulk coverage, click Auto Explore: pick 1D, Size, or 2D, then how many steps per axis (2–16). The tuner sweeps the bracket and records every clean hit.
  6. Hover a filled cell to see W, H, freq, and cents-to-note. Click any filled cell to load and play that shape.
Discover is shape-first: it doesn't try to hit a specific note, it records whatever notes happen to fall within threshold of a real chromatic. Pair the chromatic grid with the scatter view on the right — the latter shows the (W, H) coordinates of every clean hit.
Refine a discovered note to ±5 cents
~ 30 s
  1. Discover or Build a few notes first (see previous recipe).
  2. Switch to Refine mode. Same grid view, but cells are now clickable and start a Fine-W session for that cell's chromatic freq.
  3. Click any filled cell — the shape loads at its saved (W, H), Fine-W kicks in.
  4. When it converges, the cell updates with the refined values; the displayed cents tightens up.
Refine reuses Fine-W per cell, targeting the chromatic Hz the cell represents. With HF acceleration this typically takes 1–3 iterations because the starting shape is already close.
When Bayes-W beats Fine-W
~ 1 min

Use Bayes-W in these situations:

  • Fine-W keeps "giving up" with a suboptimal-best message — the cost surface has a local minimum the binary search keeps falling into.
  • The shape has multiple plausible modes near the target, and the loudest changes with W (which confuses Fine-W's bisection).
  • You want to map the W-axis as a side effect; Bayes-W's 8 LHS seeds give you a near-uniform survey for free.
  1. In Create / Refine mode, instead of Fine-W click Bayes-W.
  2. Watch the GP heatmap in the Experimental tab populate. Green = low |cents| (good).
  3. Convergence: typically 8–15 samples. Falls back to "budget exhausted" with best-so-far if it can't get under threshold.
Bayes-W draws 8 Latin-hypercube seeds first, then Expected-Improvement picks the next sample based on the GP surrogate of the cost surface. Handles multi-modal cost surfaces that bisection gets stuck on.
Tune a string with realistic tension
~ 2 min
  1. Create mode → Component combo → String.
  2. Set Width = 0.15 (≈ 30 cm half-length).
  3. Set Height = 0.05 (controls beam cross-section: beam_w = H · 0.01 = 0.5 mm, realistic).
  4. Open the post_process panel. Drag String tension from 0 to ~100 N. The shape re-solves on release.
  5. Strike it. At 100 N the fundamental should land around 80–150 Hz depending on material.
  6. Now run Fine-W on a chromatic target. Width still controls fundamental (longer = lower); tension shifts the whole spectrum.
Adding tension activates the geometric-stiffness term K_geom = T/L · ... on every beam edge. The fundamental moves toward (1/2L)·√(T/μ) — pure-string physics. Without tension the string only has bending stiffness, which makes for a stiff piano-wire- without-tension sound rather than a guitar string.
Build a resonance-cavity instrument
~ 3 min
  1. Create mode → Component combo → Strung. You get a hard-frame / soft-panel cavity with a steel string + bridge above the top panel. Material legend shows three regions.
  2. Open post_process. Set String tension to 80 N.
  3. Strike the string. The top panel rings via the bridge — soft panels radiate stronger than the hard frame.
  4. Now go to the Experimental tab → Air cavity. Toggle on, set freq ≈ 120 Hz, Q ≈ 12, gain ≈ 0.6.
  5. Strike again. The bandpass adds a body resonance around 120 Hz — the audible "boom" of a small cavity.
Joint modes between string, frame, and soft panels do most of the work — the frame defines the structural skeleton, the panels radiate. The air-cavity bandpass approximates the Helmholtz resonance the enclosed air would have if we modelled it as a separate FEM domain (currently lumped into a single parallel resonator).
Mass-spring scout → FEM commit
~ 30 s
  1. Create mode. Open the Experimental tab.
  2. Drag Width or Height sliders. The mass-spring preview thumbnails update in microseconds — every mode shape recomputes live.
  3. When you see a shape with the mode distribution you want, release the slider. That fires the full FEM solve.
  4. FEM thumbnails appear next to the mass-spring ones (A/B view). Compare topology directly.
The mass-spring lattice runs Jacobi eigensolve on a 10×6 grid (~96 DOFs) in microseconds. Absolute frequencies drift 2–5× from FEM, but mode shape ordering matches. Use it to navigate parameter space at cursor-speed, commit to FEM only when the topology looks right.
Manual-W: track a specific overtone
~ 1 min
  1. Create or Refine mode. Strike the shape so the spectrum has visible bars.
  2. Left-click the spectrum bar of the mode you want to tune. A glowing "Mode N pinned" banner appears.
  3. Click Manual-W. The binary search now targets the pinned mode's frequency, not the fundamental.
  4. As the shape changes, the white arrow follows the pinned mode automatically (via MAC mode-shape correlation). The arrow's tint colour-codes confidence: green ≥ 0.60, yellow ≥ 0.30, red below.
  5. Red arrow = mode identity changed too much. Re-click a bar to re-pin.
  6. Right-click in the spectrum to clear the pin. Manual-W then falls through to fundamental tracking (same as Fine-W).
Bell-like shapes often have overtones the user finds more "in tune" than the fundamental. MAC tracks the mode you picked via eigenvector correlation, not frequency proximity — so it survives mode crossings that pure-frequency tracking would lose.

02 Keyboard shortcuts

Global

KeyWhat
Esc Cascade-cancel: fine-tune → draw-mode → create/test → no-op. Held doesn't double-fire (edge-detected).
Space Clear all modal energy (silence). Useful when something rings forever.
D Toggle polygon-draw mode. Click vertices, press Enter to triangulate. Disabled while solving.
S Cycle slow-motion: realtime → 20× slow → 200× → 5000× → realtime.
C Toggle Chladni mode — viewport shows the nodal pattern of the selected mode instead of live oscillation.
V Toggle 2D / 3D view of the mesh.
M Toggle medium-propagation visualization (acoustic waves around the shape).
P Toggle plate-bending mode (DKT elements). Forces a re-solve.
B Toggle boundary-beam stiffeners along the mesh outline.
Home Reset camera (zoom = 1, pan = 0, 3D orbit reset).

Play-mode preset hotkeys

KeyPreset shape
1RECT (square)
2CIRCLE
3L_SHAPE
4EQUILATERAL (triangle)
5TUNING_FORK
6RING
7STRING
8CROSS
9STAR

Spectrum interactions

ActionEffect
Left-click barPin that mode (in Create / Refine tuning contexts).
Right-click anywhere in spectrumClear the pin.
HoverTooltip: mode index + Hz.
Right-click on drift sliderReset freq-drift to 0 semitones.

03 Fine-tune state machine

Every fine-tune mode (1, 2, 3, 4, 5, 6, 7, 8, 9) shares the same fine_phase_ state machine. The pick-next logic varies per mode; the phase transitions are universal.

fine_mode_ = 0 (idle) │ │ user clicks Fine-W / Bayes-W / etc. ▼ fine_mode_ = M, fine_phase_ = 1 (WAIT for strike) │ │ user strikes the shape ─ or ─ auto-skip if Auto mode + has prior strike ▼ fine_phase_ = 2 (build component + start solve) │ │ solve_worker runs in background ▼ fine_phase_ = 3 (wait for audio settle, ~20 frames) │ ▼ fine_phase_ = 4 (READ detected freq, decide next) │ ├──── |cents| < threshold ─── CONVERGED → fine_mode_ = 0 ├──── iter cap exceeded ─── SUBOPTIMAL → fine_mode_ = 0 └──── otherwise: pick next param via mode-specific picker update tuning_width_ / tuning_height_ fine_phase_ = 2 (back to solve)

The pickers vary by mode:

  • Mode 1, 6, 9: bisection mid-point, or HF Newton step when available.
  • Mode 2: bisection on a uniform scale factor.
  • Mode 3: alternating W/H bisections per round.
  • Mode 4: adaptive sampler scores bracket / near-best / gap regions, picks highest.
  • Mode 5: sweep then per-bracket bisection.
  • Mode 7, 8: GP fit + Expected-Improvement argmax on a fine grid.

04 Threading & data flow

Three threads operate concurrently. Mutexes guard the small set of shared state; the rest is single-owner.

Render thread (60 Hz)

  1. Process input (keyboard, mouse).
  2. Drain solve worker result if ready: modal_res_ = solve_worker_.take_result().
  3. Apply per-frame state pushes (drift, damping, sympathetic config, gravity).
  4. Read displacements via modal_get_displacements.
  5. Write displacement VBO → draw mesh (OpenGL).
  6. ImGui begin → draw_layout (tabs, panels, viewport) → end.
  7. Swap buffers.

Solve worker (background thread)

  1. Started by solve_worker_.start(modal_obj_, num_modes) from the render thread.
  2. Runs modal_solve: assemble K / M → eigensolve → build ModalResult.
  3. Result picked up by the next render frame via is_done().
  4. Joined before any destroy of modal_obj_ / modal_res_ to avoid use-after-free.

Audio callback (PortAudio, ~5 ms blocks)

  1. Drain strike events from libmodal's strike_buf, project onto modes via compute_strike_impulse.
  2. Run GPU synthesis (CUDA kernel) → PCM samples per mode → sum.
  3. Apply air-cavity bandpass (if enabled).
  4. Run noise gate (HP-filter envelope, smoothstep).
  5. Post-process: gain → low-pass → soft-clip → auto-normalize.
  6. Write to PortAudio output buffer.

Strike flow: a click in the viewport calls modal_strike(res, x, y, mag). That pushes to the audio strike_buf (drained by the audio callback) AND immediately injects into vis_impulses for visualisation (no audio latency).

05 File layout

Top-level split: libmodal/ (pure physics + audio synthesis, no UI) and modal_viewer/ (App, OpenGL renderer, ImGui UI).

PathResponsibility
libmodal/include/modal/modal.hPublic C++ API surface. Start here when integrating.
libmodal/src/modal_api.cppObject / Result lifecycle, all preset shapes, strike dispatch, multi-material, sympathetic, MAC primitives, HF derivatives.
libmodal/src/fem/assembler.cppPure 2D CST assembly + multi-material variant.
libmodal/src/fem/assembler_mixed.cppMixed CST + beam assembly (3 DOFs per node).
libmodal/src/fem/element_beam.cppBernoulli beam element + geometric stiffness for tension.
libmodal/src/fem/element_cst.cppConstant-strain triangle element (Ke, Me).
libmodal/src/solver/eigensolve_sparse.cppSparse Lanczos eigensolve.
libmodal/src/audio/synthesis.cuCUDA kernel for per-sample modal sum on GPU.
libmodal/src/mesh/preset.cppPolygon vertex lists for every PresetShape.
modal_viewer/src/app/app.cppMain loop, strike handling, fine-tune state machine, all per-frame logic. Largest file.
modal_viewer/src/app/gaussian_process.hHeader-only GP for Bayes-W / Bayes-WH.
modal_viewer/src/app/mass_spring.hHeader-only mass-spring lattice + Jacobi eigensolve.
modal_viewer/src/app/solve_worker.cppBackground thread wrapping modal_solve.
modal_viewer/src/audio/audio_engine.cppPortAudio callback. Cavity bandpass, gate, post-process.
modal_viewer/src/ui/ui_manager.cppImGui dock layout + every tab and panel. Second-largest file.
modal_viewer/src/ui/viz_spectrum.cppFrequency spectrum bar chart + pin handling + glowing banner.
modal_viewer/src/renderer/mesh_renderer.cppOpenGL VAO / VBO for mesh + displacements.

06 Glossary

Mode
A standing-wave pattern of vibration. The shape vibrates as a sum of decoupled modes; each has a frequency, a shape, and a decay rate.
Eigenmode
Same thing as a mode. The "eigen" emphasises that it's the solution to the generalised eigenvalue problem K φ = λ M φ.
K
Stiffness matrix. Encodes how much force a unit displacement at each DOF generates. Symmetric, sparse, positive-semi-definite (3 rigid-body modes give it a 3-dim nullspace before BCs).
M
Mass matrix. Inertia per DOF. Diagonal-ish; we use the consistent form.
ω (omega)
Angular frequency in rad/s. The audible frequency in Hz is ω / (2π).
ζ (zeta)
Damping ratio. Decay rate of a mode is ζ · ω; the e-folding time is 1 / (ζ·ω).
α (alpha)
Radiation efficiency per mode. Computed from the modal velocity normal to the boundary and the medium's impedance. Controls how loud each mode is in the air.
φ (phi)
Eigenvector. The mode's shape, stored as displacement per DOF. M-normalised so φᵀ M φ = 1.
CST
Constant-Strain Triangle. The simplest 2D plane-stress finite element. 3 nodes × 2 DOFs = 6 DOFs per element.
DKT
Discrete Kirchhoff Triangle. Plate-bending element with 3 DOFs/node (w, θx, θy). Produces out-of-plane modes.
MAC
Modal Assurance Criterion. |φ_a · φ_b|² / ((φ_a · φ_a)(φ_b · φ_b)). 1 = identical shape, 0 = orthogonal. We use it for mode tracking across W changes in Manual-W.
HF
Hellmann-Feynman theorem. Says dλ/dp = φᵀ(dK/dp − λ dM/dp)φ for an eigenpair. Gives us df/dW without doing a new eigensolve, used for Newton steps in Fine-W / Manual-W.
K_geom
Geometric stiffness. Extra stiffness from pre-stress (tension, compression, gravity-induced strain). For a tensioned beam, scales as T/L and stiffens the transverse motion.
GP / EI
Gaussian Process / Expected Improvement. The GP fits a surrogate over (W, H) → |cents|; EI picks the next sample by trading off predicted improvement vs uncertainty. Drives Bayes-W and Bayes-WH.
LHS
Latin Hypercube Sampling. Better space coverage than uniform random for small sample counts. Used to seed Bayes-W / Bayes-WH before EI takes over.
Lanczos
The sparse iterative eigensolver we use. Computes the lowest N eigenvalues of (K, M) without dense factorisation. Cheap for small N.
Rayleigh quotient
R(x) = (xᵀ K x) / (xᵀ M x). Equals the eigenvalue when x is the eigenvector; stationary at eigenvectors. Used internally by HF for second-order-accurate FD of dλ/dW.
Strike impulse
Per-mode amplitude added when you hit the shape. Computed as the eigenvector's value at the nearest node, projected onto the strike direction (boundary normal for boundary nodes; +Y for interior).
Helmholtz mode
The lowest acoustic mode of an enclosed air volume connected to outside via a small opening. Has a simple closed form: f = (c/2π)·√(A/(V·L)). We currently model this as a single bandpass; full cavity-FEM is planned.
Boundary clamp / fix_level
How many boundary nodes are pinned to zero displacement. 0 = free-free (rigid-body modes appear), 1–3 = partial, 4 = clamped all-around (membrane-only modes).

07 Troubleshooting

Q · My instrument is silent / very quiet after a strike.
Check the noise gate: post_process panel, gate thresholds. If your shape produces a sub-bass-only mode, the gate's 80 Hz HP filter rejects it (intentionally — see commit history). Either raise the mode into audible range with the freq-drift slider, or temporarily disable the gate.
Q · A single mode keeps ringing forever after I strike.
Add damping: post_process panel → Damping slider. Pushed to libmodal in real time. For a hard cutoff, the Search timeout slider clears all modal energy N seconds after the last strike — but only during fine-tune sessions (Play / Test get infinite sustain by design).
Q · Fine-W keeps "giving up after N retries; best ±12 cents".
The bracket is converging to a non-target mode. Three things to try: (1) Switch to Bayes-W — its GP surrogate handles multi-modal cost surfaces. (2) Use Manual-W: click the spectrum bar of the mode you actually want. (3) Use Filter-W and dial in a decay window that excludes whichever mode is misleading the search.
Q · The viewport says "Solve failed (degenerate mesh)".
The triangulator produced a near-zero-area element, usually because W or H was pushed extreme. App auto-recovers to the last known-good W/H. Common cause: Fine-W+ adaptive sampler exploring a corner of the bracket. Lower the budget, or use Bayes-W which spaces samples better.
Q · The note label in Test mode says "C4" but the shape sounds like D.
Two possibilities. (1) The freq-drift slider is non-zero — playback is shifted by 2^(s/12). Either reset drift to 0 or enable comp next to it so future fine-tune sessions pre-compensate. (2) The instrument was tuned with the flexible toggle on; the saved freqs are correct but the chromatic labels reflect a relative octave, not absolute. Save with flexible off if you want absolute pitch labels.
Q · MAC arrow turns red after a few tune iterations.
The pinned mode's shape changed too much for the reference snapshot to match. Re-click the bar in the spectrum to re-pin (captures a new MAC reference). For very-wide tuning ranges, expect to re-pin once or twice mid-session.
Q · GP heatmap in Bayes-WH looks all-red / all-green.
Normal early in the session — the heatmap auto-scales lo/hi from the grid predictions, so before EI has any data the predictions are nearly uniform. After 4–6 samples you'll see structure emerge.
Q · I changed material but the mass-spring preview doesn't update.
It does, but the change is subtle if you only nudged E or rho slightly. The preview reads E and rho from the material panel each frame. For multi-material scenes the material panel is disabled and the preview shows the default mat — change comp_type or edit obj->mat directly to affect it.
Q · Audio cuts out for ~1 second when I change shape.
Expected: shape changes trigger a re-solve, which destroys the audio stream and rebuilds. There's an unavoidable gap of ~100 ms while the solver runs (longer for high mesh resolution). Lower mesh resolution in the Physics tab if the gap is too disruptive.
Q · The strung preset sounds dull / muddy regardless of tension.
The string's beam material defaults to whatever obj->mat was set to before the multi-material palette took over. With the material panel disabled, you can't edit obj->mat through the UI. Workaround: pick a stiffer single-material preset (e.g. Box-Hard) first, set material to Steel, then switch to Strung — obj->mat persists. Per-beam material assignment is the planned proper fix.

08 Basics: modal analysis from first principles

This section builds up from the simplest example (a plucked guitar string) to the framework the rest of the viewer uses. No prior knowledge of FEM or eigenvalues assumed — equations introduced as they become necessary, never before.

8.1 · What is modal analysis?

Pluck a guitar string. You hear a pitch (the fundamental), plus quieter higher pitches (overtones). The string isn't vibrating in just one shape — it's vibrating in many shapes at once, each with its own frequency. Each of those independent vibration patterns is a mode, and the observation that any motion of the string can be written as a sum of them is the heart of modal analysis.

Why is this useful? Because each mode evolves independently. The fundamental does its thing, the second overtone does its thing, and they don't interfere. So if we can find the modes once and know how hard each one was struck, we can compute the entire future of the vibrating object by summing analytical sine waves — no iterative physics simulation needed.

Compared to other audio approaches: Recorded samples sound real but don't respond to where you strike. Physical simulation (time-stepping the wave equation) responds correctly but costs millions of operations per audio sample. Modal synthesis is the middle ground: one expensive solve upfront, then cheap sums forever after.

8.2 · Eigenmodes of a 1D string

The simplest case. A string of length L, tension T, and mass per length μ, pinned at both ends, has displacement v(x, t) obeying the wave equation:

μ ∂²v/∂t² = T ∂²v/∂x², v(0, t) = v(L, t) = 0

Try a separated-variables solution v(x, t) = φ(x) · sin(ωt). Substituting gives

T φ''(x) = −μ ω² φ(x)

plus the boundary conditions φ(0) = φ(L) = 0. The only solutions are

φ_n(x) = sin(n π x / L), ω_n = (n π / L) √(T/μ), n = 1, 2, 3, …

Each φ_n is a mode shape; each ω_n is its natural angular frequency. The pair (φ_n, ω_n) is called an eigenmode because the equation above is an eigenvalue problem in disguise: φ_n is an eigenvector of the operator −(T/μ) ∂²/∂x² with eigenvalue ω_n².

The first three modes look like:

mode 1 (fundamental, f₁ = (1/2L)√(T/μ)): ●━━━━━━━━━━━━━━━━━━━━━━━━● ← endpoints pinned \ / \ / '─────────────────' ← single bulge mode 2 (1st overtone, f₂ = 2 f₁): ●━━━━━━━●━━━━━━━━━━━━━━━━● ← node in the middle \ / \ / \ / \ / '─' '──────────' mode 3 (2nd overtone, f₃ = 3 f₁): ●━━●━━━━━━━━●━━━━━━━━●━━━━● ← two interior nodes \/ /\ /\ / /\ / \ / \ / ● '──────' '───' '

Three things to notice that generalise to all modes, in any geometry:

  • Each mode has its own frequency. Higher modes oscillate faster.
  • Each mode has a fixed shape. Mode 3 always vibrates with two interior zeros (nodes); it doesn't morph into mode 2.
  • Different modes don't interfere. A plucked string's motion is exactly the sum of every mode's contribution, each evolving independently in time.

8.3 · Striking, sound, and decay

A real strike is a transient: a brief force impulse at one position on the string. The string's response is the sum of mode contributions, each scaled by how much the strike "looked like" that mode:

v(x, t) = Σₙ A_n · φ_n(x) · sin(ω_n t)

Where A_n is the projection of the strike onto mode n — essentially, "how much does mode n's shape match where I struck?". Strike at the middle of the string (x = L/2): odd modes (1, 3, 5, …) have a peak there, so they ring loud; even modes (2, 4, …) have a zero there, so they ring not at all. This is why striking different parts of an instrument changes its timbre.

Real materials dissipate energy. We model that with a damping ratio ζ_n per mode, giving an exponential envelope:

v(x, t) = Σₙ A_n · φ_n(x) · exp(−ζ_n ω_n t) · sin(ω_n √(1−ζ_n²) t)

Two consequences worth absorbing:

  • Higher modes decay faster at the same ζ, because the exponent ζ ω t grows quicker with ω. That's why a struck bell's bright initial timbre mellows out over time — the overtones die first.
  • Decay time is 1 / (ζ ω). For ζ = 0.01 and an audible 440 Hz mode (ω ≈ 2765 rad/s), that's ~36 ms — a percussive sound. For ζ = 0.0001 it's 3.6 seconds — a bell.

To turn vibration into sound, every mode shape radiates into the surrounding air with an efficiency α_n that depends on its shape and the medium. The final audio signal is the radiated pressure at the listener position; in the viewer we approximate this as a weighted sum where each mode's contribution scales by α_n:

p(t) = Σₙ α_n · A_n · exp(−ζ_n ω_n t) · sin(ω_dn t)

α_n is computed from the modal velocity normal to the boundary and the medium's impedance. This is why setting the Acoustic Medium to vacuum-like properties silences the output — the structure still vibrates, it just doesn't radiate.

8.4 · Going from 1D to 2D shapes

A 1D string has modes with a single index n: shape number 1, 2, 3, …, ordered by frequency. A 2D shape has modes indexed by two numbers in the simplest case (number of bulges along x, number along y), and ordered modes start to mix the two indices. For a rectangular plate fixed at the edges:

mode (1,1) mode (2,1) mode (1,2) mode (2,2) ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ │ │ │ │ │ │ │ │ (single │ │ │ │ │ ─────────── │ │ │ │ │ bulge) │ │ one │ node │ │ │ │ + ─── ─── + │ │ │ │ │ │ │ node │ │ │ │ │ │ │ │ │ │ │ │ ─── + + ─── │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ fundamental vertical node line horizontal node crossed nodes

The patterns of zero displacement are nodal lines; for higher modes they form intricate curves. In Chladni's classical experiment (the demo this viewer's Chladni-mode C toggle is named for), sand sprinkled on a plate gathers along these nodal lines because they're the only places that don't shake the sand off.

For arbitrary shapes (not just rectangles), the modes can't be written as products of 1D sines — they're whatever shape comes out of solving the 2D wave equation with the actual boundary. Closed-form solutions rarely exist. We need a numerical method: FEM.

8.5 · FEM: from continuous PDE to a matrix problem

The 2D wave equation we want to solve is

ρ ∂²u/∂t² = ∇·(E ∇u)

in some 2D region, with material parameters E (stiffness), ρ (density), and boundary conditions on the region's edge. Three steps turn this into something a computer can chew on:

Step 1: mesh the shape

Cut the 2D region into a triangulation. Each triangle has three nodes at its corners. The displacement field u(x, t) is approximated as linear interpolation between the displacement values at those nodes. So instead of a continuous unknown function we have a finite list of unknowns: u_i(t), one per node DOF.

Triangulation in this viewer is done with shewchuk's Triangle library, invoked via mesh::triangulate in libmodal/src/mesh/triangulator.cpp.

Step 2: assemble K and M

For each triangle, you can derive (from the wave equation + linear interpolation) a small element stiffness matrix K_e (6×6 for the 2D CST element — 3 nodes × 2 DOFs) and an element mass matrix M_e. The full system is built by summing contributions from every element into global sparse matrices:

M ü + K u = f_external

K encodes how much force each unit displacement produces; M encodes inertia. Both are sparse because each node only couples to its triangle neighbours.

Assembly happens in libmodal/src/fem/assembler.cpp (pure 2D CST) and assembler_mixed.cpp (CST + beam elements for strings). The element matrices themselves come from element_cst.cpp and element_beam.cpp.

Step 3: eigensolve

For free vibration (no external force, f = 0) we look for solutions of the form u(t) = φ · sin(ωt). Substituting into M ü + K u = 0 gives

−ω² M φ · sin(ωt) + K φ · sin(ωt) = 0

Dropping the time factor leaves the generalised eigenvalue problem:

K φ = ω² M φ

Solve this and you get a discrete set of eigenpairs (ω_k, φ_k) — exactly the modes from §8.2, but for an arbitrary 2D geometry instead of a 1D string. We solve typically 24 – 120 of the lowest modes using sparse Lanczos iteration in libmodal/src/solver/eigensolve_sparse.cpp.

Why generalised, not standard? Because M isn't the identity. A standard eigenvalue problem looks like A φ = λ φ — what we have is K φ = ω² M φ, a generalised eigenvalue problem. It's still solvable cheaply because M is positive-definite, so we can reduce to standard form via Cholesky factorisation of M, but the algorithm is slightly different from the basic power method.

8.6 · How a click becomes a sound

End-to-end trace of what happens when you click the viewport. This is the complete pipeline; every step touches a specific file in the codebase.

User clicks viewport at (sx, sy) [app.cpp · on_mouse_click] │ │ screen → world coords via the camera's zoom/pan ▼ modal_strike(res, x, y, magnitude) [modal_api.cpp] │ ├─── push StrikeEvent onto strike_buf (audio path; lock-free queue) │ └─── compute_strike_impulse(mesh, mode_shapes, x, y, f_impulse) │ │ for each mode k: │ f_impulse[k] = φ_k(nearest_node) · strike_direction │ ├─── apply_sympathetic_coupling (if enabled) │ └─── for each mode: vis_impulses[k].add(f_impulse[k] * mag) (visual path; ring buffer for analytical decay) VISUAL THREAD (60 Hz, in render() loop) │ ├─── modal_get_displacements(res, disp, t) │ for each node n: │ disp[n] = Σₖ φ_k(n) · vis_impulses[k].eval(ω_k, ζ_k, t) │ ├─── apply gravity sag (if enabled) │ └─── write disp to OpenGL VBO → draw mesh AUDIO THREAD (PortAudio callback, ~5 ms blocks) │ ├─── drain strike_buf into mode_states (apply impulses to A, t0) │ ├─── upload mode_states to GPU (cudaMemcpyAsync) │ ├─── CUDA kernel [synthesis.cu] │ for each PCM sample s in this block: │ t = t_start + s · dt │ pcm[s] = Σₖ α_k · A_k · exp(−ζ_k ω_k (t−t0_k)) │ · sin(ω_k √(1−ζ_k²) (t−t0_k)) │ ├─── air-cavity bandpass (if enabled) — parallel SVF │ ├─── noise gate (HP-filter amplitude envelope) │ ├─── post-process: gain → LP → soft-clip → auto-normalize │ └─── write to PortAudio buffer → speaker

Three details that surprise newcomers:

  • Visual and audio are decoupled. They share strike events but run on different threads with different time bases. Visual uses scaled time (S key cycles slow-motion), audio always runs at real time. Each path stores its own copy of recent impulses.
  • Audio synthesis is analytical, not simulated. The CUDA kernel computes the exact sum Σ α_k A_k exp(−ζω t) sin(ω_d t) at every PCM sample, with no time-stepping. No accumulated error, no stability concerns, and trivially parallel across samples.
  • The strike acts on one node. We snap the world click to the nearest mesh node, then project that node's eigenvector entry onto the strike direction (outward boundary normal for boundary nodes, +Y for interior). The whole strike-to-modes projection lives in compute_strike_impulse.

8.7 · Why the same machinery handles strings, plates, and bells

Everything in §8.5 and §8.6 was general. The viewer has different elements (CST triangle, Bernoulli beam, DKT plate) but each one just contributes its own little K_e and M_e block during assembly. After assembly the eigenvalue problem looks identical regardless of which elements were used. Same Lanczos solve, same modal sum for synthesis.

That's why adding tension to strings (§7) was a small change: tension contributes an extra term K_geom to the beam element's K_e. Everything downstream — eigensolve, MAC, HF derivatives, audio synthesis — is unchanged. The element layer is where the physics lives; everything above it operates on abstract sparse matrices.

Multi-material support (§5) works the same way: each triangle's element matrix is computed with its own material, then the assembler sums all elements into a single global K and M. The eigensolver doesn't know or care that the material varies — it just sees a different sparse pattern of stiffness values.

8.8 · Deeper: Hellmann-Feynman, MAC, geometric stiffness

With the basics in place, the three "advanced" results in the rest of the viewer become short corollaries.

Why Hellmann-Feynman is cheap

Conventional derivative df/dW: change W, redo the entire eigensolve, finite-difference. Cost: one extra eigensolve per derivative.

Hellmann-Feynman insight: for an eigenpair (λ, φ) of K φ = λ M φ with M-normalisation φᵀ M φ = 1,

dλ/dp = φᵀ (dK/dp − λ dM/dp) φ

No new eigensolve. Cost: one matrix re-assembly at perturbed W (much cheaper than eigendecomposition) plus a quadratic-form evaluation. Total ~5% of an eigensolve. We use this for Newton-step Fine-W / Manual-W via the Rayleigh-quotient formulation, which handles M-normalisation automatically (see features §3).

MAC mode tracking

Two eigenvectors φ_a and φ_b from different solves are "the same mode" if their shapes correlate strongly. The Modal Assurance Criterion measures this with a single scalar in [0, 1]:

MAC(φ_a, φ_b) = |φ_a · φ_b|² / ((φ_a · φ_a)(φ_b · φ_b))

The square handles sign flips (eigenvectors are only defined up to sign). Above 0.6 means "clearly the same mode topology"; below 0.3 means the modes diverged — the geometry change crossed a mode-identity boundary and the tracker needs a fresh reference. Used by Manual-W to auto-retrack the pinned mode across W changes.

Geometric stiffness from tension

A taut string's pitch comes from tension, not bending. Why? The transverse equation for a beam under axial tension T is

EI ∂⁴v/∂x⁴ − T ∂²v/∂x² + μ ∂²v/∂t² = 0

When T · L² ≫ EI the bending term EI ∂⁴v/∂x⁴ is negligible and the equation collapses to the pure-string wave equation from §8.2. Frequencies follow f_n = (n/2L) · √(T/μ).

In FEM terms, the tension contributes a geometric stiffness matrix K_geom = T/L · [transverse-block] that adds to the usual bending stiffness:

K_total = K_bending + K_geom

High tension → geometric term dominates → string-like behaviour. Zero tension → pure bending beam. Same eigensolve handles both regimes because the only thing that changed is the values in K.

09 Further reading

Modal synthesis

  • Adrien, J.-M. "The Missing Link: Modal Synthesis" — the foundational paper on the analytical sum-of-modes approach to audio.
  • van den Doel, K. & Pai, D. "The Sounds of Physical Shapes" — modal sound generation for shape-aware audio in graphics.
  • O'Brien, J., Cook, P., Essl, G. "Synthesizing Sounds from Physically Based Motion" — coupling FEM to audio in real time.

Finite element method

  • Cook, R. Concepts and Applications of Finite Element Analysis. Canonical reference; CST, beam, plate elements all derive here.
  • Bathe, K.-J. Finite Element Procedures. The standard graduate text. Sparse eigensolve methods covered in depth.

Mode tracking and sensitivity

  • Allemang, R. "The Modal Assurance Criterion — Twenty Years of Use and Abuse" (Sound and Vibration, 2003). Practical guide to MAC.
  • Fox, R., Kapoor, M. "Rates of Change of Eigenvalues and Eigenvectors". Original Hellmann-Feynman application to FEM eigenproblems.

Bayesian optimisation

  • Rasmussen, C., Williams, C. Gaussian Processes for Machine Learning. Free online; chapters 2–5 cover what we use.
  • Frazier, P. "A Tutorial on Bayesian Optimization" (arXiv 1807.02811). EI, acquisition strategies, when BO works.

Acoustic radiation

  • Fahy, F. Sound and Structural Vibration. Structure → air coupling, radiation efficiency, near vs far field.
  • Rossing, T. Science of Sound. Approachable, covers Helmholtz, instrument-cavity physics, anisotropic materials.

This codebase

  • features.html — what's here, command-reference style.
  • roadmap.html — upcoming work, longer-term trajectory.
  • project-structure.html — full code tree with descriptions.