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:
parent
c92c398d18
commit
e6f5a2f0d5
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user