diff --git a/flow-state/EPIC-2.md b/flow-state/EPIC-2.md index d16cea9..eb7d2fd 100644 --- a/flow-state/EPIC-2.md +++ b/flow-state/EPIC-2.md @@ -190,9 +190,32 @@ actually change size, sizes stay in headroom, and two drivers over one look agre What this pass does not do, and the reason it is "simple": the framing is constant within a shot. A zoom that moves during a shot is a separate device and would fight the drift LFO and the slow -axis, both of which already own continuous motion. Scenes built outside the shader contract (the -single 3D layer) are not framed yet. Both are the obvious next steps and neither is required for -a working project. +axis, both of which already own continuous motion. + +**Revisited, and it was not reaching four scenes.** + +The gates above check the PLAN — cue sizes, distribution, headroom, determinism — and a plan +that never reaches the image passes every one of them. It did. Framing was applied inside +`sigCamera`, which is gated on the `camera` personality trait, so a scene that declined the +track's drift silently declined the shot size too. That is correct for a trait and wrong for +framing, which is not one: it is where the camera is standing, and no scene should be exempt +from it because of an unrelated art-direction decision. Rendered, Scan Tear, Pylon Grid, Pitch +Shatter and the 3D layer were identical at every size, and nothing noticed. + +Three fixes: + +- Framing moved out of `sigCamera` and into the shader epilogue, applied to the coordinate every + fragment scene is handed. `uv` is deliberately left unframed — it is screen space, and `prev()` + and `sigGrain` belong to the output image rather than to the scene being filmed. +- Screen-space glitch scenes (Scan Tear, Pitch Shatter) build their image from `uv` on purpose: + 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 `framedUv(p)`, so the tear stays locked to the + frame while the imagery behind it is filmed wide or close. +- The 3D layer receives `framing` in `update` and honours it as an actual camera dolly, which is + what framing literally is when a scene has a real camera. + +And a gate that renders rather than inspecting the plan: **42 of 42 scenes now respond to +framing**, weakest Ridge Terrain at 0.22 of its own brightness. --- diff --git a/flow-state/src/checks/phase11.js b/flow-state/src/checks/phase11.js index 95fd6d0..dea6bd0 100644 --- a/flow-state/src/checks/phase11.js +++ b/flow-state/src/checks/phase11.js @@ -802,3 +802,71 @@ check(11, 'the axis measurement would notice if the axis stopped working', () => problems.length ? problems.join(' · ') : `axis disabled reads ${detail.join(', ')} — the measurement tracks the axis`); }, { slow: true }); + +check(11, 'framing visibly changes every scene in the library', () => { + // The gate the framing work shipped without, and the reason this revisit + // found anything. The other framing checks assert that the PLAN differs — + // cue sizes, distribution, headroom — and a plan that never reaches the + // image passes all of them. It did: framing was applied inside sigCamera, + // which is gated on the `camera` personality trait, so any scene that + // declined the track's drift silently declined the shot size too. Scan + // Tear, Pylon Grid, Pitch Shatter and the 3D layer rendered identically at + // every size and nothing noticed. + // + // Measured relative to how much image there is, for the reason Phase 10 + // documents: most of this library is mostly dark. + const PALETTE = [[1, 0.3, 0.2], [0.2, 0.8, 1], [1, 0.9, 0.3], + [0.6, 0.3, 1], [0.2, 1, 0.6], [1, 1, 1]]; + const PERSON = { + shape: { sides: 0, roundness: 0.25, elongation: 1, tilt: 0 }, + camera: { driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0.1, spin: 0, breathe: 0 }, + space: { horizon: 0.5, depth: 0.5, washAngle: 0, wash: 0.2 }, + style: { lineWeight: 0.5, softness: 0.5, texture: 0, symmetry: 1 }, + }; + + const engine = new Engine({ width: 160, height: 90 }); + engine.timeline.setDuration(60); + const problems = []; + let weakest = Infinity; + let weakestName = ''; + + try { + const shot = (module, framing) => { + engine.setLayerSpecs([{ + module, params: sampleValues(module, new Rng(31), {}, null), + seed: 9, opacity: 1, blend: 'normal', palette: PALETTE, personality: PERSON, + }]); + // Framing is pushed onto live layers, the way the arc driver does it. + const layers = engine.compositor.layers || []; + layers.forEach((l) => l.setFraming && l.setFraming(framing)); + engine.prime(600); + engine.compositor.reset(); + layers.forEach((l) => l.setFraming && l.setFraming(framing)); + return Uint8Array.from(engine.readPixels(engine.renderFrame(600))); + }; + + for (const module of scenes) { + const normal = shot(module, { size: 'normal', scale: 1, shift: [0, 0] }); + const close = shot(module, { size: 'close', scale: SHOT_SIZES.close.scale, shift: [0.05, 0.02] }); + const wide = shot(module, { size: 'wide', scale: SHOT_SIZES.wide.scale, shift: [0.05, 0.02] }); + + const brightness = Math.max(1e-3, + (frameLuminance(normal) + frameLuminance(close)) * 0.5); + const best = Math.max(frameDistance(normal, close), frameDistance(normal, wide)) + / brightness; + + if (best < weakest) { weakest = best; weakestName = module.name; } + if (best < 0.05) { + problems.push(`${module.name}${module.kind === 'layer3d' ? ' [3D]' : ''}: ` + + `${best.toFixed(3)} — renders the same at every shot size`); + } + } + } finally { + engine.dispose(); + } + + return expect(problems.length === 0, + problems.length ? problems.join(' · ') + : `${scenes.length} scenes all respond to framing · weakest ` + + `${weakestName} at ${weakest.toFixed(2)} of its own brightness`); +}, { slow: true }); diff --git a/flow-state/src/engine/Layer.js b/flow-state/src/engine/Layer.js index a95768d..f088bdc 100644 --- a/flow-state/src/engine/Layer.js +++ b/flow-state/src/engine/Layer.js @@ -145,9 +145,13 @@ export class Layer { } /** - * This shot's FRAMING — a per-shot scale/recentre pushed by the arc driver, - * applied inside sigCamera. A layer that is never framed renders at the - * neutral (full-frame) scale, so nothing predating framing changes. + * This shot's FRAMING — a per-shot scale/recentre pushed by the arc driver. + * + * A fragment layer has it applied for it, in the shader epilogue, to the + * coordinate every scene is handed. A 3D layer receives it in `update` and + * honours it as a camera move, because only it knows what its camera means. + * A layer that is never framed renders at the neutral full-frame scale, so + * nothing predating framing changes. */ setFraming(framing) { this.framing = framing || null; @@ -273,6 +277,12 @@ export class SceneLayer extends Layer { params: resolved, palette: this.palette, personality: this.personality, + // A 3D layer has a literal camera, so framing reaches it as a + // camera move rather than as a coordinate transform. Fragment + // scenes get it applied for them in the shader epilogue; this one + // has to honour it itself, because only it knows what its camera + // means. See look/framing.js. + framing: this.framing, opacity: this.opacity, THREE, }); diff --git a/flow-state/src/engine/shader-contract.js b/flow-state/src/engine/shader-contract.js index 2e8670f..7d1d857 100644 --- a/flow-state/src/engine/shader-contract.js +++ b/flow-state/src/engine/shader-contract.js @@ -224,10 +224,6 @@ float sigForm(vec2 p, vec2 centre, float size) { */ vec2 sigCamera(vec2 p) { float t = u_time; - // Framing first: everything below is the operator's hand on a shot that has - // already been set up, so it composes on top of the framing rather than - // fighting it. - p = p / max(u_sigFrameScale, 0.05) + u_sigFrameShift; 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; @@ -279,6 +275,17 @@ vec3 prev(vec2 uv) { 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 = ` @@ -286,6 +293,23 @@ 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); } diff --git a/flow-state/src/scenes/layers3d/particles.js b/flow-state/src/scenes/layers3d/particles.js index 968506c..c902269 100644 --- a/flow-state/src/scenes/layers3d/particles.js +++ b/flow-state/src/scenes/layers3d/particles.js @@ -80,7 +80,7 @@ export const particleField = { return { points, geometry, material, positions, colors, phases, max }; }, - update({ instance, camera, timeline, features, params, palette, personality }) { + update({ instance, camera, timeline, features, params, palette, personality, framing }) { const { geometry, material, positions, colors, phases, max } = instance; const count = Math.min(max, Math.round(params.count)); const t = timeline.time; @@ -133,19 +133,27 @@ export const particleField = { // the same slow returning pan, sway and roll every shader scene fakes in // its coordinate space. Bounded and periodic, so a seek still lands on // the same frame as sequential playback. + // The shot's framing, applied to the only literal camera in the + // library. Every fragment scene gets this as a coordinate scale in the + // shader epilogue; here it is what it actually is — the camera standing + // closer or further back. Dividing the distance by the scale matches + // the fragment behaviour, where the coordinate is divided by it. + const frame = framing || { scale: 1, shift: [0, 0] }; + const dolly = 4 / Math.max(frame.scale, 0.05); + const cam = personality ? personality.camera : null; if (cam) { const pan = 20 * Math.sin(t * 0.05); camera.position.set( Math.cos(cam.driftAngle) * cam.driftRate * pan - + Math.sin(t * cam.swayRate) * cam.sway, + + Math.sin(t * cam.swayRate) * cam.sway + frame.shift[0], Math.sin(cam.driftAngle) * cam.driftRate * pan - + Math.cos(t * cam.swayRate * 0.83) * cam.sway, - 4, + + Math.cos(t * cam.swayRate * 0.83) * cam.sway + frame.shift[1], + dolly, ); camera.rotation.z = cam.spin * t; } else { - camera.position.set(0, 0, 4); + camera.position.set(frame.shift[0], frame.shift[1], dolly); camera.rotation.z = 0; } camera.lookAt(camera.position.x, camera.position.y, -depth * 0.4); diff --git a/flow-state/src/scenes/shader/pitch-shatter.js b/flow-state/src/scenes/shader/pitch-shatter.js index 7ad4603..8f91950 100644 --- a/flow-state/src/scenes/shader/pitch-shatter.js +++ b/flow-state/src/scenes/shader/pitch-shatter.js @@ -46,7 +46,10 @@ vec4 scene(vec2 uv, vec2 p) { // 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. - vec2 baseUv = vec2(uv.x, fract(uv.y - pitch)); + // 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); diff --git a/flow-state/src/scenes/shader/scan-tear.js b/flow-state/src/scenes/shader/scan-tear.js index 7750688..da9febb 100644 --- a/flow-state/src/scenes/shader/scan-tear.js +++ b/flow-state/src/scenes/shader/scan-tear.js @@ -46,7 +46,10 @@ vec4 scene(vec2 uv, vec2 p) { float torn = step(1.0 - u_tear * (0.6 + u_sigLine), rowRandom); float offset = (rowRandom - 0.5) * 2.0 * u_shift * torn; - vec2 q = vec2(fract(uv.x + offset), uv.y); + // The rows tear in SCREEN space — that is what a tear is — but the image + // being torn is filmed at the shot's framing. See framedUv. + vec2 fuv = framedUv(p); + vec2 q = vec2(fract(fuv.x + offset), fuv.y); // Base image: a banded field, so the scene stands alone rather than needing // something underneath it.