Skip to content

Commit 269d8d2

Browse files
authored
feat(portfolio): global mouse-interactive 3D background (#179)
* feat(portfolio): global cursor-reactive constellation background Add InteractiveConstellation: a fixed full-viewport 2D canvas of drifting particles linked by proximity lines, with mouse repulsion. Mounted at the app root so it spans every section, not just the hero. - Pulls cyan/purple from theme tokens, respects prefers-reduced-motion - DPR-clamped, area-scaled particle count with desktop/mobile caps - Pauses RAF on tab hide to save battery * feat(portfolio): bolder constellation + cursor-connection lines The effect was too subtle to register against the hero's 3D scene. Increase particle density, size, and link opacity so it reads on every section, and draw lines from the cursor to nearby particles for a clearer interactive feel (not just repulsion). * feat(portfolio): reduced-motion static frame + touch support Make the constellation show for everyone: - prefers-reduced-motion now paints a single static frame (and redraws on resize) instead of rendering nothing - touchmove/touchend tracked alongside mouse so the cursor lines work on phones/tablets; registered passive so scroll stays smooth Render logic unified into one draw(animate) function shared by both the static and animated paths. * feat(portfolio): global mouse-interactive 3D background Promote the hero's 3D scene to a single global SceneBackground mounted at the app root, so the wireframe geometry + particle field spans every section. The whole scene tilts toward the pointer and the camera parallaxes, steered by a window-level mouse/touch listener (the canvas stays pointer-events:none). - Replaces the 2D InteractiveConstellation as the primary background; constellation stays as the no-WebGL / error fallback - Reduced motion renders a frozen 3D frame (frameloop=demand) - PerformanceMonitor + mobile particle caps keep weak devices smooth - Hero loses its now-duplicate local scene + tsparticles fallback; delete orphaned HeroScene, trim unused particlesOptions * fix(portfolio): restore continuous ambient motion to 3D background The InteractiveRig lerped the scene toward the pointer and stopped, so an idle mouse froze the group (only per-mesh spin remained). Drive the group's rotation from the clock instead -- a continuous orbit always runs, with the smoothed pointer offset layered on top for steering.
1 parent dda14ff commit 269d8d2

6 files changed

Lines changed: 442 additions & 222 deletions

File tree

src/App.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, lazy, Suspense } from "react";
1+
import { useEffect, useState, lazy, Suspense } from "react";
22
import { ReactLenis } from "lenis/react";
33
import Nav from "@components/layout/Navigation/Nav";
44
import Hero from "@components/layout/Header/Hero";
@@ -13,9 +13,15 @@ import SectionTransition from "@components/ui/SectionTransition";
1313
import ParallaxElements from "@components/ui/ParallaxElements";
1414
import AuroraBlobs from "@components/ui/AuroraBlobs";
1515
import ShootingStars from "@components/ui/ShootingStars";
16+
import InteractiveConstellation from "@components/ui/InteractiveConstellation";
1617
import SystemStatus from "@components/ui/SystemStatus";
18+
import { hasWebGL } from "@components/layout/Header/heroConstants";
1719
import { BreakpointProvider } from "@hooks/BreakpointProvider";
1820

21+
// Global interactive 3D background, lazy-loaded so Three.js stays out of the
22+
// initial bundle. Falls back to the 2D constellation when WebGL is unavailable.
23+
const SceneBackground = lazy(() => import("@components/3d/SceneBackground"));
24+
1925
// Lazy Load "Below the fold" sections for massive performance gains
2026
const About = lazy(() => import("@pages/about/About"));
2127
const Experience = lazy(() => import("@pages/experience/Experience"));
@@ -28,6 +34,8 @@ const Contact = lazy(() => import("@pages/contact/Contact"));
2834
const GitHub = lazy(() => import("@pages/github/GitHub"));
2935

