Which equations and which numbers actually move the sea and the body in this piece.
The explainer, *A deep dive into high-school physics and mathematics*, is written so a high-school student can read it. These notes are what sits behind it: the code and the numbers as they really run. Made by aTELiER FACTORY.
/3d/creature/leviathan/page/
index.html the piece + the explainer (one address; it switches views internally)
sea.js sea, sky, foam, spray, the heart of the rendering (~2,800 lines)
creature.js the shape of the body (segments, thickness, spines, fins)
rig.js attaches bones to the exported body (glb)
route.js the path the body travels (the order of the set pieces)
swim.js rippling, orientation, accumulation
muscle.js how segments connect (smoothness)
glsl.js noise and sky colour (read from the shaders)
web/
lev_body.glb the body mesh (~20MB)
Everything is computed inside the browser. Each frame runs in this order: position along the path → body segments → deformation of the water surface → foam and spray → render.
The sea in this piece is a superposition of eight sine waves differing in direction, height and wavelength. These are all the numbers there are (WAVES in sea.js).
| # | dir x | dir z | height A (m) | wavelength λ (m) | steepness |
|---|---|---|---|---|---|
| 1 | 1.00 | 0.10 | 1.144 | 31.7 | 0.42 |
| 2 | 0.90 | −0.42 | 0.638 | 16.7 | 0.46 |
| 3 | 1.00 | 0.52 | 0.361 | 9.2 | 0.51 |
| 4 | 0.78 | −0.62 | 0.194 | 5.1 | 0.56 |
| 5 | 1.00 | 0.20 | 0.106 | 2.7 | 0.72 |
| 6 | 0.58 | 0.82 | 0.055 | 1.5 | 0.74 |
| 7 | 1.00 | −0.30 | 0.026 | 0.8 | 0.74 |
| 8 | 0.40 | 0.92 | 0.012 | 0.4 | 0.75 |
Both the heights and the wavelengths are a stormy sea on Earth scaled down to one fifth: the body is 300m, and at full scale the waves would have to be tens of metres high. Gravity is not scaled — it stays at 9.81 m/s².
In deep water the phase speed depends only on the wavelength.
ω = √(g k), k = 2π / λ, c = ω / k = √(g / k)
The 31.7m wave runs at about 7.0 m/s, the 0.4m wave at about 0.8 m/s. Longer waves are faster, so the eight crests keep sliding relative to one another and the same shape never comes back.
Adding sines alone gives crests and troughs of equal roundness. A real sea has sharp crests and flat troughs. So each water particle is moved not only up and down but along its direction of travel as well (a Gerstner, or trochoidal, wave).
// for one wave (Q is the steepness, the "steepness" column above)
float phase = k * dot(dir, xz) - omega * t;
y += A * cos(phase);
xz += dir * (Q * A * sin(phase)); // <- this is what sharpens the crest
In a Gerstner wave the surface area collapses at the top of the crest. How far it has collapsed is measured by the Jacobian — how much area stretches or shrinks when you change coordinates.
J = 1 − Σ Qᵢ Aᵢ kᵢ cos(phaseᵢ)
J close to 0 = the area has collapsed = that is a whitecap
The smaller J gets, the denser the white foam laid on top. The positions of the whitecaps are never painted by hand.
The same noise is layered eight times, each layer half the size and 1/1.35 the strength (the fbm in glsl.js).
value = Σ (1/1.35)ⁿ · noise(2ⁿ · p)
From the strength ratio g and the size ratio 2, the Hurst exponent is H = log(1/g) / log 2 ≈ 0.43 — chosen to match the fractal dimension measured for real clouds (Lovejoy 1982, about 1.35).
Raindrops fall at 7m/s and the wind blows at 17m/s. Adding the two vectors slants the rain.
tanθ = 17 / 7 → θ ≈ 68° (from the vertical)
Travelling through murky water or air, light weakens exponentially with the distance covered (the Beer–Lambert law).
T = exp( −(0.012 d + 0.0069 D) )
d: distance travelled sideways (m) D: depth of water (m)
In water it halves about every 100m (0.0069 × 100 ≈ 0.69 = ln 2).
const SEG = 240; // number of segments
const STEP = 1.25; // spacing between segments (m)
// total length = 240 × 1.25 = 300 m
Thickness runs from head to tail by this formula (creature.js).
const radiusAt = i => {
const s = i / SEG;
return 5.2 * Math.pow(Math.sin(Math.PI * Math.pow(Math.min(s + 0.030, 1), 0.42)), 0.62);
};
At its thickest the radius is 5.2m (10.4m across). The exponent 0.42 puts the thickest part towards the front, after which it tapers slowly to the tail.
Each segment takes the orientation of the segment in front and rotates it by however much the path has turned — close in spirit to a parallel-transport (Bishop) frame. Angles are carried as unbounded numbers and the nearest representative is chosen each time, so nothing flips over at the moment 360° is crossed.
For an animal that swims by rippling (the eel type), speed follows this relation.
U = f · λ · slip
f: strokes per second (1/s) λ: wavelength of the wave travelling down the body (m) slip: 0–1
The individual swimming on the last page of the explainer uses λ = 195m, slip = 0.75 and U = 24 m/s, and from those the stroke period of 6.1 seconds is calculated — none of it is hard-coded.
const om = 2 * Math.PI * (U / SLIP) / LAM; // 1.03 rad/s -> period 6.1 s
const off = AMP * Math.sin(2 * Math.PI * back / LAM - om * t);
The sideways amplitude is AMP = 14m (±14m, about 9% of body length). Real eels also sweep their tails by roughly a tenth of their body length.
The larger the animal, the slower each stroke. The power it can produce scales with muscle volume (length cubed), while the power lost to drag scales with cross-section × speed cubed (length squared × v³), so
L³ ∝ L² v³ → v ∝ L^(1/3)
Taking a 30m blue whale sprinting at 10 m/s as the reference, a 300m body gives 10 × 10^(1/3) ≈ 21–24 m/s. The 150 m/s reached in the set pieces is far beyond that estimate — it is staging (see "9. Where the physics was set aside").
F = ρ V g (Archimedes' principle)
a = ( s − 1 ) g s: how much of the segment is submerged (0–1)
The body is given the same density as water (ρ_body = 1). Fully submerged, it neither rises nor sinks; only the part out of the water tips the balance towards weight, and falls.
The velocity is split into a component along the body and one across it, and each is applied squared. Across the body it counts 25 times more, because pushing water sideways with a long thin body is very hard work.
v = v∥ + v⊥
a∥ = −k v∥²
a⊥ = −25 k v⊥² k ≈ 0.010 (derived from the radius + 1m)
That difference is what makes the body follow the track the head has cut.
When a peak in the path rises above the surface, the head comes out into the air. A real animal would tire of holding its neck up, so it is brought down faster than falling alone.
Δy = −½ A u² A = 5g ≈ 49 m/s² u: seconds since the snout left the water
The push continues only until the snout reaches 10m below the surface, after which a cubic joins it smoothly back to the original path.
As the body enters the water, only the leading edge opens a hole. The segments behind pass through the hole already open.
entering: leading edge π r², body behind π ( r² − r_hole² )
leaving: the lifted sheet of water tears away and follows y = v₀ t − ½ g t²
Every metre travelled displaces π r² of water, which flows around the sides of the body. The radius of the spreading ring is
r(d) = √(g d) × (distance behind) ÷ v
Pressure drops around anything moving fast, and water boils while still at room temperature. Whether it happens is decided by the cavitation number σ.
const RHO = 1025, P_ATM = 101325, P_VAP = 2300; // sea-water density / atmospheric pressure / vapour pressure at 20°C
const cavSigma = (depth, v) => (P_ATM + RHO * 9.81 * depth - P_VAP) / Math.max(0.5 * RHO * v * v, 1);
Boiling occurs where σ falls below the local threshold σᵢ. In this piece σᵢ = 0.6 just behind the neck (0–12m from the head), 0.3 further back, and 1.0 at the tips of the spines — so the spine tips boil the most readily.
The bubbles are kept in three separate pools.
const CAV_P = 40000; // bubbles boiled off by pressure (short-lived, thousands per frame)
const CAV_AIR = 20000; // air dragged down from the surface (lasts 5–15 s)
const CAV_BRK = 30000; // foam from breaking waves
Not the exact Fresnel equations, but Schlick's approximation.
float fres = 0.022 + 0.978 * pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 5.0);
0.022 is water's reflectance at normal incidence (it follows from the refractive index 1.33). The shallower the angle, the closer to 1, which is why the distant surface mirrors the sky.
Anywhere that has just left the water stays looking wet for a while.
light returned = light arriving × 0.78 (wet is darker)
reflection from running water = 1 − (seconds since leaving) ÷ 3 (dry in 3 s)
| # | How the explainer puts it | The formula actually used | Where |
|---|---|---|---|
| 01 | Waves add up | eight superposed sines + Gerstner | sea.js WAVES |
| 02 | Further is darker | T = exp(−(0.012d + 0.0069D)) | haze in sea.js |
| 03 | Paths add up too | the path is built from sines of differing periods | route.js |
| 04 | Corkscrew | rotate around the direction of travel (rotation matrix) | route.js MOVE_HELIX |
| 05 | A ceiling on height | y′ = B + K(1 − e^(−(y−B)/K)) | route.js |
| 06 | How sharp the turn | R = h / (2 sin(θ/2)) (sine rule) | swim.js |
| 07 | Back into the water | Δy = −½ A u², A = 5g | route.js |
| 08 | Float or sink | a = (s − 1) g | swim.js |
| 09 | Water resistance | a∥ = −k v∥², a⊥ = −25k v⊥² | swim.js |
| 10 | Which way up | a = v²/R, cos τ = 2g ÷ 0.6a | swim.js |
| 11 | Speed boils water | σ = (p₀ + ρgh − pᵥ) ÷ ½ρv² | sea.js cavSigma |
| 12 | Breaking the surface | π r² and π(r² − r_hole²), projectile motion | sea.js emitAirSheet |
| 13 | Getting wet | ×0.78, dry in 3 s | rig.js |
| 14 | The water pushed aside | π r², spreading as √(g d) | sea.js updateSnow |
| 15 | Painting the sky | fbm (half the size, 1/1.35 the strength) | glsl.js NOISE |
| 16 | University tools | Jacobian, potential flow, Fresnel, turbulence | in the explainer |
The piece cares about running on high-school equations, but there are places where that does not hold as it stands. Here they are, unhidden.
?knobs=1 is experimental). Drag, buoyancy and orientation are used to decide how it looks *on* that pathOne single file containing all sixteen sections of the explainer. Open it in a browser and it runs. All the code is inside that one file, numbered 【01】 to 【16】 so you can see which part is which.
| No. | What the demo does there |
|---|---|
| 01 | eight Gerstner waves (the same numbers as the table in 1-1) |
| 02 | darkening with depth (e^(−0.0069 D)) and distant haze |
| 03 | one of the real paths from the piece, unchanged (a set piece from page/route.js): it rises out of the dark at 68m, sweeps past the window and dives again |
| 04 | twisting the body around its direction of travel (the "twist" slider) |
| 05 | capping the path exponentially so it does not rise above the surface |
| 06 | finding the radius of the turn from the circle through three points (the circumcircle) |
| 07 | pulling the head back at 5g once it leaves the water |
| 08 | a = (s − 1) g from each segment's submerged fraction s |
| 09 | drag −k v² (25× vertically). The forward speed also settles where drag balances it |
| 10 | banking the body from the radius of the turn |
| 11 | emitting bubbles from the cavitation number σ |
| 12 | spray flying as a projectile from wherever the body crosses the surface |
| 13 | anything out of the water darkens to ×0.78 and recovers over 3 seconds |
| 14 | particles around the body being pushed aside and flowing outwards |
| 15 | cloud in the sky (six layers, each half the size and 1/1.35 the strength) |
| 16 | whitecaps wherever the Jacobian has collapsed |
Controls: drag to orbit, wheel to zoom, R to return to the chase camera. There are four sliders (forward speed, wave height, rippling amplitude, twist).
The top left of the screen shows the numbers being computed right now: stroke period, submerged fraction, σ, radius of the turn, bank.
To run it on your own machine: save the file and replace the single import line at the top with this.
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/" } }
</script>
What is not in it: the look of the body (scales, spines, eyes, the inside of the mouth), the exported mesh (~20MB), the rain, and the finer detail of the marine snow. The body is just a tube. This demo exists to be read, so you can see how it works.
The text of these technical notes, and the code of the working demo, are CC0 1.0 (public domain).
Anyone may use them, for anything, without attribution. Commercial use is fine. No need to get in touch.
For the form, the look, the exported data (.glb) and the images of the piece *Leviathan*, no rights are claimed either. If you want to use them, use them.
One thing stated plainly. All of this assumes that the people who made it (aTELiER FACTORY) remain free to keep using this piece and these methods. No one may claim rights over the same material and stop us from using it. This document is published with a date, and that in itself is a record that it was out in the world first.
Note that the three.js used by the demo is MIT-licensed (Copyright 2010–2024 Three.js Authors). If you redistribute three.js alongside it, keep that notice.
Made by aTELiER FACTORY