procedural-films

lib

The part of the engine that survives a change of style. The rule for what goes here: if a film with a different look could not reuse it, it stays in the film. The reasoning behind the design is in ../docs/method.md.

Files are plain scripts that define globals, loaded in this order: core.js, tone.js, space.js, water.js, stage.js, sound.js.

core.js

Everything below is independent of palette, frame size and scene.

section contents
maths TAU, clamp, lerp, smoothstep, easeIn, easeOut
randomness mulberry, makeR(key) with .range, .logRange, .gauss
noise and fields hash2, gnoise (with gradient), noise2, fbm, curl, streamline
time setBoil(F, hold), breathe(i, j, key), setHand
hand resample, catmull, strokeRibbon, Batch
matter springTo, Chain, furCoat, grow, poisson
colour hex, mix, rgba
shapes pathFrom, bboxOf

A shape is always an array of points [[x, y], ...], never a canvas primitive. A circle is 120 points, so it can be bent, clipped, stroked and filled the same way as anything else.

tone.js

A scene declares a field dens(x, y) -> 0..1; a rule decides how the field becomes marks.

screen(ctx, shape, ink, {
  dens:   (x, y) => 0..1,   // the field, required
  rule:   'dot',            // defaults to TONE.rule, shared by the film
  coord:  (x, y) => u,      // hatch: whose isolines become lines
  dir:    (x, y) => angle,  // flow: where lines run
  cell:   12,               // screen pitch
  levels: 3,                // band
  stable: true,             // freeze boiling
  alpha:  .9,
});

Rules: dot, hatch, dither (Bayer or blue noise), stipple, flow, band. Restyling a whole film is one line: TONE.rule = 'hatch'.

bakeField(bb, step, fn) evaluates an expensive field once into a buffer and exposes channels as ordinary (x, y) functions through .ch(k). fn may return several channels (tone, highlight, mask) from one ray trace.

The pitch is set once per film and does not scale with objects. A real print has one screen per sheet; two pitches in one frame make moiré.

space.js

Camera and depth, with no models: shapes already are point arrays, so they only need a third coordinate.

camSet({ x, y, z, yaw, pitch, roll, f, cx, cy, haze });
project(x, y, z)       -> [sx, sy, scale, depth] | null
poly3(points3, open)   -> { P: [[x, y], ...], z }   bridge to screen() and Batch
rayOf(sx, sy)          -> world direction of a pixel
hitPlaneY(sx, sy, y)   -> where that ray meets a horizontal plane
vanish(dx, dy, dz)     -> vanishing point of a direction | null
horizon(), extrude(), planeGrid(), quad3(), camFly(), sortFar(), aerial(), thin()

Axes: x right, y down, z into the scene, to match the canvas. 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 zero yaw and pitch gives one point, yaw gives two, yaw and pitch give three. test/spaceproof.html computes and marks them.

Depth is not carried by scale, which only zooms. It comes from a screen pitch that stays on the sheet, contrast lost to air (aerial), fewer marks rather than smaller ones (thin) and parallax when the camera moves.

water.js

“Water” is three different problems.

problem model used for
open surface makeSea / seaAt, Gerstner waves sea, lake; height is analytic, perspective comes free
response to a disturbance Ripples, wave equation on a grid rings from a drop, interference, reflection from walls
liquid that changes shape Fluid, position-based fluids only where the water actually moves as a body

Plus isoLines (marching squares) and the fields that make water read as water: foamOfJac, specOf, causticOf.

A single drop is handled analytically, without ray marching:

traceDrop(o, d, D, env)                         // eye ray through the drop -> { v, g, hit }
dropPath(o, d, D)                               // entry, Fresnel F, exit ray, transmitted T
dropCaustic(D, toSun, planeY, box, cell, nr)    // floor caustic by forward splatting
dropHit(o, d, D)                                // for shadows and outlines
reflect3, refract3, schlick, raySphere, rayEll

D = { c, r, absorb, ax?, rz?, bubble? }: ax are ellipsoid semi-axes (the Rayleigh l = 2 mode), bubble is an air bubble inside. The inverted world, both highlights, the rim, the caustic focus and the silver ring of the bubble all come out of the optics. About 12–20 ms per frame.

Three things that were found by measurement rather than by eye:

stage.js

A world made of depth cards plus volumetric drops. Every sample of the frame is a ray that accumulates ink through the cards with coverage, and refracts when it meets a drop. The result is a field per ink, printed by screen(). Built for HYDRO MASSACRE but independent of it.

const S = makeStage({ inks, bg });              // bg(d, acc, T): sky along the ray
addCard(S, { z, ink, ink2, at: (x, y) => [tone, coverage, glint, tone2] | null });
S.drops.push({ c: [x, y, z], r, ax, rz, absorb });
bakeCard(fn, box, step, blur) -> at             // card baked and blurred by its own circle of confusion
cocWorld(z, focus, A)                           // blur radius of a card, in world units
rasterCard(box, step) -> { disc, seg, poly, at } // card built from many small marks
renderStage(ctx, S, vp, { step, inks, glint })  // trace, then print each ink
dropSilhouette(D, n)                            // outline of a drop, found by rays

Defocus happens in the field, not on pixels. A blurred halftone turns into grey mush; here the card image is blurred before printing and the marks that print it stay sharp. Tone is stored premultiplied by coverage, or a dark object in defocus would grow a light halo that no lens produces.

sound.js

Sound is synthesised, like the picture: no samples.

job how
note stringVoice, a waveguide: delay line SR/f, one-pole filter in the loop, allpass for the fractional delay (without it notes drift tens of cents)
timbre BODY (cello, bass, piano, harp, metal): three band-pass biquads after the string
knock knock, an impulse through the same resonators
bell bellVoice, modal synthesis at inharmonic ratios
air, splashes, thunder gust, noise through a swept band
filters bpSet/bpRun, lpSet/lpRun, RBJ biquads, stable under sweeps
room reverb, eight combs, four allpasses, early reflections
delay delayLine, darker on every repeat
balance calibrate, measures each instrument’s RMS on a test note
mix mkMix, play, mixdown (room, delay, low shelf, master filter over time, percentile normalisation), wavOf

mixdown({ raw: 1 }) skips normalisation. Only then can stems be compared: a layer’s share is sqrt(full² − without²), and with normalisation a removed layer makes the rest louder and the share comes out as zero.

The score itself belongs to each film (<film>/sound.js). FLIP THE COIN predates this file and keeps its own copy of the engine.

Checks

python test/proof.py                   tone rules: one sphere, eight panels
python test/proof.py spaceproof.html   perspective and depth
python test/proof.py waterproof.html   the three water models
python test/proof.py dropproof.html    drop: orbit, bubble, l = 2 mode
python test/proof.py stageproof.html   stage of cards: defocus in the field
python test/measure.py ripples         isotropy of the wave equation
python test/measure.py settle          incompressibility and rest of the fluid

proof.py produces pictures and measure.py numbers. Both are needed: the first will not tell you the scheme is anisotropic, the second will not tell you the composition fell apart.