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.
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.
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é.
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” 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:
Ripples uses nine points. With five, a ring from a drop
turns into a square within a few dozen steps, because waves travel faster
along the axes. After the fix the front reaches the same distance along
the axis and the diagonal to within one cell, a ratio of 1.00 to 1.03
(python test/measure.py ripples).rho0 and eps in Fluid are calibrated on the first step. As
constants they were off by 196x in density and nine orders of magnitude
in eps; lambda came out zero and there was no pressure at all.kcorr is a share of lambda, not an absolute value; 0.002 was picked by
a sweep that gives density 1.000 and a settled layer 95 px thick against
a theoretical 102 (python test/measure.py settle).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 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.
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.