import { Canvas, useFrame } from '@react-three/fiber'; import gsap from 'gsap'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as THREE from 'three'; const vertexShader = ` precision highp float; attribute vec3 aLogoPosition; attribute vec3 aSpreadPosition; attribute vec3 aIntroPosition; attribute float aIntroDelay; attribute float aIntroBend; attribute float aSeed; attribute float aScale; attribute float aColorMix; uniform float uTime; uniform float uScroll; uniform float uIntro; uniform float uGather; uniform float uPixelRatio; uniform float uPointerActive; uniform float uAspect; uniform vec2 uPointer; varying float vAlpha; varying float vBrightness; varying float vColorMix; mat3 rotateX(float angle) { float s = sin(angle); float c = cos(angle); return mat3(1.0, 0.0, 0.0, 0.0, c, -s, 0.0, s, c); } mat3 rotateY(float angle) { float s = sin(angle); float c = cos(angle); return mat3(c, 0.0, s, 0.0, 1.0, 0.0, -s, 0.0, c); } mat3 rotateZ(float angle) { float s = sin(angle); float c = cos(angle); return mat3(c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0); } void main() { float expansion = smoothstep(0.055, 0.72, uScroll); float rotationStrength = smoothstep(0.09, 0.48, uScroll); float settle = smoothstep(0.74, 1.0, uScroll); float introLinear = clamp((uIntro - aIntroDelay) / 0.38, 0.0, 1.0); float introProgress = 1.0 - pow(1.0 - introLinear, 3.0); vec2 introTravel = normalize(aLogoPosition.xy - aIntroPosition.xy + vec2(0.0001)); vec2 introTangent = vec2(-introTravel.y, introTravel.x); float introArc = sin(introLinear * 3.14159265) * aIntroBend * (1.0 - introProgress * 0.32); vec3 introPosition = mix(aIntroPosition, aLogoPosition, introProgress); introPosition.xy += introTangent * introArc; introPosition.z += sin(introLinear * 3.14159265) * abs(aIntroBend) * 0.68; float idleStrength = (1.0 - smoothstep(0.045, 0.24, uScroll)) * introProgress; vec3 position = mix(introPosition, aSpreadPosition, expansion * introProgress); vec2 logoRadial = normalize(aLogoPosition.xy + vec2(0.0001)); vec2 logoTangent = vec2(-logoRadial.y, logoRadial.x); float idleBreath = sin(uTime * 0.62) * 0.011; float idleFloat = sin(uTime * 0.78 + aSeed * 21.0) * 0.032; float idleSway = cos(uTime * 0.46 + aSeed * 13.0) * 0.016; position.xy *= 1.0 + idleBreath * idleStrength; position.xy += (logoRadial * idleFloat + logoTangent * idleSway) * idleStrength; position.z += sin(uTime * 0.58 + aSeed * 17.0) * 0.11 * idleStrength; position = rotateZ(sin(uTime * 0.28) * 0.007 * idleStrength) * position; float drift = sin(uTime * 0.42 + aSeed * 18.0) * 0.12 * rotationStrength; position += normalize(vec3(position.xy + 0.001, 0.35)) * drift; float spinY = (uTime * 0.085 + uScroll * 1.18) * rotationStrength; float spinX = (0.16 + sin(uTime * 0.14) * 0.08) * rotationStrength; float spinZ = sin(uTime * 0.11) * 0.08 * rotationStrength; position = rotateY(spinY) * rotateX(spinX) * rotateZ(spinZ) * position; position.xy *= mix(1.0, 0.82, settle); position.z -= settle * 0.85; position = mix(position, aLogoPosition, uGather); vec4 viewPosition = modelViewMatrix * vec4(position, 1.0); vec4 clipPosition = projectionMatrix * viewPosition; vec2 particleNdc = clipPosition.xy / max(clipPosition.w, 0.0001); vec2 pointerDelta = particleNdc - uPointer; vec2 aspectPointerDelta = vec2(pointerDelta.x * uAspect, pointerDelta.y); float pointerDistance = length(aspectPointerDelta); float repel = smoothstep(0.3, 0.0, pointerDistance) * uPointerActive * (1.0 - uGather); vec2 repelDirection = normalize(aspectPointerDelta + vec2(0.0001)); vec2 ndcDirection = vec2(repelDirection.x / max(uAspect, 0.0001), repelDirection.y); clipPosition.xy += ndcDirection * repel * 0.16 * clipPosition.w; float nearFactor = clamp((position.z + 4.5) / 9.0, 0.0, 1.0); float perspectiveSize = 10.0 / max(3.0, -viewPosition.z); gl_PointSize = aScale * mix(1.75, 4.35, nearFactor) * uPixelRatio * perspectiveSize * (1.0 + repel * 0.12); gl_Position = clipPosition; float contentFade = 1.0 - smoothstep(0.22, 0.48, uScroll) * 0.76; float idleShimmer = 1.0 + sin(uTime * 0.92 + aSeed * 29.0) * 0.07 * idleStrength; float introFade = smoothstep(0.0, 0.2, introLinear); vAlpha = mix(0.28, 0.78, nearFactor) * contentFade * (1.0 - settle * 0.18) * idleShimmer * introFade; vBrightness = mix(0.46, 1.0, nearFactor) * (1.0 + sin(uTime * 0.54 + aSeed * 9.0) * 0.045 * idleStrength); vColorMix = aColorMix; } `; const fragmentShader = ` precision highp float; varying float vAlpha; varying float vBrightness; varying float vColorMix; void main() { vec2 point = gl_PointCoord - 0.5; float distanceToCenter = length(point); float body = 1.0 - smoothstep(0.28, 0.5, distanceToCenter); float core = 1.0 - smoothstep(0.0, 0.22, distanceToCenter); if (body <= 0.001) discard; vec3 cyan = vec3(0.278, 0.898, 0.937); vec3 blue = vec3(0.259, 0.647, 1.0); vec3 violet = vec3(0.537, 0.510, 1.0); vec3 color = mix(cyan, blue, smoothstep(0.18, 0.62, vColorMix)); color = mix(color, violet, smoothstep(0.66, 1.0, vColorMix)); color *= vBrightness + core * 0.22; gl_FragColor = vec4(color, body * vAlpha); } `; function randomUnit(seed) { const value = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; return value - Math.floor(value); } function buildGeometry(image, count) { const sampleSize = 256; const canvas = document.createElement('canvas'); const context = canvas.getContext('2d', { willReadFrequently: true }); canvas.width = sampleSize; canvas.height = sampleSize; context.clearRect(0, 0, sampleSize, sampleSize); context.drawImage(image, 0, 0, sampleSize, sampleSize); const pixels = context.getImageData(0, 0, sampleSize, sampleSize).data; const candidates = []; for (let y = 0; y < sampleSize; y += 1) { for (let x = 0; x < sampleSize; x += 1) { const alpha = pixels[(y * sampleSize + x) * 4 + 3]; if (alpha > 34) candidates.push([x, y, alpha / 255]); } } const positions = new Float32Array(count * 3); const logoPositions = new Float32Array(count * 3); const spreadPositions = new Float32Array(count * 3); const introPositions = new Float32Array(count * 3); const introDelays = new Float32Array(count); const introBends = new Float32Array(count); const seeds = new Float32Array(count); const scales = new Float32Array(count); const colors = new Float32Array(count); for (let index = 0; index < count; index += 1) { const seed = randomUnit(index + 1); const candidate = candidates[Math.floor(seed * candidates.length)] || [sampleSize / 2, sampleSize / 2, 1]; const jitterX = (randomUnit(index * 3.17 + 4) - 0.5) * 0.032; const jitterY = (randomUnit(index * 5.23 + 7) - 0.5) * 0.032; const x = ((candidate[0] / sampleSize) - 0.5) * 7.35 + jitterX; const y = (0.5 - (candidate[1] / sampleSize)) * 7.35 + jitterY; const radius = Math.hypot(x, y); const angle = Math.atan2(y, x); const orbit = randomUnit(index * 7.91 + 11); const depth = (randomUnit(index * 11.41 + 3) - 0.5) * 7.8; const spreadRadius = radius * (0.58 + orbit * 0.58) + orbit * 1.25; const spreadAngle = angle + (orbit - 0.5) * 1.3; const offset = index * 3; positions[offset] = x; positions[offset + 1] = y; positions[offset + 2] = 0; logoPositions[offset] = x; logoPositions[offset + 1] = y; logoPositions[offset + 2] = 0; spreadPositions[offset] = Math.cos(spreadAngle) * spreadRadius + (randomUnit(index + 27) - 0.5) * 1.1; spreadPositions[offset + 1] = Math.sin(spreadAngle) * spreadRadius * 0.72 + (randomUnit(index + 39) - 0.5) * 1.1; spreadPositions[offset + 2] = depth; const introLane = Math.floor(randomUnit(index * 13.77 + 17) * 8); const introAngle = introLane * (Math.PI / 4) + (randomUnit(index * 4.61 + 29) - 0.5) * 0.52; const introRadius = 0.78 + randomUnit(index * 9.43 + 47) * 0.5; introPositions[offset] = Math.cos(introAngle) * 10.8 * introRadius + (randomUnit(index * 2.19 + 61) - 0.5) * 1.2; introPositions[offset + 1] = Math.sin(introAngle) * 6.7 * introRadius + (randomUnit(index * 8.31 + 73) - 0.5) * 0.9; introPositions[offset + 2] = (randomUnit(index * 6.07 + 83) - 0.5) * 10.5; introDelays[index] = randomUnit(index * 5.37 + 97) * 0.6; const bendSeed = randomUnit(index * 7.13 + 109); introBends[index] = (bendSeed < 0.5 ? -1 : 1) * (0.34 + Math.abs(bendSeed - 0.5) * 1.8); seeds[index] = seed; scales[index] = 0.68 + randomUnit(index * 2.73 + 5) * 1.08; colors[index] = Math.min(1, Math.max(0, orbit * 0.74 + candidate[2] * 0.24)); } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('aLogoPosition', new THREE.BufferAttribute(logoPositions, 3)); geometry.setAttribute('aSpreadPosition', new THREE.BufferAttribute(spreadPositions, 3)); geometry.setAttribute('aIntroPosition', new THREE.BufferAttribute(introPositions, 3)); geometry.setAttribute('aIntroDelay', new THREE.BufferAttribute(introDelays, 1)); geometry.setAttribute('aIntroBend', new THREE.BufferAttribute(introBends, 1)); geometry.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 1)); geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1)); geometry.setAttribute('aColorMix', new THREE.BufferAttribute(colors, 1)); geometry.computeBoundingSphere(); return geometry; } function LogoParticles({ progressRef, introRef, gatherRef, pointerRef, pointerActiveRef, onReady }) { const materialRef = useRef(null); const [geometry, setGeometry] = useState(null); const particleTransform = useMemo(() => { if (window.innerWidth > 900) return { position: [1.92, 0, 0], scale: 0.86 }; if (window.innerWidth > 640) return { position: [1.45, 0, 0], scale: 0.5 }; return { position: [0, 0, 0], scale: 1 }; }, []); const particleCount = useMemo(() => { if (typeof window === 'undefined') return 18000; if (window.innerWidth < 480) return 7000; if (window.innerWidth < 768) return 10000; if (window.innerWidth < 1200) return 18000; return 24000; }, []); const uniforms = useMemo(() => ({ uTime: { value: 0 }, uScroll: { value: 0 }, uIntro: { value: 0 }, uGather: { value: 0 }, uPixelRatio: { value: Math.min(window.devicePixelRatio || 1, 1.5) }, uPointerActive: { value: 0 }, uAspect: { value: 1 }, uPointer: { value: new THREE.Vector2(20, 20) } }), []); useEffect(() => { let disposed = false; const image = new Image(); image.decoding = 'async'; image.src = '/assets/brand-logo-final.png'; image.onload = () => { if (disposed) return; const nextGeometry = buildGeometry(image, particleCount); setGeometry(nextGeometry); onReady?.(); }; return () => { disposed = true; }; }, [onReady, particleCount]); useEffect(() => () => geometry?.dispose(), [geometry]); useFrame(({ clock, size }) => { if (!materialRef.current) return; materialRef.current.uniforms.uTime.value = clock.getElapsedTime(); materialRef.current.uniforms.uScroll.value = progressRef.current; materialRef.current.uniforms.uIntro.value = introRef.current.value; materialRef.current.uniforms.uGather.value = gatherRef.current.value; materialRef.current.uniforms.uAspect.value = size.width / Math.max(size.height, 1); materialRef.current.uniforms.uPointerActive.value += ( Number(pointerActiveRef.current) - materialRef.current.uniforms.uPointerActive.value ) * 0.18; materialRef.current.uniforms.uPointer.value.copy(pointerRef.current); }); if (!geometry) return null; return ( ); } function useReducedMotion() { const [reduced, setReduced] = useState(false); useEffect(() => { const media = window.matchMedia('(prefers-reduced-motion: reduce)'); const update = () => setReduced(media.matches); update(); media.addEventListener?.('change', update); return () => media.removeEventListener?.('change', update); }, []); return reduced; } export function ParticleStage({ progressRef }) { const stageRef = useRef(null); const pointerRef = useRef(new THREE.Vector2(20, 20)); const pointerActiveRef = useRef(false); const introRef = useRef({ value: 0 }); const gatherRef = useRef({ value: 0 }); const [ready, setReady] = useState(false); const [active, setActive] = useState(true); const [failed, setFailed] = useState(false); const reducedMotion = useReducedMotion(); const handleReady = useCallback(() => setReady(true), []); useEffect(() => { if (!ready) return undefined; gsap.killTweensOf(introRef.current); if (reducedMotion) { introRef.current.value = 1; return undefined; } introRef.current.value = 0; const tween = gsap.to(introRef.current, { value: 1, duration: 3.15, ease: 'none' }); return () => tween.kill(); }, [ready, reducedMotion]); useEffect(() => { if (!stageRef.current) return undefined; const observer = new IntersectionObserver(([entry]) => setActive(entry.isIntersecting), { rootMargin: '120px 0px' }); observer.observe(stageRef.current); return () => observer.disconnect(); }, []); const handlePointerMove = (event) => { if (reducedMotion || !window.matchMedia('(pointer: fine)').matches) return; const bounds = stageRef.current.getBoundingClientRect(); const normalizedX = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; const normalizedY = 1 - ((event.clientY - bounds.top) / bounds.height) * 2; pointerRef.current.set(normalizedX, normalizedY); pointerActiveRef.current = true; }; const handleGather = (event) => { if (reducedMotion || event.button !== 0) return; gsap.killTweensOf(introRef.current); gsap.killTweensOf(gatherRef.current); gsap.to(introRef.current, { value: 1, duration: 0.48, ease: 'power2.out' }); gsap.timeline() .to(gatherRef.current, { value: 1, duration: 0.62, ease: 'power3.out' }) .to(gatherRef.current, { value: 1, duration: 0.26 }) .to(gatherRef.current, { value: 0, duration: 0.9, ease: 'power2.inOut' }); }; return (
{ pointerActiveRef.current = false; }} onPointerDown={handleGather} aria-hidden="true" > {!reducedMotion && !failed && ( { gl.setClearColor(0x000000, 0); gl.domElement.addEventListener('webglcontextlost', () => setFailed(true), { once: true }); }} > )}
); }