Files
portifolio-evandrodevbr/app/components/cinematic/HeroFieldScene.tsx
T
Evandro Fonseca JuniorandClaude Opus 5.5 df1c1888da feat(home): rolagem suave (Lenis) e seis transicoes de secao ligadas a rolagem
Cada passagem segura a secao que sai e a que entra na tela enquanto a
transicao acontece: dissolucao do hero no notebook, cartao, iris, corte
lateral, perspectiva, cortina diagonal e zoom. Medidas pelo layout
(offsetTop) para nao confundir os gatilhos com os transforms.
Remove letreiros e divisores entre secoes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 00:32:32 -03:00

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.94, 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>
);
}