Petals: 2D sprite blossoms instead of shader blobs

The procedural WebGL petals read as faint smudges. Replace with a 2D
canvas that pre-renders real notched sakura petal sprites (tip→base
gradient, soft central sheen, transparent edges) and blits falling,
rotating, swaying instances. Count scales with viewport; pauses when
hidden; skipped under prefers-reduced-motion.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 07:47:13 -07:00
parent f6fa73b17b
commit 045ec19b92

View File

@@ -1,125 +1,84 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
// PetalFall is the cozy ambient layer: a full-window WebGL canvas that drifts a // PetalFall is the cozy ambient layer: a fixed, non-interactive 2D canvas that
// handful of soft flower petals down the page. It sits behind everything, // drifts soft cherry-blossom petals down the page. Each petal is a real
// ignores pointer events, and stays sparse so it reads as "occasional petals on // petal-shaped sprite (notched sakura silhouette with a gentle gradient and
// a breeze" rather than confetti. Honors prefers-reduced-motion by rendering // soft alpha), pre-rendered once per color and then blitted many times with
// nothing. No external libraries — a single tiny shader program animates every // rotation + sway. It sits behind the chrome, ignores pointer events, pauses
// petal entirely on the GPU, so the CPU just spins a clock. // 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 // Saturated-but-soft blossom colors. Each petal fades from a light, near-white
// quad as 6 vertices; per-vertex we send the corner offset (which corner) plus // tip to its color at the base, so it reads as a real petal rather than a blob.
// the petal's static randomness (lane, speed, size, sway, tint, spin). const PALETTE: { r: number; g: number; b: number }[] = [
const PETAL_COUNT = 26 { r: 255, g: 150, b: 190 }, // rose pink
{ r: 255, g: 184, b: 205 }, // pale blossom
// Cherry-blossom tints — saturated enough to read as colorful petals over the { r: 255, g: 158, b: 130 }, // warm peach
// warm cream canvas (pale pastels just wash out to grey). RGB 0..1. { r: 209, g: 160, b: 255 }, // lavender
const TINTS: [number, number, number][] = [ { r: 255, g: 122, b: 172 }, // deep rose
[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
] ]
const VERT = ` const SPRITE_PX = 160 // off-screen render resolution per petal sprite
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;
void main() { function rgba(c: { r: number; g: number; b: number }, a: number): string {
v_uv = a_corner + 0.5; return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${a})`
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 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 }
const FRAG = ` }
precision mediump float; function scale(c: { r: number; g: number; b: number }, t: number) {
varying vec2 v_uv; return { r: c.r * t, g: c.g * t, b: c.b * t }
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;
} }
void main() { // Draw one petal sprite: a notched sakura silhouette filled with a tip→base
float m = petalMask(v_uv); // gradient and a faint central highlight, on a transparent canvas.
if (m < 0.01) discard; function makeSprite(color: { r: number; g: number; b: number }): HTMLCanvasElement {
int idx = int(v_tint + 0.5); const s = SPRITE_PX
vec3 col = u_tints[0]; const cv = document.createElement('canvas')
if (idx == 1) col = u_tints[1]; cv.width = s
else if (idx == 2) col = u_tints[2]; cv.height = s
else if (idx == 3) col = u_tints[3]; const g = cv.getContext('2d')!
else if (idx == 4) col = u_tints[4]; g.translate(s / 2, s / 2)
// 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);
}
`
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null { const hw = s * 0.3 // half width
const sh = gl.createShader(type) const hh = s * 0.44 // half height
if (!sh) return null
gl.shaderSource(sh, src) g.beginPath()
gl.compileShader(sh) g.moveTo(0, hh) // pointed base
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { g.bezierCurveTo(hw, hh * 0.42, hw * 0.92, -hh * 0.52, hw * 0.2, -hh * 0.92) // right side → near tip
console.error('petal shader compile failed', gl.getShaderInfoLog(sh)) g.quadraticCurveTo(0, -hh * 0.72, -hw * 0.2, -hh * 0.92) // the sakura notch
gl.deleteShader(sh) g.bezierCurveTo(-hw * 0.92, -hh * 0.52, -hw, hh * 0.42, 0, hh) // left side → base
return null g.closePath()
}
return sh 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 interface Petal {
// SSR safety, but here in an effect it's fine; kept simple). baseX: number
function rand(): number { y: number
return Math.random() 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() { export function PetalFall() {
@@ -131,120 +90,90 @@ export function PetalFall() {
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
if (reduce) return // respect users who don't want ambient motion if (reduce) return // respect users who don't want ambient motion
const canvas = canvasRef.current const el = canvasRef.current
if (!canvas) return if (!el) return
const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: true }) const c2d = el.getContext('2d')
if (!gl) return // no WebGL — silently skip the effect if (!c2d) return
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)
// Non-null aliases so the render closures keep the narrowed types. // Non-null aliases so the render closures keep the narrowed types.
const view = canvas const canvas = el
const glc = gl const ctx = c2d
const sprites = PALETTE.map(makeSprite)
const dpr = Math.min(window.devicePixelRatio || 1, 2) 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() { function resize() {
const w = Math.floor(window.innerWidth * dpr) W = window.innerWidth
const h = Math.floor(window.innerHeight * dpr) H = window.innerHeight
if (view.width !== w || view.height !== h) { canvas.width = Math.floor(W * dpr)
view.width = w canvas.height = Math.floor(H * dpr)
view.height = h canvas.style.width = `${W}px`
} canvas.style.height = `${H}px`
glc.viewport(0, 0, view.width, view.height) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
glc.uniform2f(uRes, view.width, view.height)
} }
resize() resize()
window.addEventListener('resize', resize) window.addEventListener('resize', resize)
let raf = 0 let raf = 0
let running = true let running = true
const start = performance.now() let last = performance.now()
function frame() {
function frame(now: number) {
if (!running) return if (!running) return
const t = (performance.now() - start) / 1000 const dt = Math.min(0.05, (now - last) / 1000) // clamp big gaps (tab wake)
glc.clearColor(0, 0, 0, 0) last = now
glc.clear(glc.COLOR_BUFFER_BIT) const t = now / 1000
glc.uniform1f(uTime, t) ctx.clearRect(0, 0, W, H)
glc.drawArrays(glc.TRIANGLES, 0, PETAL_COUNT * 6)
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) raf = requestAnimationFrame(frame)
} }
// Pause when the tab is hidden to save the battery.
const onVisibility = () => { const onVisibility = () => {
if (document.hidden) { if (document.hidden) {
running = false running = false
cancelAnimationFrame(raf) cancelAnimationFrame(raf)
} else if (!running) { } else if (!running) {
running = true running = true
last = performance.now()
raf = requestAnimationFrame(frame) raf = requestAnimationFrame(frame)
} }
} }
@@ -256,10 +185,7 @@ export function PetalFall() {
cancelAnimationFrame(raf) cancelAnimationFrame(raf)
window.removeEventListener('resize', resize) window.removeEventListener('resize', resize)
document.removeEventListener('visibilitychange', onVisibility) document.removeEventListener('visibilitychange', onVisibility)
gl.deleteBuffer(buf) petals = []
gl.deleteProgram(prog)
gl.deleteShader(vs)
gl.deleteShader(fs)
} }
}, []) }, [])