From b1e58a66036e49e4bc28b9055e5f0ad24da7c9b6 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Tue, 18 Aug 2026 16:39:35 +0200 Subject: [PATCH] A focus: somewhere for a full-frame field to be about, tried on Voronoi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edge-to-edge textures have no composition to vary. Two songs of Voronoi Shatter measured 0.004 apart in the layout block, and that was honest rather than a metric failure — the cells changed and the arrangement did not, because there was no arrangement. So the identity now decides a FOCUS: one to three points, a reach, and a pull that either draws the field in and densifies it or opens a void. The points come off the same lattice everything else is placed on, so a scene using them is composing in the song's terms rather than inventing a centre of its own. focusField and focusWarp are in the contract, available to any field scene that wants somewhere to be about. Voronoi Shatter is the pilot. It tiles in the warped coordinate, so cells crowd toward the focus or pull away from it, and its seams tighten where the field gathers so the effect reads as a change in the shatter rather than a brightness blob laid over one. 0.0463 to 0.0588, comfortably clear of the bar. It did NOT work the way the hypothesis said. The gain is in orientation, 0.140 to 0.194, and in texture; the layout block moved from 0.004 to 0.007, which is still nothing. Warping where the cells sit changes what they look like without changing where the energy is, because the field still covers the frame corner to corner. Layout stays blind to this family until a field is allowed to actually fall away from its focus and stop being full-frame — which is a bigger change to what these scenes are, and worth deciding rather than sliding into. Co-Authored-By: Claude Opus 5 --- flow-state/src/engine/shader-contract.js | 55 +++++++++++++++++++ flow-state/src/look/Identity.js | 18 ++++++ .../src/scenes/shader/voronoi-shatter.js | 12 +++- flow-state/tools/lint-scenes.js | 1 + 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/flow-state/src/engine/shader-contract.js b/flow-state/src/engine/shader-contract.js index 20b2445..ea3c1a5 100644 --- a/flow-state/src/engine/shader-contract.js +++ b/flow-state/src/engine/shader-contract.js @@ -128,6 +128,10 @@ export const IDENTITY_UNIFORMS = { u_latScaleSpread: 'float', // 0 = all one size, 1 = a few large, many small u_latScaleBias: 'float', // + puts the large ones in the middle u_latScale: 'float', // the song's element size, ~0.1 tiny .. ~0.9 huge + + 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 }; export const FRAME_UNIFORMS = [ @@ -412,6 +416,57 @@ vec3 stageNode(float i, float n) { /** The song's element size as a multiplier a stage applies to its own size. */ float stageScale() { return u_latScale / 0.35; } +// --- the focus ------------------------------------------------------------- +// Somewhere for a full-frame field to be ABOUT. +// +// An edge-to-edge texture has no composition to vary: measured, two songs of +// Voronoi Shatter differ by 0.004 in the layout block, which is nothing, and +// that is honest rather than a metric failure — the cells change and the +// arrangement does not. These give the field one to three points to organise +// itself around, drawn from the same lattice everything else is placed on, so a +// scene using them is composing in the song's terms rather than inventing a +// centre of its own. + +/** Where focal point i sits, in scene units. */ +vec2 focusAt(float i) { + return stageNode(i * 3.0 + 1.0, max(u_focusN, 1.0) * 3.0).xy * 0.8; +} + +/** + * Influence at p: 1 at a focal point, falling to 0 at its reach. + * Zero everywhere when the song asked for no focus. + */ +float focusField(vec2 p) { + if (u_focusN < 0.5) return 0.0; + float best = 0.0; + for (int i = 0; i < 3; i++) { + if (float(i) >= u_focusN) break; + float d = length(p - focusAt(float(i))); + best = max(best, smoothstep(u_focusR, u_focusR * 0.15, d)); + } + return best; +} + +/** + * Warp a coordinate toward the focus, or away from it. + * + * The amount argument is the scene's own appetite for it. A field applies this to the + * coordinate it tiles in, and the tiling densifies or opens up around the point + * without the scene needing to know where the point is or why. + */ +vec2 focusWarp(vec2 p, 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 d = p - focusAt(float(i)); + float r = length(d); + float w = smoothstep(u_focusR, 0.0, r); + q += normalize(d + 1e-5) * w * u_focusPull * amount * u_focusR * 0.45; + } + return q; +} + // --- the ink --------------------------------------------------------------- // How the cast is drawn. Changes every pixel of every stage at once, and does // it structurally rather than chromatically — which is the point, since colour diff --git a/flow-state/src/look/Identity.js b/flow-state/src/look/Identity.js index 69b7dbc..84fe68e 100644 --- a/flow-state/src/look/Identity.js +++ b/flow-state/src/look/Identity.js @@ -148,6 +148,19 @@ export function generateIdentity(summary, rng, sections = 4) { // hundred tiny ones are different videos before anything else is // decided; that decision belongs here. elementScale: 0.35 * 2 ** rng.range(-1.4, 1.4) * (1.25 - intricate * 0.5), + + // FOCUS: one to three points the field is disturbed around. + // + // A full-frame texture has no composition — measured, its layout + // distance between two songs is 0.004, which is nothing, because + // edge-to-edge content sits in the same place however its cells fall. + // That is not the metric failing; there genuinely is nothing to compose. + // A focal point gives the field somewhere to be about, and because the + // point moves with the song it gives the layout something to say. + focusCount: rng.pickWeighted([1, 2, 3], [4, 2, 1]), + 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), }; return { @@ -166,6 +179,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, }; /** @@ -214,6 +228,9 @@ export function identityUniforms(identity, shape = null) { u_latScaleSpread: identity.lattice.scaleSpread, u_latScaleBias: identity.lattice.scaleBias, u_latScale: identity.lattice.elementScale, + u_focusN: identity.lattice.focusCount, + u_focusR: identity.lattice.focusRadius, + u_focusPull: identity.lattice.focusPull, }; } @@ -228,6 +245,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` + ` at ${identity.lattice.elementScale < 0.2 ? 'tiny' : identity.lattice.elementScale > 0.6 ? 'huge' : 'mid'} scale`; } diff --git a/flow-state/src/scenes/shader/voronoi-shatter.js b/flow-state/src/scenes/shader/voronoi-shatter.js index 2cc085d..c9978f3 100644 --- a/flow-state/src/scenes/shader/voronoi-shatter.js +++ b/flow-state/src/scenes/shader/voronoi-shatter.js @@ -29,6 +29,11 @@ export const voronoiShatter = { seam: { type: 'float', range: [0.01, 0.14], default: 0.05, uniform: 'u_seam' }, recut: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_recut' }, relief: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_relief' }, + // How hard the shatter gathers around the song's focal points. At zero + // this is the old uniform tiling, which measured a layout distance of + // 0.004 between songs — edge-to-edge cells sit in the same place + // however they fall, so there was no arrangement for the song to change. + focus: { type: 'float', range: [0, 1], default: 0.55, uniform: 'u_focus', bias: 'density' }, drift: { type: 'float', range: [0.01, 0.4], default: 0.09, uniform: 'u_drift', bias: 'motion', rate: true }, palette: { type: 'palette', count: 5 }, }, @@ -42,7 +47,9 @@ vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_drift + u_seed; p = sigCamera(p); - vec2 g = p * u_cells; + // Tile in a warped coordinate: the cells crowd toward the focal points, or + // pull away from them, so the field has somewhere to be about. + vec2 g = focusWarp(p, u_focus) * u_cells; vec2 base = floor(g); vec2 f = g - base; @@ -70,6 +77,9 @@ vec4 scene(vec2 uv, vec2 p) { } float border = (d2 - d1) / u_cells; // scene units to the seam + // Seams tighten where the field gathers, so the focus reads as a change in + // the SHATTER rather than as a brightness blob laid over it. + border *= 1.0 - focusField(p) * u_focus * 0.55; // Plates: each one flat, tinted by its own identity, and tilted a little so // the tessellation reads as broken glass rather than as a colour chart. diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js index ef60b61..29e3be3 100644 --- a/flow-state/tools/lint-scenes.js +++ b/flow-state/tools/lint-scenes.js @@ -93,6 +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', ]); /**