3036
const App = () => {
37+
const [webGLSupported] = useState(() => hasWebGL());
38+
3139
useEffect(() => {
3240
globalThis.history.scrollRestoration = "manual";
3341
globalThis.scrollTo(0, 0);
@@ -48,6 +56,15 @@ const App = () => {
4856
<KeyboardNav />
4957
<AuroraBlobs />
5058
<ShootingStars />
59+
{webGLSupported ? (
60+
<ErrorBoundary fallback={<InteractiveConstellation />}>
61+
<Suspense fallback={null}>
62+
<SceneBackground />
63+
</Suspense>
64+
</ErrorBoundary>
65+
) : (
66+
<InteractiveConstellation />
67+
)}
5168
<ParallaxElements />
5269
<div className="relative min-h-screen">
5370
<Nav />

src/components/3d/HeroScene.tsx

Lines changed: 0 additions & 125 deletions
This file was deleted.
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/* eslint-disable react/no-unknown-property */
2+
import { useEffect, useRef, useState, type RefObject } from "react";
3+
import { Canvas, useFrame } from "@react-three/fiber";
4+
import { PerformanceMonitor } from "@react-three/drei";
5+
import * as THREE from "three";
6+
import FloatingGeometry from "./FloatingGeometry";
7+
import ParticleField from "./ParticleField";
8+
import useBreakpoint from "@hooks/useBreakpoint";
9+
import useReducedMotion from "@hooks/useReducedMotion";
10+
11+
// Global interactive 3D background. A single fixed full-viewport canvas that
12+
// spans every section. The whole scene tilts toward the pointer and the camera
13+
// parallaxes, so moving the mouse (or dragging a finger) steers the view.
14+
//
15+
// Pointer is tracked on `window` -- NOT via R3F's built-in state.pointer --
16+
// because the canvas is pointer-events:none (it must not intercept clicks on
17+
// the content above it), which would otherwise freeze R3F's pointer at origin.
18+
19+
interface Pointer {
20+
x: number; // normalized -1..1
21+
y: number; // normalized -1..1
22+
}
23+
24+
// Lerps the scene group + camera toward the pointer for a parallax steer.
25+
const InteractiveRig = ({
26+
pointer,
27+
children,
28+
}: {
29+
pointer: RefObject<Pointer>;
30+
children: React.ReactNode;
31+
}) => {
32+
const group = useRef<THREE.Group>(null);
33+
// Smoothed pointer offset, lerped each frame so steering eases in/out.
34+
const steer = useRef({ x: 0, y: 0 });
35+
36+
// Read camera via the frame state (not a captured useThree value) so the
37+
// React Compiler immutability rule allows the per-frame mutation.
38+
useFrame((state, delta) => {
39+
const p = pointer.current;
40+
const k = Math.min(1, delta * 2); // frame-rate-independent lerp factor
41+
const t = state.clock.elapsedTime;
42+
43+
// Ease the pointer offset toward the live pointer.
44+
steer.current.x += (p.x - steer.current.x) * k;
45+
steer.current.y += (p.y - steer.current.y) * k;
46+
47+
if (group.current) {
48+
// Continuous ambient orbit (always running) + pointer steer on top.
49+
group.current.rotation.y = t * 0.12 + steer.current.x * 0.4;
50+
group.current.rotation.x =
51+
Math.sin(t * 0.15) * 0.12 - steer.current.y * 0.28;
52+
}
53+
54+
// Subtle camera parallax -- the scene appears to shift behind the glass.
55+
const { camera } = state;
56+
camera.position.x += (steer.current.x * 1.2 - camera.position.x) * k;
57+
camera.position.y += (-steer.current.y * 0.8 - camera.position.y) * k;
58+
camera.lookAt(0, 0, 0);
59+
});
60+
61+
return <group ref={group}>{children}</group>;
62+
};
63+
64+
const SceneBackground = () => {
65+
const { isMobile } = useBreakpoint();
66+
const reducedMotion = useReducedMotion();
67+
const [degraded, setDegraded] = useState(false);
68+
const pointer = useRef<Pointer>({ x: 0, y: 0 });
69+
70+
useEffect(() => {
71+
const setFromXY = (clientX: number, clientY: number) => {
72+
pointer.current.x = (clientX / globalThis.innerWidth) * 2 - 1;
73+
pointer.current.y = (clientY / globalThis.innerHeight) * 2 - 1;
74+
};
75+
const onMouseMove = (e: MouseEvent) => setFromXY(e.clientX, e.clientY);
76+
const onTouchMove = (e: TouchEvent) => {
77+
const t = e.touches[0];
78+
if (t) setFromXY(t.clientX, t.clientY);
79+
};
80+
81+
globalThis.addEventListener("mousemove", onMouseMove);
82+
globalThis.addEventListener("touchmove", onTouchMove, { passive: true });
83+
return () => {
84+
globalThis.removeEventListener("mousemove", onMouseMove);
85+
globalThis.removeEventListener("touchmove", onTouchMove);
86+
};
87+
}, []);
88+
89+
const desktopParticles = degraded ? 150 : 300;
90+
const particleCount = isMobile ? 100 : desktopParticles;
91+
const dpr: [number, number] = isMobile ? [0.5, 1] : [1, 2];
92+
93+
return (
94+
<Canvas
95+
aria-hidden="true"
96+
style={{
97+
position: "fixed",
98+
inset: 0,
99+
zIndex: 0,
100+
pointerEvents: "none",
101+
}}
102+
camera={{ position: [0, 0, 8], fov: 50 }}
103+
dpr={dpr}
104+
// Reduced motion -> render once and freeze (static 3D snapshot).
105+
frameloop={reducedMotion ? "demand" : "always"}
106+
gl={{
107+
antialias: !isMobile,
108+
alpha: true,
109+
powerPreference: "high-performance",
110+
}}
111+
>
112+
<PerformanceMonitor
113+
onDecline={() => setDegraded(true)}
114+
onIncline={() => setDegraded(false)}
115+
/>
116+
117+
{/* Lighting matching glassmorphism theme */}
118+
<ambientLight intensity={0.2} />
119+
<directionalLight position={[5, 5, 5]} intensity={0.5} color="#06b6d4" />
120+
<pointLight position={[-5, -3, 3]} intensity={0.3} color="#a855f7" />
121+
122+
<InteractiveRig pointer={pointer}>
123+
{/* Center: wireframe torus knot */}
124+
<FloatingGeometry
125+
geometry="torusKnot"
126+
position={[0, 0, 0]}
127+
scale={isMobile ? 1.2 : 1.8}
128+
color="#06b6d4"
129+
wireframe
130+
rotationSpeed={0.15}
131+
floatIntensity={0.5}
132+
opacity={0.3}
133+
/>
134+
135+
{/* Left: glass-like icosahedron */}
136+
{!isMobile && (
137+
<FloatingGeometry
138+
geometry="icosahedron"
139+
position={[-4, 1.5, -2]}
140+
scale={0.8}
141+
color="#a855f7"
142+
wireframe={false}
143+
rotationSpeed={0.25}
144+
floatIntensity={1.2}
145+
distortion={0.3}
146+
opacity={0.2}
147+
/>
148+
)}
149+
150+
{/* Right: wireframe octahedron */}
151+
{!isMobile && (
152+
<FloatingGeometry
153+
geometry="octahedron"
154+
position={[3.5, -1, -1]}
155+
scale={0.7}
156+
color="#06b6d4"
157+
wireframe
158+
rotationSpeed={0.35}
159+
floatIntensity={0.8}
160+
opacity={0.25}
161+
/>
162+
)}
163+
164+
{/* Scattered small decorative dodecahedrons - desktop, non-degraded */}
165+
{!isMobile && !degraded && (
166+
<>
167+
<FloatingGeometry
168+
geometry="dodecahedron"
169+
position={[-2.5, -2, -3]}
170+
scale={0.3}
171+
color="#06b6d4"
172+
wireframe
173+
rotationSpeed={0.5}
174+
floatIntensity={1.5}
175+
opacity={0.15}
176+
/>
177+
<FloatingGeometry
178+
geometry="dodecahedron"
179+
position={[2, 2.5, -4]}
180+
scale={0.25}
181+
color="#a855f7"
182+
wireframe
183+
rotationSpeed={0.4}
184+
floatIntensity={1.8}
185+
opacity={0.12}
186+
/>
187+
</>
188+
)}
189+
190+
{/* 3D particle field */}
191+
<ParticleField count={particleCount} />
192+
</InteractiveRig>
193+
</Canvas>
194+
);
195+
};
196+
197+
export default SceneBackground;

0 commit comments

Comments
 (0)