diff --git a/web/src/effects/PetalFall.tsx b/web/src/effects/PetalFall.tsx index 9812696..d1adb45 100644 --- a/web/src/effects/PetalFall.tsx +++ b/web/src/effects/PetalFall.tsx @@ -1,125 +1,84 @@ import { useEffect, useRef } from 'react' -// PetalFall is the cozy ambient layer: a full-window WebGL canvas that drifts a -// handful of soft flower petals down the page. It sits behind everything, -// ignores pointer events, and stays sparse so it reads as "occasional petals on -// a breeze" rather than confetti. Honors prefers-reduced-motion by rendering -// nothing. No external libraries — a single tiny shader program animates every -// petal entirely on the GPU, so the CPU just spins a clock. +// PetalFall is the cozy ambient layer: a fixed, non-interactive 2D canvas that +// drifts soft cherry-blossom petals down the page. Each petal is a real +// petal-shaped sprite (notched sakura silhouette with a gentle gradient and +// soft alpha), pre-rendered once per color and then blitted many times with +// rotation + sway. It sits behind the chrome, ignores pointer events, pauses +// when the tab is hidden, and renders nothing for prefers-reduced-motion. -// Each petal is a quad whose corners we expand in the vertex shader. We pack one -// quad as 6 vertices; per-vertex we send the corner offset (which corner) plus -// the petal's static randomness (lane, speed, size, sway, tint, spin). -const PETAL_COUNT = 26 - -// Cherry-blossom tints — saturated enough to read as colorful petals over the -// warm cream canvas (pale pastels just wash out to grey). RGB 0..1. -const TINTS: [number, number, number][] = [ - [1.0, 0.55, 0.74], // bright rose pink - [1.0, 0.74, 0.83], // soft blossom pink - [1.0, 0.66, 0.5], // warm peach - [0.78, 0.56, 1.0], // lavender violet - [1.0, 0.42, 0.66], // deep magenta-rose +// Saturated-but-soft blossom colors. Each petal fades from a light, near-white +// tip to its color at the base, so it reads as a real petal rather than a blob. +const PALETTE: { r: number; g: number; b: number }[] = [ + { r: 255, g: 150, b: 190 }, // rose pink + { r: 255, g: 184, b: 205 }, // pale blossom + { r: 255, g: 158, b: 130 }, // warm peach + { r: 209, g: 160, b: 255 }, // lavender + { r: 255, g: 122, b: 172 }, // deep rose ] -const VERT = ` -precision mediump float; -attribute vec2 a_corner; // quad corner in [-0.5,0.5] -attribute vec4 a_rand; // x: lane(0..1), y: speed, z: size, w: phase -attribute vec4 a_rand2; // x: swayAmp, y: swayFreq, z: spin, w: tintIdx -uniform float u_time; -uniform vec2 u_res; -varying vec2 v_uv; -varying float v_tint; -varying float v_alpha; +const SPRITE_PX = 160 // off-screen render resolution per petal sprite -void main() { - v_uv = a_corner + 0.5; - v_tint = a_rand2.w; - - float lane = a_rand.x; - float speed = a_rand.y; - float size = a_rand.z; - float phase = a_rand.w; - - // Vertical fall: loop 0..1 over time, offset per petal so they don't sync. - float fall = fract(u_time * speed + phase); - // Map to clip space: start above the top (1.15) fall to below bottom (-1.15). - float y = 1.15 - fall * 2.3; - - // Horizontal: a base lane plus a gentle sway as it descends. - float sway = a_rand2.x * sin(u_time * a_rand2.y + phase * 6.2831); - float x = (lane * 2.0 - 1.0) + sway; - - // Spin the quad over time. - float ang = u_time * a_rand2.z + phase * 6.2831; - float c = cos(ang), s = sin(ang); - vec2 corner = a_corner * size; - // Keep petals visually square despite aspect ratio. - corner.x *= u_res.y / u_res.x; - vec2 rot = vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c); - - // Fade in near the top and out near the bottom so they don't pop. - v_alpha = smoothstep(0.0, 0.12, fall) * (1.0 - smoothstep(0.86, 1.0, fall)); - - gl_Position = vec4(x + rot.x, y + rot.y, 0.0, 1.0); +function rgba(c: { r: number; g: number; b: number }, a: number): string { + return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${a})` } -` - -const FRAG = ` -precision mediump float; -varying vec2 v_uv; -varying float v_tint; -varying float v_alpha; -uniform vec3 u_tints[5]; - -// A soft petal/teardrop alpha mask centered in the quad. -float petalMask(vec2 uv) { - vec2 p = uv - vec2(0.5); - // Teardrop: pinch the top, round the bottom. - float r = length(vec2(p.x * 1.65, p.y * 1.15 + 0.12)); - float body = smoothstep(0.5, 0.16, r); - // A subtle crease down the middle for a petal feel. - float crease = 1.0 - 0.18 * exp(-pow(p.x * 7.0, 2.0)); - return body * crease; +function mixWhite(c: { r: number; g: number; b: number }, t: number) { + return { r: c.r + (255 - c.r) * t, g: c.g + (255 - c.g) * t, b: c.b + (255 - c.b) * t } +} +function scale(c: { r: number; g: number; b: number }, t: number) { + return { r: c.r * t, g: c.g * t, b: c.b * t } } -void main() { - float m = petalMask(v_uv); - if (m < 0.01) discard; - int idx = int(v_tint + 0.5); - vec3 col = u_tints[0]; - if (idx == 1) col = u_tints[1]; - else if (idx == 2) col = u_tints[2]; - else if (idx == 3) col = u_tints[3]; - else if (idx == 4) col = u_tints[4]; - // Glossy core: brighten toward the petal's centre and deepen the lower edge - // so each blossom has a little dimensional shading instead of a flat blob. - vec2 p = v_uv - vec2(0.5); - float core = smoothstep(0.5, 0.05, length(p)); - col = mix(col, vec3(1.0), core * 0.35); // luminous centre - col = mix(col * 0.82, col, smoothstep(-0.4, 0.4, p.y)); // soft shadow at base - gl_FragColor = vec4(col, m * v_alpha * 0.92); -} -` +// Draw one petal sprite: a notched sakura silhouette filled with a tip→base +// gradient and a faint central highlight, on a transparent canvas. +function makeSprite(color: { r: number; g: number; b: number }): HTMLCanvasElement { + const s = SPRITE_PX + const cv = document.createElement('canvas') + cv.width = s + cv.height = s + const g = cv.getContext('2d')! + g.translate(s / 2, s / 2) -function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null { - const sh = gl.createShader(type) - if (!sh) return null - gl.shaderSource(sh, src) - gl.compileShader(sh) - if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { - console.error('petal shader compile failed', gl.getShaderInfoLog(sh)) - gl.deleteShader(sh) - return null - } - return sh + const hw = s * 0.3 // half width + const hh = s * 0.44 // half height + + g.beginPath() + g.moveTo(0, hh) // pointed base + g.bezierCurveTo(hw, hh * 0.42, hw * 0.92, -hh * 0.52, hw * 0.2, -hh * 0.92) // right side → near tip + g.quadraticCurveTo(0, -hh * 0.72, -hw * 0.2, -hh * 0.92) // the sakura notch + g.bezierCurveTo(-hw * 0.92, -hh * 0.52, -hw, hh * 0.42, 0, hh) // left side → base + g.closePath() + + const grad = g.createLinearGradient(0, -hh, 0, hh) + grad.addColorStop(0, rgba(mixWhite(color, 0.55), 0.78)) // light, soft tip + grad.addColorStop(0.45, rgba(color, 0.88)) + grad.addColorStop(1, rgba(scale(color, 0.8), 0.95)) // deeper base + g.fillStyle = grad + g.fill() + + // A soft white sheen down the centre for a delicate, glossy feel. + const sheen = g.createLinearGradient(-hw * 0.3, 0, hw * 0.3, 0) + sheen.addColorStop(0, 'rgba(255,255,255,0)') + sheen.addColorStop(0.5, 'rgba(255,255,255,0.28)') + sheen.addColorStop(1, 'rgba(255,255,255,0)') + g.fillStyle = sheen + g.fill() + + return cv } -// Deterministic-ish pseudo random (we can't use Math.random at module scope for -// SSR safety, but here in an effect it's fine; kept simple). -function rand(): number { - return Math.random() +interface Petal { + baseX: number + y: number + size: number + vy: number // fall speed (px/s) + angle: number + spin: number // rad/s + swayAmp: number + swayFreq: number + swayPhase: number + alpha: number + sprite: number } export function PetalFall() { @@ -131,120 +90,90 @@ export function PetalFall() { window.matchMedia?.('(prefers-reduced-motion: reduce)').matches if (reduce) return // respect users who don't want ambient motion - const canvas = canvasRef.current - if (!canvas) return - const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: true }) - if (!gl) return // no WebGL — silently skip the effect - - const vs = compile(gl, gl.VERTEX_SHADER, VERT) - const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG) - if (!vs || !fs) return - const prog = gl.createProgram()! - gl.attachShader(prog, vs) - gl.attachShader(prog, fs) - gl.linkProgram(prog) - if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { - console.error('petal program link failed', gl.getProgramInfoLog(prog)) - return - } - gl.useProgram(prog) - - // Build interleaved vertex data: 6 verts per petal, each with corner(2), - // rand(4), rand2(4) = 10 floats. - const FLOATS_PER_VERT = 10 - const corners = [ - [-0.5, -0.5], - [0.5, -0.5], - [-0.5, 0.5], - [-0.5, 0.5], - [0.5, -0.5], - [0.5, 0.5], - ] - const data = new Float32Array(PETAL_COUNT * 6 * FLOATS_PER_VERT) - let o = 0 - for (let i = 0; i < PETAL_COUNT; i++) { - const lane = rand() - const speed = 0.018 + rand() * 0.03 // slow, gentle fall - const size = 0.05 + rand() * 0.06 - const phase = rand() - const swayAmp = 0.04 + rand() * 0.09 - const swayFreq = 0.4 + rand() * 0.7 - const spin = (rand() - 0.5) * 1.2 - const tint = Math.floor(rand() * TINTS.length) - for (let c = 0; c < 6; c++) { - data[o++] = corners[c][0] - data[o++] = corners[c][1] - data[o++] = lane - data[o++] = speed - data[o++] = size - data[o++] = phase - data[o++] = swayAmp - data[o++] = swayFreq - data[o++] = spin - data[o++] = tint - } - } - - const buf = gl.createBuffer() - gl.bindBuffer(gl.ARRAY_BUFFER, buf) - gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW) - - const stride = FLOATS_PER_VERT * 4 - const aCorner = gl.getAttribLocation(prog, 'a_corner') - const aRand = gl.getAttribLocation(prog, 'a_rand') - const aRand2 = gl.getAttribLocation(prog, 'a_rand2') - gl.enableVertexAttribArray(aCorner) - gl.vertexAttribPointer(aCorner, 2, gl.FLOAT, false, stride, 0) - gl.enableVertexAttribArray(aRand) - gl.vertexAttribPointer(aRand, 4, gl.FLOAT, false, stride, 2 * 4) - gl.enableVertexAttribArray(aRand2) - gl.vertexAttribPointer(aRand2, 4, gl.FLOAT, false, stride, 6 * 4) - - const uTime = gl.getUniformLocation(prog, 'u_time') - const uRes = gl.getUniformLocation(prog, 'u_res') - const uTints = gl.getUniformLocation(prog, 'u_tints') - gl.uniform3fv(uTints, TINTS.flat()) - - gl.enable(gl.BLEND) - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) - gl.disable(gl.DEPTH_TEST) - + const el = canvasRef.current + if (!el) return + const c2d = el.getContext('2d') + if (!c2d) return // Non-null aliases so the render closures keep the narrowed types. - const view = canvas - const glc = gl + const canvas = el + const ctx = c2d + + const sprites = PALETTE.map(makeSprite) const dpr = Math.min(window.devicePixelRatio || 1, 2) + let W = window.innerWidth + let H = window.innerHeight + + // Scale petal count to the viewport so it stays sparse and gentle. + const count = Math.round(Math.min(46, Math.max(16, (W * H) / 46000))) + const rnd = (a: number, b: number) => a + Math.random() * (b - a) + + const spawn = (initial: boolean): Petal => ({ + baseX: rnd(0, W), + y: initial ? rnd(-H, H) : rnd(-60, -20), + size: rnd(16, 38), + vy: rnd(22, 52), + angle: rnd(0, Math.PI * 2), + spin: rnd(-0.9, 0.9), + swayAmp: rnd(14, 42), + swayFreq: rnd(0.3, 0.85), + swayPhase: rnd(0, Math.PI * 2), + alpha: rnd(0.5, 0.82), + sprite: Math.floor(Math.random() * sprites.length), + }) + + let petals = Array.from({ length: count }, () => spawn(true)) + function resize() { - const w = Math.floor(window.innerWidth * dpr) - const h = Math.floor(window.innerHeight * dpr) - if (view.width !== w || view.height !== h) { - view.width = w - view.height = h - } - glc.viewport(0, 0, view.width, view.height) - glc.uniform2f(uRes, view.width, view.height) + W = window.innerWidth + H = window.innerHeight + canvas.width = Math.floor(W * dpr) + canvas.height = Math.floor(H * dpr) + canvas.style.width = `${W}px` + canvas.style.height = `${H}px` + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) } resize() window.addEventListener('resize', resize) let raf = 0 let running = true - const start = performance.now() - function frame() { + let last = performance.now() + + function frame(now: number) { if (!running) return - const t = (performance.now() - start) / 1000 - glc.clearColor(0, 0, 0, 0) - glc.clear(glc.COLOR_BUFFER_BIT) - glc.uniform1f(uTime, t) - glc.drawArrays(glc.TRIANGLES, 0, PETAL_COUNT * 6) + const dt = Math.min(0.05, (now - last) / 1000) // clamp big gaps (tab wake) + last = now + const t = now / 1000 + ctx.clearRect(0, 0, W, H) + + for (const p of petals) { + p.y += p.vy * dt + p.angle += p.spin * dt + const x = p.baseX + p.swayAmp * Math.sin(t * p.swayFreq + p.swayPhase) + + // Recycle once fully past the bottom. + if (p.y - p.size > H) { + Object.assign(p, spawn(false)) + continue + } + + ctx.save() + ctx.translate(x, p.y) + ctx.rotate(p.angle) + ctx.globalAlpha = p.alpha + ctx.drawImage(sprites[p.sprite], -p.size / 2, -p.size / 2, p.size, p.size) + ctx.restore() + } raf = requestAnimationFrame(frame) } - // Pause when the tab is hidden to save the battery. + const onVisibility = () => { if (document.hidden) { running = false cancelAnimationFrame(raf) } else if (!running) { running = true + last = performance.now() raf = requestAnimationFrame(frame) } } @@ -256,10 +185,7 @@ export function PetalFall() { cancelAnimationFrame(raf) window.removeEventListener('resize', resize) document.removeEventListener('visibilitychange', onVisibility) - gl.deleteBuffer(buf) - gl.deleteProgram(prog) - gl.deleteShader(vs) - gl.deleteShader(fs) + petals = [] } }, [])