perf(movimento): sem tempo morto, vídeos regravados e 3D sem React Three Fiber

Legenda do mergulho até Projetos cobrir a cena; transição de página sem blur e com fundo escuro; foco no conteúdo ao trocar de página; listas com marcador e dois pesos de título; vídeos da SEV e da SC Plus regravados sem quadros brancos; scrub a 12-15 fps (-30%); vitrine com uma parada de Tab e setas, contador no celular e CSS sob demanda; âncoras num módulo só (fragment-nav); FramePulse com setInterval; CSS morto removido.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 17:01:37 -03:00
1 parent f8545cd442
commit 6ac87824f3
68 files changed
+5735 -3300

No files matched your search

+467 -275
View File
@@ -1,16 +1,26 @@
"use client";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { type RefObject, useEffect, useRef } from "react";
import {
Component,
type ReactNode,
type RefObject,
useEffect,
useMemo,
useRef,
} from "react";
import * as THREE from "three";
import FramePulse from "./FramePulse";
AdditiveBlending,
BufferAttribute,
BufferGeometry,
type Camera,
Float32BufferAttribute,
Group,
MathUtils,
PerspectiveCamera,
Plane,
Points,
Raycaster,
Scene,
ShaderMaterial,
Sphere,
Vector2,
Vector3,
WebGLRenderer,
} from "three";
import { startFramePulse } from "./FramePulse";
export type HeroFieldSceneProps = {
progressRef: RefObject<number>;
@@ -30,9 +40,10 @@ export type HeroFieldSceneProps = {
* 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.
* molduras da direita. O Three.js é usado direto (sem React Three Fiber, que levava junto o reconciliador do React
* só para uma malha e um shader). O desenho é sob demanda: a cena pede um quadro a 30 por segundo (FramePulse, um
* setInterval) e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, ou depois de uns segundos
* sem cursor nem rolagem, não desenha nada.
*/
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
@@ -259,19 +270,16 @@ function buildField(dense: boolean) {
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);
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(f.base, 3));
geometry.setAttribute("aTo", new Float32BufferAttribute(f.to, 3));
geometry.setAttribute("aMove", new Float32BufferAttribute(f.move, 1));
geometry.setAttribute("aTone", new Float32BufferAttribute(f.tone, 1));
geometry.setAttribute("aSize", new Float32BufferAttribute(f.size, 1));
geometry.setAttribute("aAlpha", new Float32BufferAttribute(f.alpha, 1));
geometry.setAttribute("aScatter", new BufferAttribute(scatter, 3));
geometry.setAttribute("aRand", new BufferAttribute(rand, 4));
geometry.boundingSphere = new Sphere(new Vector3(), 12);
return geometry;
}
@@ -374,8 +382,9 @@ 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.
* imagem, uma tabela de dados). As legendas de Hero.tsx (--from/--to em globals.css) entram um pouco antes de a
* câmera chegar a cada camada e saem um pouco depois: tela 0,18–0,49; integrações 0,51–0,75; dados 0,79 em diante
* (a última fica até Projetos subir e cobri-la).
* 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.
*/
@@ -398,7 +407,7 @@ function cameraFrames(rest: number, spread: number, frame: number) {
const isDense = () =>
window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4;
let prepared: THREE.BufferGeometry | null = null;
let prepared: BufferGeometry | null = null;
/**
* Monta a geometria (dezenas de milhares de pontos) fora do caminho crítico: quem carrega este módulo chama
@@ -416,7 +425,7 @@ function takeField() {
return geometry;
}
type Uniforms = THREE.ShaderMaterial["uniforms"];
type Uniforms = 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(
@@ -426,7 +435,7 @@ function frameLayout(
pixelRatio: number,
) {
const aspect = width / height;
const tanHalf = Math.tan(THREE.MathUtils.degToRad(fov / 2));
const tanHalf = Math.tan(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);
@@ -466,120 +475,140 @@ function updateUniforms(
type FieldState = {
progress: number;
pointer: THREE.Vector2;
pointer: Vector2;
lastMove: number;
mouse: THREE.Vector2;
tilt: THREE.Vector2;
mouse: Vector2;
tilt: 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;
ray: Raycaster;
plane: Plane;
up: Vector3;
hit: Vector3;
zero: Vector2;
target: 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,
/** Tudo o que um quadro lê e move: o grupo, a câmera, os uniforms, o enquadramento atual, o estado e os auxiliares. */
export type Rig = {
group: Group;
camera: Camera;
uniforms: Uniforms;
layout: Layout;
state: FieldState;
helpers: Helpers;
};
/** O que muda de um quadro para o outro: progresso, quanto as camadas já estão de frente, relógio, passo e cursor. */
export type Tick = {
p: number;
face: number;
time: number;
dt: number;
cursorActive: boolean;
};
/** A cena se inclina devagar para o cursor (ou volta ao centro, sem cursor). */
function updateTilt({ state, helpers }: Rig, { dt, cursorActive }: Tick) {
state.tilt.lerp(
cursorActive ? state.pointer : helpers.zero,
1 - Math.exp(-dt * 3),
);
// 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);
/** Em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas. */
function updateRotation({ group, state }: Rig, { face, time }: Tick) {
group.rotation.set(
0.42 + (Math.PI / 2 - 0.42) * face - state.tilt.y * 0.08,
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
state.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.
*/
function updatePosition({ group, layout }: Rig, { p }: Tick) {
const settle = 1 - smooth(0, 0.28, p);
const down = layout.wide ? -0.5 : -layout.rest * layout.tanHalf * 0.38;
group.position.set(0, down * settle, 0);
}
/** Camada que a lente enquadra: a próxima à frente da câmera (as de trás desfocam como em uma lente). */
function updateFocus({ camera, uniforms }: Rig) {
const spread = uniforms.uSpread.value;
const behind = camera.position.z - 0.2;
let ahead = -spread;
if (spread < behind) ahead = spread;
else if (0 < behind) ahead = 0;
uniforms.uFocus.value = Math.max(1.5, camera.position.z - ahead);
}
/** 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. */
export function updateRig(rig: Rig, tick: Tick) {
updateTilt(rig, tick);
updateRotation(rig, tick);
updatePosition(rig, tick);
rig.camera.position.z = keyframes(rig.layout.camera, tick.p);
updateFocus(rig);
rig.group.updateMatrixWorld();
}
/** Ponto da camada sob o cursor, em coordenadas do grupo, deixado em helpers.target. Falso se o raio não toca a camada. */
function cursorOnLayer({ group, camera, state, helpers }: Rig, spread: number) {
helpers.ray.setFromCamera(state.pointer, camera);
helpers.plane.set(helpers.up, -spread).applyMatrix4(group.matrixWorld);
if (!helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) return false;
group.worldToLocal(helpers.hit);
helpers.target.set(helpers.hit.x, helpers.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,
) {
/** Leva a onda do cursor ao mouse (na camada) ou, sem mouse, a um cursor fantasma que percorre a tela. */
function followCursor(rig: Rig, { time, dt, cursorActive }: Tick) {
const { state, helpers, uniforms } = rig;
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),
);
if (cursorOnLayer(rig, uniforms.uSpread.value))
state.mouse.lerp(helpers.target, 1 - Math.exp(-dt * 10));
return;
}
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));
state.mouse.lerp(
helpers.target.set(
Math.sin(time * 0.31) * 1.5,
Math.sin(time * 0.23 + 1.3) * 1.1,
),
1 - Math.exp(-dt * 1.5),
);
}
/** Onda do cursor: segue o mouse na camada ou, sem mouse, o cursor fantasma. Devolve a força que ela busca. */
export function updatePointer(rig: Rig, tick: Tick) {
const { uniforms, state } = rig;
followCursor(rig, tick);
uniforms.uMouse.value.copy(state.mouse);
const targetAmt =
(tick.cursorActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, tick.p));
uniforms.uMouseAmt.value +=
(targetAmt - uniforms.uMouseAmt.value) * (1 - Math.exp(-tick.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
* rolagem); o quadro pede o 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(
export function needsFrame(
u: Uniforms,
s: FieldState,
target: number,
@@ -593,176 +622,339 @@ function needsFrame(
return settling || (visible && interacting);
}
function Field({
/** O material de pontos: o shader, a mistura aditiva e os uniforms de partida. */
function createMaterial() {
return new ShaderMaterial({
vertexShader,
fragmentShader,
transparent: true,
depthWrite: false,
blending: 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 Vector2(9, 9) },
uMouseAmt: { value: 0 },
},
});
}
const createHelpers = (): Helpers => ({
ray: new Raycaster(),
plane: new Plane(),
up: new Vector3(0, 1, 0),
hit: new Vector3(),
zero: new Vector2(),
target: new Vector2(),
});
const createState = (): FieldState => ({
progress: 0,
pointer: new Vector2(),
lastMove: -10,
mouse: new Vector2(),
tilt: new Vector2(),
frames: 0,
});
/** A resolução de desenho: a do aparelho, de 1 a 1,5 (cada ponto custa fragmentos, e o fundo é ambiente). */
const pixelRatio = () =>
Math.min(1.5, Math.max(1, window.devicePixelRatio || 1));
export type FieldCallbacks = {
/** Progresso do mergulho (0 a 1), escrito pela rolagem. */
progressRef: RefObject<number>;
/** Dois quadros já foram desenhados: o fundo estático pode sair. */
onReady: () => void;
/** Sem WebGL, contexto perdido ou erro ao desenhar: o hero volta ao fundo estático. */
onError: () => void;
};
export type FieldEngine = {
/** Hero à vista (e aba visível): liga a batida de 30 quadros por segundo; fora disso, nenhum quadro é pedido. */
setVisible: (visible: boolean) => void;
/** O quadro que o requestAnimationFrame chama, com o relógio (ms) dado. Os testes o chamam direto. */
renderAt: (now: number) => void;
/** Solta o contexto WebGL, os ouvintes e tudo o que a cena criou. */
dispose: () => void;
};
/** O renderizador transparente, sem antialias, na GPU de alto desempenho, com o canvas dentro de `host`. */
function createRenderer(host: HTMLElement) {
const renderer = new WebGLRenderer({
alpha: true,
antialias: false,
powerPreference: "high-performance",
});
renderer.setPixelRatio(pixelRatio());
const canvas = renderer.domElement;
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "display:block;width:100%;height:100%";
host.append(canvas);
return renderer;
}
/** A cena em si: a câmera, o grupo com a nuvem de pontos e o material do shader. */
type World = {
scene: Scene;
camera: PerspectiveCamera;
group: Group;
geometry: BufferGeometry;
material: ShaderMaterial;
};
function buildWorld(): World {
const camera = new PerspectiveCamera(35, 1, 0.05, 120);
camera.position.set(0, 0, 9);
const geometry = takeField();
const material = createMaterial();
const points = new Points(geometry, material);
points.frustumCulled = false;
const group = new Group();
group.add(points);
const scene = new Scene();
scene.add(group);
return { scene, camera, group, geometry, material };
}
/** O que o quadro lê de fora: o progresso da rolagem, se o hero está à vista e o relógio. */
type StepInput = {
target: number;
visible: boolean;
time: number;
delta: number;
};
/**
* Um quadro: rolagem amortecida, uniforms, câmera e cursor. Diz se foi o segundo quadro (a página pode tirar o fundo
* estático) e se algo ainda muda (então o próximo quadro é pedido de imediato).
*/
function stepField(
world: World,
state: FieldState,
helpers: Helpers,
layout: Layout,
{ target, visible, time, delta }: StepInput,
) {
const dt = Math.min(delta, 1 / 20);
const u = world.material.uniforms;
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
state.progress += (target - state.progress) * (1 - Math.exp(-dt * 5));
const p = state.progress;
const cursorActive = cursorIsActive(state, time);
const face = updateUniforms(u, layout, p, time, dt);
const rig: Rig = {
group: world.group,
camera: world.camera,
uniforms: u,
layout,
state,
helpers,
};
const tick: Tick = { p, face, time, dt, cursorActive };
updateRig(rig, tick);
const amount = updatePointer(rig, tick);
state.frames += 1;
return {
ready: state.frames === 2,
again: needsFrame(u, state, target, amount, cursorActive, visible),
};
}
/** Põe o renderizador e a câmera no tamanho do `host` (nunca zero) e devolve o enquadramento novo. */
function fitToHost(
renderer: WebGLRenderer,
camera: PerspectiveCamera,
host: HTMLElement,
) {
const width = Math.max(1, host.clientWidth);
const height = Math.max(1, host.clientHeight);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
return frameLayout(width, height, camera.fov, renderer.getPixelRatio());
}
type FieldListeners = {
host: HTMLElement;
canvas: HTMLCanvasElement;
resize: () => void;
/** O mouse (não o toque) se mexeu: posição de -1 a 1 na janela. */
onPointer: (x: number, y: number) => void;
/** O contexto WebGL se perdeu. */
onLost: () => void;
};
/** Tamanho do host, mouse e perda do contexto. Devolve a função que solta os ouvintes. */
function listenToField({
host,
canvas,
resize,
onPointer,
onLost,
}: FieldListeners) {
const onMove = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
onPointer(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
);
};
const lost = (event: Event) => {
event.preventDefault();
onLost();
};
const observer =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
observer?.observe(host);
if (!observer) window.addEventListener("resize", resize);
window.addEventListener("pointermove", onMove, { passive: true });
canvas.addEventListener("webglcontextlost", lost);
return () => {
observer?.disconnect();
window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onMove);
canvas.removeEventListener("webglcontextlost", lost);
};
}
/**
* A cena sem React: cria o renderizador dentro de `host` e desenha sob demanda. `invalidate` pede um quadro no
* próximo requestAnimationFrame (vários pedidos viram um); cada quadro atualiza os uniforms, a câmera e o cursor e
* pede o seguinte só se algo ainda muda. Lança se o aparelho não cria um contexto WebGL.
*/
export function createField(
host: HTMLElement,
{ progressRef, onReady, onError }: FieldCallbacks,
): FieldEngine {
const renderer = createRenderer(host);
const world = buildWorld();
const state = createState();
const helpers = createHelpers();
const started = performance.now();
let layout = frameLayout(1, 1, world.camera.fov, renderer.getPixelRatio());
let visible = false;
let alive = true;
let raf = 0;
let last = 0;
let stopPulse: (() => void) | undefined;
const invalidate = () => {
if (!raf && alive) raf = requestAnimationFrame(render);
};
const fail = () => {
if (alive) onError();
};
function render(now: number) {
raf = 0;
if (!alive) return;
const delta = last ? (now - last) / 1000 : 1 / 60;
last = now;
try {
const step = stepField(world, state, helpers, layout, {
target: progressRef.current ?? 0,
visible,
time: (now - started) / 1000,
delta,
});
if (step.ready) onReady();
if (step.again) invalidate();
renderer.render(world.scene, world.camera);
} catch {
fail();
}
}
const resize = () => {
layout = fitToHost(renderer, world.camera, host);
invalidate();
};
const stopListening = listenToField({
host,
canvas: renderer.domElement,
resize,
onPointer: (x, y) => {
state.pointer.set(x, y);
// na mesma base do relógio do quadro (segundos desde que a cena nasceu)
state.lastMove = (performance.now() - started) / 1000;
},
onLost: fail,
});
resize();
return {
renderAt: render,
setVisible(next) {
visible = next;
if (next) stopPulse ??= startFramePulse(invalidate);
else {
stopPulse?.();
stopPulse = undefined;
}
},
dispose() {
alive = false;
cancelAnimationFrame(raf);
stopPulse?.();
stopListening();
world.geometry.dispose();
world.material.dispose();
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
},
};
}
export default function HeroFieldScene({
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 hostRef = useRef<HTMLDivElement>(null);
const engine = useRef<FieldEngine | null>(null);
// sempre a última versão dos avisos, sem refazer a cena quando o pai troca de função
const callbacks = useRef({ onReady, onError });
useEffect(() => {
callbacks.current = { onReady, onError };
});
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);
const host = hostRef.current;
if (!host) return;
try {
engine.current = createField(host, {
progressRef,
onReady: () => callbacks.current.onReady(),
onError: () => callbacks.current.onError(),
});
} catch {
// sem WebGL (ou o contexto não abriu): o hero fica com o fundo estático
callbacks.current.onError();
return;
}
return () => {
window.removeEventListener("pointermove", onMove);
gl.domElement.removeEventListener("webglcontextlost", onLost);
geometry.dispose();
material.dispose();
engine.current?.dispose();
engine.current = null;
};
}, [gl, geometry, material, onError]);
}, [progressRef]);
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();
});
useEffect(() => {
engine.current?.setVisible(visible);
}, [visible]);
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>
<div
ref={hostRef}
aria-hidden="true"
style={{ width: "100%", height: "100%" }}
/>
);
}