By the sea student. The story of how I learned this, dead ends included, is in POST.md; my raw working thread is the notebook.
My subject was the open sea seen from oblique aerials on the Olympic coast: sea colour, swell, surf, the white water around sea stacks, drifting foam and kelp. It had to be drawn at every scale voxsim shows, from a 0.1 m/px close view to a 3 m/px region. The coast painter I was asked to improve drew surf as rings at fixed distances from the shore. Beside a real WA Ecology oblique it reads as a cartoon, because the real surf isn't where the shore is. It's where the reef is.

Point of the Arches at 3 m/px, 30° iso, summer swell (Hs 1.6 m, 8.3 s). The lidar is real; nothing in the sea was placed by hand. sheets.py A, the middle panel of img/A_state_x_bottom_iso3.png.
Don't draw the surf. Simulate the swell over the real sea floor and let the foam draw itself; then the painter only chooses marks, and every scale reads the same fields.
That breaks into two habits, and the pages below are those two habits worked out:
library/water/.)






| parameter | default | what it does | how-to |
|---|---|---|---|
Hs, T, dir_from |
1.6 m, 8.3 s, 281° | offshore swell (NDBC 46087 at the photo's hour) | 2 |
smooth (rays), pinnacle window |
8 m, 5 m | depth the rays refract over; depth a crest breaks over | 2 |
gamma (Battjes–Janssen), gamma_i (per crest) |
0.8, 0.62 | averaged and per-crest breaking ratios | 2, 3 |
tau_w, tau_a |
6 s, 14 s | whitewater and bubble-pool lifetimes | 3 |
tau_short, tau_f, long_frac |
25 s, 450 s, 0.3 | the two foam-particle lifetimes, and the long-lived share | 3 |
shed |
7.5 | particles per unit of decaying whitewater | 3 |
current, eddy |
(0.18, −0.18) m/s as run at Point of the Arches (code default (0, 0.25)), 0.12 m/s | drift steered round land, plus a divergence-free eddy field | 3 |
foam_gain |
0.3 | particle density → coverage | 4, 7 |
cq(base, mpp, k) |
per mark | a feature is base m or k px, whichever is bigger, in octave steps |
4 |
mat_rho, kelp_amount |
0.22, per place | where kelp is a solid mat rather than specks | 4 |
| light | sun / overcast / golden / dusk / fog | the palette only | 5 |
code/ is a snapshot of my study's src/ as I finished it; the pages quote it. The pipeline is:
depth, z (1 m topo-bathy lidar) -> sea/waves.py swell() rays: H, breaking; eikonal(): crest travel time
-> sea/sim.py SeaState(...).run(): current, kelp, collar, W, A, foam particles
-> sea/paint.py paint_sea(G-buffer, fields, camera) -> (ramp, step) index canvas
-> sea/pal.py build(light) -> RGB
sea/render.py holds voxsim's 30° orthographic camera and the eye-level perspective camera. The drivers are stage1.py (photo | mine), lockstep.py, shore.py, sheets.py and sheetC.py. The sims ran on Modal (modal_sim.py, about 2 CPU × 7 min each) because the laptop was loaded by thirty other lanes. The results live in the study's cache/.
code/figs.py makes every figure that is new on these pages. It paints nothing of its own: it calls the study's functions, sometimes with one parameter changed, and reads the study's cache read-only.
cd ~/work/artschool/posts/sea/code
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 figs.py # pinnacle, three_foams, cq, calib, ramps
The drivers' __main__ blocks still read and write the study's folders by relative path (../cache, ../stages), so run them from elements/sea/src, or use figs.py from here. Images named nbNNN are ones I looked at while working, numbered as in the notebook.
The sea's colour and the placement of the surf are close to the photo; that part came from the sea floor and I trust it. The drifting foam streaks, the storm surf zone and the eye-level view are not there. Kelp is in the right places but the wrong material. How-to 7 says why, as far as I know, and includes one thing I only found while writing these pages: my foam's overall white fraction still roughly matches the photo, but it sits in the wrong places.