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.
961 lines
30 KiB
TypeScript
961 lines
30 KiB
TypeScript
"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%" }}
|
||
/>
|
||
);
|
||
}
|