diff --git a/public/hiptyc_2020_8k.webp b/public/hiptyc_2020_8k.webp
new file mode 100644
index 0000000..2a2806d
Binary files /dev/null and b/public/hiptyc_2020_8k.webp differ
diff --git a/src/App.jsx b/src/App.jsx
index b43274f..a160c3f 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,4 +1,5 @@
import { BrowserRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { useState } from 'react';
import Header from './components/Header';
import Home from './pages/Home';
import Research from './pages/Research';
@@ -16,11 +17,26 @@ function App() {
function AppContent() {
const location = useLocation();
const isSpacePage = location.pathname === '/space';
+ const [uiVisible, setUiVisible] = useState(false);
+
+ // Handle skybox loaded - trigger UI fade in
+ const handleSkyboxLoaded = () => {
+ setTimeout(() => {
+ setUiVisible(true);
+ }, 500);
+ };
return (
<>
-
-
+
+
{!isSpacePage &&
}
diff --git a/src/components/Starfield.jsx b/src/components/Starfield.jsx
index df55cd6..6a26132 100644
--- a/src/components/Starfield.jsx
+++ b/src/components/Starfield.jsx
@@ -1,142 +1,284 @@
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
-const Starfield = () => {
+const Starfield = ({ onSkyboxLoaded = () => { }, uiVisible = false }) => {
+ // Three.js scene references
const mountRef = useRef(null);
const sceneRef = useRef(null);
const cameraRef = useRef(null);
const rendererRef = useRef(null);
- const starsRef = useRef([]);
const animationFrameRef = useRef(null);
+ // Component state
+ const [skyboxLoaded, setSkyboxLoaded] = useState(false);
+ const [starsVisible, setStarsVisible] = useState(false);
+ const [starsAnimating, setStarsAnimating] = useState(false);
+
+ // Animation and scroll tracking references
+ const scrollProgressRef = useRef(0);
+ const scrollVelocityRef = useRef(0);
+ const lastScrollTimeRef = useRef(0);
+ const lastScrollPositionRef = useRef(0);
+ const starLayersRef = useRef([]);
+ const starsVisibleRef = useRef(false);
+ const starsAnimatingRef = useRef(false);
+
+ // Sync states with refs for animation loop access
useEffect(() => {
- const createStarfield = (num, range, layerIndex) => {
+ starsVisibleRef.current = starsVisible;
+ }, [starsVisible]);
+
+ useEffect(() => {
+ starsAnimatingRef.current = starsAnimating;
+ }, [starsAnimating]);
+
+ // Start animation when UI becomes visible
+ useEffect(() => {
+ if (uiVisible && starsVisible && !starsAnimating) {
+ setStarsAnimating(true);
+ }
+ }, [uiVisible, starsVisible, starsAnimating]);
+
+ useEffect(() => {
+ let scene, camera, renderer, skyboxMesh;
+
+ // Function to create a circular texture for perfect star circles
+ const createCircularTexture = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 64;
+ canvas.height = 64;
+ const ctx = canvas.getContext('2d');
+
+ // Clear canvas
+ ctx.clearRect(0, 0, 64, 64);
+
+ // Create radial gradient for smooth circular falloff
+ const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
+ gradient.addColorStop(0, 'rgba(255, 255, 255, 1.0)');
+ gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.8)');
+ gradient.addColorStop(0.8, 'rgba(255, 255, 255, 0.3)');
+ gradient.addColorStop(1, 'rgba(255, 255, 255, 0.0)');
+
+ // Draw the circle
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, 64, 64);
+
+ // Create texture from canvas
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.generateMipmaps = false;
+ texture.minFilter = THREE.LinearFilter;
+ texture.magFilter = THREE.LinearFilter;
+
+ return texture;
+ };
+
+ // Configuration constants (increased minimum sizes to prevent twinkling)
+ const STAR_LAYER_CONFIG = {
+ VERY_DISTANT: { count: 3000, distance: 800, size: 0.6, opacity: 0.4 },
+ FAR: { count: 2500, distance: 600, size: 0.7, opacity: 0.5 },
+ DISTANT: { count: 2000, distance: 450, size: 0.8, opacity: 0.6 },
+ MEDIUM_FAR: { count: 1500, distance: 350, size: 0.9, opacity: 0.7 },
+ MEDIUM: { count: 1000, distance: 250, size: 1.0, opacity: 0.8 }
+ };
+
+ const ANIMATION_CONFIG = {
+ skyboxFadeSpeed: 0.015,
+ starFadeSpeed: 0.02,
+ baseFlightSpeed: 0.5,
+ velocityDecay: 0.95,
+ maxVelocity: 2000,
+ scrollAmplification: { base: 2.5, max: 40 },
+ fadeInDuration: 2.0
+ };
+
+ // Function to create a layer of stars
+ const createStarLayer = (starCount, distance, size, opacity) => {
const geometry = new THREE.BufferGeometry();
- const vertices = new Float32Array(num * 3);
- const colors = new Float32Array(num * 3);
- const sizes = new Float32Array(num);
+ const vertices = new Float32Array(starCount * 3);
+ const colors = new Float32Array(starCount * 3);
+ const sizes = new Float32Array(starCount);
- // Star colors based on temperature (from red to blue-white)
+ // Star colors (warm to cool)
const starColors = [
- new THREE.Color(0xffcccc), // Reddish
new THREE.Color(0xffffff), // White
- new THREE.Color(0xccccff), // Bluish
+ new THREE.Color(0xfffacd), // Light warm
+ new THREE.Color(0xffd700), // Gold
+ new THREE.Color(0xffb6c1), // Light pink
+ new THREE.Color(0x87ceeb), // Sky blue
+ new THREE.Color(0xccccff), // Light blue
];
- for (let i = 0; i < num; i++) {
+ for (let i = 0; i < starCount; i++) {
const i3 = i * 3;
- vertices[i3] = THREE.MathUtils.randFloatSpread(range);
- vertices[i3 + 1] = THREE.MathUtils.randFloatSpread(range);
- vertices[i3 + 2] = THREE.MathUtils.randFloatSpread(range);
- // Random color from our palette
+ // Random distribution in a sphere around the camera
+ const theta = Math.random() * Math.PI * 2;
+ const phi = Math.acos(Math.random() * 2 - 1);
+
+ vertices[i3] = distance * Math.sin(phi) * Math.cos(theta);
+ vertices[i3 + 1] = distance * Math.sin(phi) * Math.sin(theta);
+ vertices[i3 + 2] = distance * Math.cos(phi);
+
+ // Random color
const color = starColors[Math.floor(Math.random() * starColors.length)];
colors[i3] = color.r;
colors[i3 + 1] = color.g;
colors[i3 + 2] = color.b;
- // Varied sizes based on layer and random factor
- sizes[i] = (Math.random() * 2 + 0.5) * (1 + layerIndex * 0.5);
+ // Star size with controlled variation (ensure minimum size)
+ const sizeVariation = 0.8 + Math.random() * 0.4;
+ sizes[i] = Math.max(size * sizeVariation, 0.6); // Minimum size to prevent subpixel rendering
+ }
+
+ // Add age tracking for fade-in effect
+ const ages = new Float32Array(starCount);
+ for (let i = 0; i < starCount; i++) {
+ ages[i] = Math.random() * 5.0;
}
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
+ geometry.setAttribute('age', new THREE.BufferAttribute(ages, 1));
const material = new THREE.PointsMaterial({
- size: 1.5,
+ size: size,
vertexColors: true,
transparent: true,
- opacity: 0,
- sizeAttenuation: true
+ opacity: opacity,
+ sizeAttenuation: true,
+ blending: THREE.AdditiveBlending,
+ alphaTest: 0.001, // Lower alpha test to prevent small stars from disappearing
+ map: createCircularTexture(),
});
- return new THREE.Points(geometry, material);
- };
+ const stars = new THREE.Points(geometry, material);
+ stars.userData = {
+ distance: distance,
+ baseOpacity: opacity,
+ originalColors: colors.slice()
+ };
- let scene, camera, renderer;
- const starLayers = [];
+ return stars;
+ };
const init = () => {
+ // Create the scene
scene = new THREE.Scene();
sceneRef.current = scene;
- scene.background = new THREE.Color(0x000000);
- camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 2000);
+ // Create camera
+ camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
cameraRef.current = camera;
- camera.position.z = 500;
+ camera.position.set(0, 0, 0);
- renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
+ // Create and configure renderer
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
rendererRef.current = renderer;
renderer.setSize(window.innerWidth, window.innerHeight);
- renderer.setClearColor(0x000000, 0);
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ renderer.setClearColor(0x000000, 1.0);
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.toneMapping = THREE.NoToneMapping;
+ renderer.toneMappingExposure = 1.0;
mountRef.current.appendChild(renderer.domElement);
- // Create multiple star layers
+ // Create sphere geometry for skybox
+ const sphereGeometry = new THREE.SphereGeometry(500, 60, 40);
+
+ // Load the galaxy texture
+ const textureLoader = new THREE.TextureLoader();
+
+ // Load galaxy texture
+ textureLoader.load(
+ '/hiptyc_2020_8k.webp',
+ (texture) => {
+ // Configure texture for skybox
+ texture.mapping = THREE.EquirectangularReflectionMapping;
+ texture.wrapS = THREE.RepeatWrapping;
+ texture.wrapT = THREE.RepeatWrapping;
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.flipY = false;
+ texture.generateMipmaps = true;
+
+ // Create skybox material
+ const skyboxMaterial = new THREE.MeshBasicMaterial({
+ map: texture,
+ side: THREE.BackSide,
+ transparent: true,
+ opacity: 0
+ });
+
+ // Create and add skybox mesh
+ skyboxMesh = new THREE.Mesh(sphereGeometry, skyboxMaterial);
+ scene.add(skyboxMesh);
+
+ // Initialize stars and notify completion
+ setSkyboxLoaded(true);
+ onSkyboxLoaded();
+ setStarsVisible(true);
+
+ // Start the animation loop
+ animate();
+ },
+ undefined, // Progress callback not needed
+ (error) => {
+ console.error('Failed to load galaxy texture:', error);
+ // No fallback - just proceed with black background and stars
+ setSkyboxLoaded(true);
+ onSkyboxLoaded();
+ setStarsVisible(true);
+ animate();
+ }
+ );
+
+ // Create star layers with fade-in effect
+ const starLayers = [];
const layerConfigs = [
- { stars: 5000, range: 1000, speed: 12 },
- { stars: 4000, range: 800, speed: 8 },
- { stars: 3000, range: 600, speed: 5 }
+ STAR_LAYER_CONFIG.VERY_DISTANT,
+ STAR_LAYER_CONFIG.FAR,
+ STAR_LAYER_CONFIG.DISTANT,
+ STAR_LAYER_CONFIG.MEDIUM_FAR,
+ STAR_LAYER_CONFIG.MEDIUM
];
- layerConfigs.forEach((config, index) => {
- const stars = createStarfield(config.stars, config.range, index);
- stars.userData.speed = config.speed;
- scene.add(stars);
- starLayers.push(stars);
+ layerConfigs.forEach(config => {
+ const starLayer = createStarLayer(config.count, config.distance, config.size, 0.0);
+ starLayer.userData.targetOpacity = config.opacity;
+ scene.add(starLayer);
+ starLayers.push(starLayer);
});
- starsRef.current = starLayers;
- let lastTime = performance.now();
- const maxDelta = 1 / 60;
- const fadeInDuration = 2; // Fade in duration (seconds)
- let fadeInTime = 0;
+ starLayersRef.current = starLayers;
- const animate = () => {
+ // Scroll event handler for velocity tracking
+ const handleScroll = () => {
const currentTime = performance.now();
- let delta = (currentTime - lastTime) / 1000;
- delta = Math.min(delta, maxDelta);
- lastTime = currentTime;
-
- // Handle fade in
- if (fadeInTime < fadeInDuration) {
- fadeInTime += delta;
- const opacity = Math.min(fadeInTime / fadeInDuration, 1) * 0.8; // Max opacity 0.8
- starLayers.forEach(stars => {
- stars.material.opacity = opacity;
- });
+ const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
+ const documentHeight = document.documentElement.scrollHeight - window.innerHeight;
+ const scrollProgress = Math.min(scrollTop / Math.max(documentHeight, 1), 1);
+
+ // Calculate scroll velocity, but only if page is visible
+ const deltaTime = currentTime - lastScrollTimeRef.current;
+ const deltaScroll = scrollTop - lastScrollPositionRef.current;
+
+ // Prevent velocity accumulation when switching tabs
+ if (deltaTime > 0 && deltaTime < 100 && !document.hidden) {
+ const velocity = Math.abs(deltaScroll) / (deltaTime / 1000);
+ scrollVelocityRef.current = velocity;
+ } else {
+ // Reset velocity if too much time has passed or tab was hidden
+ scrollVelocityRef.current = 0;
}
- starLayers.forEach(stars => {
- const positions = stars.geometry.attributes.position.array;
- const sizes = stars.geometry.attributes.size.array;
-
- for (let i = 0; i < positions.length; i += 3) {
- // Move stars
- positions[i + 2] += stars.userData.speed * delta;
-
- // Reset position when star goes too far
- if (positions[i + 2] > 500) {
- positions[i] = THREE.MathUtils.randFloatSpread(1000);
- positions[i + 1] = THREE.MathUtils.randFloatSpread(1000);
- positions[i + 2] = -500;
- }
-
- // Twinkle effect
- const starIndex = i / 3;
- sizes[starIndex] *= 0.9 + Math.random() * 0.2;
- }
-
- stars.geometry.attributes.position.needsUpdate = true;
- stars.geometry.attributes.size.needsUpdate = true;
- });
-
- renderer.render(scene, camera);
- animationFrameRef.current = requestAnimationFrame(animate);
+ // Update references for next calculation
+ lastScrollTimeRef.current = currentTime;
+ lastScrollPositionRef.current = scrollTop;
+ scrollProgressRef.current = scrollProgress;
};
- animate();
-
+ // Handle window resize
const handleResize = () => {
const width = window.innerWidth;
const height = window.innerHeight;
@@ -148,20 +290,68 @@ const Starfield = () => {
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
};
+
+
+ // Handle page visibility changes to reset scroll tracking
+ const handleVisibilityChange = () => {
+ if (document.hidden) {
+ // Reset scroll velocity when tab becomes hidden
+ scrollVelocityRef.current = 0;
+ } else {
+ // Reset timing when tab becomes visible again
+ lastScrollTimeRef.current = performance.now();
+ lastScrollPositionRef.current = window.pageYOffset || document.documentElement.scrollTop;
+ }
+ };
+
+ // Add event listeners
window.addEventListener('resize', handleResize);
+ window.addEventListener('scroll', handleScroll, { passive: true });
+ document.addEventListener('visibilitychange', handleVisibilityChange);
- // Capture the current value of mountRef.current
+ // Initialize scroll tracking
+ lastScrollTimeRef.current = performance.now();
+ lastScrollPositionRef.current = window.pageYOffset || document.documentElement.scrollTop;
+ handleScroll();
+
+ // Store the current mount reference for cleanup
const currentMount = mountRef.current;
+ // Cleanup function
return () => {
window.removeEventListener('resize', handleResize);
+ window.removeEventListener('scroll', handleScroll);
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
cancelAnimationFrame(animationFrameRef.current);
+
if (currentMount && renderer) {
currentMount.removeChild(renderer.domElement);
}
- // Dispose of Three.js objects
+
+ // Clean up Three.js resources
+ if (skyboxMesh) {
+ if (skyboxMesh.material.map) {
+ skyboxMesh.material.map.dispose();
+ }
+ skyboxMesh.material.dispose();
+ skyboxMesh.geometry.dispose();
+ if (scene) {
+ scene.remove(skyboxMesh);
+ }
+ }
+
+ starLayersRef.current.forEach(starLayer => {
+ if (starLayer) {
+ starLayer.material.dispose();
+ starLayer.geometry.dispose();
+ if (scene) {
+ scene.remove(starLayer);
+ }
+ }
+ });
+
if (scene) {
- scene.dispose();
+ scene.clear();
}
if (renderer) {
renderer.dispose();
@@ -169,21 +359,109 @@ const Starfield = () => {
};
};
- init();
+ // Animation loop
+ const animate = () => {
+ // Fade in skybox
+ if (skyboxMesh && skyboxMesh.material.opacity < 1) {
+ skyboxMesh.material.opacity += ANIMATION_CONFIG.skyboxFadeSpeed;
+ }
+
+ // Calculate scroll-based motion
+ const scrollProgress = scrollProgressRef.current;
+
+ // Decay scroll velocity when not actively scrolling
+ scrollVelocityRef.current *= ANIMATION_CONFIG.velocityDecay;
+
+ // Convert scroll velocity to speed multiplier (restore original values)
+ const velocityFactor = Math.min(scrollVelocityRef.current / ANIMATION_CONFIG.maxVelocity, 1.0);
+ const scrollAmplification = 2.5 + velocityFactor * 40;
+
+ starLayersRef.current.forEach((starLayer, index) => {
+ if (starLayer && starLayer.userData) {
+ const distance = starLayer.userData.distance;
+
+ // Handle star fade-in when stars become visible
+ if (starsVisibleRef.current && starLayer.material.opacity < starLayer.userData.targetOpacity) {
+ starLayer.material.opacity += ANIMATION_CONFIG.starFadeSpeed;
+ starLayer.material.opacity = Math.min(starLayer.material.opacity, starLayer.userData.targetOpacity);
+ }
+
+ // Only animate star movement if animation is enabled
+ if (starsAnimatingRef.current && starLayer.material.opacity > 0) {
+ // Flying through space motion - stars move toward camera (restore original speed)
+ const baseFlightSpeed = 0.5;
+ const flightSpeed = baseFlightSpeed * scrollAmplification;
+
+ // Update star positions
+ const positions = starLayer.geometry.attributes.position.array;
+ const originalColors = starLayer.userData.originalColors;
+ const ages = starLayer.geometry.attributes.age.array;
+ const colors = starLayer.geometry.attributes.color.array;
+ const deltaTime = 1.0 / 60.0;
+
+ for (let i = 0; i < positions.length; i += 3) {
+ const starIndex = i / 3; // Get the star index for age array
+
+ // Move stars toward camera
+ positions[i + 2] += flightSpeed * deltaTime;
+
+ // If star has passed the camera, redistribute it behind us
+ if (positions[i + 2] > 50) {
+ positions[i] = THREE.MathUtils.randFloatSpread(distance * 2);
+ positions[i + 1] = THREE.MathUtils.randFloatSpread(distance * 2);
+ positions[i + 2] = -distance - (Math.random() * distance);
+ ages[starIndex] = 0.0;
+ }
+
+ // Update star age and apply fade-in effect
+ ages[starIndex] += deltaTime;
+ const fadeInProgress = Math.min(ages[starIndex] / ANIMATION_CONFIG.fadeInDuration, 1.0);
+ const fadeInAlpha = fadeInProgress * fadeInProgress;
+
+ // Apply fade-in to star color
+ const colorIndex = i;
+ const originalColorR = originalColors[colorIndex];
+ const originalColorG = originalColors[colorIndex + 1];
+ const originalColorB = originalColors[colorIndex + 2];
+
+ colors[colorIndex] = originalColorR * fadeInAlpha;
+ colors[colorIndex + 1] = originalColorG * fadeInAlpha;
+ colors[colorIndex + 2] = originalColorB * fadeInAlpha;
+ }
+
+ // Mark geometry attributes for update
+ starLayer.geometry.attributes.position.needsUpdate = true;
+ starLayer.geometry.attributes.color.needsUpdate = true;
+ starLayer.geometry.attributes.age.needsUpdate = true;
+ }
+ }
+ });
- return () => {
- // No-op
+ renderer.render(scene, camera);
+ animationFrameRef.current = requestAnimationFrame(animate);
};
+
+ // Initialize the component
+ const cleanup = init();
+
+ // Return cleanup function
+ return cleanup;
}, []);
- return ;
+ return (
+
+ );
};
-export default Starfield;
\ No newline at end of file
+export default Starfield;