procedural-films

Procedural films

Two short animated films in which every frame and every sound is computed in plain JavaScript on Canvas 2D. No images, fonts or audio samples are loaded: every dot of the halftone, every hatch line, every glyph and every note is placed by code, and any frame can be rendered on its own from its number.

FLIP THE COIN (29.5 s, finished). A cat’s tail knocks a coin off a kitchen table, and it falls through eleven unrelated worlds. The object in the centre never changes size or spin rate; every cut lands when it is edge-on, and the swap to the next object (moon, apple, ball, bottle cap, disc, ring, chip, tablet, eye) hides in that sliver.

FLIP THE COIN

HYDRO MASSACRE (41 s, part 1, nearly finished). Split screen: two raindrops, launched by a tin gutter and a poplar leaf, fly toward each other across a rainy city. The city reaches for them with spikes and never touches them; it can only kill their reflections in the glass.

HYDRO MASSACRE

Full clips with their synthesised sound: flip-the-coin.mp4, hydro-massacre.mp4. Stills: kitchen, reflection, glass trap.

How it works

The longer version, with measurements and the mistakes that led to each decision, is in docs/method.md. The shared engine is in lib/.

Tone is a field; the print style is a rule

A scene never draws tone. It declares dens(x, y) -> 0..1, the amount of ink at each point, derived from lighting, depth and material. A separate rule turns the field into marks: AM halftone dots sized by area, engraving lines, ordered or blue-noise dither, stipple, pen lines along a flow, or flat cel bands. Changing one line (TONE.rule = 'hatch') restyles a film without touching a scene. Hatch lines are isolines of a coordinate the scene supplies, not strokes traced along a direction field, so they can never converge into blots. FLIP THE COIN also has a riso mode (?v=2) that separates each fill into per-ink coverage layers, then screens every ink once at its own angle with a little misregistration.

Defocus in the field, not on pixels

HYDRO MASSACRE’s frames are a stage of depth cards traced per sample: a ray crosses the cards front to back, accumulates ink with coverage and refracts through drops. Each card is baked in its own coordinates and blurred by its own circle of confusion before printing, so the marks stay crisp while the image goes soft (blurring a halftone gives grey mush). Tone is stored premultiplied by coverage so dark objects in defocus do not grow halos. A focus pull bakes a card twice and blends the two bakes.

Water as three models, picked per job

Open water is Gerstner waves (analytic height anywhere; the Jacobian is a free foam mask). Ripples are the wave equation on a grid with a nine-point Laplacian, because five points turn rings into squares. Liquid that changes shape is position-based fluids with constants calibrated on the first step. A single drop is pure optics: ray-ellipsoid intersection, Snell, Fresnel, total internal reflection in bubbles, Beer–Lambert, and floor caustics by pushing sun rays through it. Mirrors send the ray into a separate reflected world, which is how a drop’s double can die in the glass while the drop flies on.

A small 3D camera over point arrays

Every shape is an array of points, so 3D is just a third coordinate and one project(x, y, z). Vanishing points are not a setting; they appear from yaw and pitch. Depth comes from marks rather than scale: the halftone pitch stays fixed on the sheet, far things lose contrast and get fewer marks.

Deterministic randomness: any frame renders alone

All randomness is seeded per purpose (makeR(key)), and every random draw happens before any early continue, or one skipped mark reshuffles the whole field every frame. HYDRO MASSACRE simulates its whole act once at load at 1/960 s and renderFrame(f) only reads the record. So frames render in any order, a film can be split across processes, and the comment clean-up of this repository was verified by rendering reference frames and both soundtracks before and after and comparing them byte for byte.

Choreography is solved, not tuned

Contacts are fixed first and the paths are solved backwards. Two drops launched from different launchers arrived 3.6 m apart in height; since height difference grows linearly under equal gravity, one launch-speed correction closes it, found by re-running the flight with a secant update. Sets are placed where the drops will be on screen, and near misses are guaranteed by geometry (a bat pivots outside the frame at exactly its length plus the ball radius plus the gap).

Frame-level QA that catches pop-ins and teleports

tools/verify.py checks FLIP THE COIN’s invariants on finished frames: cut grid, edge-on cuts, spin direction, byte-identical holds, no frame throwing. For HYDRO MASSACRE, scenes register their objects every frame and tools/act.py walks every frame of a range, flagging anything that appears or vanishes mid-frame without entering past an edge or growing from zero, jumps more than three times its own typical step, or has a spike with no root on its pane. tools/pop.py asks the same question of the pixels, for everything that is not registered: a compact region whose mean tone changes far more in one frame than in its neighbours is a pop-in, while lightning is told apart by changing the whole frame in one direction.

Sound from models

FLIP THE COIN’s score is a waveguide string into a body of band-pass resonators, modal bells and a comb-and-allpass room, calibrated by RMS and balanced with stems. Its harmony follows the colour chain: neighbouring chords share two tones and two voices hold through every cut, and the beat is the 12-frame cut grid. HYDRO MASSACRE’s heart beats on the frames where the on-screen ECG crosses the centre, and the rain thins in slow motion.

Running it

Open flip-the-coin/fall.html in a browser and it plays, drawing every frame live. It is not real-time: in headless Chromium on my machine a frame takes about 60 ms, so it runs near 15 fps against the film’s 24, without sound. ?f=120 shows a single frame, ?s=2 renders at 2160×2160, ?v=2 switches to the riso separation.

hydro-massacre/hydro.html has no playback loop. It spends about 20 s simulating the act on load and sets window.READY; then renderFrame(400) in the console draws a frame in 2–5 s. docs/viewer.html wraps this in a frame slider (it needs the files served over http, e.g. python -m http.server).

Rendering frames and video needs Python 3 with Playwright, Pillow and imageio-ffmpeg (pip install playwright pillow imageio-ffmpeg, then python -m playwright install chromium). Output goes to each film’s out/.

cd flip-the-coin
python tools/shoot.py one 120          # one frame
python tools/shoot.py video 708        # all frames and out/fall.mp4
python tools/sound.py                  # synthesise the score and mux it
python tools/verify.py                 # invariants over all 708 frames

cd ../hydro-massacre
python tools/film.py frame 400         # one frame (step=2, scale=2 for final quality)
python tools/film.py video             # all 984 frames, split across processes
python tools/sound.py                  # synthesised score, muxed
python tools/act.py 440 520            # per-frame object bookkeeping
python tools/pop.py                    # pixel-level pop-in check over out/act1

python test/proof.py toneproof.html    # proof sheets for the library
python test/measure.py ripples         # numeric checks: ripples, fluid, settle

Layout

lib/              shared engine: core (noise, strokes, physics), tone, space,
                  water, stage, sound; README.md is the map
flip-the-coin/    fall.html driver, engine.js and kit.js, scene1–11.js,
                  transitions.js, press.js (riso mode), sound.js, tools/
hydro-massacre/   hydro.html driver, world.js and act1.js (simulation),
                  scene and set files, glass.js, flood.js, sound.js, tools/
test/             proof pages for lib and numeric measurements
docs/             method.md, the Pages index and a frame viewer (publish Pages
                  from the repository root; the index is at /docs/)
media/            preview clips, GIF loops and stills

Known limitations

What I’d do next

License

MIT, see LICENSE.