Files
roncarve-official-web/src/harness/ParticleStage.jsx
T

403 lines
16 KiB
React

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 (
<points
geometry={geometry}
frustumCulled={false}
position={particleTransform.position}
scale={particleTransform.scale}
>
<shaderMaterial
ref={materialRef}
uniforms={uniforms}
vertexShader={vertexShader}
fragmentShader={fragmentShader}
transparent
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</points>
);
}
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 (
<div
className={`particle-stage ${ready ? 'is-ready' : ''} ${failed ? 'has-failed' : ''} ${reducedMotion ? 'is-reduced' : ''}`}
ref={stageRef}
onPointerMove={handlePointerMove}
onPointerLeave={() => { pointerActiveRef.current = false; }}
onPointerDown={handleGather}
aria-hidden="true"
>
<img className="particle-static-logo" src="/assets/brand-logo-final.png" alt="" />
{!reducedMotion && !failed && (
<Canvas
className="particle-canvas"
dpr={[1, 1.5]}
camera={{ position: [0, 0, 10], fov: 42, near: 0.1, far: 50 }}
gl={{ alpha: true, antialias: false, powerPreference: 'high-performance' }}
frameloop={active ? 'always' : 'demand'}
onCreated={({ gl }) => {
gl.setClearColor(0x000000, 0);
gl.domElement.addEventListener('webglcontextlost', () => setFailed(true), { once: true });
}}
>
<LogoParticles
progressRef={progressRef}
introRef={introRef}
gatherRef={gatherRef}
pointerRef={pointerRef}
pointerActiveRef={pointerActiveRef}
onReady={handleReady}
/>
</Canvas>
)}
</div>
);
}