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