feat: replace canvas-based particle background with high-performance LiquidEther fluid simulation component

This commit is contained in:
yoyuzh
2026-03-31 19:15:18 +08:00
parent ddfffc1ebe
commit c0b9aa7e7c
13 changed files with 4547 additions and 349 deletions

View File

@@ -5,6 +5,9 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>personal_front</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>

3503
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.8",
"shadcn": "^4.1.1",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",

View File

@@ -1,22 +1,45 @@
import { lazy, Suspense } from 'react';
import { Hero } from './components/Hero';
import { About } from './components/About';
import { TechStack } from './components/TechStack';
import { Projects } from './components/Projects';
import { Capabilities } from './components/Capabilities';
import { Contact } from './components/Contact';
import { AnimatedBackground } from './components/AnimatedBackground';
// 首屏以下的组件全部懒加载,减少初始 bundle 体积
const About = lazy(() => import('./components/About').then(m => ({ default: m.About })));
const Capabilities = lazy(() => import('./components/Capabilities').then(m => ({ default: m.Capabilities })));
const TechStack = lazy(() => import('./components/TechStack').then(m => ({ default: m.TechStack })));
const Projects = lazy(() => import('./components/Projects').then(m => ({ default: m.Projects })));
const Contact = lazy(() => import('./components/Contact').then(m => ({ default: m.Contact })));
// 轻量占位:仅一条 div不影响布局
const SectionSkeleton = () => (
<div className="w-full py-32 flex items-center justify-center">
<div className="w-10 h-10 rounded-full border-2 border-white/10 border-t-blue-500/60 animate-spin" />
</div>
);
function App() {
return (
<div className="relative w-full min-h-screen">
<AnimatedBackground />
<main className="relative z-10 w-full text-slate-100 min-h-screen font-sans selection:bg-primary/30 selection:text-white pb-10">
{/* Hero 是关键路径,不懒加载 */}
<Hero />
{/* 以下组件等主线程空闲后再加载 */}
<Suspense fallback={<SectionSkeleton />}>
<About />
</Suspense>
<Suspense fallback={<SectionSkeleton />}>
<Capabilities />
</Suspense>
<Suspense fallback={<SectionSkeleton />}>
<TechStack />
</Suspense>
<Suspense fallback={<SectionSkeleton />}>
<Projects />
</Suspense>
<Suspense fallback={<SectionSkeleton />}>
<Contact />
</Suspense>
</main>
</div>
);

View File

@@ -1,225 +1,69 @@
import { useEffect, useRef } from 'react';
import { useMemo } from 'react';
import LiquidEther from './ui/LiquidEther';
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
size: number;
opacity: number;
hue: number;
pulse: number;
pulseSpeed: number;
}
/**
* 根据设备能力选择合适的渲染质量
* - 低端/移动设备:降低分辨率 + 关闭 BFECC + 减少迭代次数
* - 高端桌面:完整质量
*/
function useDeviceQuality() {
return useMemo(() => {
const isMobile = window.matchMedia('(max-width: 768px)').matches;
const isMedium = window.matchMedia('(max-width: 1280px)').matches;
// 检测 GPU 能力(通过 hardwareConcurrency 粗略判断)
const isLowEnd = navigator.hardwareConcurrency <= 4;
interface WaveBlob {
x: number;
y: number;
radius: number;
hue: number;
speed: number;
angle: number;
}
// 极简、优雅、现代的颜色搭配 (更暗、更有质感)
const SECTION_THEMES = [
{ hues: [220, 240, 210] }, // Hero: 经典的深蓝/紫罗兰
{ hues: [250, 270, 230] }, // About: 偏神秘的深紫
{ hues: [200, 210, 220] }, // Capabilities: 清透的深墨蓝
{ hues: [190, 200, 180] }, // TechStack: 深邃的青绿/蓝松石
{ hues: [230, 250, 210] }, // Projects: 靛蓝与深紫交织
{ hues: [220, 230, 240] }, // Contact: 回归深海蓝
];
export function AnimatedBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const scrollRef = useRef(0);
const mouseRef = useRef({ x: -999, y: -999 });
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let W = window.innerWidth;
let H = window.innerHeight;
const resize = () => { W = window.innerWidth; H = window.innerHeight; canvas.width = W; canvas.height = H; };
resize();
window.addEventListener('resize', resize);
window.addEventListener('mousemove', (e) => { mouseRef.current = { x: e.clientX, y: e.clientY }; });
window.addEventListener('scroll', () => { scrollRef.current = window.scrollY; }, { passive: true });
// 光斑 (Auroras) - 更大、更缓慢
const BLOB_COUNT = 5;
const blobs: WaveBlob[] = Array.from({ length: BLOB_COUNT }, (_, i) => ({
x: (W / BLOB_COUNT) * i + W / (BLOB_COUNT * 2),
y: Math.random() * H * 0.8 + H * 0.1,
radius: Math.random() * 300 + 200, // 更大的光斑
hue: SECTION_THEMES[0].hues[i % 3],
speed: Math.random() * 0.0003 + 0.0001, // 移动极度缓慢
angle: Math.random() * Math.PI * 2,
}));
// 环境粒子 (萤火虫/星光) - 减少数量,更精细
const PARTICLE_COUNT = 80;
const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => ({
x: Math.random() * W,
y: Math.random() * H,
vx: (Math.random() - 0.5) * 0.15, // 速度极慢
vy: (Math.random() - 0.5) * 0.15,
size: Math.random() * 1.5 + 0.5,
opacity: Math.random() * 0.5 + 0.1,
hue: 220,
pulse: Math.random() * Math.PI * 2,
pulseSpeed: Math.random() * 0.01 + 0.003,
}));
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
let currentHues = [...SECTION_THEMES[0].hues, ...SECTION_THEMES[0].hues];
let t = 0;
// 平滑滚动的插值
let smoothScrollTarget = 0;
let animationId: number;
const draw = () => {
t += 0.004; // 极其缓慢的时间流逝
// 增加滑动阻尼感,让颜色渐变极其丝滑,不会因为快速滑动而突兀
smoothScrollTarget = lerp(smoothScrollTarget, scrollRef.current, 0.02);
const pageH = Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
const scrollFrac = smoothScrollTarget / pageH; // 取值 0~1
const rawIdx = Math.max(0, Math.min(scrollFrac * (SECTION_THEMES.length - 1), SECTION_THEMES.length - 1));
const sectionA = Math.floor(rawIdx);
const sectionB = Math.min(sectionA + 1, SECTION_THEMES.length - 1);
const blend = rawIdx - sectionA;
for (let i = 0; i < BLOB_COUNT; i++) {
const hA = SECTION_THEMES[sectionA].hues[i % 3];
const hB = SECTION_THEMES[sectionB].hues[i % 3];
currentHues[i] = lerp(currentHues[i], lerp(hA, hB, blend), 0.015);
}
const domHue = lerp(SECTION_THEMES[sectionA].hues[0], SECTION_THEMES[sectionB].hues[0], blend);
// 深邃底色
ctx.fillStyle = '#030305';
ctx.fillRect(0, 0, W, H);
// ── 绘制环境极光光斑 (Auroras) ──
for (let i = 0; i < blobs.length; i++) {
const blob = blobs[i];
blob.angle += blob.speed;
// 光斑的微小位移
const dx = Math.cos(blob.angle * 0.7) * 40;
const dy = Math.sin(blob.angle) * 30;
// 鼠标对光斑产生微弱吸引,增加沉浸感
const mx = mouseRef.current.x - blob.x;
const my = mouseRef.current.y - blob.y;
const dist = Math.sqrt(mx * mx + my * my);
if (dist < 600) {
blob.x += mx * 0.00003;
blob.y += my * 0.00003;
}
blob.hue = lerp(blob.hue, currentHues[i], 0.03);
const cx = blob.x + dx;
const cy = blob.y + dy;
const r = blob.radius + Math.sin(blob.angle * 2) * 20;
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
// 使用非常非常低的透明度,打造"高级的高斯模糊"感觉
const alpha = 0.04 + Math.sin(t + blob.angle) * 0.01;
grad.addColorStop(0, `hsla(${blob.hue}, 70%, 50%, ${alpha})`);
grad.addColorStop(0.5, `hsla(${blob.hue + 15}, 60%, 45%, ${alpha * 0.5})`);
grad.addColorStop(1, 'transparent');
ctx.beginPath();
// 压扁椭圆形更像极光
ctx.ellipse(cx, cy, r * 1.5, r * 0.8, blob.angle * 0.1, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
}
// ── 绘制细微星光粒子 (Particles) ──
for (const p of particles) {
p.x += p.vx; p.y += p.vy; p.pulse += p.pulseSpeed;
// 边界循环
if (p.x < -10) p.x = W + 10;
if (p.x > W + 10) p.x = -10;
if (p.y < -10) p.y = H + 10;
if (p.y > H + 10) p.y = -10;
// 鼠标排斥极其微弱
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
const mdx = p.x - mx, mdy = p.y - my;
const mdist = Math.sqrt(mdx * mdx + mdy * mdy);
if (mdist < 150 && mdist > 0) {
const force = (150 - mdist) / 150;
p.vx += (mdx / mdist) * force * 0.01;
p.vy += (mdy / mdist) * force * 0.01;
}
// 阻尼,让其缓慢恢复原速
p.vx *= 0.99; p.vy *= 0.99;
// 粒子受环境主色调影响
p.hue = lerp(p.hue, domHue + Math.sin(p.pulse * 0.5) * 15, 0.02);
const po = p.opacity * (0.5 + 0.5 * Math.sin(p.pulse));
const ps = p.size;
// 星光耀斑
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, ps * 3);
glow.addColorStop(0, `hsla(${p.hue}, 80%, 75%, ${po * 0.8})`);
glow.addColorStop(1, `hsla(${p.hue}, 80%, 75%, 0)`);
ctx.beginPath(); ctx.arc(p.x, p.y, ps * 3, 0, Math.PI * 2);
ctx.fillStyle = glow; ctx.fill();
// 核心实心点
ctx.beginPath(); ctx.arc(p.x, p.y, ps * 0.5, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${p.hue}, 90%, 85%, ${po})`;
ctx.fill();
}
// ── 鼠标氛围聚光灯 ──
if (mouseRef.current.x > -900) {
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
const spotlight = ctx.createRadialGradient(mx, my, 0, mx, my, 350);
spotlight.addColorStop(0, `hsla(${domHue}, 60%, 60%, 0.025)`);
spotlight.addColorStop(0.5, `hsla(${domHue}, 60%, 50%, 0.01)`);
spotlight.addColorStop(1, 'transparent');
ctx.beginPath();
ctx.arc(mx, my, 350, 0, Math.PI * 2);
ctx.fillStyle = spotlight;
ctx.fill();
}
animationId = requestAnimationFrame(draw);
if (isMobile || isLowEnd) {
return {
resolution: 0.2,
iterationsPoisson: 8,
iterationsViscous: 8,
BFECC: false,
mouseForce: 15,
cursorSize: 80,
};
animationId = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', resize);
}
if (isMedium) {
return {
resolution: 0.35,
iterationsPoisson: 16,
iterationsViscous: 16,
BFECC: true,
mouseForce: 20,
cursorSize: 100,
};
}
// 高端桌面:全质量
return {
resolution: 0.5,
iterationsPoisson: 32,
iterationsViscous: 32,
BFECC: true,
mouseForce: 25,
cursorSize: 120,
};
}, []);
}
export function AnimatedBackground() {
const quality = useDeviceQuality();
return (
<canvas
ref={canvasRef}
className="fixed inset-0 w-full h-full pointer-events-none"
style={{ zIndex: 0 }}
<div
className="fixed inset-0 w-full h-full z-0 bg-[#030712]"
style={{ pointerEvents: 'none' }}
aria-hidden="true"
>
<LiquidEther
colors={['#3b82f6', '#8b5cf6', '#06b6d4', '#1e1b4b']}
autoDemo={true}
autoSpeed={0.4}
autoIntensity={2.5}
autoResumeDelay={2000}
className="w-full h-full"
style={{ width: '100%', height: '100%', pointerEvents: 'auto' }}
{...quality}
/>
</div>
);
}

View File

@@ -6,10 +6,7 @@ export const Hero = () => {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.2,
},
transition: { staggerChildren: 0.15, delayChildren: 0.2 },
},
};
@@ -19,14 +16,29 @@ export const Hero = () => {
};
return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden px-6">
<div className="absolute inset-0 z-0 bg-grid-pattern opacity-[0.1]" />
<section
className="relative min-h-screen flex items-center justify-center overflow-hidden px-6"
style={{ contain: 'layout' }}
>
{/* Grid texture */}
<div className="absolute inset-0 z-0 bg-grid-pattern opacity-[0.08]" />
{/* Background Glowing Orbs */}
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-primary/20 rounded-full blur-[128px] animate-blob z-0" />
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-secondary/20 rounded-full blur-[128px] animate-blob animation-delay-2000 z-0" />
<div className="absolute bottom-1/4 left-1/2 w-96 h-96 bg-accent/20 rounded-full blur-[128px] animate-blob animation-delay-4000 z-0" />
{/* 合并三个 orb 为单个合成层 — 避免多个大面积 blur 的 GPU 压力 */}
<div
className="absolute inset-0 z-0 pointer-events-none"
style={{
background: [
'radial-gradient(ellipse 900px 500px at 50% 0%, rgba(59,130,246,0.10) 0%, transparent 70%)',
'radial-gradient(ellipse 600px 600px at 100% 25%, rgba(139,92,246,0.09) 0%, transparent 70%)',
'radial-gradient(ellipse 500px 500px at 0% 100%, rgba(6,182,212,0.07) 0%, transparent 70%)',
'radial-gradient(ellipse at center, transparent 40%, rgba(3,7,18,0.7) 100%)',
].join(', '),
willChange: 'auto',
contain: 'paint',
}}
/>
{/* Content */}
<div className="relative z-10 w-full max-w-5xl mx-auto flex flex-col items-center text-center">
<motion.div
variants={containerVariants}
@@ -34,27 +46,35 @@ export const Hero = () => {
animate="visible"
className="flex flex-col items-center"
>
<motion.div variants={itemVariants} className="mb-6 inline-flex items-center rounded-full border border-white/10 bg-white/5 px-4 py-1.5 backdrop-blur-md">
<span className="flex h-2 w-2 rounded-full bg-green-500 mr-2 shadow-[0_0_8px_rgba(34,197,94,0.8)]" />
<span className="text-sm font-medium text-white/80"></span>
{/* Status badge — glassmorphism */}
<motion.div
variants={itemVariants}
className="mb-8 inline-flex items-center gap-2 px-4 py-1.5 rounded-full
glass-premium glass-refract text-xs font-semibold tracking-wide"
>
<span className="flex h-2 w-2 rounded-full bg-green-400 shadow-[0_0_8px_rgba(74,222,128,0.9)]" />
<span className="text-slate-200"></span>
</motion.div>
{/* Headline */}
<motion.h1
variants={itemVariants}
className="text-5xl md:text-7xl lg:text-8xl font-semibold tracking-tight text-white mb-6 leading-[1.1] text-glow"
className="scroll-m-20 text-4xl sm:text-5xl md:text-7xl lg:text-8xl font-extrabold tracking-tight text-foreground mb-6 leading-[1.1] text-glow"
>
<br className="hidden md:block" />
<span className="text-shimmer"></span> <br className="hidden md:block" />
<span className="text-shimmer"></span>.
</motion.h1>
{/* Subtitle */}
<motion.p
variants={itemVariants}
className="text-lg md:text-xl text-slate-400 max-w-2xl mb-10 font-light"
className="text-lg md:text-xl text-muted-foreground leading-7 max-w-2xl mb-10"
>
<span className="text-white/80 font-medium"></span> Java Web AI
<span className="text-foreground font-medium"></span> Java Web AI
</motion.p>
{/* CTA buttons */}
<motion.div variants={itemVariants} className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<Button size="lg" variant="primary" onClick={() => document.getElementById('projects')?.scrollIntoView({ behavior: 'smooth' })}>
@@ -73,7 +93,7 @@ export const Hero = () => {
transition={{ delay: 1.5, duration: 1 }}
className="absolute bottom-10 left-1/2 -translate-x-1/2 flex flex-col items-center z-10"
>
<span className="text-white/30 text-sm mb-2 font-medium tracking-widest uppercase"></span>
<span className="text-white/30 text-xs mb-2 font-medium tracking-widest uppercase"></span>
<div className="w-[1px] h-12 bg-gradient-to-b from-white/30 to-transparent" />
</motion.div>
</section>

View File

@@ -43,21 +43,21 @@ export const Projects = () => {
return (
<Section id="projects">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-5xl font-semibold tracking-tight text-white mb-4"></h2>
<p className="text-slate-400"></p>
<h2 className="scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0 text-foreground mb-4"></h2>
<p className="text-lg text-muted-foreground"></p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-5xl mx-auto">
{projects.map((project) => (
<TiltCard key={project.title} glow className="flex flex-col h-full">
<h3 className="text-2xl font-semibold text-white mb-2">{project.title}</h3>
<p className="text-slate-400 text-sm mb-6 flex-grow">{project.description}</p>
<TiltCard key={project.title} glow className="flex flex-col h-full p-6 glass-premium glass-refract glass-shine spotlight rounded-xl">
<h3 className="text-xl font-semibold leading-none tracking-tight mb-2">{project.title}</h3>
<p className="text-muted-foreground text-sm mb-6 flex-grow">{project.description}</p>
<div className="mb-6">
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-3"></h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3"></h4>
<ul className="space-y-2">
{project.highlights.map(h => (
<li key={h} className="text-sm text-slate-300 flex items-center">
<li key={h} className="text-sm text-foreground flex items-center">
<span className="w-1.5 h-1.5 rounded-full bg-primary mr-2" />
{h}
</li>
@@ -65,9 +65,9 @@ export const Projects = () => {
</ul>
</div>
<div className="flex flex-wrap gap-2 mb-8">
<div className="flex flex-wrap gap-2 mb-8 mt-4">
{project.tech.map(t => (
<span key={t} className="text-xs font-medium px-2 py-1 bg-white/5 border border-white/10 rounded-md text-white/80">
<span key={t} className="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80">
{t}
</span>
))}

View File

@@ -11,23 +11,23 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', children, ...props }, ref) => {
const variants = {
primary: 'bg-white text-black hover:bg-gray-200 shadow-[0_0_20px_rgba(255,255,255,0.3)]',
secondary: 'bg-white/10 text-white backdrop-blur-md border border-white/20 hover:bg-white/20',
outline: 'bg-transparent text-white border border-white/30 hover:bg-white/10',
ghost: 'bg-transparent text-white/70 hover:text-white hover:bg-white/10',
primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
};
const sizes = {
sm: 'px-4 py-2 text-sm',
md: 'px-6 py-3 text-base',
lg: 'px-8 py-4 text-lg',
sm: 'h-8 px-3 text-xs',
md: 'h-9 px-4 py-2 text-sm',
lg: 'h-10 px-8 text-base',
};
return (
<button
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-full font-medium transition-all duration-300 active:scale-95',
'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
variants[variant],
sizes[size],
className

View File

@@ -0,0 +1,587 @@
import React, { useEffect, useRef } from 'react';
import * as THREE from 'three';
export interface LiquidEtherProps {
mouseForce?: number;
cursorSize?: number;
isViscous?: boolean;
viscous?: number;
iterationsViscous?: number;
iterationsPoisson?: number;
dt?: number;
BFECC?: boolean;
resolution?: number;
isBounce?: boolean;
colors?: string[];
style?: React.CSSProperties;
className?: string;
autoDemo?: boolean;
autoSpeed?: number;
autoIntensity?: number;
takeoverDuration?: number;
autoResumeDelay?: number;
autoRampDuration?: number;
}
interface SimOptions {
iterations_poisson: number;
iterations_viscous: number;
mouse_force: number;
resolution: number;
cursor_size: number;
viscous: number;
isBounce: boolean;
dt: number;
isViscous: boolean;
BFECC: boolean;
}
interface LiquidEtherWebGL {
output?: { simulation?: { options: SimOptions; resize: () => void } };
autoDriver?: {
enabled: boolean;
speed: number;
resumeDelay: number;
rampDurationMs: number;
mouse?: { autoIntensity: number; takeoverDuration: number };
forceStop: () => void;
};
resize: () => void;
start: () => void;
pause: () => void;
dispose: () => void;
}
const defaultColors = ['#5227FF', '#FF9FFC', '#B19EEF'];
export default function LiquidEther({
mouseForce = 20,
cursorSize = 100,
isViscous = false,
viscous = 30,
iterationsViscous = 32,
iterationsPoisson = 32,
dt = 0.014,
BFECC = true,
resolution = 0.5,
isBounce = false,
colors = defaultColors,
style = {},
className = '',
autoDemo = true,
autoSpeed = 0.5,
autoIntensity = 2.2,
takeoverDuration = 0.25,
autoResumeDelay = 1000,
autoRampDuration = 0.6
}: LiquidEtherProps): React.ReactElement {
const mountRef = useRef<HTMLDivElement | null>(null);
const webglRef = useRef<LiquidEtherWebGL | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const rafRef = useRef<number | null>(null);
const intersectionObserverRef = useRef<IntersectionObserver | null>(null);
const isVisibleRef = useRef<boolean>(true);
const resizeRafRef = useRef<number | null>(null);
useEffect(() => {
if (!mountRef.current) return;
function makePaletteTexture(stops: string[]): THREE.DataTexture {
let arr: string[];
if (Array.isArray(stops) && stops.length > 0) {
arr = stops.length === 1 ? [stops[0], stops[0]] : stops;
} else {
arr = ['#ffffff', '#ffffff'];
}
const w = arr.length;
const data = new Uint8Array(w * 4);
for (let i = 0; i < w; i++) {
const c = new THREE.Color(arr[i]);
data[i * 4 + 0] = Math.round(c.r * 255);
data[i * 4 + 1] = Math.round(c.g * 255);
data[i * 4 + 2] = Math.round(c.b * 255);
data[i * 4 + 3] = 255;
}
const tex = new THREE.DataTexture(data, w, 1, THREE.RGBAFormat);
tex.magFilter = THREE.LinearFilter;
tex.minFilter = THREE.LinearFilter;
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.generateMipmaps = false;
tex.needsUpdate = true;
return tex;
}
const paletteTex = makePaletteTexture(colors);
const bgVec4 = new THREE.Vector4(0, 0, 0, 0);
class CommonClass {
width = 0; height = 0; aspect = 1; pixelRatio = 1; isMobile = false;
breakpoint = 768; fboWidth: number | null = null; fboHeight: number | null = null;
time = 0; delta = 0; container: HTMLElement | null = null;
renderer: THREE.WebGLRenderer | null = null; clock: THREE.Clock | null = null;
init(container: HTMLElement) {
this.container = container;
this.pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
this.resize();
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.autoClear = false;
this.renderer.setClearColor(new THREE.Color(0x000000), 0);
this.renderer.setPixelRatio(this.pixelRatio);
this.renderer.setSize(this.width, this.height);
const el = this.renderer.domElement;
el.style.width = '100%'; el.style.height = '100%'; el.style.display = 'block';
this.clock = new THREE.Clock(); this.clock.start();
}
resize() {
if (!this.container) return;
const rect = this.container.getBoundingClientRect();
this.width = Math.max(1, Math.floor(rect.width));
this.height = Math.max(1, Math.floor(rect.height));
this.aspect = this.width / this.height;
if (this.renderer) this.renderer.setSize(this.width, this.height, false);
}
update() {
if (!this.clock) return;
this.delta = this.clock.getDelta(); this.time += this.delta;
}
}
const Common = new CommonClass();
class MouseClass {
mouseMoved = false; coords = new THREE.Vector2(); coords_old = new THREE.Vector2();
diff = new THREE.Vector2(); timer: number | null = null; container: HTMLElement | null = null;
docTarget: Document | null = null; listenerTarget: Window | null = null;
isHoverInside = false; hasUserControl = false; isAutoActive = false;
autoIntensity = 2.0; takeoverActive = false; takeoverStartTime = 0;
takeoverDuration = 0.25; takeoverFrom = new THREE.Vector2(); takeoverTo = new THREE.Vector2();
onInteract: (() => void) | null = null;
private _onMouseMove = this.onDocumentMouseMove.bind(this);
private _onTouchStart = this.onDocumentTouchStart.bind(this);
private _onTouchMove = this.onDocumentTouchMove.bind(this);
private _onTouchEnd = this.onTouchEnd.bind(this);
private _onDocumentLeave = this.onDocumentLeave.bind(this);
init(container: HTMLElement) {
this.container = container;
this.docTarget = container.ownerDocument || null;
const defaultView = this.docTarget?.defaultView || (typeof window !== 'undefined' ? window : null);
if (!defaultView) return;
this.listenerTarget = defaultView;
this.listenerTarget.addEventListener('mousemove', this._onMouseMove);
this.listenerTarget.addEventListener('touchstart', this._onTouchStart, { passive: true });
this.listenerTarget.addEventListener('touchmove', this._onTouchMove, { passive: true });
this.listenerTarget.addEventListener('touchend', this._onTouchEnd);
this.docTarget?.addEventListener('mouseleave', this._onDocumentLeave);
}
dispose() {
if (this.listenerTarget) {
this.listenerTarget.removeEventListener('mousemove', this._onMouseMove);
this.listenerTarget.removeEventListener('touchstart', this._onTouchStart);
this.listenerTarget.removeEventListener('touchmove', this._onTouchMove);
this.listenerTarget.removeEventListener('touchend', this._onTouchEnd);
}
if (this.docTarget) this.docTarget.removeEventListener('mouseleave', this._onDocumentLeave);
this.listenerTarget = null; this.docTarget = null; this.container = null;
}
private isPointInside(clientX: number, clientY: number) {
if (!this.container) return false;
const rect = this.container.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
}
private updateHoverState(clientX: number, clientY: number) {
this.isHoverInside = this.isPointInside(clientX, clientY); return this.isHoverInside;
}
setCoords(x: number, y: number) {
if (!this.container) return;
if (this.timer) window.clearTimeout(this.timer);
const rect = this.container.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const nx = (x - rect.left) / rect.width; const ny = (y - rect.top) / rect.height;
this.coords.set(nx * 2 - 1, -(ny * 2 - 1)); this.mouseMoved = true;
this.timer = window.setTimeout(() => { this.mouseMoved = false; }, 100);
}
setNormalized(nx: number, ny: number) { this.coords.set(nx, ny); this.mouseMoved = true; }
onDocumentMouseMove(event: MouseEvent) {
if (!this.updateHoverState(event.clientX, event.clientY)) return;
if (this.onInteract) this.onInteract();
if (this.isAutoActive && !this.hasUserControl && !this.takeoverActive) {
if (!this.container) return;
const rect = this.container.getBoundingClientRect();
const nx = (event.clientX - rect.left) / rect.width;
const ny = (event.clientY - rect.top) / rect.height;
this.takeoverFrom.copy(this.coords);
this.takeoverTo.set(nx * 2 - 1, -(ny * 2 - 1));
this.takeoverStartTime = performance.now();
this.takeoverActive = true; this.hasUserControl = true; this.isAutoActive = false; return;
}
this.setCoords(event.clientX, event.clientY); this.hasUserControl = true;
}
onDocumentTouchStart(event: TouchEvent) {
if (event.touches.length !== 1) return;
const t = event.touches[0];
if (!this.updateHoverState(t.clientX, t.clientY)) return;
if (this.onInteract) this.onInteract();
this.setCoords(t.clientX, t.clientY); this.hasUserControl = true;
}
onDocumentTouchMove(event: TouchEvent) {
if (event.touches.length !== 1) return;
const t = event.touches[0];
if (!this.updateHoverState(t.clientX, t.clientY)) return;
if (this.onInteract) this.onInteract();
this.setCoords(t.clientX, t.clientY);
}
onTouchEnd() { this.isHoverInside = false; }
onDocumentLeave() { this.isHoverInside = false; }
update() {
if (this.takeoverActive) {
const t = (performance.now() - this.takeoverStartTime) / (this.takeoverDuration * 1000);
if (t >= 1) {
this.takeoverActive = false; this.coords.copy(this.takeoverTo);
this.coords_old.copy(this.coords); this.diff.set(0, 0);
} else {
const k = t * t * (3 - 2 * t); this.coords.copy(this.takeoverFrom).lerp(this.takeoverTo, k);
}
}
this.diff.subVectors(this.coords, this.coords_old); this.coords_old.copy(this.coords);
if (this.coords_old.x === 0 && this.coords_old.y === 0) this.diff.set(0, 0);
if (this.isAutoActive && !this.takeoverActive) this.diff.multiplyScalar(this.autoIntensity);
}
}
const Mouse = new MouseClass();
class AutoDriver {
mouse: MouseClass; manager: WebGLManager; enabled: boolean; speed: number;
resumeDelay: number; rampDurationMs: number; active = false;
current = new THREE.Vector2(0, 0); target = new THREE.Vector2();
lastTime = performance.now(); activationTime = 0; margin = 0.2;
private _tmpDir = new THREE.Vector2();
constructor(mouse: MouseClass, manager: WebGLManager, opts: { enabled: boolean; speed: number; resumeDelay: number; rampDuration: number }) {
this.mouse = mouse; this.manager = manager; this.enabled = opts.enabled;
this.speed = opts.speed; this.resumeDelay = opts.resumeDelay || 3000;
this.rampDurationMs = (opts.rampDuration || 0) * 1000; this.pickNewTarget();
}
pickNewTarget() {
const r = Math.random;
this.target.set((r() * 2 - 1) * (1 - this.margin), (r() * 2 - 1) * (1 - this.margin));
}
forceStop() { this.active = false; this.mouse.isAutoActive = false; }
update() {
if (!this.enabled) return;
const now = performance.now();
const idle = now - this.manager.lastUserInteraction;
if (idle < this.resumeDelay) { if (this.active) this.forceStop(); return; }
if (this.mouse.isHoverInside) { if (this.active) this.forceStop(); return; }
if (!this.active) {
this.active = true; this.current.copy(this.mouse.coords);
this.lastTime = now; this.activationTime = now;
}
if (!this.active) return;
this.mouse.isAutoActive = true;
let dtSec = (now - this.lastTime) / 1000; this.lastTime = now;
if (dtSec > 0.2) dtSec = 0.016;
const dir = this._tmpDir.subVectors(this.target, this.current);
const dist = dir.length();
if (dist < 0.01) { this.pickNewTarget(); return; }
dir.normalize();
let ramp = 1;
if (this.rampDurationMs > 0) {
const t = Math.min(1, (now - this.activationTime) / this.rampDurationMs);
ramp = t * t * (3 - 2 * t);
}
const step = this.speed * dtSec * ramp; const move = Math.min(step, dist);
this.current.addScaledVector(dir, move); this.mouse.setNormalized(this.current.x, this.current.y);
}
}
const face_vert = `attribute vec3 position;uniform vec2 px;uniform vec2 boundarySpace;varying vec2 uv;precision highp float;void main(){vec3 pos=position;vec2 scale=1.0-boundarySpace*2.0;pos.xy=pos.xy*scale;uv=vec2(0.5)+(pos.xy)*0.5;gl_Position=vec4(pos,1.0);}`;
const line_vert = `attribute vec3 position;uniform vec2 px;precision highp float;varying vec2 uv;void main(){vec3 pos=position;uv=0.5+pos.xy*0.5;vec2 n=sign(pos.xy);pos.xy=abs(pos.xy)-px*1.0;pos.xy*=n;gl_Position=vec4(pos,1.0);}`;
const mouse_vert = `precision highp float;attribute vec3 position;attribute vec2 uv;uniform vec2 center;uniform vec2 scale;uniform vec2 px;varying vec2 vUv;void main(){vec2 pos=position.xy*scale*2.0*px+center;vUv=uv;gl_Position=vec4(pos,0.0,1.0);}`;
const advection_frag = `precision highp float;uniform sampler2D velocity;uniform float dt;uniform bool isBFECC;uniform vec2 fboSize;uniform vec2 px;varying vec2 uv;void main(){vec2 ratio=max(fboSize.x,fboSize.y)/fboSize;if(isBFECC==false){vec2 vel=texture2D(velocity,uv).xy;vec2 uv2=uv-vel*dt*ratio;vec2 newVel=texture2D(velocity,uv2).xy;gl_FragColor=vec4(newVel,0.0,0.0);}else{vec2 spot_new=uv;vec2 vel_old=texture2D(velocity,uv).xy;vec2 spot_old=spot_new-vel_old*dt*ratio;vec2 vel_new1=texture2D(velocity,spot_old).xy;vec2 spot_new2=spot_old+vel_new1*dt*ratio;vec2 error=spot_new2-spot_new;vec2 spot_new3=spot_new-error/2.0;vec2 vel_2=texture2D(velocity,spot_new3).xy;vec2 spot_old2=spot_new3-vel_2*dt*ratio;vec2 newVel2=texture2D(velocity,spot_old2).xy;gl_FragColor=vec4(newVel2,0.0,0.0);}}`;
const color_frag = `precision highp float;uniform sampler2D velocity;uniform sampler2D palette;uniform vec4 bgColor;varying vec2 uv;void main(){vec2 vel=texture2D(velocity,uv).xy;float lenv=clamp(length(vel),0.0,1.0);vec3 c=texture2D(palette,vec2(lenv,0.5)).rgb;vec3 outRGB=mix(bgColor.rgb,c,lenv);float outA=mix(bgColor.a,1.0,lenv);gl_FragColor=vec4(outRGB,outA);}`;
const divergence_frag = `precision highp float;uniform sampler2D velocity;uniform float dt;uniform vec2 px;varying vec2 uv;void main(){float x0=texture2D(velocity,uv-vec2(px.x,0.0)).x;float x1=texture2D(velocity,uv+vec2(px.x,0.0)).x;float y0=texture2D(velocity,uv-vec2(0.0,px.y)).y;float y1=texture2D(velocity,uv+vec2(0.0,px.y)).y;float divergence=(x1-x0+y1-y0)/2.0;gl_FragColor=vec4(divergence/dt);}`;
const externalForce_frag = `precision highp float;uniform vec2 force;uniform vec2 center;uniform vec2 scale;uniform vec2 px;varying vec2 vUv;void main(){vec2 circle=(vUv-0.5)*2.0;float d=1.0-min(length(circle),1.0);d*=d;gl_FragColor=vec4(force*d,0.0,1.0);}`;
const poisson_frag = `precision highp float;uniform sampler2D pressure;uniform sampler2D divergence;uniform vec2 px;varying vec2 uv;void main(){float p0=texture2D(pressure,uv+vec2(px.x*2.0,0.0)).r;float p1=texture2D(pressure,uv-vec2(px.x*2.0,0.0)).r;float p2=texture2D(pressure,uv+vec2(0.0,px.y*2.0)).r;float p3=texture2D(pressure,uv-vec2(0.0,px.y*2.0)).r;float div=texture2D(divergence,uv).r;float newP=(p0+p1+p2+p3)/4.0-div;gl_FragColor=vec4(newP);}`;
const pressure_frag = `precision highp float;uniform sampler2D pressure;uniform sampler2D velocity;uniform vec2 px;uniform float dt;varying vec2 uv;void main(){float step=1.0;float p0=texture2D(pressure,uv+vec2(px.x*step,0.0)).r;float p1=texture2D(pressure,uv-vec2(px.x*step,0.0)).r;float p2=texture2D(pressure,uv+vec2(0.0,px.y*step)).r;float p3=texture2D(pressure,uv-vec2(0.0,px.y*step)).r;vec2 v=texture2D(velocity,uv).xy;vec2 gradP=vec2(p0-p1,p2-p3)*0.5;v=v-gradP*dt;gl_FragColor=vec4(v,0.0,1.0);}`;
const viscous_frag = `precision highp float;uniform sampler2D velocity;uniform sampler2D velocity_new;uniform float v;uniform vec2 px;uniform float dt;varying vec2 uv;void main(){vec2 old=texture2D(velocity,uv).xy;vec2 new0=texture2D(velocity_new,uv+vec2(px.x*2.0,0.0)).xy;vec2 new1=texture2D(velocity_new,uv-vec2(px.x*2.0,0.0)).xy;vec2 new2=texture2D(velocity_new,uv+vec2(0.0,px.y*2.0)).xy;vec2 new3=texture2D(velocity_new,uv-vec2(0.0,px.y*2.0)).xy;vec2 newv=4.0*old+v*dt*(new0+new1+new2+new3);newv/=4.0*(1.0+v*dt);gl_FragColor=vec4(newv,0.0,0.0);}`;
type Uniforms = Record<string, { value: any }>;
class ShaderPass {
props: any; uniforms?: Uniforms; scene: THREE.Scene | null = null;
camera: THREE.Camera | null = null; material: THREE.RawShaderMaterial | null = null;
geometry: THREE.BufferGeometry | null = null; plane: THREE.Mesh | null = null;
constructor(props: any) { this.props = props || {}; this.uniforms = this.props.material?.uniforms; }
init(..._args: any[]) {
this.scene = new THREE.Scene(); this.camera = new THREE.Camera();
if (this.uniforms) {
this.material = new THREE.RawShaderMaterial(this.props.material);
this.geometry = new THREE.PlaneGeometry(2, 2);
this.plane = new THREE.Mesh(this.geometry, this.material);
this.scene.add(this.plane);
}
}
update(..._args: any[]) {
if (!Common.renderer || !this.scene || !this.camera) return;
Common.renderer.setRenderTarget(this.props.output || null);
Common.renderer.render(this.scene, this.camera);
Common.renderer.setRenderTarget(null);
}
}
class Advection extends ShaderPass {
line!: THREE.LineSegments;
constructor(simProps: any) {
super({ material: { vertexShader: face_vert, fragmentShader: advection_frag, uniforms: { boundarySpace: { value: simProps.cellScale }, px: { value: simProps.cellScale }, fboSize: { value: simProps.fboSize }, velocity: { value: simProps.src.texture }, dt: { value: simProps.dt }, isBFECC: { value: true } } }, output: simProps.dst });
this.uniforms = this.props.material.uniforms; this.init();
}
init() {
super.init();
const boundaryG = new THREE.BufferGeometry();
const vertices_boundary = new Float32Array([-1,-1,0,-1,1,0,-1,1,0,1,1,0,1,1,0,1,-1,0,1,-1,0,-1,-1,0]);
boundaryG.setAttribute('position', new THREE.BufferAttribute(vertices_boundary, 3));
const boundaryM = new THREE.RawShaderMaterial({ vertexShader: line_vert, fragmentShader: advection_frag, uniforms: this.uniforms! });
this.line = new THREE.LineSegments(boundaryG, boundaryM); this.scene!.add(this.line);
}
update(...args: any[]) {
const { dt, isBounce, BFECC } = (args[0] || {}) as { dt?: number; isBounce?: boolean; BFECC?: boolean };
if (!this.uniforms) return;
if (typeof dt === 'number') this.uniforms.dt.value = dt;
if (typeof isBounce === 'boolean') this.line.visible = isBounce;
if (typeof BFECC === 'boolean') this.uniforms.isBFECC.value = BFECC;
super.update();
}
}
class ExternalForce extends ShaderPass {
mouse!: THREE.Mesh;
constructor(simProps: any) { super({ output: simProps.dst }); this.init(simProps); }
init(simProps: any) {
super.init();
const mouseG = new THREE.PlaneGeometry(1, 1);
const mouseM = new THREE.RawShaderMaterial({ vertexShader: mouse_vert, fragmentShader: externalForce_frag, blending: THREE.AdditiveBlending, depthWrite: false, uniforms: { px: { value: simProps.cellScale }, force: { value: new THREE.Vector2(0,0) }, center: { value: new THREE.Vector2(0,0) }, scale: { value: new THREE.Vector2(simProps.cursor_size, simProps.cursor_size) } } });
this.mouse = new THREE.Mesh(mouseG, mouseM); this.scene!.add(this.mouse);
}
update(...args: any[]) {
const props = args[0] || {};
const forceX = (Mouse.diff.x / 2) * (props.mouse_force || 0);
const forceY = (Mouse.diff.y / 2) * (props.mouse_force || 0);
const cellScale = props.cellScale || { x: 1, y: 1 };
const cursorSize = props.cursor_size || 0;
const cursorSizeX = cursorSize * cellScale.x; const cursorSizeY = cursorSize * cellScale.y;
const centerX = Math.min(Math.max(Mouse.coords.x, -1 + cursorSizeX + cellScale.x * 2), 1 - cursorSizeX - cellScale.x * 2);
const centerY = Math.min(Math.max(Mouse.coords.y, -1 + cursorSizeY + cellScale.y * 2), 1 - cursorSizeY - cellScale.y * 2);
const uniforms = (this.mouse.material as THREE.RawShaderMaterial).uniforms;
uniforms.force.value.set(forceX, forceY); uniforms.center.value.set(centerX, centerY); uniforms.scale.value.set(cursorSize, cursorSize);
super.update();
}
}
class Viscous extends ShaderPass {
constructor(simProps: any) {
super({ material: { vertexShader: face_vert, fragmentShader: viscous_frag, uniforms: { boundarySpace: { value: simProps.boundarySpace }, velocity: { value: simProps.src.texture }, velocity_new: { value: simProps.dst_.texture }, v: { value: simProps.viscous }, px: { value: simProps.cellScale }, dt: { value: simProps.dt } } }, output: simProps.dst, output0: simProps.dst_, output1: simProps.dst });
this.init();
}
update(...args: any[]) {
const { viscous, iterations, dt } = (args[0] || {}) as { viscous?: number; iterations?: number; dt?: number };
if (!this.uniforms) return;
let fbo_in: any, fbo_out: any;
if (typeof viscous === 'number') this.uniforms.v.value = viscous;
const iter = iterations ?? 0;
for (let i = 0; i < iter; i++) {
if (i % 2 === 0) { fbo_in = this.props.output0; fbo_out = this.props.output1; }
else { fbo_in = this.props.output1; fbo_out = this.props.output0; }
this.uniforms.velocity_new.value = fbo_in.texture; this.props.output = fbo_out;
if (typeof dt === 'number') this.uniforms.dt.value = dt;
super.update();
}
return fbo_out;
}
}
class Divergence extends ShaderPass {
constructor(simProps: any) {
super({ material: { vertexShader: face_vert, fragmentShader: divergence_frag, uniforms: { boundarySpace: { value: simProps.boundarySpace }, velocity: { value: simProps.src.texture }, px: { value: simProps.cellScale }, dt: { value: simProps.dt } } }, output: simProps.dst });
this.init();
}
update(...args: any[]) {
const { vel } = (args[0] || {}) as { vel?: any };
if (this.uniforms && vel) this.uniforms.velocity.value = vel.texture;
super.update();
}
}
class Poisson extends ShaderPass {
constructor(simProps: any) {
super({ material: { vertexShader: face_vert, fragmentShader: poisson_frag, uniforms: { boundarySpace: { value: simProps.boundarySpace }, pressure: { value: simProps.dst_.texture }, divergence: { value: simProps.src.texture }, px: { value: simProps.cellScale } } }, output: simProps.dst, output0: simProps.dst_, output1: simProps.dst });
this.init();
}
update(...args: any[]) {
const { iterations } = (args[0] || {}) as { iterations?: number };
let p_in: any, p_out: any; const iter = iterations ?? 0;
for (let i = 0; i < iter; i++) {
if (i % 2 === 0) { p_in = this.props.output0; p_out = this.props.output1; }
else { p_in = this.props.output1; p_out = this.props.output0; }
if (this.uniforms) this.uniforms.pressure.value = p_in.texture;
this.props.output = p_out; super.update();
}
return p_out;
}
}
class Pressure extends ShaderPass {
constructor(simProps: any) {
super({ material: { vertexShader: face_vert, fragmentShader: pressure_frag, uniforms: { boundarySpace: { value: simProps.boundarySpace }, pressure: { value: simProps.src_p.texture }, velocity: { value: simProps.src_v.texture }, px: { value: simProps.cellScale }, dt: { value: simProps.dt } } }, output: simProps.dst });
this.init();
}
update(...args: any[]) {
const { vel, pressure } = (args[0] || {}) as { vel?: any; pressure?: any };
if (this.uniforms && vel && pressure) { this.uniforms.velocity.value = vel.texture; this.uniforms.pressure.value = pressure.texture; }
super.update();
}
}
class Simulation {
options: SimOptions;
fbos: Record<string, THREE.WebGLRenderTarget | null> = { vel_0: null, vel_1: null, vel_viscous0: null, vel_viscous1: null, div: null, pressure_0: null, pressure_1: null };
fboSize = new THREE.Vector2(); cellScale = new THREE.Vector2(); boundarySpace = new THREE.Vector2();
advection!: Advection; externalForce!: ExternalForce; viscous!: Viscous;
divergence!: Divergence; poisson!: Poisson; pressure!: Pressure;
constructor(options?: Partial<SimOptions>) {
this.options = { iterations_poisson: 32, iterations_viscous: 32, mouse_force: 20, resolution: 0.5, cursor_size: 100, viscous: 30, isBounce: false, dt: 0.014, isViscous: false, BFECC: true, ...options };
this.init();
}
init() { this.calcSize(); this.createAllFBO(); this.createShaderPass(); }
getFloatType() { const isIOS = /(iPad|iPhone|iPod)/i.test(navigator.userAgent); return isIOS ? THREE.HalfFloatType : THREE.FloatType; }
createAllFBO() {
const type = this.getFloatType();
const opts = { type, depthBuffer: false, stencilBuffer: false, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, wrapS: THREE.ClampToEdgeWrapping, wrapT: THREE.ClampToEdgeWrapping } as const;
for (const key in this.fbos) this.fbos[key] = new THREE.WebGLRenderTarget(this.fboSize.x, this.fboSize.y, opts);
}
createShaderPass() {
this.advection = new Advection({ cellScale: this.cellScale, fboSize: this.fboSize, dt: this.options.dt, src: this.fbos.vel_0, dst: this.fbos.vel_1 });
this.externalForce = new ExternalForce({ cellScale: this.cellScale, cursor_size: this.options.cursor_size, dst: this.fbos.vel_1 });
this.viscous = new Viscous({ cellScale: this.cellScale, boundarySpace: this.boundarySpace, viscous: this.options.viscous, src: this.fbos.vel_1, dst: this.fbos.vel_viscous1, dst_: this.fbos.vel_viscous0, dt: this.options.dt });
this.divergence = new Divergence({ cellScale: this.cellScale, boundarySpace: this.boundarySpace, src: this.fbos.vel_viscous0, dst: this.fbos.div, dt: this.options.dt });
this.poisson = new Poisson({ cellScale: this.cellScale, boundarySpace: this.boundarySpace, src: this.fbos.div, dst: this.fbos.pressure_1, dst_: this.fbos.pressure_0 });
this.pressure = new Pressure({ cellScale: this.cellScale, boundarySpace: this.boundarySpace, src_p: this.fbos.pressure_0, src_v: this.fbos.vel_viscous0, dst: this.fbos.vel_0, dt: this.options.dt });
}
calcSize() {
const width = Math.max(1, Math.round(this.options.resolution * Common.width));
const height = Math.max(1, Math.round(this.options.resolution * Common.height));
this.cellScale.set(1 / width, 1 / height); this.fboSize.set(width, height);
}
resize() { this.calcSize(); for (const key in this.fbos) this.fbos[key]!.setSize(this.fboSize.x, this.fboSize.y); }
update() {
if (this.options.isBounce) this.boundarySpace.set(0, 0); else this.boundarySpace.copy(this.cellScale);
this.advection.update({ dt: this.options.dt, isBounce: this.options.isBounce, BFECC: this.options.BFECC });
this.externalForce.update({ cursor_size: this.options.cursor_size, mouse_force: this.options.mouse_force, cellScale: this.cellScale });
let vel: any = this.fbos.vel_1;
if (this.options.isViscous) vel = this.viscous.update({ viscous: this.options.viscous, iterations: this.options.iterations_viscous, dt: this.options.dt });
this.divergence.update({ vel });
const pressure = this.poisson.update({ iterations: this.options.iterations_poisson });
this.pressure.update({ vel, pressure });
}
}
class Output {
simulation: Simulation; scene: THREE.Scene; camera: THREE.Camera; output: THREE.Mesh;
constructor() {
this.simulation = new Simulation();
this.scene = new THREE.Scene(); this.camera = new THREE.Camera();
this.output = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.RawShaderMaterial({ vertexShader: face_vert, fragmentShader: color_frag, transparent: true, depthWrite: false, uniforms: { velocity: { value: this.simulation.fbos.vel_0!.texture }, boundarySpace: { value: new THREE.Vector2() }, palette: { value: paletteTex }, bgColor: { value: bgVec4 } } }));
this.scene.add(this.output);
}
resize() { this.simulation.resize(); }
render() { if (!Common.renderer) return; Common.renderer.setRenderTarget(null); Common.renderer.render(this.scene, this.camera); }
update() { this.simulation.update(); this.render(); }
}
class WebGLManager implements LiquidEtherWebGL {
props: any; output!: Output; autoDriver?: AutoDriver;
lastUserInteraction = performance.now(); running = false;
private _loop = this.loop.bind(this); private _resize = this.resize.bind(this);
private _onVisibility?: () => void;
constructor(props: any) {
this.props = props;
Common.init(props.$wrapper); Mouse.init(props.$wrapper);
Mouse.autoIntensity = props.autoIntensity; Mouse.takeoverDuration = props.takeoverDuration;
Mouse.onInteract = () => { this.lastUserInteraction = performance.now(); if (this.autoDriver) this.autoDriver.forceStop(); };
this.autoDriver = new AutoDriver(Mouse, this as any, { enabled: props.autoDemo, speed: props.autoSpeed, resumeDelay: props.autoResumeDelay, rampDuration: props.autoRampDuration });
this.init();
window.addEventListener('resize', this._resize);
this._onVisibility = () => {
const hidden = document.hidden;
if (hidden) { this.pause(); } else if (isVisibleRef.current) { this.start(); }
};
document.addEventListener('visibilitychange', this._onVisibility);
}
init() { if (!Common.renderer) return; this.props.$wrapper.prepend(Common.renderer.domElement); this.output = new Output(); }
resize() { Common.resize(); this.output.resize(); }
render() { if (this.autoDriver) this.autoDriver.update(); Mouse.update(); Common.update(); this.output.update(); }
loop() { if (!this.running) return; this.render(); rafRef.current = requestAnimationFrame(this._loop); }
start() { if (this.running) return; this.running = true; this._loop(); }
pause() { this.running = false; if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } }
dispose() {
try {
window.removeEventListener('resize', this._resize);
if (this._onVisibility) document.removeEventListener('visibilitychange', this._onVisibility);
Mouse.dispose();
if (Common.renderer) { const canvas = Common.renderer.domElement; if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas); Common.renderer.dispose(); Common.renderer.forceContextLoss(); }
} catch { /* noop */ }
}
}
const container = mountRef.current;
container.style.position = container.style.position || 'relative';
container.style.overflow = container.style.overflow || 'hidden';
const webgl = new WebGLManager({ $wrapper: container, autoDemo, autoSpeed, autoIntensity, takeoverDuration, autoResumeDelay, autoRampDuration });
webglRef.current = webgl;
const applyOptionsFromProps = () => {
if (!webglRef.current) return;
const sim = webglRef.current.output?.simulation;
if (!sim) return;
const prevRes = sim.options.resolution;
Object.assign(sim.options, { mouse_force: mouseForce, cursor_size: cursorSize, isViscous, viscous, iterations_viscous: iterationsViscous, iterations_poisson: iterationsPoisson, dt, BFECC, resolution, isBounce });
if (resolution !== prevRes) sim.resize();
};
applyOptionsFromProps();
webgl.start();
const io = new IntersectionObserver(entries => {
const entry = entries[0];
const isVisible = entry.isIntersecting && entry.intersectionRatio > 0;
isVisibleRef.current = isVisible;
if (!webglRef.current) return;
if (isVisible && !document.hidden) { webglRef.current.start(); } else { webglRef.current.pause(); }
}, { threshold: [0, 0.01, 0.1] });
io.observe(container);
intersectionObserverRef.current = io;
const ro = new ResizeObserver(() => {
if (!webglRef.current) return;
if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
resizeRafRef.current = requestAnimationFrame(() => { if (!webglRef.current) return; webglRef.current.resize(); });
});
ro.observe(container);
resizeObserverRef.current = ro;
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
if (resizeObserverRef.current) { try { resizeObserverRef.current.disconnect(); } catch { /* noop */ } }
if (intersectionObserverRef.current) { try { intersectionObserverRef.current.disconnect(); } catch { /* noop */ } }
if (webglRef.current) { webglRef.current.dispose(); }
webglRef.current = null;
};
}, [BFECC, cursorSize, dt, isBounce, isViscous, iterationsPoisson, iterationsViscous, mouseForce, resolution, viscous, colors, autoDemo, autoSpeed, autoIntensity, takeoverDuration, autoResumeDelay, autoRampDuration]);
return (
<div
ref={mountRef}
className={`w-full h-full relative overflow-hidden pointer-events-none touch-none ${className || ''}`}
style={style}
/>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useRef, useState, useCallback } from 'react';
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { cn } from '../../utils';
interface TiltCardProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -7,24 +7,51 @@ interface TiltCardProps extends React.HTMLAttributes<HTMLDivElement> {
tiltMax?: number;
}
// 检测用户是否偏好减少动画(无障碍 + 性能双重考虑)
function usePrefersReducedMotion() {
const [reduced, setReduced] = useState(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return reduced;
}
export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
({ className, children, glow = false, tiltMax = 14, style, ...props }, _ref) => {
const cardRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<number>(0);
const inViewRef = useRef(false);
const reducedMotion = usePrefersReducedMotion();
const [tilt, setTilt] = useState({ rotX: 0, rotY: 0, scale: 1 });
const [glare, setGlare] = useState({ x: 50, y: 50, opacity: 0 });
// normalized -1..1 mouse position for the border gradient
const [border, setBorder] = useState({ x: 0.5, y: 0.5, opacity: 0 });
// IntersectionObserver: 只对在视口内的卡片处理鼠标事件状态
useEffect(() => {
const card = cardRef.current;
if (!card) return;
const io = new IntersectionObserver(
([entry]) => { inViewRef.current = entry.isIntersecting; },
{ threshold: 0.1 }
);
io.observe(card);
return () => io.disconnect();
}, []);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
// 不在视口或用户偏好减少动画时跳过
if (!inViewRef.current || reducedMotion) return;
const card = cardRef.current;
if (!card) return;
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const nx = x / rect.width; // 0..1
const ny = y / rect.height;
const nx = (e.clientX - rect.left) / rect.width;
const ny = (e.clientY - rect.top) / rect.height;
cancelAnimationFrame(frameRef.current);
frameRef.current = requestAnimationFrame(() => {
@@ -36,7 +63,7 @@ export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
setGlare({ x: nx * 100, y: ny * 100, opacity: 0.2 });
setBorder({ x: nx, y: ny, opacity: 1 });
});
}, [tiltMax]);
}, [tiltMax, reducedMotion]);
const handleMouseLeave = useCallback(() => {
cancelAnimationFrame(frameRef.current);
@@ -45,9 +72,10 @@ export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
setBorder(b => ({ ...b, opacity: 0 }));
}, []);
const isResting = tilt.rotX === 0 && tilt.rotY === 0;
// 清理 RAF on unmount
useEffect(() => () => cancelAnimationFrame(frameRef.current), []);
// Dynamic border: a conic gradient anchored to mouse position
const isResting = tilt.rotX === 0 && tilt.rotY === 0;
const borderAngle = Math.atan2(border.y - 0.5, border.x - 0.5) * (180 / Math.PI) + 90;
return (
@@ -57,24 +85,24 @@ export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
onMouseLeave={handleMouseLeave}
style={{
...style,
transform: `perspective(900px) rotateX(${tilt.rotX}deg) rotateY(${tilt.rotY}deg) scale3d(${tilt.scale},${tilt.scale},${tilt.scale})`,
transform: reducedMotion
? 'none'
: `perspective(900px) rotateX(${tilt.rotX}deg) rotateY(${tilt.rotY}deg) scale3d(${tilt.scale},${tilt.scale},${tilt.scale})`,
transition: isResting
? 'transform 0.55s cubic-bezier(.03,.98,.52,.99), box-shadow 0.55s ease'
: 'transform 0.08s linear',
willChange: 'transform',
// will-change 仅在活跃交互时提示 GPU 层
willChange: isResting ? 'auto' : 'transform',
transformStyle: 'preserve-3d',
// Glow box shadow that intensifies on hover
boxShadow: border.opacity
? `0 0 40px -8px hsla(${220 + border.x * 40},80%,60%,0.35), 0 20px 80px -20px hsla(260,80%,50%,0.2)`
: '0 0 0 0 transparent',
}}
className={cn(
'relative rounded-3xl overflow-hidden group',
className
)}
className={cn('relative rounded-3xl overflow-hidden group', className)}
{...props}
>
{/* Animated neon border */}
{/* Animated neon border — 仅在非 reduced-motion 时渲染 */}
{!reducedMotion && (
<div
className="absolute inset-0 rounded-3xl transition-opacity duration-300 pointer-events-none"
style={{
@@ -93,6 +121,7 @@ export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
maskComposite: 'exclude',
}}
/>
)}
{/* Card body */}
<div className={cn(
@@ -102,6 +131,7 @@ export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
)} />
{/* Glare highlight */}
{!reducedMotion && (
<div
className="absolute inset-0 rounded-3xl pointer-events-none transition-opacity duration-300"
style={{
@@ -109,22 +139,27 @@ export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
background: `radial-gradient(circle at ${glare.x}% ${glare.y}%, rgba(255,255,255,0.18) 0%, transparent 65%)`,
}}
/>
)}
{/* Laser shimmer sweep on hover */}
{/* Laser shimmer:仅在 hover 时才触发 CSS animation静止状态不消耗 GPU */}
{!reducedMotion && (
<div className="absolute inset-0 rounded-3xl pointer-events-none overflow-hidden">
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500"
style={{
background: `linear-gradient(105deg, transparent 30%, rgba(255,255,255,0.06) 50%, transparent 70%)`,
background: 'linear-gradient(105deg, transparent 30%, rgba(255,255,255,0.06) 50%, transparent 70%)',
animation: 'shimmer-sweep 2s ease-in-out infinite',
// 告诉浏览器只在 hover 时分配合成层
contain: 'paint',
}}
/>
</div>
)}
{/* Content — slight Z lift for depth */}
{/* Content */}
<div
className="relative p-6 md:p-8"
style={{ transform: 'translateZ(20px)', transformStyle: 'preserve-3d' }}
style={reducedMotion ? {} : { transform: 'translateZ(20px)', transformStyle: 'preserve-3d' }}
>
{children}
</div>

View File

@@ -4,18 +4,42 @@
@layer base {
:root {
--bg-color: #050505;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--radius: 0.5rem;
--bg-color: hsl(var(--background));
--text-primary: hsl(var(--foreground));
--text-secondary: hsl(var(--muted-foreground));
}
}
@layer base {
* {
@apply border-border;
}
html {
scroll-behavior: smooth;
background-color: var(--bg-color);
}
body {
@apply bg-background text-slate-100 font-sans antialiased selection:bg-primary/30 selection:text-white;
@apply bg-background text-foreground font-sans antialiased selection:bg-primary/30 selection:text-white;
min-height: 100vh;
overflow-x: hidden;
}
@@ -34,16 +58,8 @@
@apply bg-clip-text text-transparent bg-gradient-to-r from-blue-400 via-indigo-400 to-purple-400;
}
/* Animated shimmer text */
.text-shimmer {
background: linear-gradient(
90deg,
#60a5fa 0%,
#a78bfa 25%,
#22d3ee 50%,
#a78bfa 75%,
#60a5fa 100%
);
background: linear-gradient(90deg, #60a5fa 0%, #a78bfa 25%, #22d3ee 50%, #a78bfa 75%, #60a5fa 100%);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
@@ -58,16 +74,12 @@
background-size: 50px 50px;
}
/* Glow behind cards */
.glow-effect::before {
content: '';
@apply absolute inset-0 -z-10 bg-primary/20 blur-2xl rounded-full opacity-0 transition-opacity duration-300;
}
.glow-effect:hover::before {
@apply opacity-100;
}
.glow-effect:hover::before { @apply opacity-100; }
/* Neon text glow */
.text-glow {
text-shadow:
0 0 20px rgba(99, 130, 246, 0.5),
@@ -75,16 +87,106 @@
0 0 100px rgba(99, 130, 246, 0.1);
}
/* Pulsing ring */
.pulse-ring {
position: relative;
}
.pulse-ring { position: relative; }
.pulse-ring::after {
content: '';
@apply absolute inset-0 rounded-full;
border: 1px solid rgba(99, 130, 246, 0.5);
animation: pulse-expand 2s ease-out infinite;
}
/* ── Premium Glassmorphism ── */
.glass-premium {
background: linear-gradient(135deg, rgba(255,255,255,0.07) 0%, rgba(255,255,255,0.03) 100%);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255,255,255,0.08);
box-shadow:
0 8px 32px 0 rgba(0,0,0,0.45),
inset 0 1px 0 rgba(255,255,255,0.10),
inset 0 -1px 0 rgba(0,0,0,0.20);
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.glass-premium:hover {
border-color: rgba(255,255,255,0.14);
box-shadow:
0 16px 48px 0 rgba(0,0,0,0.5),
inset 0 1px 0 rgba(255,255,255,0.15),
inset 0 -1px 0 rgba(0,0,0,0.20),
0 0 0 1px rgba(99,130,246,0.12);
}
/* Light refraction lines (top & left edge highlight) */
.glass-refract {
position: relative;
overflow: hidden;
}
.glass-refract::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.45) 35%, rgba(255,255,255,0.12) 70%, transparent 100%);
z-index: 2;
pointer-events: none;
}
.glass-refract::after {
content: '';
position: absolute;
top: 0; bottom: 0; left: 0;
width: 1px;
background: linear-gradient(180deg, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0.05) 50%, transparent 100%);
z-index: 2;
pointer-events: none;
}
/* Spotlight radial glow above element */
.spotlight {
position: relative;
isolation: isolate;
}
.spotlight::before {
content: '';
position: absolute;
inset: -80px;
background: radial-gradient(circle at 50% -10%, rgba(99,130,246,0.18) 0%, transparent 55%);
z-index: -1;
pointer-events: none;
border-radius: inherit;
}
/* Shine sweep on hover */
.glass-shine {
position: relative;
overflow: hidden;
}
.glass-shine::after {
content: '';
position: absolute;
top: -50%; left: -75%;
width: 50%; height: 200%;
background: linear-gradient(to right, rgba(255,255,255,0) 0%, rgba(255,255,255,0.07) 50%, rgba(255,255,255,0) 100%);
transform: skewX(-20deg);
transition: left 0.65s ease;
pointer-events: none;
z-index: 2;
}
.glass-shine:hover::after { left: 125%; }
/* Section ambient orb helper */
.ambient-glow {
position: relative;
}
.ambient-glow::before {
content: '';
position: absolute;
top: -200px; left: -150px;
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(59,130,246,0.07) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
border-radius: 50%;
}
}
/* ── Keyframes ── */
@@ -126,7 +228,6 @@
50% { transform: translateY(-10px); }
}
/* Scan line across elements */
@keyframes scan-line {
0% { top: -2px; opacity: 0.8; }
80% { opacity: 0.6; }

View File

@@ -7,13 +7,47 @@ export default {
theme: {
extend: {
colors: {
background: '#050505',
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
surface: '#111111',
primary: '#3b82f6',
secondary: '#8b5cf6',
accent: '#06b6d4',
glow: 'rgba(59, 130, 246, 0.5)',
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ['Inter', 'system-ui', 'Avenir', 'Helvetica', 'Arial', 'sans-serif'],
},
@@ -22,6 +56,9 @@ export default {
'fade-in': 'fadeIn 0.5s ease-out forwards',
'fade-up': 'fadeUp 0.8s ease-out forwards',
'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"in": "animate-in 0.3s ease-out forwards"
},
keyframes: {
blob: {
@@ -37,6 +74,18 @@ export default {
fadeUp: {
'0%': { opacity: '0', transform: 'translateY(20px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
"animate-in": {
"0%": { opacity: 0, transform: "translateY(10px)" },
"100%": { opacity: 1, transform: "translateY(0)" }
}
}
},

View File

@@ -4,4 +4,36 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
// 函数形式是正确的 Rollup TS 类型
manualChunks(id) {
if (id.includes('node_modules/three')) return 'three';
if (id.includes('node_modules/framer-motion')) return 'motion';
if (id.includes('node_modules/react-dom')) return 'react-vendor';
if (id.includes('node_modules/react/')) return 'react-vendor';
},
},
},
// 提升 chunk 体积警告阈值three.js 较大)
chunkSizeWarningLimit: 700,
minify: 'esbuild',
cssCodeSplit: true,
sourcemap: false,
},
server: {
warmup: {
clientFiles: [
'./src/App.tsx',
'./src/components/Hero.tsx',
'./src/components/AnimatedBackground.tsx',
'./src/components/ui/LiquidEther.tsx',
],
},
},
// 依赖预构建:让 Vite 提前 bundle 大型 CJS 库
optimizeDeps: {
include: ['three', 'framer-motion', 'react', 'react-dom'],
},
})