Give a full-frame field a subject the eye can track, and let the song choose

what it does

"One pattern across the screen" scenes have nothing to watch. Warping the field
around a focal point was the previous attempt and it did not fix that — measured
on Voronoi it raised orientation variety while layout stayed at 0.004, which is
another way of saying the cells changed and the frame still had no subject.

So a subject is now a form that does something TO the field, and which thing is
the song's decision rather than the scene's: shift, warp, punch, morph or
overlay. The same form punching a hole, bending the pattern or running it at
another rate are three different videos, and across six songs the bank picks
overlay, morph, shift, morph, warp and punch — so it is a real axis rather than
a constant with five names.

subjectSDF, subjectMask, subjectEdge and subjectWarp are in the contract, so any
field scene can take a subject without knowing where the focal points are.

Moiré Grid is the pilot and implements all five impacts. 0.031 to 0.037 — up a
fifth, and the layout block moved from essentially nothing to 0.018, which is
the first time anything has moved layout on a full-frame field. That is the
number that speaks to the complaint: the frame's energy is no longer spread
evenly, so there is somewhere to look.

It is still under the 0.04 bar, and I have not seen it. The score says there is
now a subject; whether it is worth watching is the question the gallery cannot
answer.

One bug worth recording: the subject helpers were placed above the ink in the
preamble, and subjectEdge draws with inkStroke. GLSL has no forward
declarations, so the whole preamble failed to compile and every scene using it
rendered pure black — which the gallery reported as a variety of exactly 0.000
across all six blocks rather than as an error. An all-zero row means a dead
shader, not a boring scene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-18 16:52:57 +02:00
parent 10eeddcf6d
commit c950f8f3b1
4 changed files with 134 additions and 3 deletions

View File

