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]>
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
"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>
|
|
);
|
|
}
|