feat: launch Roncarve Harness product page
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user