procedural-films

Method

How the two films in this repository are made, and what carried over from the first one to the second. The code is plain JavaScript on Canvas 2D; the only external tools are a headless browser for rendering frames and ffmpeg for encoding.

1. Every frame is computed

Nothing is drawn by hand and nothing is loaded. Shapes come from geometry, tone from fields, motion from simulation, sound from synthesis, and quality is checked by measurement. The unit of work is not a canvas primitive but a placed mark: a dot of a halftone screen, a hatch line, a brush stroke.

A shape is always an array of points, never ctx.arc(). A circle is 120 points, so it can be bent, clipped, stroked, filled, projected into 3D or fed to a tone rule in exactly the same way as anything else.

2. Invariants first, frames second

Before any drawing, FLIP THE COIN fixed a handful of quantities that never change: the frame centre, the hero’s radius, its spin rate, the fall speed, the near-miss gap, and a cut grid of 12 frames (half a second at 24 fps, one beat of the score). Eleven unrelated scenes read as one film because the object in the middle is always the same size, at the same place, spinning at the same rate.

The invariants also do real work:

3. Scenes declare fields; rules print them

A scene never draws tone. It declares a field, dens(x, y) -> 0..1: how much ink falls on each point of the plane, derived from lighting, curvature, depth or material. A separate rule decides how that field becomes marks.

rule tone is carried by reads as needs from the scene
dot dot radius on a fixed grid (by area, r = .564·cell·√v) risograph, comics nothing
hatch line width woodcut, engraving a scalar coordinate for form-following lines
dither ordered or blue-noise threshold one-bit graphics an honest field, there is nothing to hide behind
stipple dot frequency scientific plates nothing
flow line length pen drawing a direction or gradient
band quantised steps cel animation real normals

TONE.rule = 'hatch' restyles a whole film without touching a scene (test/toneproof.html prints one sphere through all of them). The practical consequence: effort goes into fields. No rule rescues an empty field; a flat fill stays flat in any style.

Findings that shaped the rules:

4. Marks, not primitives

Measured on real drawings, the thing that reads as a hand is not tremor (0.03–0.58 px, invisible) but decision noise: where a mark lands, whether it is laid at all, how long it is. A quarter of human marks miss by more than a millimetre. Visible waviness is 2–5 cycles per stroke, pressure follows a minimum-jerk profile (p(τ) = 10τ³ − 15τ⁴ + 6τ⁵: thin slow ends, a confident middle), and strokes close in time share one oscillator. Thousands of strokes are collected into one Path2D and filled once.

5. Determinism

That last property pays off everywhere: any frame can be rendered alone, a film can be split across processes with no synchronisation, a changed range can be re-rendered without touching the rest, and a refactor can be checked by comparing frames byte for byte, which is how the comment clean-up of this repository was verified.

6. Motion from simulation, choreography from arithmetic

Tweens give “moved from A to B”; simulation gives weight. The chain is springs plus one root-to-tip pass that clamps each joint at exact link length (no iterations, no stretch). Fur is individual hair around a hidden core. Branches grow by space colonisation with pipe-model thickness.

The drops of HYDRO MASSACRE are a centre of mass plus one deformation mode, the Rayleigh l = 2 mode, with exaggerated surface tension. Deformation is a nematic vector Q = q·(cos 2φ, sin 2φ) (a stretch axis φ is the same as φ + π), so squash along a support normal and stretch along velocity add as plain vectors, and the whole deformation is one damped 2D oscillator.

Choreography is solved rather than tuned: path first, then the world. Contacts are fixed in time and space and starts are solved backwards. The two drops launched from different launchers ended 3.6 m apart in height at the meeting; since gravity is equal, the difference grows linearly in time, so one correction to one launch speed closes it, found by re-running the flight with a secant update (four runs to a centimetre). Sets are then placed where the drops will be on their reference frames, measured in frame pixels, so a change to the air model moves the sets with the path. Near misses are guaranteed by geometry: a bat pivots outside the frame at exactly bat length + ball radius + gap from the ball, so its tip cannot get closer at any angle.

Slow motion is a time map: the film runs evenly while world time slows, and every simulation, including rain and sign jitter, steps by world time.

7. Space without models

