diff --git a/flow-state/src/engine/shader-contract.js b/flow-state/src/engine/shader-contract.js index ea3c1a5..78c8a1a 100644 --- a/flow-state/src/engine/shader-contract.js +++ b/flow-state/src/engine/shader-contract.js @@ -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; diff --git a/flow-state/src/look/Identity.js b/flow-state/src/look/Identity.js index 84fe68e..6d1e5aa 100644 --- a/flow-state/src/look/Identity.js +++ b/flow-state/src/look/Identity.js @@ -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`; } diff --git a/flow-state/src/scenes/shader/moire-grid.js b/flow-state/src/scenes/shader/moire-grid.js index 300e685..8fd257e 100644 --- a/flow-state/src/scenes/shader/moire-grid.js +++ b/flow-state/src/scenes/shader/moire-grid.js @@ -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); } diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js index 29e3be3..b7cdb45 100644 --- a/flow-state/tools/lint-scenes.js +++ b/flow-state/tools/lint-scenes.js @@ -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', ]); /**