Files
portifolio-evandrodevbr/app/components/cinematic/HeroFieldScene.tsx
T
Evandro Fonseca Junior 6ac87824f3 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.
2026-10-14 17:01:37 -03:00

961 lines
30 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 { type RefObject, useEffect, useRef } from "react";
import {
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>;
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 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
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 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;
}
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) 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.
*/
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: 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 = 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(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: Vector2;
lastMove: number;
mouse: Vector2;
tilt: Vector2;
frames: number;
};
/** Objetos reaproveitados a cada quadro (nada é alocado dentro do useFrame). */
type Helpers = {
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;
/** 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, 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;
}
/** 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(rig, uniforms.uSpread.value))
state.mouse.lerp(helpers.target, 1 - Math.exp(-dt * 10));
return;
}
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); 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.
*/
export 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);
}
/** 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 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 };
});
useEffect(() => {
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 () => {
engine.current?.dispose();
engine.current = null;
};
}, [progressRef]);
useEffect(() => {
engine.current?.setVisible(visible);
}, [visible]);
return (
<div
ref={hostRef}
aria-hidden="true"
style={{ width: "100%", height: "100%" }}
/>
);
}