@ -132,6 +132,7 @@ export const IDENTITY_UNIFORMS = {
u_focusN: 'float', // how many focal points, 0 = none
u_focusR: 'float', // their reach, in scene units
u_focusPull: 'float', // + draws the field in, - opens a void
u_impact: 'float', // index into Identity.IMPACTS
};
export const FRAME_UNIFORMS = [
@ -553,6 +554,80 @@ vec3 inkValue(vec3 col) {
return floor(col * n + 0.5) / n;
}
// The subject lives below the ink because subjectEdge draws with inkStroke,
// and GLSL has no forward declarations — placed above, the whole preamble
// failed to compile and every scene using it rendered black.
/**
* The song's protagonist, standing at a focal point, as a signed distance.
*
* The missing piece for full-frame fields. A pattern spread evenly over the
* screen has nothing to watch and nothing to track: there is no subject, so the
* eye has nowhere to rest and no way to tell one song's version from another's.
*
* Warping the field around a focus was the previous attempt and it did not fix
* this. Measured on Voronoi Shatter it raised orientation variety from 0.140 to
* 0.194 while layout stayed at 0.004 the cells changed shape and the frame's
* energy stayed exactly as evenly spread as before, which is the same thing as
* saying it still had no subject. A subject has to occupy part of the frame and
* leave the rest alone.
*
* The index argument picks which focal point. Returns a large positive distance when the
* song asked for no focus, so a scene can add it unconditionally.
*/
float subjectSDF(vec2 p, float index, float size) {
if (u_focusN < 0.5) return 1e3;
float s = max(size * stageScale(), 1e-3);
return castMain((p - focusAt(index)) / s) * s;
}
/** The nearest subject, for scenes that just want "is there one here". */
float subjectSDF(vec2 p, float size) {
float d = 1e3;
for (int i = 0; i < 3; i++) {
if (float(i) >= u_focusN) break;
d = min(d, subjectSDF(p, float(i), size));
}
return d;
}
/** 0..1 inside the subject, with a soft edge. */
float subjectMask(vec2 p, float size) {
float d = subjectSDF(p, size);
return smoothstep(0.012, -0.012, d);
}
/** The subject's rim, for a line the eye can follow. */
float subjectEdge(vec2 p, float size) {
return inkStroke(subjectSDF(p, size));
}
/**
* Displace a coordinate around the subject: the field bends near the form and
* is untouched away from it.
*
* This is the WARP impact, and it is what a scene reaches for when it wants the
* pattern itself disturbed rather than replaced.
*/
vec2 subjectWarp(vec2 p, float size, float amount) {
if (u_focusN < 0.5 || amount <= 0.0) return p;
vec2 q = p;
for (int i = 0; i < 3; i++) {
if (float(i) >= u_focusN) break;
vec2 c = focusAt(float(i));
vec2 d = p - c;
float r = length(d);
float s = max(size * stageScale(), 1e-3);
// Strongest at the form's edge, nothing at its centre or far away.
float w = smoothstep(s * 2.2, s * 0.9, r) * smoothstep(0.0, s * 0.6, r);
q += normalize(d + 1e-5) * w * amount * s * 0.9;
}
return q;
}
/** Which impact the song chose. 0 shift, 1 warp, 2 punch, 3 morph, 4 overlay. */
bool impactIs(float which) { return abs(u_impact - which) < 0.5; }
vec3 prev(vec2 uv) {
if (u_hasPrev == 0) return vec3(0.0);
return texture2D(u_prev, uv).rgb;

View File

@ -32,6 +32,21 @@ export const FILLS = ['flat', 'ramp', 'hatch', 'stipple', 'halftone', 'hollow'];
/** Lattices, as the shader's `u_latKind` index. */
export const LATTICES = ['grid', 'radial', 'spiral', 'scatter', 'strata'];
/**
* How the song's subject IMPACTS a field, as the shader's `u_impact` index.
*
* A pattern spread evenly over the screen has nothing to watch: no subject, so
* nowhere for the eye to rest and no way to tell one song's version from
* another's. Warping the field around a focal point was the first attempt and it
* changed the cells without changing where the frame's energy was, which is the
* same thing as still having no subject.
*
* So the subject is a form that does something TO the field, and which thing is
* the song's decision the same form punching a hole, bending the pattern, or
* running it at a different rate are three different videos.
*/
export const IMPACTS = ['shift', 'warp', 'punch', 'morph', 'overlay'];
const clamp01 = (x) => Math.max(0, Math.min(1, x));
/**
@ -161,6 +176,7 @@ export function generateIdentity(summary, rng, sections = 4) {
focusRadius: rng.range(0.35, 1.1),
// Positive draws the field in and densifies it; negative opens a void.
focusPull: (rng.bool(0.65) ? 1 : -1) * rng.range(0.3, 1),
impact: rng.pick(IMPACTS),
};
return {
@ -179,7 +195,7 @@ export const NEUTRAL_IDENTITY_UNIFORMS = {
u_inkHatchScale: 80, u_inkOutline: 0, u_inkPosterize: 0,
u_latKind: 3, u_latJitter: 0.5, u_latSpread: 0.9,
u_latScaleSpread: 0.3, u_latScaleBias: 0, u_latScale: 0.35,
u_focusN: 0, u_focusR: 0.6, u_focusPull: 0,
u_focusN: 0, u_focusR: 0.6, u_focusPull: 0, u_impact: 0,
};
/**
@ -231,6 +247,7 @@ export function identityUniforms(identity, shape = null) {
u_focusN: identity.lattice.focusCount,
u_focusR: identity.lattice.focusRadius,
u_focusPull: identity.lattice.focusPull,
u_impact: IMPACTS.indexOf(identity.lattice.impact),
};
}
@ -245,7 +262,7 @@ export function describeIdentity(identity) {
return `cast ${form(a)} + ${form(b)} · ink ${ink.fill}` +
`${ink.outline ? '+outline' : ''}${ink.posterize ? `/${ink.posterize}-tone` : ''}` +
` w${ink.weight.toFixed(2)} · on ${identity.lattice.kind}` +
` · ${identity.lattice.focusCount} focus` +
` · ${identity.lattice.focusCount} focus/${identity.lattice.impact}` +
` at ${identity.lattice.elementScale < 0.2 ? 'tiny' :
identity.lattice.elementScale > 0.6 ? 'huge' : 'mid'} scale`;
}

View File

@ -16,6 +16,12 @@ export const moireGrid = {
traits: ['camera', 'style'],
params: {
// The subject: the song's protagonist standing in the field, with the
// interference running at a different phase inside it. A window rather
// than a shape laid on top — the pattern is still the picture, it just
// has somewhere to be about, and the edge gives the eye a line to track.
subject: { type: 'float', range: [0, 1], default: 0.7, uniform: 'u_subject' },
subjSize: { type: 'float', range: [0.2, 0.75], default: 0.42, uniform: 'u_subjSize' },
density: { type: 'float', range: [6, 60], default: 22, uniform: 'u_density', bias: 'density' },
offset: { type: 'float', range: [0.0, 0.5], default: 0.08, uniform: 'u_offset' },
rotate: { type: 'float', range: [0, 0.25], default: 0.04, uniform: 'u_rotate', bias: 'motion', rate: true },
@ -55,14 +61,47 @@ vec4 scene(vec2 uv, vec2 p) {
float a = grid(rot(t * 6.28318530718) * q, u_density, weight);
float b = grid(rot(-t * 6.28318530718 + u_offset * 3.14159) * (q + u_offset), u_density, weight);
// The subject does something TO the field, and which thing is the song's
// decision rather than this scene's — the same form punching a hole,
// bending the grid or running it at another rate are three different
// videos. See Identity.IMPACTS.
float d = subjectSDF(p, u_subjSize);
float inside = smoothstep(0.01, -0.01, d) * u_subject;
// WARP bends the pattern around the form instead of replacing it inside,
// so it is applied to the coordinate everything below is drawn from.
if (impactIs(1.0)) q = subjectWarp(q, u_subjSize, u_subject * 1.2);
vec2 qi = q + vec2(u_offset * 0.7, -u_offset * 0.4);
float ai = grid(rot(t * 6.28318530718 * 1.35) * qi, u_density * 1.18, weight);
float bi = grid(rot(-t * 6.28318530718 * 0.7 + u_offset * 3.14159) * (qi + u_offset), u_density * 0.86, weight);
// The interference term is the point: where both grids land, it peaks.
float interference = a * b;
float either = max(a, b);
if (impactIs(0.0)) { // shift: a different beat inside
interference = mix(interference, ai * bi, inside);
either = mix(either, max(ai, bi), inside);
} else if (impactIs(2.0)) { // punch: the field stops at the form
interference *= 1.0 - inside;
either *= 1.0 - inside;
} else if (impactIs(3.0)) { // morph: one grid survives inside
interference = mix(interference, a * 0.35, inside);
either = mix(either, a, inside);
} else if (impactIs(4.0)) { // overlay: the form reads as solid
either = max(either, inside);
interference = max(interference, inside * 0.6);
}
vec3 col = pal(0) * 0.05;
col += pal(1) * either * 0.35;
col += pal(2) * interference * (0.8 + 0.35);
// The rim, so the form has an edge to follow rather than only a change of
// texture. Drawn in the song's hand like everything else.
col = mix(col, pal(3), inkStroke(d) * u_subject);
col *= 0.6 + 0.4 * exp(-dot(p, p) * 0.3);
return vec4(inkValue(col), 1.0);
}

View File

@ -93,7 +93,7 @@ const CONTRACT_UNIFORMS = new Set([
'u_inkHatchScale', 'u_inkOutline', 'u_inkPosterize',
'u_latKind', 'u_latJitter', 'u_latSpread',
'u_latScaleSpread', 'u_latScaleBias', 'u_latScale',
'u_focusN', 'u_focusR', 'u_focusPull',
'u_focusN', 'u_focusR', 'u_focusPull', 'u_impact',
]);
/**