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;