A section used to be one scene, and two thirds of the library is composable —
sparse by design, elements ON something. Cast as backgrounds anyway, they left
9 of 40 sampled frames under 20% painted, the darkest at 0.3%: a minute and a
half of a few bright things on black, invisible to every gate because every
gate on the stack was a limit rather than a floor.
Every section now stands on a GROUND: a canvas that fills the frame, cast per
section kind so a shot cut changes the shot and not the world. When the shot
fills the frame itself it IS the ground — two canvases stacked is two pictures
fighting. Above that, a coverage BUDGET: director appetite times the section's
energy times where the story is, capped at two frames' worth of material.
The measured facts move into the repo. scenes/metadata.json is generated from
the gallery — coverage as a shot, coverage as a bed, variety, the structural
profile — tracked in git, stamped with a fingerprint of the scenes and the
metric definitions, and refreshed from gallery.html. `surface` is derived from
it rather than declared; nine scenes claimed `canvas` while painting under a
third of the frame, and declaring it is now a lint error. The generator weights
every layering choice by measured structural distance, because family labels
and the render disagree: two `geometric` scenes can be 0.31 apart and a `flow`
and an `organic` scene 0.04.
The gallery's 0.1 red line is gone. It was right when a section was one scene
and wrong now — nineteen scenes were failing a bar for being consistent, which
is a virtue in an ingredient.
Chasing the numbers turned up four real faults:
* A scene that reads prev() cannot be a ground. It returns the whole
composited frame including the layers above it, so a datamosh under a shot
is eating it: the render stopped reproducing from a seek and two WebGL
contexts diverged by 91/255 against a tolerance of 4.
* Screen was the wrong operator for a shot over a bed. It lightens, so a
median quarter of every frame clipped to paper and whole sections rendered
100% white. Replaced by a lumakey — the shot's brightness is its alpha.
* Feedback was an accumulator: a still image settled at 2.3x its own
brightness. Fine over black, fatal over a filled ground. Normalised at 0.6,
plus a highlight shoulder so the top rolls off instead of clipping.
* useTrack never prewarmed, so a fresh Show's first frame differed from every
later render of it — the export-breaking hazard Compositor.prime documents.
Blazing is a decision now, not a side effect: directors declare an appetite for
it, a section must be loud and late in the story to earn one, and quiet kinds
never do. The ceiling gate matches that — a hard cap per section, and no more
than a fifth of them hot at all.
Rendered across twelve videos, middle of every section:
painted 51% mean, darkest 0.3% -> 87% mean, darkest 43%
clipped white 24% median, worst 100% -> 1% mean, worst 30%
separation 0.10 -> 0.44
Seven scenes can ground a section — five geometric, two organic — so every
quiet section of every video stands on one of two beds. That is the library's
largest hole and it is scene work: there is no minimal or flow canvas that
fills half the frame without reading prev().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 lines
3.9 KiB
JavaScript
92 lines
3.9 KiB
JavaScript
// Minimal family: a nearly empty volume with a shaft of light through it and a
|
|
// few motes suspended in the beam.
|
|
//
|
|
// Distinct from Firefly Drift (a swarm advected along a flow) and Slow Orb (one
|
|
// body): almost nothing moves here. The motes hold position and only breathe;
|
|
// what changes is the light. That makes this one of the very few scenes in the
|
|
// library that can hold a thirty-second intro without asking for attention.
|
|
//
|
|
// The beam lands on the track's horizon, so the room is the same room every
|
|
// other scene with a floor is standing in.
|
|
|
|
export const dustChamber = {
|
|
name: 'Dust Chamber',
|
|
family: 'minimal',
|
|
kind: 'fragment',
|
|
consumes: ['cast', 'ink'],
|
|
traits: ['shape', 'camera', 'space', 'style'],
|
|
|
|
params: {
|
|
motes: { type: 'int', range: [6, 40], default: 18, uniform: 'u_motes', bias: 'density' },
|
|
moteSize: { type: 'float', range: [0.004, 0.05], default: 0.014, uniform: 'u_moteSize' },
|
|
beam: { type: 'float', range: [0.1, 1.0], default: 0.45, uniform: 'u_beam', bias: 'energy' },
|
|
beamWidth:{ type: 'float', range: [0.15, 1.2], default: 0.5, uniform: 'u_beamWidth' },
|
|
sway: { type: 'float', range: [0, 0.12], default: 0.04, uniform: 'u_sway' },
|
|
drift: { type: 'float', range: [0.01, 0.3], default: 0.06, uniform: 'u_drift', bias: 'motion', rate: true },
|
|
palette: { type: 'palette', count: 4 },
|
|
},
|
|
|
|
reactive: {
|
|
beam: { feature: 'loudness', amount: 0.25, response: 'smooth' },
|
|
moteSize: { feature: 'beat', amount: 0.2, response: 'spike' },
|
|
},
|
|
|
|
shader: `
|
|
vec4 scene(vec2 uv, vec2 p) {
|
|
float t = u_time * u_drift + u_seed;
|
|
p = sigCamera(p);
|
|
|
|
float floorY = sigHorizonY() * 0.7 - 0.6;
|
|
|
|
// The shaft: a soft wedge widening as it falls, cut off at the floor.
|
|
float axis = sin(u_seed * 0.7) * 0.35;
|
|
float down = sat((1.0 - (p.y - floorY)) * 0.6);
|
|
float halfWidth = u_beamWidth * (0.25 + down * 0.75);
|
|
float inBeam = exp(-pow((p.x - axis) / max(halfWidth, 1e-3), 2.0) * 2.2);
|
|
inBeam *= smoothstep(floorY - 0.05, floorY + 0.5, p.y);
|
|
|
|
// The light is the thing that moves here, and it has to move on its own
|
|
// clock rather than on u_drift: at a low drift the motes barely breathe and
|
|
// the whole frame measured as static. Fixed rate, so it is not a param and
|
|
// cannot be reacted into a phase jump.
|
|
float breath = 0.82 + 0.18 * sin(u_time * 0.4 + u_seed);
|
|
// Floored, because the shaft IS the scene. An intro sampling u_beam near
|
|
// its minimum with a narrow beam produced a frame dark enough to fail the
|
|
// live-frame gate — a dim room is the point, an empty one is a bug.
|
|
float beam = (0.22 + u_beam * 0.78) * breath;
|
|
|
|
// Ambient fill. It carries the whole frame wherever the shaft is not, so it
|
|
// is what decides whether "almost black" stays on the right side of black.
|
|
vec3 col = pal(0) * 0.11;
|
|
col += pal(1) * inBeam * beam * 0.5;
|
|
|
|
// The pool where the shaft meets the floor.
|
|
float pool = exp(-abs(p.y - floorY) * 14.0)
|
|
* exp(-pow((p.x - axis) / max(halfWidth * 1.3, 1e-3), 2.0));
|
|
col += pal(2) * pool * beam * 0.7;
|
|
|
|
// Motes: fixed positions, breathing brightness, only visible in the light.
|
|
for (int i = 0; i < 40; i++) {
|
|
if (i >= u_motes) break;
|
|
float fi = float(i);
|
|
float s = u_seed + fi * 53.7;
|
|
|
|
vec2 at = vec2(
|
|
(hash11(s) - 0.5) * 2.4 + sin(t * 0.8 + s) * u_sway,
|
|
(hash11(s + 9.1) - 0.5) * 1.8 + cos(t * 0.6 + s * 1.3) * u_sway
|
|
);
|
|
|
|
float lit = exp(-pow((at.x - axis) / max(halfWidth, 1e-3), 2.0) * 2.0);
|
|
float pulse = 0.55 + 0.45 * sin(t * 2.0 + s * 3.0);
|
|
float m = castForm(p, at, u_moteSize * (0.6 + hash11(s + 3.3)));
|
|
col += pal(3) * m * lit * pulse * (0.5 + beam);
|
|
}
|
|
|
|
col = sigAir(col, p, smoothstep(0.0, 1.5, length(p)));
|
|
return vec4(inkValue(col), 1.0);
|
|
}
|
|
`,
|
|
};
|
|
|
|
export default dustChamber;
|