feat: replace canvas-based particle background with high-performance LiquidEther fluid simulation component
This commit is contained in:
@@ -5,6 +5,9 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>personal_front</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
3503
package-lock.json
generated
3503
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
|
"shadcn": "^4.1.1",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.57.0",
|
"typescript-eslint": "^8.57.0",
|
||||||
|
|||||||
33
src/App.tsx
33
src/App.tsx
@@ -1,22 +1,45 @@
|
|||||||
|
import { lazy, Suspense } from 'react';
|
||||||
import { Hero } from './components/Hero';
|
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';
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full min-h-screen">
|
<div className="relative w-full min-h-screen">
|
||||||
<AnimatedBackground />
|
<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">
|
<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 />
|
<Hero />
|
||||||
|
|
||||||
|
{/* 以下组件等主线程空闲后再加载 */}
|
||||||
|
<Suspense fallback={<SectionSkeleton />}>
|
||||||
<About />
|
<About />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={<SectionSkeleton />}>
|
||||||
<Capabilities />
|
<Capabilities />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={<SectionSkeleton />}>
|
||||||
<TechStack />
|
<TechStack />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={<SectionSkeleton />}>
|
||||||
<Projects />
|
<Projects />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={<SectionSkeleton />}>
|
||||||
<Contact />
|
<Contact />
|
||||||
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,225 +1,69 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import LiquidEther from './ui/LiquidEther';
|
||||||
|
|
||||||
interface Particle {
|
/**
|
||||||
x: number;
|
* 根据设备能力选择合适的渲染质量
|
||||||
y: number;
|
* - 低端/移动设备:降低分辨率 + 关闭 BFECC + 减少迭代次数
|
||||||
vx: number;
|
* - 高端桌面:完整质量
|
||||||
vy: number;
|
*/
|
||||||
size: number;
|
function useDeviceQuality() {
|
||||||
opacity: number;
|
return useMemo(() => {
|
||||||
hue: number;
|
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
||||||
pulse: number;
|
const isMedium = window.matchMedia('(max-width: 1280px)').matches;
|
||||||
pulseSpeed: number;
|
// 检测 GPU 能力(通过 hardwareConcurrency 粗略判断)
|
||||||
}
|
const isLowEnd = navigator.hardwareConcurrency <= 4;
|
||||||
|
|
||||||
interface WaveBlob {
|
if (isMobile || isLowEnd) {
|
||||||
x: number;
|
return {
|
||||||
y: number;
|
resolution: 0.2,
|
||||||
radius: number;
|
iterationsPoisson: 8,
|
||||||
hue: number;
|
iterationsViscous: 8,
|
||||||
speed: number;
|
BFECC: false,
|
||||||
angle: number;
|
mouseForce: 15,
|
||||||
}
|
cursorSize: 80,
|
||||||
|
|
||||||
// 极简、优雅、现代的颜色搭配 (更暗、更有质感)
|
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
animationId = requestAnimationFrame(draw);
|
if (isMedium) {
|
||||||
return () => {
|
return {
|
||||||
cancelAnimationFrame(animationId);
|
resolution: 0.35,
|
||||||
window.removeEventListener('resize', resize);
|
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 (
|
return (
|
||||||
<canvas
|
<div
|
||||||
ref={canvasRef}
|
className="fixed inset-0 w-full h-full z-0 bg-[#030712]"
|
||||||
className="fixed inset-0 w-full h-full pointer-events-none"
|
style={{ pointerEvents: 'none' }}
|
||||||
style={{ zIndex: 0 }}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ export const Hero = () => {
|
|||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: { staggerChildren: 0.15, delayChildren: 0.2 },
|
||||||
staggerChildren: 0.15,
|
|
||||||
delayChildren: 0.2,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,14 +16,29 @@ export const Hero = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative min-h-screen flex items-center justify-center overflow-hidden px-6">
|
<section
|
||||||
<div className="absolute inset-0 z-0 bg-grid-pattern opacity-[0.1]" />
|
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 */}
|
{/* 合并三个 orb 为单个合成层 — 避免多个大面积 blur 的 GPU 压力 */}
|
||||||
<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
|
||||||
<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" />
|
className="absolute inset-0 z-0 pointer-events-none"
|
||||||
<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" />
|
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">
|
<div className="relative z-10 w-full max-w-5xl mx-auto flex flex-col items-center text-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
@@ -34,27 +46,35 @@ export const Hero = () => {
|
|||||||
animate="visible"
|
animate="visible"
|
||||||
className="flex flex-col items-center"
|
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">
|
{/* Status badge — glassmorphism */}
|
||||||
<span className="flex h-2 w-2 rounded-full bg-green-500 mr-2 shadow-[0_0_8px_rgba(34,197,94,0.8)]" />
|
<motion.div
|
||||||
<span className="text-sm font-medium text-white/80">正在寻找暑期实习机会</span>
|
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>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Headline */}
|
||||||
<motion.h1
|
<motion.h1
|
||||||
variants={itemVariants}
|
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" />
|
构建 <br className="hidden md:block" />
|
||||||
<span className="text-shimmer">稳健架构</span> 与 <br className="hidden md:block" />
|
<span className="text-shimmer">稳健架构</span> 与 <br className="hidden md:block" />
|
||||||
<span className="text-shimmer">极简交互</span>.
|
<span className="text-shimmer">极简交互</span>.
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
|
{/* Subtitle */}
|
||||||
<motion.p
|
<motion.p
|
||||||
variants={itemVariants}
|
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>
|
</motion.p>
|
||||||
|
|
||||||
|
{/* CTA buttons */}
|
||||||
<motion.div variants={itemVariants} className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
<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' })}>
|
<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 }}
|
transition={{ delay: 1.5, duration: 1 }}
|
||||||
className="absolute bottom-10 left-1/2 -translate-x-1/2 flex flex-col items-center z-10"
|
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" />
|
<div className="w-[1px] h-12 bg-gradient-to-b from-white/30 to-transparent" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -43,21 +43,21 @@ export const Projects = () => {
|
|||||||
return (
|
return (
|
||||||
<Section id="projects">
|
<Section id="projects">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-3xl md:text-5xl font-semibold tracking-tight text-white mb-4">精选项目</h2>
|
<h2 className="scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0 text-foreground mb-4">精选项目</h2>
|
||||||
<p className="text-slate-400">展现后端架构设计与前端工程实现能力的核心代表作。</p>
|
<p className="text-lg text-muted-foreground">展现后端架构设计与前端工程实现能力的核心代表作。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-5xl mx-auto">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-5xl mx-auto">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<TiltCard key={project.title} glow className="flex flex-col h-full">
|
<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-2xl font-semibold text-white mb-2">{project.title}</h3>
|
<h3 className="text-xl font-semibold leading-none tracking-tight mb-2">{project.title}</h3>
|
||||||
<p className="text-slate-400 text-sm mb-6 flex-grow">{project.description}</p>
|
<p className="text-muted-foreground text-sm mb-6 flex-grow">{project.description}</p>
|
||||||
|
|
||||||
<div className="mb-6">
|
<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">
|
<ul className="space-y-2">
|
||||||
{project.highlights.map(h => (
|
{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" />
|
<span className="w-1.5 h-1.5 rounded-full bg-primary mr-2" />
|
||||||
{h}
|
{h}
|
||||||
</li>
|
</li>
|
||||||
@@ -65,9 +65,9 @@ export const Projects = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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 => (
|
{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}
|
{t}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,23 +11,23 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
({ className, variant = 'primary', size = 'md', children, ...props }, ref) => {
|
({ className, variant = 'primary', size = 'md', children, ...props }, ref) => {
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: 'bg-white text-black hover:bg-gray-200 shadow-[0_0_20px_rgba(255,255,255,0.3)]',
|
primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
secondary: 'bg-white/10 text-white backdrop-blur-md border border-white/20 hover:bg-white/20',
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
outline: 'bg-transparent text-white border border-white/30 hover:bg-white/10',
|
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
ghost: 'bg-transparent text-white/70 hover:text-white hover:bg-white/10',
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: 'px-4 py-2 text-sm',
|
sm: 'h-8 px-3 text-xs',
|
||||||
md: 'px-6 py-3 text-base',
|
md: 'h-9 px-4 py-2 text-sm',
|
||||||
lg: 'px-8 py-4 text-lg',
|
lg: 'h-10 px-8 text-base',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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],
|
variants[variant],
|
||||||
sizes[size],
|
sizes[size],
|
||||||
className
|
className
|
||||||
|
|||||||
587
src/components/ui/LiquidEther.tsx
Normal file
587
src/components/ui/LiquidEther.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, useState, useCallback } from 'react';
|
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||||
import { cn } from '../../utils';
|
import { cn } from '../../utils';
|
||||||
|
|
||||||
interface TiltCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface TiltCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
@@ -7,24 +7,51 @@ interface TiltCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
tiltMax?: number;
|
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>(
|
export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
|
||||||
({ className, children, glow = false, tiltMax = 14, style, ...props }, _ref) => {
|
({ className, children, glow = false, tiltMax = 14, style, ...props }, _ref) => {
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
const frameRef = useRef<number>(0);
|
const frameRef = useRef<number>(0);
|
||||||
|
const inViewRef = useRef(false);
|
||||||
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
|
|
||||||
const [tilt, setTilt] = useState({ rotX: 0, rotY: 0, scale: 1 });
|
const [tilt, setTilt] = useState({ rotX: 0, rotY: 0, scale: 1 });
|
||||||
const [glare, setGlare] = useState({ x: 50, y: 50, opacity: 0 });
|
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 });
|
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>) => {
|
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
// 不在视口或用户偏好减少动画时跳过
|
||||||
|
if (!inViewRef.current || reducedMotion) return;
|
||||||
const card = cardRef.current;
|
const card = cardRef.current;
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const rect = card.getBoundingClientRect();
|
const rect = card.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const nx = (e.clientX - rect.left) / rect.width;
|
||||||
const y = e.clientY - rect.top;
|
const ny = (e.clientY - rect.top) / rect.height;
|
||||||
const nx = x / rect.width; // 0..1
|
|
||||||
const ny = y / rect.height;
|
|
||||||
|
|
||||||
cancelAnimationFrame(frameRef.current);
|
cancelAnimationFrame(frameRef.current);
|
||||||
frameRef.current = requestAnimationFrame(() => {
|
frameRef.current = requestAnimationFrame(() => {
|
||||||
@@ -36,7 +63,7 @@ export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
|
|||||||
setGlare({ x: nx * 100, y: ny * 100, opacity: 0.2 });
|
setGlare({ x: nx * 100, y: ny * 100, opacity: 0.2 });
|
||||||
setBorder({ x: nx, y: ny, opacity: 1 });
|
setBorder({ x: nx, y: ny, opacity: 1 });
|
||||||
});
|
});
|
||||||
}, [tiltMax]);
|
}, [tiltMax, reducedMotion]);
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
cancelAnimationFrame(frameRef.current);
|
cancelAnimationFrame(frameRef.current);
|
||||||
@@ -45,9 +72,10 @@ export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
|
|||||||
setBorder(b => ({ ...b, opacity: 0 }));
|
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;
|
const borderAngle = Math.atan2(border.y - 0.5, border.x - 0.5) * (180 / Math.PI) + 90;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,24 +85,24 @@ export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
|
|||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
style={{
|
style={{
|
||||||
...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
|
transition: isResting
|
||||||
? 'transform 0.55s cubic-bezier(.03,.98,.52,.99), box-shadow 0.55s ease'
|
? 'transform 0.55s cubic-bezier(.03,.98,.52,.99), box-shadow 0.55s ease'
|
||||||
: 'transform 0.08s linear',
|
: 'transform 0.08s linear',
|
||||||
willChange: 'transform',
|
// will-change 仅在活跃交互时提示 GPU 层
|
||||||
|
willChange: isResting ? 'auto' : 'transform',
|
||||||
transformStyle: 'preserve-3d',
|
transformStyle: 'preserve-3d',
|
||||||
// Glow box shadow that intensifies on hover
|
|
||||||
boxShadow: border.opacity
|
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 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',
|
: '0 0 0 0 transparent',
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn('relative rounded-3xl overflow-hidden group', className)}
|
||||||
'relative rounded-3xl overflow-hidden group',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{/* Animated neon border */}
|
{/* Animated neon border — 仅在非 reduced-motion 时渲染 */}
|
||||||
|
{!reducedMotion && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 rounded-3xl transition-opacity duration-300 pointer-events-none"
|
className="absolute inset-0 rounded-3xl transition-opacity duration-300 pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
@@ -93,6 +121,7 @@ export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
|
|||||||
maskComposite: 'exclude',
|
maskComposite: 'exclude',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Card body */}
|
{/* Card body */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -102,6 +131,7 @@ export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
|
|||||||
)} />
|
)} />
|
||||||
|
|
||||||
{/* Glare highlight */}
|
{/* Glare highlight */}
|
||||||
|
{!reducedMotion && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 rounded-3xl pointer-events-none transition-opacity duration-300"
|
className="absolute inset-0 rounded-3xl pointer-events-none transition-opacity duration-300"
|
||||||
style={{
|
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%)`,
|
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 rounded-3xl pointer-events-none overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
||||||
style={{
|
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',
|
animation: 'shimmer-sweep 2s ease-in-out infinite',
|
||||||
|
// 告诉浏览器只在 hover 时分配合成层
|
||||||
|
contain: 'paint',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content — slight Z lift for depth */}
|
{/* Content */}
|
||||||
<div
|
<div
|
||||||
className="relative p-6 md:p-8"
|
className="relative p-6 md:p-8"
|
||||||
style={{ transform: 'translateZ(20px)', transformStyle: 'preserve-3d' }}
|
style={reducedMotion ? {} : { transform: 'translateZ(20px)', transformStyle: 'preserve-3d' }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
149
src/index.css
149
src/index.css
@@ -4,18 +4,42 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--bg-color: #050505;
|
--background: 222.2 84% 4.9%;
|
||||||
--text-primary: #f8fafc;
|
--foreground: 210 40% 98%;
|
||||||
--text-secondary: #94a3b8;
|
--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 {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
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;
|
min-height: 100vh;
|
||||||
overflow-x: hidden;
|
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;
|
@apply bg-clip-text text-transparent bg-gradient-to-r from-blue-400 via-indigo-400 to-purple-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animated shimmer text */
|
|
||||||
.text-shimmer {
|
.text-shimmer {
|
||||||
background: linear-gradient(
|
background: linear-gradient(90deg, #60a5fa 0%, #a78bfa 25%, #22d3ee 50%, #a78bfa 75%, #60a5fa 100%);
|
||||||
90deg,
|
|
||||||
#60a5fa 0%,
|
|
||||||
#a78bfa 25%,
|
|
||||||
#22d3ee 50%,
|
|
||||||
#a78bfa 75%,
|
|
||||||
#60a5fa 100%
|
|
||||||
);
|
|
||||||
background-size: 200% auto;
|
background-size: 200% auto;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@@ -58,16 +74,12 @@
|
|||||||
background-size: 50px 50px;
|
background-size: 50px 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glow behind cards */
|
|
||||||
.glow-effect::before {
|
.glow-effect::before {
|
||||||
content: '';
|
content: '';
|
||||||
@apply absolute inset-0 -z-10 bg-primary/20 blur-2xl rounded-full opacity-0 transition-opacity duration-300;
|
@apply absolute inset-0 -z-10 bg-primary/20 blur-2xl rounded-full opacity-0 transition-opacity duration-300;
|
||||||
}
|
}
|
||||||
.glow-effect:hover::before {
|
.glow-effect:hover::before { @apply opacity-100; }
|
||||||
@apply opacity-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Neon text glow */
|
|
||||||
.text-glow {
|
.text-glow {
|
||||||
text-shadow:
|
text-shadow:
|
||||||
0 0 20px rgba(99, 130, 246, 0.5),
|
0 0 20px rgba(99, 130, 246, 0.5),
|
||||||
@@ -75,16 +87,106 @@
|
|||||||
0 0 100px rgba(99, 130, 246, 0.1);
|
0 0 100px rgba(99, 130, 246, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pulsing ring */
|
.pulse-ring { position: relative; }
|
||||||
.pulse-ring {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.pulse-ring::after {
|
.pulse-ring::after {
|
||||||
content: '';
|
content: '';
|
||||||
@apply absolute inset-0 rounded-full;
|
@apply absolute inset-0 rounded-full;
|
||||||
border: 1px solid rgba(99, 130, 246, 0.5);
|
border: 1px solid rgba(99, 130, 246, 0.5);
|
||||||
animation: pulse-expand 2s ease-out infinite;
|
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 ── */
|
/* ── Keyframes ── */
|
||||||
@@ -126,7 +228,6 @@
|
|||||||
50% { transform: translateY(-10px); }
|
50% { transform: translateY(-10px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scan line across elements */
|
|
||||||
@keyframes scan-line {
|
@keyframes scan-line {
|
||||||
0% { top: -2px; opacity: 0.8; }
|
0% { top: -2px; opacity: 0.8; }
|
||||||
80% { opacity: 0.6; }
|
80% { opacity: 0.6; }
|
||||||
|
|||||||
@@ -7,13 +7,47 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
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',
|
surface: '#111111',
|
||||||
primary: '#3b82f6',
|
|
||||||
secondary: '#8b5cf6',
|
|
||||||
accent: '#06b6d4',
|
|
||||||
glow: 'rgba(59, 130, 246, 0.5)',
|
glow: 'rgba(59, 130, 246, 0.5)',
|
||||||
},
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'system-ui', 'Avenir', 'Helvetica', 'Arial', 'sans-serif'],
|
sans: ['Inter', 'system-ui', 'Avenir', 'Helvetica', 'Arial', 'sans-serif'],
|
||||||
},
|
},
|
||||||
@@ -22,6 +56,9 @@ export default {
|
|||||||
'fade-in': 'fadeIn 0.5s ease-out forwards',
|
'fade-in': 'fadeIn 0.5s ease-out forwards',
|
||||||
'fade-up': 'fadeUp 0.8s ease-out forwards',
|
'fade-up': 'fadeUp 0.8s ease-out forwards',
|
||||||
'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
'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: {
|
keyframes: {
|
||||||
blob: {
|
blob: {
|
||||||
@@ -37,6 +74,18 @@ export default {
|
|||||||
fadeUp: {
|
fadeUp: {
|
||||||
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
||||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
'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)" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,4 +4,36 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
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'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user