// The animated backdrop, all on one canvas: drifting, twinkling stars with the // odd shooting star, the striped sun, and a perspective grid floor sliding // towards you. One canvas is one composited layer: no CSS 3D transforms, masks or // animations, which is what made the old CSS floor flicker in mobile Safari. // // Built to survive mobile browsers: star positions are stored as fractions of // the viewport so a resize (iOS fires them constantly as the toolbar collapses) // never re-scatters them; a frame that throws cannot kill the loop; and the loop // is restarted if it is ever found dead (dropped frame, back/forward cache, // throttled tab), so the animation cannot silently stop. const STAR_COUNT = 320; const TINTS = ['#ffffff', '#ffffff', '#ffffff', '#9ff8ff', '#ffb3f0']; export function startBackdrop(canvas) { const ctx = canvas.getContext('2d'); const reduce = window.matchMedia('(prefers-reduced-motion: reduce)'); // Positions are fractions of the viewport (nx, ny in [0, 1)). const stars = Array.from({ length: STAR_COUNT }, () => ({ nx: Math.random(), ny: Math.random(), z: 0.25 + Math.random() * 0.75, phase: Math.random() * Math.PI * 2, speed: 0.6 + Math.random() * 1.8, tint: TINTS[Math.floor(Math.random() * TINTS.length)], })); let shooters = []; let nextShot = 4000; let w = 0; let h = 0; let dpr = 1; let horizonY = 0; // where the floor starts let floorH = 0; let staticLayer = null; // sun + floor base, painted once per size let staticTop = 0; // the layer is empty above this row, so only the part below it is drawn let fade = null; // gradient that dissolves the grid into the horizon let shown = 0; // how many stars fit the current viewport let raf = 0; let last = performance.now(); let lastFrame = performance.now(); let resizeTimer = 0; function resize() { // The canvas is a fixed box of 100vh anchored to the bottom (see the CSS), so // it does not change size when a phone's toolbar slides in and out. const nw = canvas.clientWidth; const nh = canvas.clientHeight; if (!nw || !nh) return; dpr = Math.min(window.devicePixelRatio || 1, 2); if (nw !== w || nh !== h || canvas.width !== Math.round(nw * dpr)) { w = nw; h = nh; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); floorH = Math.round(h * 0.17); horizonY = h - floorH; staticLayer = buildStatic(); fade = ctx.createLinearGradient(0, horizonY, 0, horizonY + floorH * 0.55); fade.addColorStop(0, 'rgba(34,9,63,1)'); fade.addColorStop(1, 'rgba(34,9,63,0)'); } shown = Math.min(STAR_COUNT, Math.round((w * h) / 5500)); draw(performance.now()); } /** The parts that never move, painted once: the striped sun and the floor's base. */ function buildStatic() { const c = document.createElement('canvas'); c.width = canvas.width; c.height = canvas.height; const g = c.getContext('2d'); g.setTransform(dpr, 0, 0, dpr, 0, 0); const S = Math.max(180, Math.min(400, 0.34 * Math.min(w, h))); const R = S / 2; const cx = w / 2; const cy = horizonY - 0.16 * S; // its lower third is hidden behind the floor staticTop = Math.max(0, Math.floor(cy - R - 60)); // top of the sun's halo const halo = g.createRadialGradient(cx, cy, R, cx, cy, R + 60); halo.addColorStop(0, 'rgba(255,58,216,0.30)'); halo.addColorStop(1, 'rgba(255,58,216,0)'); g.fillStyle = halo; g.beginPath(); g.arc(cx, cy, R + 60, 0, Math.PI * 2); g.fill(); const t = document.createElement('canvas'); // the disc, with its stripes cut out t.width = t.height = Math.ceil(S * dpr); const tg = t.getContext('2d'); tg.scale(dpr, dpr); const sun = tg.createLinearGradient(0, 0, 0, S); sun.addColorStop(0, '#fff27a'); sun.addColorStop(0.32, '#ffb62e'); sun.addColorStop(0.72, '#ff3ad8'); sun.addColorStop(1, '#8a2cff'); tg.fillStyle = sun; tg.beginPath(); tg.arc(R, R, R, 0, Math.PI * 2); tg.fill(); tg.globalCompositeOperation = 'destination-out'; for (const [from, to] of [[0.46, 0.49], [0.56, 0.6], [0.67, 0.72], [0.79, 0.85], [0.91, 0.98]]) { tg.fillRect(0, from * S, S, (to - from) * S); } g.drawImage(t, cx - R, cy - R, S, S); const base = g.createLinearGradient(0, horizonY, 0, h); base.addColorStop(0, '#22093f'); base.addColorStop(1, '#0c0420'); g.fillStyle = base; g.fillRect(0, horizonY, w, floorH); const glow = g.createLinearGradient(0, horizonY - 28, 0, horizonY); glow.addColorStop(0, 'rgba(40,245,255,0)'); glow.addColorStop(1, 'rgba(40,245,255,0.45)'); g.fillStyle = glow; g.fillRect(0, horizonY - 28, w, 28); g.fillStyle = '#28f5ff'; g.fillRect(0, horizonY - 1, w, 2); return c; } /** The grid: lines at even steps in depth, projected onto the floor. */ function drawGrid(now) { const Z_FAR = 26; const phase = reduce.matches ? 0 : ((now / 1000) * 0.42) % 1; // depth steps per second ctx.save(); ctx.beginPath(); ctx.rect(0, horizonY, w, floorH); ctx.clip(); ctx.strokeStyle = '#ff3ad8'; // lines across, sliding towards the viewer for (let j = 0; j < Z_FAR; j++) { const z = 1 + j - phase; const y = horizonY + floorH / z; ctx.globalAlpha = 0.85; ctx.lineWidth = 0.7 + 1.5 / Math.max(z, 1); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } // lines running into the distance, meeting at the vanishing point const spacing = floorH * 0.55; const n = Math.ceil((w / 2 / spacing) * 6); const cx = w / 2; ctx.globalAlpha = 0.7; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = -n; i <= n; i++) { ctx.moveTo(cx + (i * spacing) / Z_FAR, horizonY + floorH / Z_FAR); ctx.lineTo(cx + (i * spacing) / 0.4, horizonY + floorH / 0.4); } ctx.stroke(); // dissolve everything into the horizon so distant lines cannot shimmer ctx.globalAlpha = 1; ctx.fillStyle = fade; ctx.fillRect(0, horizonY, w, floorH * 0.55); ctx.restore(); } function draw(now) { if (!staticLayer) return; ctx.clearRect(0, 0, w, h); for (let i = 0; i < shown; i++) { const s = stars[i]; const tw = 0.5 + 0.5 * Math.sin((now / 1000) * s.speed + s.phase); ctx.globalAlpha = reduce.matches ? 0.5 * s.z : (0.25 + 0.75 * tw) * s.z; ctx.fillStyle = s.tint; const r = 0.4 + s.z * 1.3; ctx.fillRect(s.nx * w, s.ny * h, r, r); } for (const s of shooters) { const g = ctx.createLinearGradient(s.x, s.y, s.x - s.vx * 9, s.y - s.vy * 9); g.addColorStop(0, 'rgba(255,255,255,0.95)'); g.addColorStop(1, 'rgba(255,58,216,0)'); ctx.globalAlpha = Math.max(0, Math.min(1, s.life)); ctx.strokeStyle = g; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(s.x - s.vx * 9, s.y - s.vy * 9); ctx.stroke(); } ctx.globalAlpha = 1; // sun and floor over the stars; only the part of the layer that has anything in it ctx.drawImage(staticLayer, 0, staticTop * dpr, canvas.width, canvas.height - staticTop * dpr, 0, staticTop, w, h - staticTop); drawGrid(now); } function update(dt) { for (let i = 0; i < shown; i++) { const s = stars[i]; s.nx -= (s.z * 0.012 * dt) / w; if (s.nx < 0) { s.nx += 1; s.ny = Math.random(); } } nextShot -= dt; if (nextShot <= 0) { nextShot = 7000 + Math.random() * 9000; shooters.push({ x: Math.random() * w * 0.8 + w * 0.2, y: Math.random() * h * 0.4, vx: -(6 + Math.random() * 4), vy: 2 + Math.random() * 2, life: 1.4 }); } for (const s of shooters) { s.x += (s.vx * dt) / 16; s.y += (s.vy * dt) / 16; s.life -= dt / 900; } shooters = shooters.filter((s) => s.life > 0); } function frame(now) { raf = 0; try { const dt = Math.max(0, Math.min(64, now - last)); last = now; update(dt); draw(now); } catch (e) { console.error('starfield frame failed; continuing', e); } lastFrame = performance.now(); schedule(); } function schedule() { if (raf || reduce.matches || document.visibilityState !== 'visible') return; raf = requestAnimationFrame(frame); } /** (Re)start the loop from a clean state. Safe to call at any time. */ function restart() { cancelAnimationFrame(raf); raf = 0; last = lastFrame = performance.now(); if (reduce.matches) draw(last); else schedule(); } window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(resize, 120); }); window.addEventListener('orientationchange', () => setTimeout(resize, 250)); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') restart(); }); window.addEventListener('pageshow', restart); // back/forward cache restore window.addEventListener('focus', restart); reduce.addEventListener('change', restart); // Watchdog: a visible page whose loop has gone quiet gets restarted. setInterval(() => { if (!reduce.matches && document.visibilityState === 'visible' && performance.now() - lastFrame > 1500) restart(); }, 1000); resize(); restart(); }