Revisiting the framing layer turned up that it was not reaching four of the forty-two scenes, and that none of its gates could have told us. Those gates check the PLAN — cue sizes, the distribution of shot sizes across a population, headroom, seek determinism — and a plan that never reaches the image passes every one of them. Rendered at wide, normal and close, Scan Tear, Pylon Grid, Pitch Shatter and the 3D Particle Field came back byte-identical at every size. The cause was a category error in the first implementation. Framing was applied inside sigCamera, which is gated on the `camera` personality trait — so a scene that declined the track's drift and sway silently declined the shot size as well. That gating is right for a TRAIT and wrong for framing, which is not one: framing is where the camera is standing for this shot, and no scene should be exempt from it because of an unrelated art-direction decision. - Framing now lives in the shader epilogue, applied to the coordinate every fragment scene is handed, so honouring it is not optional. uv is left unframed on purpose: it is screen space, and prev() and sigGrain belong to the output image rather than to the scene being filmed. - Scan Tear and Pitch Shatter build their image from uv deliberately — a signal artefact happens to the signal, not to the world behind it. They now slice on raw uv and build the field they displace from a new framedUv(p), so the tear stays locked to the frame while the imagery behind it is filmed wide or close. - Particle Field receives framing in update() and honours it as a camera dolly, which is what framing literally is when a layer has a real camera. Distance divided by scale, matching the fragment path where the coordinate is divided by it. New gate renders instead of inspecting: 42 of 42 scenes now respond to framing, weakest Ridge Terrain at 0.22 of its own brightness, against a 0.05 floor. Also drops a stale comment on Layer.setFraming that still claimed sigCamera applied it. 105/105 checks pass including the slow set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
74 lines
3.4 KiB
JavaScript
74 lines
3.4 KiB
JavaScript
// Glitch family: vertical transposition. The frame is cut into vertical bands,
|
|
// each of which shifts up or down by a small amount. That alone is a tear; what
|
|
// makes it churn is that the offset is re-rolled on the quantised grid and the
|
|
// previous frame's transposed image is smeared underneath — so slices translate
|
|
// to a new fixed pose each step instead of crawling, and the pitch reads as an
|
|
// edit rather than a broken renderer.
|
|
|
|
export const pitchShatter = {
|
|
name: 'Pitch Shatter',
|
|
family: 'glitch',
|
|
kind: 'fragment',
|
|
// Personality: see look/Personality.js.
|
|
traits: ['camera', 'style'],
|
|
|
|
params: {
|
|
slices: { type: 'float', range: [4, 40], default: 18, uniform: 'u_slices', bias: 'density' },
|
|
amp: { type: 'float', range: [0, 0.35], default: 0.1, uniform: 'u_amp', bias: 'energy' },
|
|
quantize: { type: 'float', range: [1, 8], default: 4, uniform: 'u_quantize' },
|
|
bleed: { type: 'float', range: [0, 0.9], default: 0.4, uniform: 'u_bleed' },
|
|
glow: { type: 'float', range: [0, 1], default: 0.3, uniform: 'u_glow', bias: 'energy' },
|
|
speed: { type: 'float', range: [0.05, 0.8], default: 0.25, uniform: 'u_speed', bias: 'motion', rate: true },
|
|
palette: { type: 'palette', count: 5 },
|
|
},
|
|
|
|
reactive: {
|
|
amp: { feature: 'beat', amount: 0.4, response: 'spike' },
|
|
bleed: { feature: 'flux', amount: 0.25, response: 'smooth' },
|
|
glow: { feature: 'bandHigh', amount: 0.2 },
|
|
},
|
|
|
|
shader: `
|
|
vec4 scene(vec2 uv, vec2 p) {
|
|
float t = u_time * u_speed + u_seed;
|
|
p = sigCamera(p);
|
|
|
|
// One shared grid for every discontinuity below: slices re-roll on the
|
|
// quantised step rather than moving continuously frame-to-frame.
|
|
float q = max(u_quantize, 1.0);
|
|
float step_ = floor(u_barPhase * q) + floor(t * 6.0) * q;
|
|
|
|
// A vertical slice is a column of pixels; each gets one fixed offset per
|
|
// step, decided by a hash of (slice, step) so it jumps cleanly on the grid.
|
|
float slice = floor(uv.x * u_slices);
|
|
float sliceRand = hash12(vec2(slice, step_ * 31.0));
|
|
float pitch = (sliceRand - 0.5) * 2.0 * u_amp;
|
|
|
|
// The base image is always sampled from the un-pitched field so the scene
|
|
// has real content behind the displacement, even on its first frame.
|
|
// Slices are screen space; the field they transpose is filmed at the
|
|
// shot's framing. See framedUv.
|
|
vec2 fuv = framedUv(p);
|
|
vec2 baseUv = vec2(fuv.x, fract(fuv.y - pitch));
|
|
float field = fbm(vec2(baseUv.x * 2.5, baseUv.y * 4.0) + t * 0.4, 4);
|
|
float ramp = fract(field * 2.0 + baseUv.y * 2.0 - t * 0.4);
|
|
vec3 col = palRamp(ramp * 0.7 + slice * 0.02);
|
|
col *= 0.4 + 0.6 * smoothstep(0.1, 0.9, field);
|
|
|
|
// The smear: pull the previous frame across the pitch so slices leave a
|
|
// transient ghost as they land, and the corruption feels sludgy.
|
|
vec3 ghost = prev(vec2(uv.x, fract(uv.y + pitch * 0.55)));
|
|
col = mix(col, ghost, u_bleed * (0.4 + sliceRand));
|
|
|
|
// A thin scan beam rooting the hard edge of each step, kept local and beat
|
|
// -pinned so the frame never swings in whole-frame luminance.
|
|
float beamY = fract(u_beatPhase) * 2.0 - 1.0;
|
|
float beam = exp(-abs(p.y - beamY * 0.6) * 6.0);
|
|
col += pal(3) * beam * u_slices * 0.001 * (0.5 + u_beat);
|
|
|
|
col += pal(int(mod(slice, 5.0))) * (1.0 - sliceRand) * u_glow * 0.3 * sliceRand;
|
|
col += sigGrain(uv);
|
|
return vec4(col, 1.0);
|
|
}
|
|
`,
|
|
}; |