music-video-gen/flow-state/src/scenes/shader/eclipse-field.js
Dejvino 44e1826fcb Six more visualizers, built through the scaffolder
Seven per family now, 42 scenes. Each one exists because of the second
line of its header comment — what makes it different from the scenes it
sits beside — since "no two scenes render the same image" is a gate with a
numeric floor:

  Smoke Column (flow)        a plume with a source, a body and a
                             dissipating head. Everything moves one way and
                             widens; an isotropic field has no up.
  Cell Divide (organic)      a partition, not objects. Every pixel belongs
                             to a cell, boundaries are hard, no background.
  Eclipse Field (minimal)    the occluder nearly fills the frame and is not
                             the subject — the corona around it is. The
                             library's one high-contrast minimal scene.
  Girder Lattice (structural) the only scene whose subject is ABOVE the
                             camera; perspective converges downward.
  Quasicrystal (geometric)   five plane waves at incommensurate angles, so
                             the pattern has local symmetry and no tile,
                             no cell and no centre.
  Time Smear (glitch)        nothing is displaced. Each band shows the same
                             image at a different age — a slit-scan built
                             from one feedback buffer by giving each band
                             its own persistence.

The scaffolder produced six skeletons that passed lint and every gate
before a line of shader was written, and all six passed their per-scene
gate first time once written. That is what it was for.

Two real faults, both found by gates rather than by eye:

Cell Divide coloured each pixel by its nearest seed, and exactly on a tie
which seed is nearest comes down to the last bit of a distance. Two renders
disagreed by a whole palette step: 6/255 against a ceiling of 1, and it
also broke Phase 6's preview/export parity because that look casts it.
Blending the nearest tint with the runner-up across the membrane makes the
two answers agree in the limit — and reads better, as membranes rather than
cuts.

The lint's own "prev() with no base image" rule fired on a scene whose
comment explained why it does NOT rely on prev(). Rules that ask "does the
code do X" now run against a comment-stripped copy; the fixed-cost opt-out
still reads the original, since it is a comment.

Phase 6's parity tolerance goes from 1/255 to 2. Measured in order:
unprimed, the first pass differed on frames 0/2/3 — fixed earlier by
priming. Primed and isolated: 0/40 at delta 0, three times over. Primed,
range-warmed and run at the end of the full suite: three frames at delta 2.
The warm-up stays because it is correct, but the residue is GPU load, not
logic, and it is the same 1-2/255 Phases 5 and 7 already account for. A
real divergence scores in the tens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:14:20 +02:00

83 lines
3.5 KiB
JavaScript

// Minimal family: a large occluder covering a bright source, with the light
// escaping past its edge.
//
// Distinct from Slow Orb (a small soft body drifting through empty space): the
// object here nearly fills the frame, its edge is hard, and it is not the
// subject — the subject is the corona around it. That inversion is the whole
// idea, and it gives the library its one genuinely high-contrast minimal scene:
// almost black, with a thin ring of the brightest thing in the video.
//
// The occluder is the track's signature form, so this is where a hexagonal
// track's hexagon is most legible.
//
// Scaffolded by tools/new-scene.js. See HOWTO-visualizers.md.
export const eclipseField = {
name: 'Eclipse Field',
family: 'minimal',
kind: 'fragment',
traits: ['shape', 'camera', 'space', 'style'],
params: {
size: { type: 'float', range: [0.2, 0.85], default: 0.45, uniform: 'u_size' },
corona: { type: 'float', range: [0.05, 1.2], default: 0.45, uniform: 'u_corona', bias: 'energy' },
rays: { type: 'float', range: [0, 1], default: 0.35, uniform: 'u_rays' },
rayCount: { type: 'float', range: [3, 24], default: 9, uniform: 'u_rayCount', bias: 'density' },
offset: { type: 'float', range: [0, 0.5], default: 0.12, uniform: 'u_offset' },
drift: { type: 'float', range: [0.01, 0.3], default: 0.05, uniform: 'u_drift', bias: 'motion', rate: true },
sky: { type: 'float', range: [0, 0.6], default: 0.15, uniform: 'u_sky' },
palette: { type: 'palette', count: 5 },
},
reactive: {
corona: { feature: 'loudness', amount: 0.3, response: 'smooth' },
rays: { feature: 'bandAir', amount: 0.25, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_drift + u_seed;
p = sigCamera(p);
// The light sits a little off centre from the occluder, so the corona is
// brighter on one side — a perfectly concentric eclipse looks like a decal.
vec2 lightAt = vec2(sin(t * 0.8), cos(t * 0.6)) * u_offset;
vec2 discAt = vec2(0.0, sigHorizonY() * 0.25);
// Distance to the occluder's edge, in the track's form.
float d = sigShape((p - discAt) / max(u_size, 1e-3)) * u_size;
// The source behind it.
float toLight = length(p - lightAt);
float source = exp(-toLight * toLight * 2.2);
vec3 col = pal(0) * u_sky * (1.0 - smoothstep(0.0, 1.6, length(p)));
col += pal(1) * source * 0.5;
// Corona: brightest exactly at the edge, falling off outward only. Inside
// the disc it must be zero, or the occluder stops occluding.
float outside = smoothstep(0.0, 0.02, d);
float ring = exp(-max(d, 0.0) / max(u_corona * 0.25, 0.01));
col += palRamp(0.35 + d * 0.5) * ring * outside * (0.8 + u_corona);
// Rays: angular streaks in the corona, rotating slowly. Confined to the
// corona by the same falloff, so they never light the whole frame.
if (u_rays > 0.01) {
float ang = atan(p.y - discAt.y, p.x - discAt.x);
float spokes = 0.5 + 0.5 * sin(ang * u_rayCount + t * 2.0);
col += pal(4) * pow(spokes, 3.0) * ring * outside * u_rays;
}
// The occluder: not pure black, so it reads as an object rather than a hole.
col = mix(col, pal(0) * 0.12, smoothstep(0.004, -0.004, d));
col += pal(3) * sigEdge(d) * (0.4 + u_corona * 0.5);
col = sigAir(col, p, smoothstep(0.0, 1.7, length(p)));
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default eclipseField;