The camera is one function, project(x, y, z). Everything else keeps working because the output is again an array of flat points. The number of vanishing points is not a setting: a family of parallel lines converges only if it is not parallel to the image plane, so yaw alone gives two points and yaw with pitch gives three (test/spaceproof.html computes and marks them). Depth comes from marks rather than scale: a fixed screen pitch, contrast lost to air, fewer marks rather than smaller ones, and parallax.

8. Water, optics and focus

“Water” is three different problems with three models (lib/water.js):

problem model
open surface Gerstner waves: analytic height anywhere, the Jacobian doubles as a foam field
response to a disturbance the wave equation on a grid, nine-point Laplacian (five points turn rings into squares)
liquid changing shape position-based fluids, with constants calibrated on the first step

A single drop is solved analytically: ray-ellipsoid intersection, Snell, Fresnel (Schlick, with the cosine taken on the less dense side), total internal reflection inside air bubbles, Beer–Lambert absorption, and floor caustics by pushing sun rays forward through the drop. The inverted world inside the drop, both highlights and the silver ring of a bubble are not drawn; they come out of the optics.

HYDRO MASSACRE builds its frames as a stage of depth cards (lib/stage.js): each sample is a ray that accumulates ink through cards with coverage and refracts through drops. Two ideas matter most:

9. Checking by numbers, frame by frame

A contact sheet does not show holes in a composition, broken invariants or an object that popped in between two frames. The checks here measure finished frames:

Numbers do not replace looking; they catch what looking misses, mostly in motion.

10. Sound, the same way

No samples in FLIP THE COIN. A note is a waveguide string (delay line SR/f, one-pole loss filter, fractional-delay allpass, DC blocker) followed by a body of three band-pass biquads; bells are modal synthesis at inharmonic ratios; the room is eight combs and four allpasses with early reflections. Instruments at equal gain differed up to 9x in loudness, so each is measured on a test note and scaled by its RMS. Layer balance is measured with stems: a layer’s share is sqrt(full² − without²), which is how the harp turned out to take 38% of the mix.

Harmony follows the same rule as the colour chain: neighbouring chords share exactly two tones, and at every cut two voices hold through it. The beat is 12 frames, so every pluck lands on an edge-on frame. In HYDRO MASSACRE the heart beats on the frames where the divider’s ECG crosses the middle of the screen (the same formula), and the rain’s density follows the time map.

11. Rendering and delivery

FLIP THE COIN was rendered at four times the size (4320×4320) and reduced with Lanczos. Beyond a cleaner screen, the encoder stops spending bits on shimmering dots, so the file got both more accurate and smaller. Measured against the source PNGs, the master went from 436 MB to 149 MB while SSIM rose from 0.9843 to 0.9891. tune=grain was not worth it: it treats the screen as noise to preserve, but the screen is deterministic geometry, and it doubled the bitrate. HYDRO MASSACRE renders natively at 3840×2160 (?scale=2), since everything is vector marks under a base transform.

A lesson from uploading: FLIP THE COIN played fine locally but jumped back to the start on YouTube after 14 seconds, because of rare open-GOP keyframes. HYDRO MASSACRE’s masters use a closed GOP of exactly 48 frames with no scene-cut keyframes, and the build counts keyframes and fails otherwise.

12. Silent failures worth remembering

Each of these looked correct in code and was only visible in a frame or a waveform:

  1. Batch.stroke with closed: true outlines but does not fill; spikes read as barbed wire.
  2. A difference blend over an empty canvas gives white, and white strokes drawn over it vanish.
  3. Random draws after a continue (section 5).
  4. A flat disc spinning about the vertical axis has no visible direction (x = u·cos θ only squashes); the eye flips it every half turn. A slightly raised camera puts points on ellipses and fixes it.
  5. fbm summed raw sits around ±0.31, so thresholds like clamp(fbm() * 1.8 - .78, 0, 1) were always zero and whole texture layers silently disappeared. It is normalised to 0..1.
  6. A five-point Laplacian makes circular waves square after a few dozen steps.
  7. PBF constants set as numbers were off by 196x (density) and nine orders of magnitude (eps): no pressure at all. They are measured on step one.
  8. A state-variable filter blows up on a cutoff sweep, and one NaN poisons the rest of the track. RBJ biquads do not.
  9. A unipolar excitation accumulates DC in a waveguide loop until the note decays to silence.
  10. Notes started a few beats before a shutter kept ringing after it closed; limits must be in time, not in beats.
  11. An exception inside page.evaluate does not reach pageerror.