If you’ve been following along #awegif2021 on twitter, you’ve seem me post a few animated gifs that look like the image above (days 1-6 specifically). These are known as Chladni figures, named after Ernst Chladni. He described the formulas that create the patterns that result when you use soundwaves to activate sand or powder on a flat surface. You can find a ton of videos on Youtube that feature real world examples of this. But it’s also fun to do in code.
Disclaimer: I’m using the basic concept behind Chladni figures as a starting point, and going on to hack it all to hell from there. So this post should be taken fully in the context of creative code, not any kind of strict scientific simulation of how sound waves work. It’s all about making pretty pictures.
In spite of their complexity, Chladni figures are really quite easy to create. They’re basically all about cosines.
For the first example, I’m going to stick to one axis – the x-axis. I’m going to loop through every pixel of a bitmap and map its x position to a range of -PI to +PI, then take the cosine of that value. Finally, I’ll take the absolute value of that result and multiply it by 255 to get a grayscale value.
for (let x = 0; x < 800; x++) {
for (let y = 0; y < 800; y++) {
const xx = Num.map(x, 0, 800, -Math.PI, Math.PI);
let d = Math.cos(xx);
d = Math.abs(d);
g = d * 255;
context.fillStyle = Color.gray(g);
context.fillRect(x, y, 1, 1);
}
}
I’m using a couple of my own library functions here. Num.map
maps a value from one range (0 to 800 pixels) to another range (-PI to +PI). And Color.gray
creates a grayscale fill style from a number between 0 and 255. Here’s the result of that:
I know the code above is horribly unoptimized, but bear with me.
So you can see some vertical bars of gray there. Normally that middle light bar would be all in the negative range, but because we’re taking the absolute value of the cosine, it’s all positive.
We can introduce a multiplier, I’ll call wave1
. This will multiply the value that we are taking the cosine of.
const wave1 = 3;
for (let x = 0; x < 800; x++) {
for (let y = 0; y < 800; y++) {
const xx = Num.map(x, 0, 800, -Math.PI, Math.PI);
let d = Math.cos(xx * wave1);
d = Math.abs(d);
g = d * 255;
context.fillStyle = Color.gray(g);
context.fillRect(x, y, 1, 1);
}
}
With wave1
set to 3, we get this:
So far, so good, hopefully. Let’s move on to the y-axis. We’ll do the exact same mapping of the y position and taking the cosine of that value times wave1
. Then we’ll multiply the result from doing this on x with the result from y.
const wave1 = 3;
for (let x = 0; x < 800; x++) {
for (let y = 0; y < 800; y++) {
const xx = Num.map(x, 0, 800, -Math.PI, Math.PI);
const yy = Num.map(y, 0, 800, -Math.PI, Math.PI);
let d = Math.cos(xx * wave1) * Math.cos(yy * wave1);
d = Math.abs(d);
g = d * 255;
context.fillStyle = Color.gray(g);
context.fillRect(x, y, 1, 1);
}
}
Cool, so you should be able to see that we have a wave going through vertically and another wave horizontally, and they are interacting with each other nicely. Next, we take it up a notch and add a second wave.
const wave1 = 3;
const wave2 = 2;
for (let x = 0; x < 800; x++) {
for (let y = 0; y < 800; y++) {
const xx = Num.map(x, 0, 800, -Math.PI, Math.PI);
const yy = Num.map(y, 0, 800, -Math.PI, Math.PI);
let d = Math.cos(xx * wave1) * Math.cos(yy * wave1);
d += Math.cos(xx * wave2) * Math.cos(yy * wave2);
d /= 2;
d = Math.abs(d);
g = d * 255;
context.fillStyle = Color.gray(g);
context.fillRect(x, y, 1, 1);
}
}
Here, we have wave2
set to 2. We do exactly the same thing with that, and add the result to d
. Of course, this will double the range of d
to become -2 to +2, so we divide that by 2 to get it back in a range of -1 to +1. The rest is all the same, but now we have two waves on each axis, all interfering and interacting with each other, and we get this:
Pretty cool. And you can just keep on adding waves to make complex patterns. Here’s three waves:
const wave1 = 3;
const wave2 = 2;
const wave3 = 7;
for (let x = 0; x < 800; x++) {
for (let y = 0; y < 800; y++) {
const xx = Num.map(x, 0, 800, -Math.PI, Math.PI);
const yy = Num.map(y, 0, 800, -Math.PI, Math.PI);
let d = Math.cos(xx * wave1) * Math.cos(yy * wave1);
d += Math.cos(xx * wave2) * Math.cos(yy * wave2);
d += Math.cos(xx * wave3) * Math.cos(yy * wave3);
d /= 3;
d = Math.abs(d);
g = d * 255;
context.fillStyle = Color.gray(g);
context.fillRect(x, y, 1, 1);
}
}
Of course, you are not limited to black and white. In the following example, rather than using the final result of the calculations as a grayscale value, I used it as the hue of an HSV function.
const wave1 = 3;
const wave2 = 2;
const wave3 = 4;
for (let x = 0; x < 800; x++) {
for (let y = 0; y < 800; y++) {
const xx = Num.map(x, 0, 800, -Math.PI, Math.PI);
const yy = Num.map(y, 0, 800, -Math.PI, Math.PI);
let d = Math.cos(xx * wave1) * Math.cos(yy * wave1);
d += Math.cos(xx * wave2) * Math.cos(yy * wave2);
d += Math.cos(xx * wave3) * Math.cos(yy * wave3);
d /= 3;
d = Math.abs(d);
h = d * 360;
context.fillStyle = Color.hsv(h, 1, 1);
context.fillRect(x, y, 1, 1);
}
}
And this is what I got from that:
More scientifically rigorous implementations will also implement a kind of reflection feature. As the sound waves reach the edges of the plate, they will bounce back, causing yet another series of waves that make things even more complex. I have not done that here.
Demo Time!
Static images are fine, but a live demo is the best way to show this off.
Here you can use one to four waves and configure them in increments of 0.1 and view in color or grayscale. I threw in some other bells and whistles as well.
- I set the resolution pretty low to begin with. This code is still pretty slow as is. In low res, you can interact in real time. High res looks a lot better but there’s a second or two lag to render. Choose your poison or toggle back and forth.
- The grayscale images tend to be a bit dark as is. There are different ways to handle this. I went with a configurable gamma function to increase their brightness.
- I added a twist function that has nothing to do with anything, but I liked the effect it creates. Set it to zero if you’re a purist.
- And finally, there’s an invert mode to swap the color or grayscale gradient.
If you write your own code for this, feel free to venture off into different functions to create the waves. Endless opportunity for experimenting exists.
Controls: MiniComps
Graphics: BLJS
Addendum:
I should add the place where I first came across this concept: http://www.mathrecreation.com/2019/05/desmos-chladni.html
This is a cool site and worth following. I followed some of the links from there to find more specifics on some of the implementation details, but as I said, it’s all surprisingly simple.