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', ]); /**