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:
@@ -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)
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user