feat(hero): campo de particulas WebGL com mergulho guiado pela rolagem e transicao de secoes em CSS
Tres camadas de pontos ligadas por colunas de dados, animadas no vertex shader; o hero fica fixo em telas grandes enquanto a camera atravessa as camadas. Secoes sobem como cartoes via animation-timeline: view() (sem JS). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3d47fe9cc7
commit
0ec0194441
7 files changed
+694
-1001
No files matched your search
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import {
|
||||
Component,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
export type HeroFieldSceneProps = {
|
||||
progressRef: RefObject<number>;
|
||||
visible: boolean;
|
||||
onReady: () => void;
|
||||
onError: () => void;
|
||||
};
|
||||
|
||||
/*
|
||||
* Três camadas de pontos (interface, integrações, dados) ligadas por colunas de partículas que sobem
|
||||
* como dados. Na entrada, os pontos saem de uma nuvem e se organizam; ao rolar, as camadas se afastam,
|
||||
* ficam de frente e a câmera atravessa as três até o escuro da próxima seção.
|
||||
* Tudo acontece no vertex shader: o JavaScript só atualiza meia dúzia de uniforms por quadro.
|
||||
*/
|
||||
|
||||
const RADIUS = 2.35;
|
||||
|
||||
function buildField(dense: boolean) {
|
||||
const grid = dense ? 76 : 46;
|
||||
const columns = dense ? 18 : 10;
|
||||
const perColumn = dense ? 64 : 40;
|
||||
const base: number[] = [];
|
||||
const kind: number[] = [];
|
||||
const step = (RADIUS * 2) / (grid - 1);
|
||||
for (const layer of [-1, 0, 1]) {
|
||||
for (let i = 0; i < grid; i++) {
|
||||
for (let j = 0; j < grid; j++) {
|
||||
// leve desalinhamento: quebra o moiré da grade perfeita
|
||||
const x = -RADIUS + (i + (Math.random() - 0.5) * 0.35) * step;
|
||||
const z = -RADIUS + (j + (Math.random() - 0.5) * 0.35) * step;
|
||||
if (Math.hypot(x, z) > RADIUS) continue;
|
||||
base.push(x, layer, z);
|
||||
kind.push(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let c = 0; c < columns; c++) {
|
||||
const angle = c * 2.39996 + 0.4; // ângulo áureo: colunas espalhadas sem se amontoar
|
||||
const r = Math.sqrt((c + 0.5) / columns) * RADIUS * 0.78;
|
||||
const x = Math.round((Math.cos(angle) * r) / step) * step;
|
||||
const z = Math.round((Math.sin(angle) * r) / step) * step;
|
||||
for (let k = 0; k < perColumn; k++) {
|
||||
base.push(x, 0, z);
|
||||
kind.push(1);
|
||||
}
|
||||
for (const layer of [-1, 0, 1]) {
|
||||
base.push(x, layer, z);
|
||||
kind.push(2);
|
||||
}
|
||||
}
|
||||
const count = kind.length;
|
||||
const scatter = new Float32Array(count * 3);
|
||||
const rand = new Float32Array(count * 4);
|
||||
for (let n = 0; n < count; n++) {
|
||||
const u = Math.random() * 2 - 1;
|
||||
const t = Math.random() * Math.PI * 2;
|
||||
const s = Math.sqrt(1 - u * u);
|
||||
const d = 4 + Math.random() * 7;
|
||||
scatter.set([Math.cos(t) * s * d, u * d, Math.sin(t) * s * d], n * 3);
|
||||
rand.set([Math.random(), Math.random(), Math.random(), Math.random()], n * 4);
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(base, 3));
|
||||
geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3));
|
||||
geometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 4));
|
||||
geometry.setAttribute("aKind", new THREE.Float32BufferAttribute(kind, 1));
|
||||
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 12);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
const vertexShader = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform float uIntro;
|
||||
uniform float uSpread;
|
||||
uniform float uScroll;
|
||||
uniform float uScale;
|
||||
uniform float uFocus;
|
||||
uniform float uFade;
|
||||
uniform vec2 uMouse;
|
||||
uniform float uMouseAmt;
|
||||
attribute vec3 aScatter;
|
||||
attribute vec4 aRand;
|
||||
attribute float aKind;
|
||||
varying vec3 vColor;
|
||||
varying float vAlpha;
|
||||
varying float vSoft;
|
||||
|
||||
const vec3 PAPER = vec3(0.957, 0.949, 0.929);
|
||||
const vec3 LIME = vec3(0.8, 1.0, 0.0);
|
||||
|
||||
void main() {
|
||||
float layer = position.y;
|
||||
float isPlane = 1.0 - step(0.5, aKind);
|
||||
float isStream = step(0.5, aKind) * (1.0 - step(1.5, aKind));
|
||||
float isNode = step(1.5, aKind);
|
||||
|
||||
// colunas: cada partícula sobe da camada de dados até a interface e recomeça
|
||||
float flow = fract(uTime * (0.08 + aRand.x * 0.07) + aRand.y);
|
||||
layer = mix(layer, flow * 2.0 - 1.0, isStream);
|
||||
|
||||
vec3 p = vec3(position.x, layer * uSpread, position.z);
|
||||
float wave = sin(position.x * 1.7 + uTime * 0.55 + layer * 2.1) * cos(position.z * 1.3 - uTime * 0.4);
|
||||
p.y += wave * (0.045 + uScroll * 0.25) * isPlane;
|
||||
p.xz += (aRand.zw - 0.5) * 0.05 * isStream;
|
||||
|
||||
// cursor: uma onda que levanta a malha e acende os pontos em verde
|
||||
float d = length(position.xz - uMouse);
|
||||
float lift = exp(-d * d * 2.2) * uMouseAmt;
|
||||
p.y += (lift * 0.32 + sin(d * 7.0 - uTime * 3.5) * lift * 0.05) * isPlane;
|
||||
|
||||
// entrada: cada ponto sai da nuvem com atraso próprio
|
||||
float t = clamp((uIntro - aRand.z * 0.4) / 0.6, 0.0, 1.0);
|
||||
float e = 1.0 - pow(1.0 - t, 4.0);
|
||||
p = mix(aScatter, p, e);
|
||||
|
||||
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
||||
gl_Position = projectionMatrix * mv;
|
||||
float depth = -mv.z;
|
||||
|
||||
float size = 0.021 * isPlane * (0.7 + aRand.w * 0.6) + 0.024 * isStream + 0.07 * isNode;
|
||||
float blur = clamp(abs(depth - uFocus) * 0.32, 0.0, 1.6);
|
||||
gl_PointSize = min(size * uScale / max(depth, 0.05) * (1.0 + blur * 1.4), 64.0);
|
||||
|
||||
// cada camada é um disco: miolo que esmaece para a borda e um aro fino que desenha o contorno
|
||||
float radial = length(position.xz);
|
||||
float edge = 1.0 - smoothstep(RADIUS_IN, RADIUS_OUT, radial);
|
||||
float rim = smoothstep(RADIUS_OUT - 0.16, RADIUS_OUT - 0.04, radial);
|
||||
float alpha = isPlane * ((0.5 + 0.2 * step(0.5, position.y)) * edge + rim * 0.75) + isStream * 0.55 + isNode;
|
||||
alpha *= (0.25 + 0.75 * e) / (1.0 + blur * 1.6);
|
||||
alpha *= smoothstep(0.25, 1.6, depth) * uFade;
|
||||
vAlpha = alpha;
|
||||
vSoft = clamp(0.35 + blur * 0.45, 0.35, 1.0);
|
||||
vColor = mix(PAPER, LIME, clamp(isStream + isNode + (lift * 1.6 + rim * 0.55) * isPlane, 0.0, 1.0));
|
||||
}
|
||||
`
|
||||
.replaceAll("RADIUS_IN", (RADIUS * 0.62).toFixed(2))
|
||||
.replaceAll("RADIUS_OUT", RADIUS.toFixed(2));
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
varying vec3 vColor;
|
||||
varying float vAlpha;
|
||||
varying float vSoft;
|
||||
void main() {
|
||||
float r = length(gl_PointCoord - 0.5) * 2.0;
|
||||
float a = 1.0 - smoothstep(1.0 - vSoft, 1.0, r);
|
||||
if (a <= 0.0) discard;
|
||||
gl_FragColor = vec4(vColor, a * vAlpha);
|
||||
}
|
||||
`;
|
||||
|
||||
const smooth = (a: number, b: number, x: number) => {
|
||||
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
|
||||
function Field({ progressRef, onReady, onError }: HeroFieldSceneProps) {
|
||||
const { gl, camera, size } = useThree();
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const dense = useMemo(
|
||||
() => window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4,
|
||||
[],
|
||||
);
|
||||
const geometry = useMemo(() => buildField(dense), [dense]);
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.ShaderMaterial({
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uIntro: { value: 0 },
|
||||
uSpread: { value: 1.05 },
|
||||
uScroll: { value: 0 },
|
||||
uScale: { value: 1 },
|
||||
uFocus: { value: 9 },
|
||||
uFade: { value: 1 },
|
||||
uMouse: { value: new THREE.Vector2(9, 9) },
|
||||
uMouseAmt: { value: 0 },
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const state = useRef({
|
||||
progress: 0,
|
||||
pointer: new THREE.Vector2(),
|
||||
lastMove: -10,
|
||||
mouse: new THREE.Vector2(),
|
||||
tilt: new THREE.Vector2(),
|
||||
frames: 0,
|
||||
});
|
||||
const helpers = useMemo(
|
||||
() => ({
|
||||
ray: new THREE.Raycaster(),
|
||||
plane: new THREE.Plane(),
|
||||
hit: new THREE.Vector3(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (event: PointerEvent) => {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
const s = state.current;
|
||||
s.pointer.set(
|
||||
(event.clientX / window.innerWidth) * 2 - 1,
|
||||
-(event.clientY / window.innerHeight) * 2 + 1,
|
||||
);
|
||||
s.lastMove = performance.now() / 1000;
|
||||
};
|
||||
const onLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onError();
|
||||
};
|
||||
window.addEventListener("pointermove", onMove, { passive: true });
|
||||
gl.domElement.addEventListener("webglcontextlost", onLost);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
gl.domElement.removeEventListener("webglcontextlost", onLost);
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
};
|
||||
}, [gl, geometry, material, onError]);
|
||||
|
||||
useFrame((frame, delta) => {
|
||||
const g = group.current;
|
||||
if (!g) return;
|
||||
const dt = Math.min(delta, 1 / 20);
|
||||
const s = state.current;
|
||||
const u = material.uniforms;
|
||||
const time = frame.clock.elapsedTime;
|
||||
|
||||
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
|
||||
s.progress += ((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5));
|
||||
const p = s.progress;
|
||||
const wide = size.width / size.height > 1.1;
|
||||
|
||||
u.uTime.value = time;
|
||||
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
|
||||
u.uScroll.value = p;
|
||||
u.uSpread.value = 1.05 + 1.3 * smooth(0, 0.55, p);
|
||||
u.uFade.value = 1 - smooth(0.86, 1, p);
|
||||
u.uScale.value =
|
||||
(gl.getPixelRatio() * size.height * 0.5) /
|
||||
Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2));
|
||||
|
||||
// sem mouse (toque, ou mouse parado), um cursor fantasma percorre a malha
|
||||
const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0;
|
||||
s.tilt.lerp(mouseActive ? s.pointer : new THREE.Vector2(), 1 - Math.exp(-dt * 3));
|
||||
|
||||
const face = smooth(0.04, 0.6, p);
|
||||
g.rotation.set(
|
||||
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
|
||||
time * 0.045 + s.tilt.x * 0.18,
|
||||
0,
|
||||
);
|
||||
const settle = 1 - smooth(0, 0.4, p);
|
||||
g.position.set(wide ? 1.85 * settle : 0, wide ? -0.15 * settle : -1.25 * settle, 0);
|
||||
const dive = smooth(0.22, 1, p);
|
||||
camera.position.z = 9 - 12.5 * dive * dive;
|
||||
u.uFocus.value = Math.max(1.5, camera.position.z - g.position.z);
|
||||
g.updateMatrixWorld();
|
||||
|
||||
if (mouseActive) {
|
||||
helpers.ray.setFromCamera(s.pointer, camera);
|
||||
helpers.plane
|
||||
.set(new THREE.Vector3(0, 1, 0), -u.uSpread.value)
|
||||
.applyMatrix4(g.matrixWorld);
|
||||
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
|
||||
g.worldToLocal(helpers.hit);
|
||||
s.mouse.lerp(new THREE.Vector2(helpers.hit.x, helpers.hit.z), 1 - Math.exp(-dt * 10));
|
||||
}
|
||||
} else {
|
||||
s.mouse.lerp(
|
||||
new THREE.Vector2(Math.sin(time * 0.31) * 1.5, Math.sin(time * 0.23 + 1.3) * 1.3),
|
||||
1 - Math.exp(-dt * 1.5),
|
||||
);
|
||||
}
|
||||
u.uMouse.value.copy(s.mouse);
|
||||
const targetAmt = (mouseActive ? 1 : 0.55) * (1 - smooth(0.05, 0.3, p));
|
||||
u.uMouseAmt.value += (targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
||||
|
||||
if (++s.frames === 2) onReady();
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={group}>
|
||||
<points geometry={geometry} material={material} frustumCulled={false} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
class SceneBoundary extends Component<
|
||||
{ children: ReactNode; onError: () => void },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
componentDidCatch() {
|
||||
this.props.onError();
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? null : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default function HeroFieldScene(props: HeroFieldSceneProps) {
|
||||
return (
|
||||
<SceneBoundary onError={props.onError}>
|
||||
<Canvas
|
||||
aria-hidden="true"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 40 }}
|
||||
dpr={[1, 1.75]}
|
||||
frameloop={props.visible ? "always" : "never"}
|
||||
gl={{ alpha: true, antialias: false, powerPreference: "high-performance" }}
|
||||
>
|
||||
<Field {...props} />
|
||||
</Canvas>
|
||||
</SceneBoundary>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user