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:
1 parent
f8545cd442
commit
6ac87824f3
68 files changed
+5735
-3300
No files matched your search
@@ -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%" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user