Two helpers made the bulk of it mechanical. `inkStroke` is a drop-in for
sigEdge — the same line at the identity's weight rather than the track's — and
`castForm` is a drop-in for sigForm, same signature so call sites do not change
shape. With those in place the substitution table is one-to-one:
sigForm( -> castForm( sigShape( -> castMain( sigEdge( -> inkStroke(
Forty-seven scenes went through that pass in one run: twenty-six take the cast
and the ink, twenty-one take the ink alone. Then the gate ran over all sixty-five
and found three the pass had broken, which is the entire reason it exists.
Spectrum Sculpture rendered pure black. It had been using sigShape as a RADIAL
METRIC rather than drawing it, and the cast carries notches and a hollow — an
annulus used as a radius turns a sculpture inside out. Reverted to sigShape and
dropped to ink only. The lesson generalises: a scene that reads a form as
geometry is not a scene that draws it, and the classifier cannot tell those
apart from the source.
Eclipse Field stopped honouring its `style` trait. It opts out of surface grain,
so sigEdge was its only style evidence, and the ink replaced it. The trait claim
is now dropped — and so is the lint change that had let inkMask count as style
evidence, which was wrong and was hiding exactly this. A trait is a property of
the track a scene may honour; an artifact is content it draws. Taking the ink
says nothing about whether a scene responds to u_sigLine.
Circuit Bloom went empty at the bottom of its `grown` range, where the pads were
carried by a hairline and the ink's stroke is thinner than the edge it replaced.
Now filled as well as stroked.
Also: the backtick check now covers every shader literal rather than only the
preamble, because a mechanical pass over sixty files reintroduced one
immediately. Three rounds lost to that typo is enough.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
558 lines
21 KiB
JavaScript
558 lines
21 KiB
JavaScript
// The uniform contract every shader scene is compiled against.
|
|
//
|
|
// Scenes do not write `main()`. They define:
|
|
//
|
|
// vec4 scene(vec2 uv, vec2 p)
|
|
//
|
|
// where `uv` is 0..1 across the frame and `p` is centred, aspect-corrected,
|
|
// roughly -1..1 on the short axis. Everything else — the preamble, the varying,
|
|
// main() itself — is injected here. That boilerplate reduction is what makes a
|
|
// thirty-scene library affordable to write and to keep consistent.
|
|
//
|
|
// RESOLUTION INDEPENDENCE: work in `uv`/`p`, never in pixels. If you genuinely
|
|
// need a pixel-sized feature, scale it by u_pixelScale so a 720p preview and a
|
|
// 4K export agree. The dual-resolution diff in tools/ exists to catch violations.
|
|
|
|
export const VERTEX_SHADER = `
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vUv = uv;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`;
|
|
|
|
/** Audio-reactive uniforms, filled per frame from the FeatureTrack. */
|
|
export const AUDIO_UNIFORMS = [
|
|
'u_loudness', // overall level, track-normalised 0..1
|
|
'u_rms',
|
|
'u_bandSub', // 20-60 Hz
|
|
'u_bandLow', // 60-250 Hz
|
|
'u_bandMid', // 250-2k
|
|
'u_bandHigh', // 2k-6k
|
|
'u_bandAir', // 6k-16k
|
|
'u_flux', // onset strength
|
|
'u_centroid', // spectral brightness 0..1
|
|
'u_flatness', // noisy vs tonal 0..1
|
|
'u_width', // stereo width 0..1
|
|
'u_beat', // decaying spike on each beat, 1 at the hit
|
|
'u_beatPhase', // 0..1 within the beat
|
|
'u_barPhase', // 0..1 within the bar
|
|
'u_phrasePhase',// 0..1 within an 8-bar phrase
|
|
'u_sectionProgress',
|
|
'u_sectionEnergy',
|
|
'u_buildSlope', // >0 while energy is ramping toward the next section
|
|
];
|
|
|
|
/**
|
|
* The track's personality, constant for the whole video. See look/Personality.js.
|
|
*
|
|
* These are what make sixteen unrelated shaders read as one production. A scene
|
|
* declares in `traits` which of them it honours, and the look generator will not
|
|
* cast a scene that cannot express what the track is built on.
|
|
*
|
|
* All of them are neutral by default, so a layer built without a personality
|
|
* renders exactly what it always rendered.
|
|
*/
|
|
export const SIGNATURE_UNIFORMS = {
|
|
u_sigSides: 'float', // signature form: 0 = round, else polygon sides
|
|
u_sigRound: 'float', // corner rounding of that form
|
|
u_sigElong: 'float', // how far from square the form is
|
|
u_sigTilt: 'float', // its resting angle
|
|
|
|
u_sigDrift: 'vec2', // camera translation per second
|
|
u_sigSway: 'float', // camera sway amplitude
|
|
u_sigSwayRate: 'float',
|
|
u_sigSpin: 'float', // slow camera roll, radians per second
|
|
u_sigBreathe: 'float', // bar-locked zoom
|
|
|
|
u_sigHorizon: 'float', // where the ground meets the sky, 0..1 up the frame
|
|
u_sigDepth: 'float', // distance falloff
|
|
u_sigWash: 'vec2', // background gradient direction and strength
|
|
|
|
u_sigLine: 'float', // line weight
|
|
u_sigSoft: 'float', // edge softness
|
|
u_sigTexture: 'float', // surface grain
|
|
u_sigFold: 'float', // kaleidoscopic folds, 1 = none
|
|
|
|
// FRAMING. Not a personality trait — this one is per SHOT, pushed by the
|
|
// arc driver rather than derived from the track. See look/framing.js.
|
|
//
|
|
// Every scene in the library is a locked-off, full-frame wide, and always
|
|
// has been. That is one shot type, held for the length of a song, and it is
|
|
// the reason cutting between two scenes changes the subject but never the
|
|
// FRAMING — which is at least half of how a real edit holds attention.
|
|
//
|
|
// Applied inside sigCamera, in scene coordinates, so a close-up is rendered
|
|
// close rather than being a magnified 720p image. That distinction is the
|
|
// whole reason this is a coordinate transform and not a post pass.
|
|
u_sigFrameScale: 'float', // >1 pushes in, <1 pulls back
|
|
u_sigFrameShift: 'vec2', // recentre, in scene units
|
|
};
|
|
|
|
/**
|
|
* The song's CAST and INK — Epic 3's content and style artifacts.
|
|
*
|
|
* These differ from the signature uniforms above in kind, not degree. A
|
|
* signature uniform is a modifier on an image the shader already had, which is
|
|
* why a scene is free to ignore one. A cast uniform IS the image: a stage that
|
|
* ignores it has nothing to draw. See look/Identity.js.
|
|
*/
|
|
export const IDENTITY_UNIFORMS = {
|
|
u_castSides: 'float', // protagonist: 0 = round, else polygon sides
|
|
u_castRound: 'float',
|
|
u_castElong: 'float',
|
|
u_castTilt: 'float',
|
|
u_castNotchN: 'float', // notches cut into the boundary, 0 = none
|
|
u_castNotchD: 'float',
|
|
u_castHollow: 'float', // >0 makes it an annulus — a form with a hole
|
|
|
|
u_chorusSides: 'float', // the second member: a relative, not a stranger
|
|
u_chorusRound: 'float',
|
|
u_chorusElong: 'float',
|
|
u_chorusTilt: 'float',
|
|
u_chorusNotchN: 'float',
|
|
u_chorusNotchD: 'float',
|
|
u_chorusHollow: 'float',
|
|
|
|
u_inkWeight: 'float', // stroke width
|
|
u_inkEdge: 'float', // 0 = soft/airbrushed, 1 = hard vector
|
|
u_inkFill: 'float', // index into Identity.FILLS
|
|
u_inkHatchAngle: 'float',
|
|
u_inkHatchScale: 'float',
|
|
u_inkOutline: 'float', // 0..1 outline strength on top of the fill
|
|
u_inkPosterize: 'float', // 0 = off, else levels
|
|
|
|
u_latKind: 'float', // index into Identity.LATTICES
|
|
u_latJitter: 'float', // how far off the lattice things sit
|
|
u_latSpread: 'float', // how much of the frame it occupies
|
|
u_latScaleSpread: 'float', // 0 = all one size, 1 = a few large, many small
|
|
u_latScaleBias: 'float', // + puts the large ones in the middle
|
|
u_latScale: 'float', // the song's element size, ~0.1 tiny .. ~0.9 huge
|
|
};
|
|
|
|
export const FRAME_UNIFORMS = [
|
|
'u_time', 'u_frame', 'u_progress', 'u_seed',
|
|
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity',
|
|
];
|
|
|
|
export const PREAMBLE = `
|
|
precision highp float;
|
|
|
|
uniform vec2 u_resolution;
|
|
uniform float u_aspect;
|
|
uniform float u_pixelScale;
|
|
uniform float u_time;
|
|
uniform float u_frame;
|
|
uniform float u_progress;
|
|
uniform float u_seed;
|
|
uniform float u_opacity;
|
|
|
|
uniform vec3 u_colors[8];
|
|
uniform int u_colorCount;
|
|
|
|
${AUDIO_UNIFORMS.map((u) => `uniform float ${u};`).join('\n')}
|
|
|
|
${Object.entries(SIGNATURE_UNIFORMS).map(([u, t]) => `uniform ${t} ${u};`).join('\n')}
|
|
|
|
${Object.entries(IDENTITY_UNIFORMS).map(([u, t]) => `uniform ${t} ${u};`).join('\n')}
|
|
|
|
uniform sampler2D u_prev;
|
|
uniform int u_hasPrev;
|
|
|
|
varying vec2 vUv;
|
|
|
|
// --- palette helpers -------------------------------------------------------
|
|
// Scenes should reach for these instead of hardcoding colours, so the look
|
|
// generator can actually recolour them per track.
|
|
|
|
vec3 pal(int i) {
|
|
int n = max(u_colorCount, 1);
|
|
int k = int(mod(float(i), float(n)));
|
|
for (int j = 0; j < 8; j++) { if (j == k) return u_colors[j]; }
|
|
return u_colors[0];
|
|
}
|
|
|
|
/** Continuous ramp through the palette; t wraps. */
|
|
vec3 palRamp(float t) {
|
|
int n = max(u_colorCount, 1);
|
|
float f = fract(t) * float(n);
|
|
int i = int(floor(f));
|
|
return mix(pal(i), pal(i + 1), smoothstep(0.0, 1.0, fract(f)));
|
|
}
|
|
|
|
// --- noise -----------------------------------------------------------------
|
|
|
|
float hash11(float n) { return fract(sin(n) * 43758.5453123); }
|
|
float hash12(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453123); }
|
|
vec2 hash22(vec2 p) {
|
|
vec3 a = fract(vec3(p.xyx) * vec3(123.34, 234.34, 345.65));
|
|
a += dot(a, a + 34.45);
|
|
return fract(vec2(a.x * a.y, a.y * a.z));
|
|
}
|
|
|
|
float vnoise(vec2 p) {
|
|
vec2 i = floor(p), f = fract(p);
|
|
f = f * f * (3.0 - 2.0 * f);
|
|
float a = hash12(i), b = hash12(i + vec2(1.0, 0.0));
|
|
float c = hash12(i + vec2(0.0, 1.0)), d = hash12(i + vec2(1.0, 1.0));
|
|
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
|
}
|
|
|
|
float fbm(vec2 p, int octaves) {
|
|
float v = 0.0, amp = 0.5;
|
|
for (int i = 0; i < 8; i++) {
|
|
if (i >= octaves) break;
|
|
v += amp * vnoise(p);
|
|
p *= 2.02;
|
|
amp *= 0.5;
|
|
}
|
|
return v;
|
|
}
|
|
|
|
/** Cheap divergence-free-ish flow field. The backbone of the "flow" family. */
|
|
vec2 curl(vec2 p, float t) {
|
|
float e = 0.1;
|
|
float n1 = fbm(p + vec2(0.0, e) + t, 4);
|
|
float n2 = fbm(p - vec2(0.0, e) + t, 4);
|
|
float n3 = fbm(p + vec2(e, 0.0) + t, 4);
|
|
float n4 = fbm(p - vec2(e, 0.0) + t, 4);
|
|
return vec2(n1 - n2, n4 - n3) / (2.0 * e);
|
|
}
|
|
|
|
mat2 rot(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }
|
|
|
|
/** N-fold kaleidoscopic fold of a centred coordinate. */
|
|
vec2 kaleido(vec2 p, float sides) {
|
|
if (sides < 1.5) return p;
|
|
float a = atan(p.y, p.x);
|
|
float r = length(p);
|
|
float seg = 6.28318530718 / sides;
|
|
a = abs(mod(a + seg * 0.5, seg) - seg * 0.5);
|
|
return vec2(cos(a), sin(a)) * r;
|
|
}
|
|
|
|
// --- personality -----------------------------------------------------------
|
|
// The four traits, as functions a scene applies in its own way. A scene that
|
|
// calls none of these must not declare the matching trait, or it will be cast
|
|
// in a track it cannot express. See look/Personality.js.
|
|
|
|
/**
|
|
* TRAIT: shape. Signed distance to the track's signature form, radius ~1.
|
|
* Round tracks return a circle, so a scene can call this unconditionally.
|
|
*/
|
|
float sigShape(vec2 q) {
|
|
q = rot(u_sigTilt) * q;
|
|
q.x /= max(u_sigElong, 0.05);
|
|
|
|
if (u_sigSides < 2.5) return length(q) - 1.0;
|
|
|
|
// Regular polygon by angular folding, then rounded back toward the circle.
|
|
float seg = 6.28318530718 / u_sigSides;
|
|
float a = atan(q.y, q.x);
|
|
float r = length(q);
|
|
float folded = cos(mod(a + seg * 0.5, seg) - seg * 0.5);
|
|
float poly = r * folded - cos(seg * 0.5);
|
|
return mix(poly, r - 1.0, clamp(u_sigRound, 0.0, 1.0));
|
|
}
|
|
|
|
/** TRAIT: shape, as a filled mask of the given radius, centred on a point. */
|
|
float sigForm(vec2 p, vec2 centre, float size) {
|
|
float d = sigShape((p - centre) / max(size, 1e-3)) * max(size, 1e-3);
|
|
return smoothstep(u_sigSoft * 0.25 + 0.004, -u_sigSoft * 0.25, d);
|
|
}
|
|
|
|
/**
|
|
* TRAIT: camera. The same operator filming every scene — a slow drift, a sway,
|
|
* a roll, and a bar-locked breath. Apply to a centred coordinate before using it.
|
|
*/
|
|
vec2 sigCamera(vec2 p) {
|
|
float t = u_time;
|
|
p = rot(u_sigSpin * t) * p;
|
|
p *= 1.0 - u_sigBreathe * sin(u_barPhase * 6.28318530718);
|
|
p += vec2(sin(t * u_sigSwayRate), cos(t * u_sigSwayRate * 0.83)) * u_sigSway;
|
|
// The pan RETURNS. A constant translation would be a camera on rails: two
|
|
// minutes in, every scene has left the frame entirely. This is a slow track
|
|
// across the subject and back, roughly a two-minute cycle.
|
|
p -= u_sigDrift * 20.0 * sin(t * 0.05);
|
|
return p;
|
|
}
|
|
|
|
/** TRAIT: style. Fold the frame the track's way. 1 fold means no fold. */
|
|
vec2 sigFolded(vec2 p) {
|
|
return kaleido(p, u_sigFold);
|
|
}
|
|
|
|
/** TRAIT: style. Turn a signed distance into an edge drawn in the track's hand. */
|
|
float sigEdge(float d) {
|
|
float w = 0.004 + u_sigLine * 0.03;
|
|
float soft = w * (0.25 + u_sigSoft * 1.5);
|
|
return smoothstep(w + soft, w - soft, abs(d));
|
|
}
|
|
|
|
/** TRAIT: style. The track's surface grain, for a scene to add at its own weight. */
|
|
float sigGrain(vec2 uv) {
|
|
if (u_sigTexture <= 0.001) return 0.0;
|
|
return (hash12(uv * 512.0 + floor(u_frame)) - 0.5) * u_sigTexture;
|
|
}
|
|
|
|
/**
|
|
* TRAIT: space. Height of the shared horizon in the same units as 'p'.
|
|
* Positive is up; a scene with any sense of ground should sit on it.
|
|
*/
|
|
float sigHorizonY() {
|
|
return (u_sigHorizon - 0.5) * 2.0;
|
|
}
|
|
|
|
/** TRAIT: space. The location's air: distance haze plus the background wash. */
|
|
vec3 sigAir(vec3 col, vec2 p, float distance01) {
|
|
vec3 far = pal(0) * (0.25 + 0.35 * u_sigDepth);
|
|
col = mix(col, far, clamp(distance01, 0.0, 1.0) * u_sigDepth);
|
|
col += pal(1) * dot(p, u_sigWash) * 0.35;
|
|
return col;
|
|
}
|
|
|
|
// --- the cast --------------------------------------------------------------
|
|
// The song's own forms. A stage that places discrete elements places THESE, and
|
|
// that is what makes two stages in one video look like one video — and two
|
|
// videos of different songs look like different work.
|
|
|
|
/** Signed distance to a cast member, radius ~1 at size 1. */
|
|
float castSDF(vec2 q, float sides, float rnd, float elong, float tilt,
|
|
float notchN, float notchD, float hollow) {
|
|
q = rot(tilt) * q;
|
|
q.x /= max(elong, 0.05);
|
|
|
|
float r = length(q);
|
|
float a = atan(q.y, q.x);
|
|
|
|
float d;
|
|
if (sides < 2.5) {
|
|
d = r - 1.0;
|
|
} else {
|
|
float seg = 6.28318530718 / sides;
|
|
float folded = cos(mod(a + seg * 0.5, seg) - seg * 0.5);
|
|
float poly = r * folded - cos(seg * 0.5);
|
|
d = mix(poly, r - 1.0, clamp(rnd, 0.0, 1.0));
|
|
}
|
|
|
|
// Notches scallop the boundary. Approximate as a radial perturbation — it
|
|
// is not a true distance any more, but every use here is a thresholded mask
|
|
// and the error is far below a pixel at the sizes these are drawn.
|
|
if (notchN > 0.5) d += notchD * cos(notchN * a);
|
|
// A hole through the middle. Cheap, and the single most recognisable thing
|
|
// a generated form can have.
|
|
if (hollow > 0.001) d = abs(d) - hollow * 0.35;
|
|
return d;
|
|
}
|
|
|
|
/** The protagonist, centred, radius ~1. */
|
|
float castMain(vec2 q) {
|
|
return castSDF(q, u_castSides, u_castRound, u_castElong, u_castTilt,
|
|
u_castNotchN, u_castNotchD, u_castHollow);
|
|
}
|
|
|
|
/** The chorus member — many of these, small. */
|
|
float castChorus(vec2 q) {
|
|
return castSDF(q, u_chorusSides, u_chorusRound, u_chorusElong, u_chorusTilt,
|
|
u_chorusNotchN, u_chorusNotchD, u_chorusHollow);
|
|
}
|
|
|
|
// --- the staging -----------------------------------------------------------
|
|
// Where things go. Shared, so two stages in one video agree about composition —
|
|
// and so the SIZE HIERARCHY is a decision the song makes once rather than one
|
|
// each stage makes for itself. The first four stages all placed similarly-sized
|
|
// elements, which left feature scale out of the measurement entirely.
|
|
|
|
/** Node i of n on the song's lattice: xy position, z scale multiplier. */
|
|
vec3 stageNode(float i, float n) {
|
|
vec2 h = hash22(vec2(i * 1.37 + 3.1, i * 0.71 + 7.7));
|
|
float total = max(n, 1.0);
|
|
vec2 pos;
|
|
|
|
if (u_latKind < 0.5) { // grid
|
|
float cols = max(1.0, floor(sqrt(total) + 0.5));
|
|
float rows = max(1.0, ceil(total / cols));
|
|
pos = vec2((mod(i, cols) / max(cols - 1.0, 1.0) - 0.5) * 2.0,
|
|
(floor(i / cols) / max(rows - 1.0, 1.0) - 0.5) * 2.0);
|
|
} else if (u_latKind < 1.5) { // radial rings
|
|
float rings = max(1.0, floor(sqrt(total * 0.5) + 0.5));
|
|
float ring = mod(i, rings) + 1.0;
|
|
float a = (i / total) * 6.28318530718 * 3.0;
|
|
pos = vec2(cos(a), sin(a)) * (ring / rings);
|
|
} else if (u_latKind < 2.5) { // spiral, golden angle
|
|
float a = i * 2.39996323;
|
|
pos = vec2(cos(a), sin(a)) * sqrt(i / total);
|
|
} else if (u_latKind < 3.5) { // scatter
|
|
pos = (h - 0.5) * 2.0;
|
|
} else { // strata
|
|
float rows = max(1.0, floor(total / 4.0 + 0.5));
|
|
pos = vec2((h.x - 0.5) * 2.0,
|
|
(mod(i, rows) / max(rows - 1.0, 1.0) - 0.5) * 2.0);
|
|
}
|
|
|
|
pos += (h - 0.5) * u_latJitter;
|
|
pos *= u_latSpread;
|
|
// Sits on the same ground every other scene in the track sits on.
|
|
pos.y += sigHorizonY() * 0.3;
|
|
|
|
// A power law when the song wants a hierarchy, near-uniform when it does
|
|
// not. Biased toward the middle or the edges.
|
|
float u = max(hash11(i * 7.13 + 1.7), 0.001);
|
|
float size = mix(1.0, pow(u, 1.0 + u_latScaleSpread * 2.5) * 2.4, u_latScaleSpread);
|
|
size *= 1.0 + u_latScaleBias * (0.5 - length(pos) * 0.5);
|
|
|
|
// The song's own element size, relative to the neutral 0.35. A stage
|
|
// multiplies its own size param by this rather than choosing outright, so
|
|
// one song is made of a few huge forms and another of many small ones.
|
|
size *= u_latScale / 0.35;
|
|
|
|
return vec3(pos, max(size, 0.05));
|
|
}
|
|
|
|
/** The song's element size as a multiplier a stage applies to its own size. */
|
|
float stageScale() { return u_latScale / 0.35; }
|
|
|
|
// --- the ink ---------------------------------------------------------------
|
|
// How the cast is drawn. Changes every pixel of every stage at once, and does
|
|
// it structurally rather than chromatically — which is the point, since colour
|
|
// was already the only register doing any work.
|
|
|
|
/** The fill treatment as a 0..1 coverage pattern. 1 everywhere when flat. */
|
|
float inkPattern(vec2 uv) {
|
|
int mode = int(u_inkFill + 0.5);
|
|
if (mode == 2) { // hatch
|
|
vec2 h = rot(u_inkHatchAngle) * uv * u_inkHatchScale;
|
|
return smoothstep(0.3, 0.7, 0.5 + 0.5 * sin(h.y));
|
|
}
|
|
if (mode == 3) { // stipple
|
|
return step(0.42, hash12(floor(uv * u_inkHatchScale * 2.0)));
|
|
}
|
|
if (mode == 4) { // halftone
|
|
vec2 g = fract(uv * u_inkHatchScale * 0.25) - 0.5;
|
|
return smoothstep(0.38, 0.28, length(g));
|
|
}
|
|
return 1.0;
|
|
}
|
|
|
|
/**
|
|
* Ink coverage for a signed distance: the fill in the track's treatment, plus
|
|
* its outline. The 'hollow' fill treatment draws the outline only.
|
|
*/
|
|
float inkMask(float d, vec2 uv) {
|
|
float soft = mix(0.03, 0.0015, clamp(u_inkEdge, 0.0, 1.0));
|
|
int mode = int(u_inkFill + 0.5);
|
|
|
|
float fillA = smoothstep(soft, -soft, d) * inkPattern(uv);
|
|
if (mode == 5) fillA = 0.0;
|
|
|
|
float w = 0.004 + u_inkWeight * 0.055;
|
|
float strength = (mode == 5) ? 1.0 : u_inkOutline;
|
|
float line = smoothstep(w + soft, w - soft, abs(d)) * strength;
|
|
|
|
return clamp(max(fillA, line), 0.0, 1.0);
|
|
}
|
|
|
|
/**
|
|
* Outline only, in the song's hand. The drop-in replacement for sigEdge.
|
|
*
|
|
* sigEdge draws a line at the track's line weight; this draws it at the
|
|
* identity's, which is the same idea one layer up. Kept separate from inkMask
|
|
* because a scene that only ever wanted an edge should not suddenly acquire a
|
|
* fill when it migrates.
|
|
*/
|
|
float inkStroke(float d) {
|
|
float soft = mix(0.03, 0.0015, clamp(u_inkEdge, 0.0, 1.0));
|
|
float w = 0.004 + u_inkWeight * 0.055;
|
|
return smoothstep(w + soft, w - soft, abs(d));
|
|
}
|
|
|
|
/**
|
|
* The protagonist as a filled, inked mask at a point. Replaces sigForm.
|
|
*
|
|
* Same signature as the thing it supersedes so the substitution is mechanical
|
|
* across the library — see MIGRATION.md. uv is recomputed here rather than
|
|
* passed, so the call site does not have to change shape.
|
|
*/
|
|
float castForm(vec2 p, vec2 centre, float size) {
|
|
float s = max(size, 1e-3);
|
|
vec2 uv = vec2(p.x / u_aspect, p.y) * 0.5 + 0.5;
|
|
return inkMask(castMain((p - centre) / s) * s, uv);
|
|
}
|
|
|
|
/** The track's value structure. Off unless the identity asked for it. */
|
|
vec3 inkValue(vec3 col) {
|
|
if (u_inkPosterize < 1.5) return col;
|
|
float n = u_inkPosterize;
|
|
return floor(col * n + 0.5) / n;
|
|
}
|
|
|
|
vec3 prev(vec2 uv) {
|
|
if (u_hasPrev == 0) return vec3(0.0);
|
|
return texture2D(u_prev, uv).rgb;
|
|
}
|
|
|
|
float sat(float x) { return clamp(x, 0.0, 1.0); }
|
|
vec3 sat3(vec3 x) { return clamp(x, 0.0, 1.0); }
|
|
|
|
/**
|
|
* The framed equivalent of uv, for scenes that build their image in uv space.
|
|
*
|
|
* Screen-space scenes — a scan tear, a vertical transposition — slice the FRAME
|
|
* and are right to do so: a signal artefact happens to the signal, not to the
|
|
* world behind it. But the imagery behind the slicing is still a subject, and a
|
|
* subject can be filmed wide or close. So the slice grid stays on raw uv while
|
|
* the field it displaces is built from this, which carries the shot's framing.
|
|
*/
|
|
vec2 framedUv(vec2 p) { return vec2(p.x / u_aspect, p.y) * 0.5 + 0.5; }
|
|
`;
|
|
|
|
const EPILOGUE = `
|
|
void main() {
|
|
vec2 uv = vUv;
|
|
vec2 p = (uv - 0.5) * 2.0;
|
|
p.x *= u_aspect;
|
|
|
|
// FRAMING is applied here, to the coordinate every scene is handed, rather
|
|
// than inside sigCamera where it started out.
|
|
//
|
|
// sigCamera is gated on the camera personality trait: a scene that does
|
|
// not want the track's drift and sway simply never calls it. That is
|
|
// correct for a TRAIT and wrong for framing, which is not one — it is where
|
|
// the camera is standing for this shot, and no scene should be exempt from
|
|
// it because of an unrelated art-direction decision. Measured, that
|
|
// accident left Scan Tear and Pylon Grid completely unframed, and the two
|
|
// are otherwise perfectly good candidates for a close-up.
|
|
//
|
|
// uv is deliberately NOT framed. It is screen space: prev() reads the
|
|
// feedback buffer with it and sigGrain speckles in it, and both of those
|
|
// belong to the output image rather than to the scene being filmed.
|
|
p = p / max(u_sigFrameScale, 0.05) + u_sigFrameShift;
|
|
|
|
vec4 col = scene(uv, p);
|
|
gl_FragColor = vec4(col.rgb, col.a * u_opacity);
|
|
}
|
|
`;
|
|
|
|
/**
|
|
* Assemble a complete fragment shader from a scene body plus its declared params.
|
|
* Param uniforms are appended to the preamble so a scene never declares them itself —
|
|
* the schema is the single source of truth, which is what the lint checks.
|
|
*/
|
|
export function buildFragmentShader(sceneModule) {
|
|
const paramUniforms = [];
|
|
for (const [name, def] of Object.entries(sceneModule.params || {})) {
|
|
if (!def.uniform) continue;
|
|
if (def.type === 'palette') continue; // palette rides in u_colors
|
|
const glslType = def.type === 'int' ? 'int' : def.type === 'vec2' ? 'vec2' : 'float';
|
|
paramUniforms.push(`uniform ${glslType} ${def.uniform}; // param: ${name}`);
|
|
}
|
|
|
|
return [
|
|
PREAMBLE,
|
|
paramUniforms.join('\n'),
|
|
'\n// ---- scene ----\n',
|
|
sceneModule.shader,
|
|
EPILOGUE,
|
|
].join('\n');
|
|
}
|