Files
portifolio-evandrodevbr/app/components/cinematic/HeroFieldScene.tsx
T
Evandro Fonseca Junior 318a764190 perf(movimento): sem tela vazia, passagens em CSS e vitrine leve
Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
2026-10-14 14:15:50 -03:00

769 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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";
import FramePulse from "./FramePulse";
export type HeroFieldSceneProps = {
progressRef: RefObject<number>;
visible: boolean;
onReady: () => void;
onError: () => void;
};
/*
* Um sistema desenhado com pontos, em três camadas que um leigo reconhece:
* em cima, a TELA de um aplicativo (janela, título, botão, cards); no meio, as INTEGRAÇÕES (sistemas
* ligados a uma central, com mensagens correndo pelas linhas); embaixo, os DADOS (uma tabela).
* Colunas de partículas sobem da tabela até a tela: a informação chegando a quem usa.
* 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 uma a uma, até parar diante dos dados (as legendas de Hero explicam cada
* camada nessa hora; no fim, a seção seguinte sobe e cobre o palco).
* Tudo se move no vertex shader; o JavaScript só atualiza alguns uniforms por quadro.
*
* É um fundo ambiente: HeroField o deixa com pouca opacidade e uma máscara que o afasta do título e das
* molduras da direita. O desenho é sob demanda (frameloop "demand"): a cena pede um quadro a 30 por segundo
* e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, ou depois de uns segundos
* sem cursor nem rolagem (FramePulse), não desenha nada.
*/
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
type PointStyle = { tone?: number; size?: number; alpha?: number };
class FieldBuilder {
base: number[] = [];
to: number[] = [];
move: number[] = [];
tone: number[] = [];
size: number[] = [];
alpha: number[] = [];
constructor(private density: number) {}
point(
x: number,
y: number,
z: number,
style: PointStyle = {},
to?: [number, number, number],
) {
this.base.push(x, y, z);
this.to.push(...(to ?? [x, y, z]));
this.move.push(to ? 1 : 0);
this.tone.push(style.tone ?? 0);
this.size.push(style.size ?? 0.02);
this.alpha.push(style.alpha ?? 0.7);
}
line(
y: Layer,
x0: number,
z0: number,
x1: number,
z1: number,
step: number,
style?: PointStyle,
) {
const s = step * this.density;
const n = Math.max(1, Math.round(Math.hypot(x1 - x0, z1 - z0) / s));
for (let i = 0; i <= n; i++)
this.point(x0 + ((x1 - x0) * i) / n, y, z0 + ((z1 - z0) * i) / n, style);
}
rect(
y: Layer,
x0: number,
z0: number,
x1: number,
z1: number,
step: number,
style?: PointStyle,
) {
this.line(y, x0, z0, x1, z0, step, style);
this.line(y, x1, z0, x1, z1, step, style);
this.line(y, x1, z1, x0, z1, step, style);
this.line(y, x0, z1, x0, z0, step, style);
}
fill(
y: Layer,
x0: number,
z0: number,
x1: number,
z1: number,
step: number,
style?: PointStyle,
) {
const s = step * this.density;
for (let x = x0; x <= x1 + 1e-6; x += s)
for (let z = z0; z <= z1 + 1e-6; z += s)
this.point(
x + (Math.random() - 0.5) * s * 0.3,
y,
z + (Math.random() - 0.5) * s * 0.3,
style,
);
}
ring(
y: Layer,
cx: number,
cz: number,
r: number,
step: number,
style?: PointStyle,
) {
const n = Math.max(
8,
Math.round((Math.PI * 2 * r) / (step * this.density)),
);
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
this.point(cx + Math.cos(a) * r, y, cz + Math.sin(a) * r, style);
}
}
disc(
y: Layer,
cx: number,
cz: number,
r: number,
step: number,
style?: PointStyle,
) {
for (let rr = r; rr > 0.005; rr -= step * this.density)
this.ring(y, cx, cz, rr, step, style);
this.point(cx, y, cz, style);
}
/** partículas que correm de a até b (a posição é calculada no shader) */
flow(
a: [number, number, number],
b: [number, number, number],
count: number,
style?: PointStyle,
) {
const n = Math.max(2, Math.round(count / this.density));
for (let i = 0; i < n; i++) this.point(...a, style, b);
}
}
// z negativo = topo da tela quando a camada fica de frente para a câmera
function drawScreen(f: FieldBuilder) {
const y: Layer = 1;
f.fill(y, -2.1, -1.4, 2.1, 1.4, 0.1, { alpha: 0.1 }); // superfície da tela
f.rect(y, -2.1, -1.4, 2.1, 1.4, 0.026, { alpha: 0.95 });
f.line(y, -2.1, -1.05, 2.1, -1.05, 0.03, { alpha: 0.6 }); // barra do navegador
for (const x of [-1.9, -1.72, -1.54])
f.disc(y, x, -1.23, 0.05, 0.025, { alpha: 0.8 });
f.rect(y, -1.1, -1.32, 1.3, -1.14, 0.035, { alpha: 0.45 }); // endereço
f.fill(y, -1.8, -0.82, 0.25, -0.58, 0.03, { alpha: 0.85 }); // título
f.line(y, -1.8, -0.38, 0.75, -0.38, 0.04, { alpha: 0.5 }); // texto
f.line(y, -1.8, -0.24, 0.35, -0.24, 0.04, { alpha: 0.5 });
f.fill(y, -1.8, -0.02, -0.75, 0.2, 0.028, {
tone: 1,
alpha: 0.95,
size: 0.022,
}); // botão
f.rect(y, 0.6, -0.82, 1.8, 0.2, 0.035, { alpha: 0.6 }); // imagem
f.fill(y, 0.6, -0.82, 1.8, 0.2, 0.08, { alpha: 0.25 });
for (const [x0, x1] of [
[-1.8, -0.7],
[-0.55, 0.55],
[0.7, 1.8],
]) {
f.rect(y, x0, 0.45, x1, 1.2, 0.035, { alpha: 0.55 }); // cards
f.line(y, x0 + 0.12, 0.62, x1 - 0.3, 0.62, 0.045, { alpha: 0.5 });
f.line(y, x0 + 0.12, 0.76, x1 - 0.5, 0.76, 0.045, { alpha: 0.35 });
}
}
/** posições dos sistemas ligados à central; as colunas de dados sobem por eles */
const NODES: [number, number][] = Array.from({ length: 6 }, (_, k) => {
const a = (k / 6) * Math.PI * 2 + Math.PI / 6;
return [Math.cos(a) * 1.7, Math.sin(a) * 1.05];
});
function drawIntegrations(f: FieldBuilder) {
const y: Layer = 0;
f.ring(y, 0, 0, 0.34, 0.02, { tone: 1, alpha: 0.95, size: 0.024 }); // central
f.disc(y, 0, 0, 0.2, 0.035, { tone: 1, alpha: 0.5 });
NODES.forEach(([x, z], k) => {
f.ring(y, x, z, 0.2, 0.02, { tone: 1, alpha: 0.9, size: 0.022 });
f.disc(y, x, z, 0.1, 0.03, { alpha: 0.6 });
const len = Math.hypot(x, z);
const ux = x / len;
const uz = z / len;
// linha da central até o sistema, e mensagens correndo nos dois sentidos
f.line(y, ux * 0.38, uz * 0.38, x - ux * 0.24, z - uz * 0.24, 0.06, {
alpha: 0.35,
});
f.flow([ux * 0.38, y, uz * 0.38], [x - ux * 0.24, y, z - uz * 0.24], 10, {
tone: 1,
alpha: 0.95,
size: 0.026,
});
f.flow([x - ux * 0.24, y, z - uz * 0.24], [ux * 0.38, y, uz * 0.38], 6, {
alpha: 0.9,
size: 0.024,
});
const [nx, nz] = NODES[(k + 1) % NODES.length];
f.line(y, x, z, nx, nz, 0.08, { alpha: 0.15 });
});
}
function drawData(f: FieldBuilder) {
const y: Layer = -1;
const x0 = -2;
const x1 = 2;
const z0 = -1.3;
const z1 = 1.3;
const cols = [x0, -0.9, 0.2, 1.1, x1];
f.rect(y, x0, z0, x1, z1, 0.03, { alpha: 0.8 });
f.fill(y, x0, z0, x1, z0 + 0.32, 0.045, { tone: 0.6, alpha: 0.45 }); // cabeçalho da tabela
for (let z = z0 + 0.32; z < z1; z += 0.33)
f.line(y, x0, z, x1, z, 0.04, { alpha: 0.4 });
for (const x of cols.slice(1, -1))
f.line(y, x, z0, x, z1, 0.04, { alpha: 0.4 });
for (let z = z0 + 0.48; z < z1; z += 0.33) {
for (let c = 0; c < cols.length - 1; c++) {
const start = cols[c] + 0.12;
const width =
(cols[c + 1] - cols[c] - 0.24) * (0.35 + Math.random() * 0.6);
f.line(y, start, z, start + width, z, 0.045, { alpha: 0.55 }); // valores nas células
}
}
}
function buildField(dense: boolean) {
const f = new FieldBuilder(dense ? 1 : 1.5);
drawScreen(f);
drawIntegrations(f);
drawData(f);
// colunas: a informação sobe dos dados, passa pelos sistemas e chega à tela
for (const [x, z] of [[0, 0] as [number, number], ...NODES])
f.flow([x, -1, z], [x, 1, z], 40, { tone: 1, alpha: 0.6, size: 0.022 });
const count = f.move.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(f.base, 3),
);
geometry.setAttribute("aTo", new THREE.Float32BufferAttribute(f.to, 3));
geometry.setAttribute("aMove", new THREE.Float32BufferAttribute(f.move, 1));
geometry.setAttribute("aTone", new THREE.Float32BufferAttribute(f.tone, 1));
geometry.setAttribute("aSize", new THREE.Float32BufferAttribute(f.size, 1));
geometry.setAttribute("aAlpha", new THREE.Float32BufferAttribute(f.alpha, 1));
geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3));
geometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 4));
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 aTo;
attribute float aMove;
attribute float aTone;
attribute float aSize;
attribute float aAlpha;
attribute vec3 aScatter;
attribute vec4 aRand;
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() {
// partículas em movimento correm de position até aTo e recomeçam
float f = fract(uTime * (0.1 + aRand.x * 0.08) + aRand.y);
vec3 b = mix(position, aTo, aMove * f);
vec3 p = vec3(b.x, b.y * uSpread, b.z);
float still = 1.0 - aMove;
float wave = sin(b.x * 1.7 + uTime * 0.55 + b.y * 2.1) * cos(b.z * 1.3 - uTime * 0.4);
p.y += wave * (0.02 + uScroll * 0.2) * still;
// cursor: uma onda que levanta a camada e acende os pontos em verde
float d = length(b.xz - uMouse);
float lift = exp(-d * d * 2.2) * uMouseAmt * still;
p.y += lift * 0.3 + sin(d * 7.0 - uTime * 3.5) * lift * 0.05;
// 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 blur = clamp(abs(depth - uFocus) * 0.3, 0.0, 1.6);
float size = aSize * (0.8 + aRand.w * 0.4);
gl_PointSize = min(size * uScale / max(depth, 0.05) * (1.0 + blur * 1.3), 64.0);
float alpha = aAlpha * mix(1.0, sin(f * 3.14159), aMove);
alpha *= (0.25 + 0.75 * e) / (1.0 + blur * 1.5);
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(aTone + lift * 1.6, 0.0, 1.0));
}
`;
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);
};
/** Interpola entre quadros-chave [progresso, valor] com suavização em cada trecho. */
function keyframes(frames: readonly (readonly [number, number])[], p: number) {
if (p <= frames[0][0]) return frames[0][1];
for (let i = 1; i < frames.length; i++) {
const [p1, v1] = frames[i];
if (p <= p1) {
const [p0, v0] = frames[i - 1];
return v0 + (v1 - v0) * smooth(p0, p1, p);
}
}
return frames[frames.length - 1][1];
}
/** Distância mínima entre as camadas no fim do mergulho: cada uma vira um "cômodo" que a câmera visita. */
const SPREAD = 5.5;
/** Distância para enquadrar uma camada inteira numa tela larga. */
const FRAME = 5.2;
/*
* Coreografia da câmera (z): afasta para enquadrar a tela inteira, atravessa, enquadra as integrações,
* atravessa, enquadra os dados e fica ali (o mergulho não acaba no escuro: Projetos sobe sobre a última
* imagem, uma tabela de dados). As legendas de Hero.tsx (--from/--to em globals.css) seguem
* estes mesmos intervalos: tela 0,26–0,46; integrações 0,52–0,71; dados 0,76–0,92.
* Em tela vertical (celular) a distância de enquadramento cresce para a camada caber na largura, e as
* camadas se afastam mais para a câmera caber entre elas.
*/
function cameraFrames(rest: number, spread: number, frame: number) {
return [
[0, rest],
[0.28, spread + frame],
[0.44, spread + frame * 0.87],
[0.5, spread - 0.3],
[0.55, frame],
[0.69, frame * 0.87],
[0.74, -0.3],
[0.78, -spread + frame],
[0.89, -spread + frame * 0.87],
[1, -spread + frame * 0.8],
] as const;
}
/** Tela larga o bastante (e CPU suficiente) para a densidade cheia de pontos. */
const isDense = () =>
window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4;
let prepared: THREE.BufferGeometry | null = null;
/**
* Monta a geometria (dezenas de milhares de pontos) fora do caminho crítico: quem carrega este módulo chama
* isto num intervalo ocioso, antes de montar o Canvas, para a criação do contexto WebGL não carregar também
* essa conta. Chamar de novo, sem a cena ter montado, não refaz nada.
*/
export function prepareField() {
prepared ??= buildField(isDense());
}
/** A geometria pronta (que a montagem consome), ou uma nova se ninguém a preparou antes. */
function takeField() {
const geometry = prepared ?? buildField(isDense());
prepared = null;
return geometry;
}
type Uniforms = THREE.ShaderMaterial["uniforms"];
/** Enquadramento da câmera para o tamanho atual da tela: só muda quando a tela muda, não a cada quadro. */
function frameLayout(
width: number,
height: number,
fov: number,
pixelRatio: number,
) {
const aspect = width / height;
const tanHalf = Math.tan(THREE.MathUtils.degToRad(fov / 2));
// enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé
const frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect));
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
const rest = Math.max(9, frameDist * 1.1);
return {
wide: aspect > 1.1,
tanHalf,
spreadMax,
rest,
// com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir
scale:
((pixelRatio * height * 0.5) / tanHalf) * Math.sqrt(frameDist / FRAME),
camera: cameraFrames(rest, spreadMax, frameDist),
};
}
type Layout = ReturnType<typeof frameLayout>;
/** Uniforms do tempo, da entrada e do mergulho. Devolve o quanto as camadas já estão de frente (0 a 1). */
function updateUniforms(
u: Uniforms,
layout: Layout,
p: number,
time: number,
dt: number,
) {
u.uTime.value = time;
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
u.uScroll.value = p;
// as camadas só se afastam enquanto ficam de frente: inclinadas e afastadas sairiam do quadro
const face = smooth(0.02, 0.28, p);
u.uSpread.value = 1.05 + (layout.spreadMax - 1.05) * face * face;
u.uFade.value = 1 - 0.4 * smooth(0.93, 1, p);
u.uScale.value = layout.scale;
return face;
}
type FieldState = {
progress: number;
pointer: THREE.Vector2;
lastMove: number;
mouse: THREE.Vector2;
tilt: THREE.Vector2;
frames: number;
};
/** Objetos reaproveitados a cada quadro (nada é alocado dentro do useFrame). */
type Helpers = {
ray: THREE.Raycaster;
plane: THREE.Plane;
up: THREE.Vector3;
hit: THREE.Vector3;
zero: THREE.Vector2;
target: THREE.Vector2;
};
/** O mouse se mexeu há pouco? Sem ele (toque, ou mouse parado), um cursor fantasma percorre a tela. */
const cursorIsActive = (s: FieldState, time: number) =>
s.lastMove > 0 && time - s.lastMove < 2.5;
/** Posição do grupo, rotação, câmera e foco: a vista de três quartos que balança e depois se endireita no mergulho. */
function updateRig(
g: THREE.Group,
camera: THREE.Camera,
u: Uniforms,
layout: Layout,
s: FieldState,
h: Helpers,
p: number,
face: number,
time: number,
dt: number,
cursorActive: boolean,
) {
s.tilt.lerp(cursorActive ? s.pointer : h.zero, 1 - Math.exp(-dt * 3));
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
g.rotation.set(
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
s.tilt.x * 0.15 * (1 - face),
0,
);
// em repouso: ao centro e um pouco abaixo (a máscara de HeroField afasta o desenho do título e das molduras
// da direita); no celular, mais abaixo, atrás dos botões
const settle = 1 - smooth(0, 0.28, p);
g.position.set(
0,
(layout.wide ? -0.5 : -layout.rest * layout.tanHalf * 0.38) * settle,
0,
);
camera.position.z = keyframes(layout.camera, p);
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
const spread = u.uSpread.value;
const behind = camera.position.z - 0.2;
const ahead = spread < behind ? spread : 0 < behind ? 0 : -spread;
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
g.updateMatrixWorld();
}
/** Ponto da camada sob o cursor, em coordenadas do grupo, deixado em h.target. Falso se o raio não toca a camada. */
function cursorOnLayer(
g: THREE.Group,
camera: THREE.Camera,
s: FieldState,
h: Helpers,
spread: number,
) {
h.ray.setFromCamera(s.pointer, camera);
h.plane.set(h.up, -spread).applyMatrix4(g.matrixWorld);
if (!h.ray.ray.intersectPlane(h.plane, h.hit)) return false;
g.worldToLocal(h.hit);
h.target.set(h.hit.x, h.hit.z);
return true;
}
/** Onda do cursor: segue o mouse na camada ou, sem mouse, o cursor fantasma. Devolve a força que ela busca. */
function updatePointer(
g: THREE.Group,
camera: THREE.Camera,
u: Uniforms,
s: FieldState,
h: Helpers,
p: number,
time: number,
dt: number,
cursorActive: boolean,
) {
if (cursorActive) {
if (cursorOnLayer(g, camera, s, h, u.uSpread.value))
s.mouse.lerp(h.target, 1 - Math.exp(-dt * 10));
} else {
s.mouse.lerp(
h.target.set(
Math.sin(time * 0.31) * 1.5,
Math.sin(time * 0.23 + 1.3) * 1.1,
),
1 - Math.exp(-dt * 1.5),
);
}
u.uMouse.value.copy(s.mouse);
const targetAmt = (cursorActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
u.uMouseAmt.value +=
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
return targetAmt;
}
/**
* Sob demanda: o próximo quadro vem do FramePulse (30 por segundo, e nenhum depois de uns segundos sem cursor nem
* rolagem); este pede o quadro seguinte (a 60) só enquanto algo ainda muda. A entrada e a rolagem terminam de
* assentar mesmo depois de o hero sair de cena (o último quadro tem de ser o final); o cursor só conta com o hero
* à vista.
*/
function needsFrame(
u: Uniforms,
s: FieldState,
target: number,
amount: number,
cursorActive: boolean,
visible: boolean,
) {
const settling = u.uIntro.value < 1 || Math.abs(target - s.progress) > 4e-4;
const interacting =
cursorActive || Math.abs(amount - u.uMouseAmt.value) > 0.01 || s.frames < 3;
return settling || (visible && interacting);
}
function Field({
progressRef,
visible,
onReady,
onError,
}: HeroFieldSceneProps) {
const { gl, camera, size } = useThree();
const group = useRef<THREE.Group>(null);
const geometry = useMemo(takeField, []);
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 layout = useMemo(
() =>
frameLayout(
size.width,
size.height,
(camera as THREE.PerspectiveCamera).fov,
gl.getPixelRatio(),
),
[camera, gl, size.height, size.width],
);
const state = useRef<FieldState>({
progress: 0,
pointer: new THREE.Vector2(),
lastMove: -10,
mouse: new THREE.Vector2(),
tilt: new THREE.Vector2(),
frames: 0,
});
const helpers = useMemo<Helpers>(
() => ({
ray: new THREE.Raycaster(),
plane: new THREE.Plane(),
up: new THREE.Vector3(0, 1, 0),
hit: new THREE.Vector3(),
zero: new THREE.Vector2(),
target: new THREE.Vector2(),
}),
[],
);
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;
const target = progressRef.current ?? 0;
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5));
const p = s.progress;
const cursorActive = cursorIsActive(s, time);
const face = updateUniforms(u, layout, p, time, dt);
updateRig(
g,
camera,
u,
layout,
s,
helpers,
p,
face,
time,
dt,
cursorActive,
);
const amount = updatePointer(
g,
camera,
u,
s,
helpers,
p,
time,
dt,
cursorActive,
);
if (++s.frames === 2) onReady();
if (needsFrame(u, s, target, amount, cursorActive, visible))
frame.invalidate();
});
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: 120 }}
dpr={[1, 1.5]}
frameloop="demand"
gl={{
alpha: true,
antialias: false,
powerPreference: "high-performance",
}}
>
<FramePulse active={props.visible} />
<Field {...props} />
</Canvas>
</SceneBoundary>
);
}