Simulation
A sea, from the wave equation
an animated sea, from the wave equation rather than from a sine.
catalog/qu_sea_waves.qu
The point of doing this in a measurement language rather than a drawing one: the water is not decoration, it is a superposition of real gravity waves, and every number in it means something.
THE PHYSICS
Deep-water gravity waves are DISPERSIVE: a wave's speed depends on its wavelength. From the dispersion relation
omega^2 = g * k (deep water, k = 2*pi/wavelength)
a long swell travels fast and a short chop travels slowly, so a sea built from several components never repeats its shape -- which is exactly why a single sine looks like a cartoon and this does not. Summing components with random phases is the standard model (a Pierson-Moskowitz-like spectrum), and the amplitudes here fall off with frequency the way a real wind sea's do.
Foam appears where the surface slope is steepest, which is where a real wave breaks. Nothing chooses it by hand: it is a threshold on d(eta)/dx.
THE HORIZON is a second, slower field drawn small and pale -- the same maths at a distance, which is all perspective is here.
What it exercises
- animate("frame", n, path, fps=) — one figure per frame, one SVG
- polygon / plot / circle — the surface, the crests, the sun
- hsv — a sky gradient whose bands all weigh
- the same
- the deep-water dispersion relation, honestly applied
How it works
The sea state
Eight components. Each carries a wavelength, an amplitude, and a phase; the frequency is not chosen, it FOLLOWS from the wavelength through the dispersion relation, which is the whole point.
N_WAVES = 8
lengths = ()
amps = ()
phases = ()
for i in 0 to N_WAVES - 1
# Wavelengths from 24 m down to 2 m, geometrically spaced.
wl = 24.0 * (0.72 ^ i)
lengths = append(lengths, wl)
# A wind sea's short waves are small: amplitude falls with wavelength.
amps = append(amps, 0.34 * (wl / 24.0) ^ 0.8)
# Fixed phases, so the animation is reproducible frame for frame.
phases = append(phases, 6.2831853 * ((i * 0.6180339887) - floor(i * 0.6180339887)))
end for
function surface(x, t, scale)
# eta(x, t) = sum a_i * cos(k_i * x - omega_i * t + phi_i),
# with omega_i = sqrt(g * k_i) -- the deep-water dispersion relation.
eta = 0.0
for i in 0 to N_WAVES - 1
k = 2 * pi / lengths[i]
omega = sqrt(G * k)
eta = eta + amps[i] * cos(k * x - omega * t + phases[i])
end for
return eta * scale
end function
function slope(x, t, scale)
# d(eta)/dx, analytically rather than by differencing -- the derivative
# of a sum of cosines is free, and a difference would need a step size
# chosen against the shortest wavelength.
d = 0.0
for i in 0 to N_WAVES - 1
k = 2 * pi / lengths[i]
omega = sqrt(G * k)
d = d - amps[i] * k * sin(k * x - omega * t + phases[i])
end for
return d * scale
end function
Drawing
Blend two colours the way the eye reads a gradient, not the way the numbers happen to sit.
This is the whole argument for having Lab. Walking HUE from a warm horizon (24 degrees) to a blue zenith (218) passes through yellow and GREEN on the way -- the first version of this sky looked like a swamp. Lab is perceptually uniform, so a straight line through it is a straight line to the eye, and the blend goes warm-to-blue without visiting a colour nobody asked for.
function mix_lab(c1, c2, t)
a = to_lab(c1)
b = to_lab(c2)
return lab(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
end function
HORIZON = "#ffb26b"
MIDSKY = "#e2726b"
ZENITH = "#1d3a63"
function sky(t)
steps = 26
for k in -1 to steps + 1
u = k / steps
# Each band overlaps the next by a fifth of a step. Abutting
# polygons leave an anti-aliased seam at every shared edge, which is
# invisible once and reads as banding twenty times over.
y0 = 4.05 + (H - 4.05) * u
y1 = 4.05 + (H - 4.05) * (u + 1.2 / steps)
# Two segments: the warm band near the horizon is narrow and the
# blue above it is wide, which is how a dusk sky actually divides.
if u < 0.30
band = mix_lab(HORIZON, MIDSKY, u / 0.30)
else
band = mix_lab(MIDSKY, ZENITH, (u - 0.30) / 0.70)
end if
polygon((0, W, W, 0), (y0, y0, y1, y1), fill = band, alpha = 1.0)
end for
end function
function sun(t)
# Low and steady, with a halo. It does not move: the sea does.
cx = 14.4
cy = 4.9 + 0.06 * sin(t * 0.5)
for k in 5 to 1 step -1
glow = hsv(32, 0.55, 1.0)
circle(cx, cy, 0.34 * k, fill = glow, alpha = 0.055, width = 0.2)
end for
disc = hsv(38, 0.32, 1.0)
circle(cx, cy, 0.42, fill = disc, alpha = 1.0, width = 0.3)
end function
function distant_sea(t)
# The same maths at a tenth of the amplitude, drawn pale. Perspective,
# cheaply and correctly: distant waves subtend a smaller angle.
xs = ()
ys = ()
n = 120
for i in 0 to n
x = W * i / n
xs = append(xs, x)
ys = append(ys, 4.05 + surface(x * 3.0, t, 0.10))
end for
for i in n to 0 step -1
xs = append(xs, W * i / n)
ys = append(ys, 0.0)
end for
far = mix_lab(ZENITH, "#2f6f96", 0.55)
polygon(xs, ys, fill = far, alpha = 1.0)
end function
function water(t)
# The surface itself, as a filled polygon from the waterline down.
n = 300
xs = ()
ys = ()
for i in 0 to n
x = W * i / n
xs = append(xs, x)
ys = append(ys, 2.9 + surface(x, t, 1.0))
end for
for i in n to 0 step -1
xs = append(xs, W * i / n)
ys = append(ys, 0.0)
end for
deep = hsv(207, 0.70, 0.34)
polygon(xs, ys, fill = deep, alpha = 1.0)
# The sun's path on the water: a bright streak that follows the surface
# and only shows where the slope tilts a facet toward the light.
for i in 0 to n
x = W * i / n
s = slope(x, t, 1.0)
if abs(x - 14.4) < 4.0 and abs(s) < 0.22
y = 2.9 + surface(x, t, 1.0)
near = 1.0 - abs(x - 14.4) / 4.0
gleam = hsv(40, 0.42, 1.0)
circle(x, y, 0.055 + 0.05 * near, fill = gleam,
alpha = 0.20 + 0.55 * near, width = 0.1)
end if
end for
end function
function foam(t)
# Where the surface is steepest is where a real wave breaks, so that is
# where the foam goes. A threshold on the analytic slope, not a
# scattering of random dots.
n = 260
for i in 0 to n
x = W * i / n
s = slope(x, t, 1.0)
if s < -0.28
y = 2.9 + surface(x, t, 1.0)
strength = min((abs(s) - 0.28) / 0.35, 1.0)
white = hsv(200, 0.06, 1.0)
circle(x, y + 0.02, 0.05 + 0.09 * strength, fill = white,
alpha = 0.35 + 0.5 * strength, width = 0.1)
end if
end for
# And the crest lines themselves, as a thin bright stroke on top of the
# surface -- what catches the light on a real sea.
xs = ()
ys = ()
n2 = 300
for i in 0 to n2
x = W * i / n2
xs = append(xs, x)
ys = append(ys, 2.9 + surface(x, t, 1.0))
end for
plot(xs, ys, color = hsv(196, 0.22, 0.94), width = 1.1)
end function
function frame(i)
# Time advances so the LONGEST wave completes exactly one period over
# the animation, which is what makes the loop seamless: every other
# component is faster, and the slowest one closing means the sea
# returns to the pose it started in.
k_long = 2 * pi / lengths[0]
period = 2 * pi / sqrt(G * k_long)
t = period * i / FRAMES
figure_size(1100, 620)
xlim(0, W)
ylim(0, H)
theme("bw")
sky(t)
sun(t)
distant_sea(t)
water(t)
foam(t)
axis off
end function
Render
print("sea state: {N_WAVES} components, {lengths[N_WAVES - 1]:.1f} m to {lengths[0]:.1f} m")
k_long = 2 * pi / lengths[0]
print("the longest wave has period {2 * pi / sqrt(G * k_long):.2f} s -- one loop")
animate("frame", FRAMES, "sea_waves.svg", fps = 20)
print("open sea_waves.svg in a browser")
Results
sea state: 8 components, 2.4 m to 24.0 m the longest wave has period 3.92 s -- one loop open sea_waves.svg in a browser
The whole program
Runnable as it stands: qu run catalog/qu_sea_waves.qu
# qu_sea_waves.qu — an animated sea, from the wave equation rather than
# from a sine.
#
# The point of doing this in a measurement language rather than a drawing
# one: the water is not decoration, it is a superposition of real gravity
# waves, and every number in it means something.
#
# THE PHYSICS
#
# Deep-water gravity waves are DISPERSIVE: a wave's speed depends on its
# wavelength. From the dispersion relation
#
# omega^2 = g * k (deep water, k = 2*pi/wavelength)
#
# a long swell travels fast and a short chop travels slowly, so a sea built
# from several components never repeats its shape -- which is exactly why a
# single sine looks like a cartoon and this does not. Summing components
# with random phases is the standard model (a Pierson-Moskowitz-like
# spectrum), and the amplitudes here fall off with frequency the way a real
# wind sea's do.
#
# Foam appears where the surface slope is steepest, which is where a real
# wave breaks. Nothing chooses it by hand: it is a threshold on d(eta)/dx.
#
# THE HORIZON is a second, slower field drawn small and pale -- the same
# maths at a distance, which is all perspective is here.
#
# Exercises:
# animate("frame", n, path, fps=) — one figure per frame, one SVG
# polygon / plot / circle — the surface, the crests, the sun
# hsv — a sky gradient whose bands all weigh
# the same
# the deep-water dispersion relation, honestly applied
backend auto
FRAMES = 60
W = 20.0
H = 11.0
G = 9.81
#%% The sea state
#
# Eight components. Each carries a wavelength, an amplitude, and a phase;
# the frequency is not chosen, it FOLLOWS from the wavelength through the
# dispersion relation, which is the whole point.
N_WAVES = 8
lengths = ()
amps = ()
phases = ()
for i in 0 to N_WAVES - 1
# Wavelengths from 24 m down to 2 m, geometrically spaced.
wl = 24.0 * (0.72 ^ i)
lengths = append(lengths, wl)
# A wind sea's short waves are small: amplitude falls with wavelength.
amps = append(amps, 0.34 * (wl / 24.0) ^ 0.8)
# Fixed phases, so the animation is reproducible frame for frame.
phases = append(phases, 6.2831853 * ((i * 0.6180339887) - floor(i * 0.6180339887)))
end for
function surface(x, t, scale)
# eta(x, t) = sum a_i * cos(k_i * x - omega_i * t + phi_i),
# with omega_i = sqrt(g * k_i) -- the deep-water dispersion relation.
eta = 0.0
for i in 0 to N_WAVES - 1
k = 2 * pi / lengths[i]
omega = sqrt(G * k)
eta = eta + amps[i] * cos(k * x - omega * t + phases[i])
end for
return eta * scale
end function
function slope(x, t, scale)
# d(eta)/dx, analytically rather than by differencing -- the derivative
# of a sum of cosines is free, and a difference would need a step size
# chosen against the shortest wavelength.
d = 0.0
for i in 0 to N_WAVES - 1
k = 2 * pi / lengths[i]
omega = sqrt(G * k)
d = d - amps[i] * k * sin(k * x - omega * t + phases[i])
end for
return d * scale
end function
#%% Drawing
# Blend two colours the way the eye reads a gradient, not the way the
# numbers happen to sit.
#
# This is the whole argument for having Lab. Walking HUE from a warm
# horizon (24 degrees) to a blue zenith (218) passes through yellow and
# GREEN on the way -- the first version of this sky looked like a swamp.
# Lab is perceptually uniform, so a straight line through it is a straight
# line to the eye, and the blend goes warm-to-blue without visiting a
# colour nobody asked for.
function mix_lab(c1, c2, t)
a = to_lab(c1)
b = to_lab(c2)
return lab(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
end function
HORIZON = "#ffb26b"
MIDSKY = "#e2726b"
ZENITH = "#1d3a63"
function sky(t)
steps = 26
for k in -1 to steps + 1
u = k / steps
# Each band overlaps the next by a fifth of a step. Abutting
# polygons leave an anti-aliased seam at every shared edge, which is
# invisible once and reads as banding twenty times over.
y0 = 4.05 + (H - 4.05) * u
y1 = 4.05 + (H - 4.05) * (u + 1.2 / steps)
# Two segments: the warm band near the horizon is narrow and the
# blue above it is wide, which is how a dusk sky actually divides.
if u < 0.30
band = mix_lab(HORIZON, MIDSKY, u / 0.30)
else
band = mix_lab(MIDSKY, ZENITH, (u - 0.30) / 0.70)
end if
polygon((0, W, W, 0), (y0, y0, y1, y1), fill = band, alpha = 1.0)
end for
end function
function sun(t)
# Low and steady, with a halo. It does not move: the sea does.
cx = 14.4
cy = 4.9 + 0.06 * sin(t * 0.5)
for k in 5 to 1 step -1
glow = hsv(32, 0.55, 1.0)
circle(cx, cy, 0.34 * k, fill = glow, alpha = 0.055, width = 0.2)
end for
disc = hsv(38, 0.32, 1.0)
circle(cx, cy, 0.42, fill = disc, alpha = 1.0, width = 0.3)
end function
function distant_sea(t)
# The same maths at a tenth of the amplitude, drawn pale. Perspective,
# cheaply and correctly: distant waves subtend a smaller angle.
xs = ()
ys = ()
n = 120
for i in 0 to n
x = W * i / n
xs = append(xs, x)
ys = append(ys, 4.05 + surface(x * 3.0, t, 0.10))
end for
for i in n to 0 step -1
xs = append(xs, W * i / n)
ys = append(ys, 0.0)
end for
far = mix_lab(ZENITH, "#2f6f96", 0.55)
polygon(xs, ys, fill = far, alpha = 1.0)
end function
function water(t)
# The surface itself, as a filled polygon from the waterline down.
n = 300
xs = ()
ys = ()
for i in 0 to n
x = W * i / n
xs = append(xs, x)
ys = append(ys, 2.9 + surface(x, t, 1.0))
end for
for i in n to 0 step -1
xs = append(xs, W * i / n)
ys = append(ys, 0.0)
end for
deep = hsv(207, 0.70, 0.34)
polygon(xs, ys, fill = deep, alpha = 1.0)
# The sun's path on the water: a bright streak that follows the surface
# and only shows where the slope tilts a facet toward the light.
for i in 0 to n
x = W * i / n
s = slope(x, t, 1.0)
if abs(x - 14.4) < 4.0 and abs(s) < 0.22
y = 2.9 + surface(x, t, 1.0)
near = 1.0 - abs(x - 14.4) / 4.0
gleam = hsv(40, 0.42, 1.0)
circle(x, y, 0.055 + 0.05 * near, fill = gleam,
alpha = 0.20 + 0.55 * near, width = 0.1)
end if
end for
end function
function foam(t)
# Where the surface is steepest is where a real wave breaks, so that is
# where the foam goes. A threshold on the analytic slope, not a
# scattering of random dots.
n = 260
for i in 0 to n
x = W * i / n
s = slope(x, t, 1.0)
if s < -0.28
y = 2.9 + surface(x, t, 1.0)
strength = min((abs(s) - 0.28) / 0.35, 1.0)
white = hsv(200, 0.06, 1.0)
circle(x, y + 0.02, 0.05 + 0.09 * strength, fill = white,
alpha = 0.35 + 0.5 * strength, width = 0.1)
end if
end for
# And the crest lines themselves, as a thin bright stroke on top of the
# surface -- what catches the light on a real sea.
xs = ()
ys = ()
n2 = 300
for i in 0 to n2
x = W * i / n2
xs = append(xs, x)
ys = append(ys, 2.9 + surface(x, t, 1.0))
end for
plot(xs, ys, color = hsv(196, 0.22, 0.94), width = 1.1)
end function
function frame(i)
# Time advances so the LONGEST wave completes exactly one period over
# the animation, which is what makes the loop seamless: every other
# component is faster, and the slowest one closing means the sea
# returns to the pose it started in.
k_long = 2 * pi / lengths[0]
period = 2 * pi / sqrt(G * k_long)
t = period * i / FRAMES
figure_size(1100, 620)
xlim(0, W)
ylim(0, H)
theme("bw")
sky(t)
sun(t)
distant_sea(t)
water(t)
foam(t)
axis off
end function
#%% Render
print("sea state: {N_WAVES} components, {lengths[N_WAVES - 1]:.1f} m to {lengths[0]:.1f} m")
k_long = 2 * pi / lengths[0]
print("the longest wave has period {2 * pi / sqrt(G * k_long):.2f} s -- one loop")
animate("frame", FRAMES, "sea_waves.svg", fps = 20)
print("open sea_waves.svg in a browser")