feat(frontend): refactor ParticleBackground for performance and type safety

This commit is contained in:
MythEclipse
2026-06-03 03:07:51 +07:00
parent 0d83ada0ef
commit 3802ca357f
2 changed files with 82 additions and 63 deletions
+3 -2
View File
@@ -15,6 +15,8 @@
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@react-three/drei": "^9.6.1",
"@react-three/fiber": "^9.6.1",
"@tanstack/react-query": "^5.100.14", "@tanstack/react-query": "^5.100.14",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.4.0", "framer-motion": "^12.4.0",
@@ -23,8 +25,6 @@
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"@react-three/drei": "^9.6.1",
"@react-three/fiber": "^9.6.1",
"three": "^0.174.0" "three": "^0.174.0"
}, },
"devDependencies": { "devDependencies": {
@@ -32,6 +32,7 @@
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.184.1",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"postcss": "^8.5.14", "postcss": "^8.5.14",
@@ -1,54 +1,73 @@
import { Sparkles } from "@react-three/drei"; import { Sparkles } from "@react-three/drei";
import { Canvas, type ThreeElement, useFrame, useThree } from "@react-three/fiber"; import { Canvas, useFrame } from "@react-three/fiber";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type * as THREE from "three";
// ─── Sakura petal geometry ─────────────────────────────────────────────────── // ─── Particle count by viewport ──────────────────────────────────────────────
function createPetalShape() { function getPetalCount(width: number) {
const shape = new THREE.Shape(); if (width < 640) return 10;
// Teardrop/petal shape if (width < 1024) return 20;
shape.moveTo(0, 0.5); return 35;
shape.bezierCurveTo(0.3, 0.3, 0.4, -0.1, 0, -0.5);
shape.bezierCurveTo(-0.4, -0.1, -0.3, 0.3, 0, 0.5);
return shape;
} }
const petalShape = createPetalShape(); function getSparkleCount(width: number) {
const petalGeometry = new THREE.ShapeGeometry(petalShape); if (width < 640) return 15;
return 30;
}
// ─── Inner particle scene ──────────────────────────────────────────────────── // ─── Sakura petal mesh ───────────────────────────────────────────────────────
function Petal({ mouse }: { mouse: React.RefObject<THREE.Vector2 | null> }) { interface PetalData {
const meshRef = useRef<THREE.Mesh>(null); x: number;
const initial = useMemo(() => ({ y: number;
z: number;
rotSpeed: number;
driftX: number;
driftZ: number;
hue: number;
sat: number;
light: number;
scale: number;
}
function createPetals(count: number): PetalData[] {
return Array.from({ length: count }, () => ({
x: (Math.random() - 0.5) * 20, x: (Math.random() - 0.5) * 20,
y: Math.random() * 20 - 5, y: Math.random() * 20 - 5,
z: (Math.random() - 0.5) * 10 - 2, z: (Math.random() - 0.5) * 10 - 2,
rotSpeed: (Math.random() - 0.5) * 2, rotSpeed: (Math.random() - 0.5) * 2,
driftX: (Math.random() - 0.5) * 0.005, driftX: (Math.random() - 0.5) * 0.005,
driftZ: (Math.random() - 0.5) * 0.003, driftZ: (Math.random() - 0.5) * 0.003,
hue: 340 + Math.random() * 30, // pink range: 340370 hue: 340 + Math.random() * 30,
saturation: 60 + Math.random() * 30, sat: 60 + Math.random() * 30,
lightness: 65 + Math.random() * 20, light: 65 + Math.random() * 20,
}), []); scale: 0.08 + Math.random() * 0.08,
}));
}
useFrame((_, delta) => { function Petal({ data, mouseRef }: { data: PetalData; mouseRef: React.RefObject<{ x: number; y: number } | null> }) {
if (!meshRef.current) return; const meshRef = useRef(null);
const mesh = meshRef.current;
useFrame((_state, delta) => {
const mesh = meshRef.current as unknown as
| { position: { x: number; y: number; z: number }; rotation: { z: number; x: number } }
| null;
if (!mesh) return;
// Slow drift downward // Slow drift downward
mesh.position.y -= delta * 0.3; mesh.position.y -= delta * 0.3;
mesh.position.x += Math.sin(Date.now() * initial.driftX) * 0.002; mesh.position.x += Math.sin(Date.now() * data.driftX) * 0.002;
mesh.position.z += Math.cos(Date.now() * initial.driftZ) * 0.001; mesh.position.z += Math.cos(Date.now() * data.driftZ) * 0.001;
mesh.rotation.z += delta * initial.rotSpeed; mesh.rotation.z += delta * data.rotSpeed;
mesh.rotation.x += delta * initial.rotSpeed * 0.3; mesh.rotation.x += delta * data.rotSpeed * 0.3;
// Gentle mouse attraction // Gentle mouse attraction
const dx = mouse.current.x * 3 - mesh.position.x; if (mouseRef.current) {
const dy = mouse.current.y * 2 - mesh.position.y; const dx = mouseRef.current.x * 3 - mesh.position.x;
mesh.position.x += dx * 0.0001; const dy = mouseRef.current.y * 2 - mesh.position.y;
mesh.position.y += dy * 0.0001; mesh.position.x += dx * 0.0001;
mesh.position.y += dy * 0.0001;
}
// Reset when out of view // Reset when out of view
if (mesh.position.y < -10) { if (mesh.position.y < -10) {
@@ -61,64 +80,63 @@ function Petal({ mouse }: { mouse: React.RefObject<THREE.Vector2 | null> }) {
return ( return (
<mesh <mesh
ref={meshRef} ref={meshRef}
geometry={petalGeometry} position={[data.x, data.y, data.z]}
position={[initial.x, initial.y, initial.z]} scale={[data.scale, data.scale, data.scale]}
scale={[0.12, 0.12, 0.12]}
> >
<planeGeometry args={[1, 1]} />
<meshStandardMaterial <meshStandardMaterial
color={`hsl(${initial.hue}, ${initial.saturation}%, ${initial.lightness}%)`} color={`hsl(${data.hue}, ${data.sat}%, ${data.light}%)`}
transparent transparent
opacity={0.7} opacity={0.65}
side={THREE.DoubleSide} side={2}
depthWrite={false} depthWrite={false}
/> />
</mesh> </mesh>
); );
} }
// ─── Scene ───────────────────────────────────────────────────────────────────
function ParticleScene() { function ParticleScene() {
const mouse = useRef(new THREE.Vector2(0, 0)); const mouseRef = useRef<{ x: number; y: number } | null>(null);
const { size } = useThree(); const [viewportWidth, setViewportWidth] = useState(
const hiddenRef = useRef(false); typeof window !== "undefined" ? window.innerWidth : 1024,
);
useEffect(() => { useEffect(() => {
const onMouse = (e: MouseEvent) => { const onMouse = (e: MouseEvent) => {
// Normalise to -1..1 relative to viewport mouseRef.current = {
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1; x: (e.clientX / window.innerWidth) * 2 - 1,
mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1; y: -(e.clientY / window.innerHeight) * 2 + 1,
};
}; };
window.addEventListener("mousemove", onMouse); window.addEventListener("mousemove", onMouse);
const onVisibility = () => { const onResize = () => setViewportWidth(window.innerWidth);
hiddenRef.current = document.hidden; window.addEventListener("resize", onResize);
};
document.addEventListener("visibilitychange", onVisibility);
return () => { return () => {
window.removeEventListener("mousemove", onMouse); window.removeEventListener("mousemove", onMouse);
document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener("resize", onResize);
}; };
}, []); }, []);
// Petal count depends on viewport width (fewer on mobile) const petalCount = getPetalCount(viewportWidth);
const petalCount = size.width < 640 ? 10 : size.width < 1024 ? 20 : 35; const sparkleCount = getSparkleCount(viewportWidth);
const petals = useMemo(
() => Array.from({ length: petalCount }, (_, i) => <Petal key={i} mouse={mouse} />), const petals = useMemo(() => createPetals(petalCount), [petalCount]);
[petalCount],
);
return ( return (
<> <>
{/* Ambient + directional for soft lighting */}
<ambientLight intensity={0.6} /> <ambientLight intensity={0.6} />
<directionalLight position={[5, 5, 5]} intensity={0.3} /> <directionalLight position={[5, 5, 5]} intensity={0.3} />
{/* Petals */} {petals.map((data, i) => (
{!hiddenRef.current && petals} <Petal key={i} data={data} mouseRef={mouseRef} />
))}
{/* Sparkles from drei */}
<Sparkles <Sparkles
count={size.width < 640 ? 15 : 30} count={sparkleCount}
scale={12} scale={12}
size={0.8} size={0.8}
speed={0.2} speed={0.2}
@@ -148,13 +166,13 @@ export function ParticleBackground() {
<div <div
className="fixed inset-0 pointer-events-none" className="fixed inset-0 pointer-events-none"
aria-hidden="true" aria-hidden="true"
style={{ zIndex: -1 }} style={{ zIndex: -1, isolation: "isolate" }}
> >
<Canvas <Canvas
camera={{ position: [0, 0, 8], fov: 75 }} camera={{ position: [0, 0, 8], fov: 75 }}
dpr={[1, 2]} dpr={[1, 2]}
gl={{ antialias: false, alpha: true }} gl={{ antialias: false, alpha: true }}
style={{ background: "transparent" }} style={{ background: "transparent", width: "100%", height: "100%" }}
> >
<ParticleScene /> <ParticleScene />
</Canvas> </Canvas>