Chladni Figures

4 min read Original article ↗

Hint: Load this page in a WebGPU-supported browser to control a live simulation!

Draw on the canvas to place/remove constraints:

  • Left click: Place a wall constraint (prevents the plate underneath from moving)
  • Right click: Place an oscillator constraint (Forces the plate to move up and down)
  • Middle click: Clear constraint

You can adjust the frequency of oscillators using "brush frequency" slider at the bottom.


This is a little simulation of Chladni patterns in Bevy, using compute shaders for the plate simulation and bevy_spatial for the particles.

Chladni figures are patterns obtained by placing sand on a metal plate, and vibrating it.

When resonating, a plate or membrane is divided into regions that vibrate in opposite directions, bounded by lines where no vibration occurs (nodal lines).

https://en.wikipedia.org/wiki/Ernst_Chladni#Chladni_figures

The sand will naturally be pushed toward the nodal lines, making the figures appear.

Depending on how you constrain the movement of the plate (e.g. pressing it down with your fingers), these regions will change and you can get a variety of different patterns.


A horizontal plate of metal with sand on top. Chladni's left middle finger & thumb press on the side of the plate. With his right hand, he bows the side of the plate using a violin bow.

There are two parts to this simulation:

  • The plate: Represented as a 128x128 square grid, where each cell stores its elevation and velocity
  • The sand: A bunch of little sprites

The plate

The plate is represented as a texure, with only a red and green channel, corresponding to elevation & velocity respectively. It is simulated within a compute shader, and each point feels a force from its 4 neighbors.

// get the data of the current cell
let data = textureLoad(input, location);
var pos = data.r;
var vel = data.g;

// sample the 4 directions around the current cell
for (var i: i32 = 0; i < 4; i++) {
	let t = f32(i) / 4.0 * TAU;
	let d = vec2f(cos(t), sin(t));
	let other_data = sample_input(loc + d);
	let other_pos = other_data.r;
	let weight = other_data.g;
	// apply a force if the other cell is at a different elevation
	vel += (other_pos - pos) * TENSION * weight;
}

// integrate velocity
pos += vel * DT;

// store the updated cell data
textureStore(output, location, vec4f(vec2f(pos, vel), 0.0, 0.0));

The sample_input function will return two values packed as a vec2f:

  • the red channel is position
  • the green channel is the weight of the connection. If it is 0.0, this neighbor will not be connected to the current cell.

At the boundaries, sample_input returns a returns a connection weight of 0.0.

Constraints

One important aspect to the diversity of these patterns is the way that the plate is constrained. There are three cases:

  • In some parts, the plate will be totally unconstrained, free to simply follow the movement of its neighbors
  • In other parts, the plate might be pressed down with fingers or clamps, forcing it to have a fixed position and velocity
  • In other parts, the plate might be bowed, resulting in an oscillation

To reflect this, we encode constraints as a separate float texture with a single channel:

  • If a cell has a constraint value of -1.0, it is unconstrained.
  • Otherwise, its position will be given by sin(time * TAU * constraint_value).
    • This means that we can fix a cell in place by giving it a constraint value of 0.0
    • And that we can force it to oscillate by setting it to any positive value

The sand

The second component of this simulation is the sand particles.
They follow very simple rules

Go where the plate moves the least

The plate oscillates above and below zero, therefore we take the absolute value of the elevation.
We then take the gradient of it at the sand particle's position. This gives us a vector going toward the biggest elevation offset from zero.
We move the sand particle in the opposite direction to that gradient.

Move away from other sand particles

To avoid them clumping up in the same location, we apply a small force to push them away from each other.
It would be very expensive to naively compute the repulsive force of every particle against every other particle. Thankfully bevy_spatial lets us efficiently query the K nearest neighbors of a given particle by keeping all positions in a spatial datastructure.

Stay on the plate

Lastly, if a particle falls off the plate, we teleport it back to a random location

Source code

You can find the full source code here! https://codeberg.org/Azorlogh/chladni_figures