From a04627c1ecdb8fd1c4df79b785883dc021e71d65 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 1 Nov 2026 21:32:31 +0100 Subject: [PATCH] added grain + spiky blob --- web/src/blob/shaders.ts | 24 +++++++++++++++++++----- web/src/main.ts | 20 ++++++++++++++++++++ web/src/style.css | 17 ++++++++++++++--- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/web/src/blob/shaders.ts b/web/src/blob/shaders.ts index 3b609b5..f10a406 100644 --- a/web/src/blob/shaders.ts +++ b/web/src/blob/shaders.ts @@ -108,7 +108,7 @@ uniform float uDist; // camera distance uniform float uTime; // flow time, runs with the tempo uniform float uMorph; // 0 flat disc (the static artwork), 1 liquid // Music, see features.ts. -uniform float uLevel, uWobble, uKickAge, uSnare, uDrop, uBass, uGrowl; +uniform float uLevel, uWobble, uKick, uKickAge, uSnare, uDrop, uBass, uGrowl; // Character of the track, see style.ts. uniform float uGain, uSpike, uStep, uTwist, uRipple, uShock; // The jump on prominent hits, from the springs in blob.ts. @@ -144,6 +144,10 @@ float disp(vec3 p) { float ripples = snoise(q * 3.2 + vec3(t * 1.3, -t, 0.0)) * (0.012 + 0.035 * uWobble + 0.045 * uGrowl * uBass) * uRipple; float n = snoise(q * 2.4 - vec3(0.0, t * 0.6, t * 0.9)); float spikes = pow(max(n, 0.0), 3.0) * (0.05 + 0.25 * uSnare + 0.15 * uGrowl * uBass + 0.1 * uDrop) * uSpike; + // Thorns: the crests of a finer noise, sharpened into points that shoot out on + // snares, kicks and drops. 1 - |n| is never negative, so pow() is safe. + float crest = 1.0 - abs(snoise(q * 4.5 + vec3(t * 0.8, t * 0.5, -t * 0.4))); + float thorns = pow(crest, 6.0) * (0.02 + 0.2 * uSnare + 0.08 * uKick + 0.12 * uDrop + 0.1 * uGrowl * uBass) * uSpike; // After each kick a wave runs from the front of the drop round to the back. // Squares are written out: pow() of a negative base is undefined in GLSL, and // Mesa really returns NaN for it, which flattened the whole drop on Linux. @@ -153,7 +157,7 @@ float disp(vec3 p) { float near = distance(p, uPointer.xyz) * 2.2; float dent = -0.18 * uPointer.w * exp(-near * near); // Calmer while the icon shows, so it stays readable. - return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4) * (1.0 - 0.6 * uCalm); + return clamp((lobes + sway + ripples + spikes + thorns + wave) * uGain + dent, -0.3, 0.5) * (1.0 - 0.6 * uCalm); } void main() { @@ -188,7 +192,7 @@ in vec3 vObj; uniform sampler2D uArt; uniform vec3 uAccent; -uniform float uTime, uMorph, uKick, uDrop, uBass, uGrowl; +uniform float uTime, uMorph, uKick, uSnare, uDrop, uBass, uGrowl; uniform float uHue; // rim hue offset, radians uniform float uIri; // how much the rim hue drifts uniform float uMetal; // sharper, brighter highlights @@ -216,7 +220,7 @@ void main() { // Looking through the surface bends the artwork like water does. vec2 bend = n.xy * (0.22 + 0.1 * uDrop) * uMorph; vec2 uv = 0.5 + vec2(vObj.x, -vObj.y) * 0.48 - vec2(bend.x, -bend.y) * 0.5; - vec2 split = n.xy * (0.004 + 0.02 * uKick + 0.008 * uGrowl * uBass) * uMorph; + vec2 split = n.xy * (0.008 + 0.025 * uKick + 0.015 * uSnare + 0.01 * uGrowl * uBass) * uMorph; // Turned sideways the projection smears; blur it into flowing colour instead. float blur = 4.0 * pow(1.0 - abs(vObj.z), 3.0) * uMorph; vec3 art = vec3(texture(uArt, uv + split, blur).r, texture(uArt, uv, blur).g, texture(uArt, uv - split, blur).b); @@ -228,9 +232,19 @@ void main() { // card's colours. vec3 rim = hueRotate(uAccent, uHue + uIri * (fres + 0.5 * sin(uTime * 0.3))); rim = mix(rim, vec3(1.0, 0.35, 0.1), uHeat * 0.6); + // Oil-slick fringes: each channel cycles at its own phase across the edge, worn + // over the accent, more for iridescent tracks. + vec3 slick = 0.5 + 0.5 * cos(6.2831853 * (fres * 1.6 + vec3(0.0, 0.33, 0.67) + uTime * 0.05)); + rim = mix(rim, rim * 0.4 + slick * length(rim) * 0.75, 0.25 + 0.5 * uIri); + // The fresnel per channel, red reaching in further than blue: a lens fringe. + float nv = 1.0 - max(dot(n, v), 0.0); + vec3 fresC = vec3(nv * nv * nv * mix(1.0, nv, -0.35), fres, nv * nv * nv * mix(1.0, nv, 0.35)); + // A thin, hot edge that flares on hits. + float edge = fres * fres; vec3 col = art * mix(1.0, 0.45, fres * uMorph); - col += rim * fres * (0.5 + 0.7 * uKick + 0.4 * uDrop + 0.5 * uBass) * uMorph; + col += rim * fresC * (0.65 + 0.9 * uKick + 0.5 * uDrop + 0.5 * uBass) * uMorph; + col += mix(rim, vec3(1.0), 0.5) * edge * (0.3 + 1.2 * uKick + 0.8 * uDrop) * uMorph; col += spec * uMorph; // Gloss: a dark room lit by the accent from above, and one soft window. They stay // put while the surface turns under them, which is what makes a spin read. diff --git a/web/src/main.ts b/web/src/main.ts index bbaee51..3097764 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -57,6 +57,8 @@ const sheets = { if ("serviceWorker" in navigator && import.meta.env.PROD) { navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((err) => console.warn("no service worker:", err)); } +paintGrain(); + const cards: Card[] = []; let active = -1; let loading = false; @@ -1172,6 +1174,24 @@ function signedIn(user: User, pl8list?: Pl8Status): void { if (!cards.length) openLinkOrFeed(); } +/// The tile of film grain the stylesheet lays under the page's content: faint +/// specks, each a little lighter or darker than what is under it. +function paintGrain(): void { + const size = 128; + const c = document.createElement("canvas"); + c.width = c.height = size; + const ctx = c.getContext("2d"); + if (!ctx) return; + const img = ctx.createImageData(size, size); + for (let i = 0; i < img.data.length; i += 4) { + const v = Math.random() < 0.5 ? 0 : 255; + img.data[i] = img.data[i + 1] = img.data[i + 2] = v; + img.data[i + 3] = Math.random() * 30; + } + ctx.putImageData(img, 0, 0); + document.documentElement.style.setProperty("--grain", `url(${c.toDataURL()})`); +} + /// `/?t=handle/slug` opens on that track; the feed carries on after it. async function openLinkOrFeed(): Promise { const link = new URLSearchParams(location.search).get("t")?.split("/"); diff --git a/web/src/style.css b/web/src/style.css index dfabb50..69e00bc 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -29,7 +29,9 @@ html, body { margin: 0; height: 100%; - background: var(--bg); + /* Film grain (main.ts paints the tile) under everything; cards lay their own + over their background, below their content. */ + background: var(--grain, none), var(--bg); color: var(--fg); font: 15px/1.4 var(--font); overflow: hidden; @@ -403,6 +405,15 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; background: radial-gradient(55% 40% at 50% 40%, color-mix(in oklab, var(--card-accent) 55%, transparent), transparent 70%); opacity: calc(0.18 + var(--energy) * 0.55); } +/* Grain over the card's background only: content and actions sit above it. */ +.card::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: var(--grain, none); +} +.card > .content, .card > .actions { z-index: 1; } .content { position: relative; @@ -456,8 +467,8 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; .card.liquid .art::before { content: ""; position: absolute; - inset: -40%; - background: radial-gradient(closest-side, color-mix(in oklab, var(--card-accent) calc(20% + var(--energy) * 40%), transparent), transparent); + inset: calc(-50% - var(--energy) * 15%); + background: radial-gradient(closest-side, color-mix(in oklab, var(--card-accent) calc(28% + var(--energy) * 55%), transparent), transparent); pointer-events: none; } .card.liquid .content > :not(.art, .chrome-layer) { position: relative; z-index: 1; }