diff --git a/flow-state/src/look/Personality.js b/flow-state/src/look/Personality.js index c3d6208..97b9fa8 100644 --- a/flow-state/src/look/Personality.js +++ b/flow-state/src/look/Personality.js @@ -46,6 +46,15 @@ export const TRAITS = ['shape', 'camera', 'space', 'style']; const clamp01 = (x) => Math.max(0, Math.min(1, x)); +// Box-Muller standard normal from the seeded rng. Two uniform draws per +// sample; the mode is at 0 and the tails are rare, which is the whole +// point — uniform boxes put as many tracks at the wild edge as near level. +function gauss(rng) { + const u1 = Math.max(rng.next(), 1e-7); + const u2 = rng.next(); + return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); +} + /** * Traits eligible to be a track's signature, and how often. * @@ -137,16 +146,29 @@ export function generatePersonality(summary, rng, countEligible = null, sections tilt: rng.range(0, Math.PI), }; + // Camera: bell-curve over level vs. wild. Most tracks stay near + // level — a little drift, a little sway, almost no roll — and only + // the tail really swings. Uniform boxes put as many tracks at the + // wild edge as near level, which is why the grid read as wild so + // often; a Gaussian puts the mode at level and rarity in the tails. + // Audio still tilts the centre (fast → more drift/sway/spin, + // dynamic → more breathe) but the seed picks within a bell around it. + const gaussAround = (centre, sigma, lo, hi) => + Math.max(lo, Math.min(hi, centre + gauss(rng) * sigma)); const camera = { driftAngle: rng.range(0, Math.PI * 2), - // A slow track should not be filmed from a moving car. - driftRate: rng.range(0.01, 0.06) * (0.6 + fast * 0.8), - sway: around(0.01 + fast * 0.035, 0.02, 0, 0.06), - swayRate: around(0.08 + fast * 0.1, 0.06, 0.05, 0.22), - spin: around((rng.bool() ? 1 : -1) * fast * 0.03, 0.025, -0.05, 0.05), + // A slow track should not be filmed from a moving car. Centre near + // level (0.016 + tempo), sigma narrow so wild drift is a tail event. + driftRate: gaussAround(0.016 + fast * 0.016, 0.010, 0.004, 0.065), + sway: gaussAround(0.010 + fast * 0.018, 0.009, 0, 0.055), + swayRate: gaussAround(0.09 + fast * 0.06, 0.028, 0.05, 0.22), + // Spin centred at zero — most tracks stay level, only the tails roll. + // Spread scales with tempo so a fast track has a wider bell, not a + // displaced one. Sign comes from the Gaussian itself. + spin: gaussAround(0, 0.012 + fast * 0.012, -0.05, 0.05), // Breathing is locked to the bar, so it is the one camera move that // reads as musical rather than as drifting. A dynamic track breathes. - breathe: around(dynamic * 0.03, 0.02, 0, 0.05), + breathe: gaussAround(dynamic * 0.022, 0.010, 0, 0.048), }; const space = { diff --git a/flow-state/src/scenes/shader/synthwave-run.js b/flow-state/src/scenes/shader/synthwave-run.js index eeb292c..cdae431 100644 --- a/flow-state/src/scenes/shader/synthwave-run.js +++ b/flow-state/src/scenes/shader/synthwave-run.js @@ -8,7 +8,9 @@ // track drove the same car. The thing on the road is now the song's own // protagonist — the same solid every other stage in the video draws, seen // here as a single hero turning slowly above the grid — so a hexagonal -// track drives a hexagonal form. See castSolid. +// track drives a hexagonal form — and the verges stream with its chorus, +// random objects rushing past that make each song's traffic unmistakably +// its own. See castSolid / castChorusSolid. export const synthwaveRun = { name: 'Synthwave Run', @@ -26,6 +28,7 @@ export const synthwaveRun = { sun: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_sun' }, mountains: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_mountains' }, car: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_car' }, + passers: { type: 'int', range: [0, 6], default: 3, uniform: 'u_passers', bias: 'density' }, palette: { type: 'palette', count: 4 }, }, @@ -81,6 +84,72 @@ vec4 scene(vec2 uv, vec2 p) { } } + // --- roadside passers — the chorus streaming past on the verges --- + // Random chorus objects outside the road, rushing from the horizon toward + // the camera. Each one is a chorus member, so a hexagonal track is + // overtaken by hexagonal debris and a round track by round — the variety + // the old car could never provide. Kept strictly off the road stripe + // (abs(p.x) < 0.8/(horizon - y + 0.05)) so the centre lane stays clear + // for the protagonist; on-road traffic that the hero weaves around would + // be a separate kind later. + if (u_passers > 0) { + for (int i = 0; i < 6; i++) { + if (i >= u_passers) break; + float fi = float(i); + float ra = hash12(vec2(fi * 7.13 + 1.1, u_seed * 0.31 + fi * 3.7)); + float rb = hash12(vec2(fi * 3.19 + 4.2, u_seed * 0.73 + fi * 1.9)); + float side = ra < 0.5 ? -1.0 : 1.0; + // each passer on its own phase so they are staggered, not a convoy + float phase = fract(t * (0.28 + rb * 0.10) + ra * 7.0 + fi * 1.63); + float f = pow(phase, 0.85); + // along-road position: horizon to bottom, perspective is in the x offset + float py = mix(horizon + 0.06, -0.92, f); + float psize = mix(0.05, 0.24, f) * stageScale() * (0.70 + rb * 0.65); + // road half-width at this depth — matches the grid's + // abs(p.x*perspective) < 0.8 => abs(p.x) < 0.8*(horizon - y + 0.05) + // clamp: just above the horizon that goes slightly negative + float roadHalf = max(0.015, 0.8 * (horizon - py + 0.05)); + // verge offset: strictly outside the road stripe at this depth. + // Grows with psize so even a large chorus form's edge stays clear + // of the road — the centre lane is reserved for the protagonist. + // On-road traffic the hero weaves around would be a separate kind. + float gap = 0.07 + rb * 0.12; + float verge = roadHalf + psize * 0.92 + gap; + vec2 passPos = vec2(side * verge, py); + // slight lateral wobble — small enough it can never carry back onto the road + // (worst inward ~0.025, gap is >= 0.07) + passPos.x += sin(t * 0.35 + ra * 6.28) * 0.018 * (0.4 + rb * 0.6); + passPos.y += cos(t * 0.5 + rb * 6.28) * 0.012; + float bound = dot(p - passPos, p - passPos) / max(psize * psize, 1e-6); + if (bound > 1.7) continue; + vec2 local = (p - passPos) / max(psize, 1e-3); + if (dot(local, local) > 1.7) continue; + mat3 turn = castTurn(t * (0.45 + rb * 0.6) + ra * 6.28 + fi * 2.1, + sin(t * 0.4 + ra * 9.0) * 0.55 + f * 0.25); + vec3 n; + float hit = castChorusSolid(local, turn, n); + float depthShade = mix(0.38, 1.0, f); + if (hit > 0.0) { + vec3 lit = castLit(n, vec3(0.0, 0.0, 1.0)); + lit *= depthShade * (0.75 + 0.25 * u_beat); + // pick palette entry per passer so the stream keeps the colour rhythm + lit *= mix(vec3(1.0), pal(int(mod(fi * 1.7 + ra * 3.0, 4.0))), 0.35); + col = mix(col, lit, 0.90); + } + float d = castChorus3(turn * vec3(local, 0.0)) * psize; + col += pal(int(mod(fi * 1.3 + rb * 2.0, 4.0))) * inkStroke(d) * 0.55 * depthShade; + // faint glow — keeps far passers readable at horizon scale + col += pal(int(mod(fi + 1.0, 4.0))) * exp(-bound * 1.1) * 0.06 * depthShade; + // soft ground ellipse under each passer, stretched with perspective + if (f > 0.15) { + vec2 shP = vec2((p.x - passPos.x) / max(psize * 1.5, 1e-3), + (p.y - passPos.y + psize * 0.42) / max(psize * 0.24, 1e-3)); + float sh = exp(-dot(shP, shP)) * 0.22 * depthShade * sat((f - 0.15) / 0.35); + col = mix(col, vec3(0.0), sh); + } + } + } + // --- protagonist on the road --- // The same solid every other stage in this video draws, now as the hero // the camera is chasing down the grid. One instance, turning slowly so