Chladni figure
Sand marks the still lines of a vibrating plate: two diagonals and a ring of small loops around a lit one.
Made with Claude Opus 5.5
- Technique
- stippling and hatching
- Inspired by
- scientific illustration
- Shape
- 16:9, 32:9, 9:19.5, 10:16 (cropped for other screens)
- Added
- 27 September 2026
Versions
Colours
- #1C1B1Abackground
- #DAD8CEforeground
- #CF6A4Caccent
Export
FormatThis browser can’t make WebP files.
Shapecropped from 16:9
Crop
SizeThis browser can’t draw a file that large.
Notes
Ernst Chladni bowed the edges of sand-covered plates and drew the figures the sand made where the plate stood still. This plate follows a common approximation for a square plate, cos(nπx) cos(mπy) − cos(mπx) cos(nπy), where n and m count half-waves across it. When both are odd the diagonals and midlines always stay still, and a larger pair sounds a higher note.
Sources
- Ernst Chladni, Entdeckungen über die Theorie des Klanges, 1787.
Source code
wallpapers/chladni/design.py, 129 lines
"""A Chladni figure: sand grains stippled along the nodal lines of a vibrating square plate, the loop around its centre picked out."""
import numpy as np
import shapely
from numpy.typing import NDArray
from shapely.geometry import Point, Polygon
from walldye import ACCENT, UI, UI_ALT, Canvas, P, Params, Path, design, knob, smoothstep
from walldye.field import gauss, iso_lines, sample_field
from walldye.geom import Affine
type Field = NDArray[np.float64]
class Mode(Params):
"""The vibration mode: two odd numbers of half-waves across the plate, n below m."""
n: int = knob(default=5, choices=(3, 5, 7), doc="the smaller mode number")
m: int = knob(default=11, choices=(7, 11, 13, 15), doc="the larger mode number")
VARIANTS = {"low": Mode(n=3, m=7), "high": Mode(n=7, m=15)}
S = 720 # plate side
FADE = 110 / S # the plate edge dissolves over this band instead of cropping the outer loops
KEEP = 0.46 # share of the grains near a nodal line that stay: sand, not ink
LOOP_SAMPLES = 356_000 # candidate grains per unit plate area for the central loop
LATTICE = 240 # cells across the plate when tracing the central loop
def wave(u: Field, v: Field, p: Mode) -> tuple[Field, Field]:
"""The plate's displacement cos(n pi u) cos(m pi v) - cos(m pi u) cos(n pi v) at plate
coordinates (u, v) in [0, 1], and the length of its gradient."""
a, b = p.n * np.pi, p.m * np.pi
val = np.cos(a * u) * np.cos(b * v) - np.cos(b * u) * np.cos(a * v)
gu = -a * np.sin(a * u) * np.cos(b * v) + b * np.sin(b * u) * np.cos(a * v)
gv = -b * np.cos(a * u) * np.sin(b * v) + a * np.cos(b * u) * np.sin(a * v)
return val, np.hypot(gu, gv)
def curved(u: Field, v: Field, p: Mode) -> Field:
"""The displacement with the four straight nodal lines (both diagonals and both midlines,
present when n and m are odd) divided out, so its zeros are the curved lines only."""
return wave(u, v, p)[0] / ((u - v) * (u + v - 1) * (u - 0.5) * (v - 0.5) + 1e-12)
def loop_dist(u: Field, v: Field, p: Mode, e: float = 1e-5) -> Field:
"""Distance in canvas units to the nearest curved nodal line, to first order."""
h = curved(u, v, p)
return np.abs(h) / (np.hypot(curved(u + e, v, p) - h, curved(u, v + e, p) - h) / e) * S
def edge_fade(u: Field, v: Field) -> Field:
"""1 inside the plate, easing to 0 over FADE at its edges."""
return smoothstep(0, FADE, np.minimum.reduce([u, v, 1 - u, 1 - v]))
def central_loop(p: Mode) -> Polygon:
"""The smallest curved nodal loop around the plate centre, in plate coordinates.
Raises ValueError when the mode has none."""
# offsets that keep every lattice node off the straight lines, where curved() is 0 / 0
du, dv = 0.3 / LATTICE, 0.6 / LATTICE
field = sample_field(
lambda i, j: curved(i / LATTICE + du, j / LATTICE + dv, p), LATTICE, LATTICE
)
centre = Point(0.5, 0.5)
loops = [
Polygon(line)
for line in iso_lines(field, 0, cell=1 / LATTICE, origin=(du, dv))
if len(line) > 3 and np.allclose(line[0], line[-1])
]
# the division is ill-conditioned right at the centre; loops that close there are noise
around = [q for q in loops if q.contains(centre) and q.exterior.distance(centre) > 0.05]
if not around:
raise ValueError(f"mode ({p.n}, {p.m}) has no nodal loop around the plate centre")
return min(around, key=lambda q: q.area)
def grains(x: Field, y: Field) -> Path:
"""One zero-length subpath per grain, drawn as a dot by a round-capped stroke."""
d = P()
for a, b in zip(x.tolist(), y.tolist(), strict=True):
d.M(a, b).H(a)
return d
@design(aspects=("16:9", "32:9", "9:19.5", "10:16"), variants=VARIANTS)
def draw(s: Canvas[Mode]) -> None:
p = s.params
# right of centre on a landscape screen, leaving the left for windows; high on a portrait one
corner = s.pick(landscape=(0.7, 0.5), portrait=(0.5, 0.42)) - (S / 2, S / 2)
band = central_loop(p).exterior.buffer(6 / S)
shapely.prepare(band)
g = s.np_rng(3)
u, v = g.random((2, 400_000))
val, grad = wave(u, v, p)
dist = np.abs(val) / np.maximum(grad, 1e-6) * S # to the nearest nodal line
keep = np.flatnonzero(g.random(u.size) < KEEP * gauss(dist, 2.6) * edge_fade(u, v))
# the loop gets its own tighter sampling below
keep = keep[~shapely.contains_xy(band, u[keep], v[keep])]
stray = g.random((2, 400))
stray = stray[:, g.random(400) < edge_fade(stray[0], stray[1])]
u, v = np.r_[u[keep], stray[0]], np.r_[v[keep], stray[1]]
axis = S * np.minimum.reduce(
[np.abs(u - v) / 2**0.5, np.abs(u + v - 1) / 2**0.5, np.abs(u - 0.5), np.abs(v - 0.5)]
)
# grains on the straight lines and towards the plate edge settle a step quieter
quiet = (axis < 5) | (g.random(u.size) > edge_fade(u, v))
x0, y0, x1, y1 = band.bounds
n = int(LOOP_SAMPLES * (x1 - x0) * (y1 - y0))
a = g.random((2, n)) * [[x1 - x0], [y1 - y0]] + [[x0], [y0]]
on = shapely.contains_xy(band, a[0], a[1])
a = a[:, (g.random(n) < gauss(loop_dist(a[0], a[1], p), 1.6)) & on]
def place(u: Field, v: Field, jitter: float) -> tuple[Field, Field]:
return u * S + g.normal(0, jitter, u.size), v * S + g.normal(0, jitter, v.size)
x, y = place(u, v, 0.6)
ax, ay = place(a[0], a[1], 0.3)
big = g.random(u.size) < 0.4
# in plate-local coordinates every grain is written a digit shorter
with s.group(transform=Affine.translate(corner.x, corner.y)):
for tone, col in ((quiet, UI), (~quiet, UI_ALT)):
for sel, w in ((~big, 1.3), (big, 1.6)):
s.stroke(grains(x[tone & sel], y[tone & sel]), col, w, cap="round")
s.stroke(grains(ax, ay), ACCENT, 1.6, cap="round")"""A Chladni figure: sand grains stippled along the nodal lines of a vibrating square plate, the loop around its centre picked out."""
import numpy as np
import shapely
from numpy.typing import NDArray
from shapely.geometry import Point, Polygon
from walldye import ACCENT, UI, UI_ALT, Canvas, P, Params, Path, design, knob, smoothstep
from walldye.field import gauss, iso_lines, sample_field
from walldye.geom import Affine
type Field = NDArray[np.float64]
class Mode(Params):
"""The vibration mode: two odd numbers of half-waves across the plate, n below m."""
n: int = knob(default=5, choices=(3, 5, 7), doc="the smaller mode number")
m: int = knob(default=11, choices=(7, 11, 13, 15), doc="the larger mode number")
VARIANTS = {"low": Mode(n=3, m=7), "high": Mode(n=7, m=15)}
S = 720 # plate side
FADE = 110 / S # the plate edge dissolves over this band instead of cropping the outer loops
KEEP = 0.46 # share of the grains near a nodal line that stay: sand, not ink
LOOP_SAMPLES = 356_000 # candidate grains per unit plate area for the central loop
LATTICE = 240 # cells across the plate when tracing the central loop
def wave(u: Field, v: Field, p: Mode) -> tuple[Field, Field]:
"""The plate's displacement cos(n pi u) cos(m pi v) - cos(m pi u) cos(n pi v) at plate
coordinates (u, v) in [0, 1], and the length of its gradient."""
a, b = p.n * np.pi, p.m * np.pi
val = np.cos(a * u) * np.cos(b * v) - np.cos(b * u) * np.cos(a * v)
gu = -a * np.sin(a * u) * np.cos(b * v) + b * np.sin(b * u) * np.cos(a * v)
gv = -b * np.cos(a * u) * np.sin(b * v) + a * np.cos(b * u) * np.sin(a * v)
return val, np.hypot(gu, gv)
def curved(u: Field, v: Field, p: Mode) -> Field:
"""The displacement with the four straight nodal lines (both diagonals and both midlines,
present when n and m are odd) divided out, so its zeros are the curved lines only."""
return wave(u, v, p)[0] / ((u - v) * (u + v - 1) * (u - 0.5) * (v - 0.5) + 1e-12)
def loop_dist(u: Field, v: Field, p: Mode, e: float = 1e-5) -> Field:
"""Distance in canvas units to the nearest curved nodal line, to first order."""
h = curved(u, v, p)
return np.abs(h) / (np.hypot(curved(u + e, v, p) - h, curved(u, v + e, p) - h) / e) * S
def edge_fade(u: Field, v: Field) -> Field:
"""1 inside the plate, easing to 0 over FADE at its edges."""
return smoothstep(0, FADE, np.minimum.reduce([u, v, 1 - u, 1 - v]))
def central_loop(p: Mode) -> Polygon:
"""The smallest curved nodal loop around the plate centre, in plate coordinates.
Raises ValueError when the mode has none."""
# offsets that keep every lattice node off the straight lines, where curved() is 0 / 0
du, dv = 0.3 / LATTICE, 0.6 / LATTICE
field = sample_field(
lambda i, j: curved(i / LATTICE + du, j / LATTICE + dv, p), LATTICE, LATTICE
)
centre = Point(0.5, 0.5)
loops = [
Polygon(line)
for line in iso_lines(field, 0, cell=1 / LATTICE, origin=(du, dv))
if len(line) > 3 and np.allclose(line[0], line[-1])
]
# the division is ill-conditioned right at the centre; loops that close there are noise
around = [q for q in loops if q.contains(centre) and q.exterior.distance(centre) > 0.05]
if not around:
raise ValueError(f"mode ({p.n}, {p.m}) has no nodal loop around the plate centre")
return min(around, key=lambda q: q.area)
def grains(x: Field, y: Field) -> Path:
"""One zero-length subpath per grain, drawn as a dot by a round-capped stroke."""
d = P()
for a, b in zip(x.tolist(), y.tolist(), strict=True):
d.M(a, b).H(a)
return d
@design(aspects=("16:9", "32:9", "9:19.5", "10:16"), variants=VARIANTS)
def draw(s: Canvas[Mode]) -> None:
p = s.params
# right of centre on a landscape screen, leaving the left for windows; high on a portrait one
corner = s.pick(landscape=(0.7, 0.5), portrait=(0.5, 0.42)) - (S / 2, S / 2)
band = central_loop(p).exterior.buffer(6 / S)
shapely.prepare(band)
g = s.np_rng(3)
u, v = g.random((2, 400_000))
val, grad = wave(u, v, p)
dist = np.abs(val) / np.maximum(grad, 1e-6) * S # to the nearest nodal line
keep = np.flatnonzero(g.random(u.size) < KEEP * gauss(dist, 2.6) * edge_fade(u, v))
# the loop gets its own tighter sampling below
keep = keep[~shapely.contains_xy(band, u[keep], v[keep])]
stray = g.random((2, 400))
stray = stray[:, g.random(400) < edge_fade(stray[0], stray[1])]
u, v = np.r_[u[keep], stray[0]], np.r_[v[keep], stray[1]]
axis = S * np.minimum.reduce(
[np.abs(u - v) / 2**0.5, np.abs(u + v - 1) / 2**0.5, np.abs(u - 0.5), np.abs(v - 0.5)]
)
# grains on the straight lines and towards the plate edge settle a step quieter
quiet = (axis < 5) | (g.random(u.size) > edge_fade(u, v))
x0, y0, x1, y1 = band.bounds
n = int(LOOP_SAMPLES * (x1 - x0) * (y1 - y0))
a = g.random((2, n)) * [[x1 - x0], [y1 - y0]] + [[x0], [y0]]
on = shapely.contains_xy(band, a[0], a[1])
a = a[:, (g.random(n) < gauss(loop_dist(a[0], a[1], p), 1.6)) & on]
def place(u: Field, v: Field, jitter: float) -> tuple[Field, Field]:
return u * S + g.normal(0, jitter, u.size), v * S + g.normal(0, jitter, v.size)
x, y = place(u, v, 0.6)
ax, ay = place(a[0], a[1], 0.3)
big = g.random(u.size) < 0.4
# in plate-local coordinates every grain is written a digit shorter
with s.group(transform=Affine.translate(corner.x, corner.y)):
for tone, col in ((quiet, UI), (~quiet, UI_ALT)):
for sel, w in ((~big, 1.3), (big, 1.6)):
s.stroke(grains(x[tone & sel], y[tone & sel]), col, w, cap="round")
s.stroke(grains(ax, ay), ACCENT, 1.6, cap="round")
Run it yourself
$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render chladni --theme fireproof -o chladni-fireproof-16x9.svg