Epic 2.5.2: make the framing actually reach the image

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>
This commit is contained in:
Dejvino
2026-08-06 20:18:08 +02:00
co-authored by Claude Opus 5
parent c92c398d18
commit e6f5a2f0d5
7 changed files with 156 additions and 17 deletions
+13 -3
View File
@@ -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,
});
+28 -4
View File
@@ -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);
}