1059 lines
34 KiB
TypeScript
1059 lines
34 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 { isSoftwareWebGL, rememberSoftwareWebGL } from "@/lib/device";
|
|
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 no ritmo da tela só enquanto a entrada ou a rolagem assenta; 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.pushStyle(style);
|
|
}
|
|
/** O tom, o tamanho e a opacidade do ponto, com o padrão onde o estilo não diz. */
|
|
private pushStyle({ tone = 0, size = 0.02, alpha = 0.7 }: PointStyle = {}) {
|
|
this.tone.push(tone);
|
|
this.size.push(size);
|
|
this.alpha.push(alpha);
|
|
}
|
|
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 do Hero (HERO_CAPTIONS, em hero-captions.ts) entram um pouco antes de
|
|
* a câmera chegar a cada camada e saem um pouco depois (a última fica até Projetos subir e cobri-la); se os
|
|
* quadros abaixo mudarem, os trechos delas mudam junto.
|
|
* 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;
|
|
}
|
|
|
|
/** A entrada ou a rolagem ainda não assentou (o progresso mostrado ainda não alcançou o da página). */
|
|
export function isSettling(u: Uniforms, s: FieldState, target: number) {
|
|
return u.uIntro.value < 1 || Math.abs(target - s.progress) > 4e-4;
|
|
}
|
|
|
|
/*
|
|
* Sob demanda, com um só relógio para o que é ambiente: o FramePulse (um setInterval de 30 por segundo, ligado só com o
|
|
* hero à vista e parado depois de uns segundos sem cursor nem rolagem) pede o quadro do cursor e da onda dele; a tela,
|
|
* a 60 ou 144 Hz, não pede nada por conta própria. Um quadro só pede o seguinte de imediato, no ritmo da tela, enquanto
|
|
* a entrada ou a rolagem assenta (`isSettling`), mesmo com o hero fora de cena: o último quadro tem de ser o final.
|
|
* Antes, o cursor também encadeava um requestAnimationFrame por quadro da tela (143 por segundo a 144 Hz) só para
|
|
* pular o desenho e pedir de novo, e a página acordava sem desenhar nada.
|
|
*/
|
|
|
|
/** 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`. Lança se o
|
|
* contexto não abre ou se ele desenha por software (SwiftShader, llvmpipe, Basic Render): abre, mas a cena sairia a
|
|
* poucos quadros por segundo na CPU, e o hero fica estático. O contexto que acabou de abrir é o do teste (nenhum outro
|
|
* é criado só para isso) e o veredito fica anotado na sessão (`rememberSoftwareWebGL`).
|
|
*/
|
|
function createRenderer(host: HTMLElement) {
|
|
const renderer = new WebGLRenderer({
|
|
alpha: true,
|
|
antialias: false,
|
|
powerPreference: "high-performance",
|
|
});
|
|
if (isSoftwareWebGL(renderer.getContext())) {
|
|
rememberSoftwareWebGL();
|
|
disposeRenderer(renderer);
|
|
throw new Error("WebGL por software");
|
|
}
|
|
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 e o relógio. */
|
|
type StepInput = {
|
|
target: number;
|
|
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 a entrada ou a rolagem ainda assenta (então o próximo quadro é pedido de imediato; o do cursor, não:
|
|
* vem da batida).
|
|
*/
|
|
function stepField(
|
|
world: World,
|
|
state: FieldState,
|
|
helpers: Helpers,
|
|
layout: Layout,
|
|
{ target, 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);
|
|
updatePointer(rig, tick);
|
|
state.frames += 1;
|
|
return {
|
|
ready: state.frames === 2,
|
|
again: isSettling(u, state, target),
|
|
};
|
|
}
|
|
|
|
/** 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);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Completa o renderizador com o que o quadro desenha: a nuvem de pontos e o estado. Separado de `createRenderer` (que
|
|
* abre o contexto WebGL) para a montagem em tarefas poder pôr um passo em cada uma.
|
|
*/
|
|
function createParts(renderer: WebGLRenderer) {
|
|
return {
|
|
renderer,
|
|
world: buildWorld(),
|
|
state: createState(),
|
|
helpers: createHelpers(),
|
|
};
|
|
}
|
|
|
|
type Parts = ReturnType<typeof createParts>;
|
|
|
|
/** Solta o contexto WebGL e tira o canvas da página. */
|
|
function disposeRenderer(renderer: WebGLRenderer) {
|
|
renderer.dispose();
|
|
renderer.forceContextLoss();
|
|
renderer.domElement.remove();
|
|
}
|
|
|
|
/** Solta o que `createParts` criou: a geometria, o material e o contexto WebGL. */
|
|
function disposeParts({ renderer, world }: Parts) {
|
|
world.geometry.dispose();
|
|
world.material.dispose();
|
|
disposeRenderer(renderer);
|
|
}
|
|
|
|
/**
|
|
* A cena sem React, a partir das peças já criadas: liga o tamanho, o mouse e a perda do contexto, 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 a entrada ou a rolagem ainda assenta (o resto vem
|
|
* da batida de 30 quadros por segundo). O primeiro quadro sai no requestAnimationFrame que `resize` pede, numa
|
|
* tarefa própria.
|
|
*/
|
|
function startField(
|
|
host: HTMLElement,
|
|
parts: Parts,
|
|
{ progressRef, onReady, onError }: FieldCallbacks,
|
|
): FieldEngine {
|
|
const { renderer, world, state, helpers } = parts;
|
|
const started = performance.now();
|
|
let layout = frameLayout(1, 1, world.camera.fov, renderer.getPixelRatio());
|
|
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();
|
|
};
|
|
|
|
/** Um quadro: avança a cena, avisa a página, pede o seguinte se ainda assenta e desenha. */
|
|
const draw = (now: number) => {
|
|
const delta = last ? (now - last) / 1000 : 1 / 60;
|
|
last = now;
|
|
const step = stepField(world, state, helpers, layout, {
|
|
target: progressRef.current ?? 0,
|
|
time: (now - started) / 1000,
|
|
delta,
|
|
});
|
|
if (step.ready) onReady();
|
|
if (step.again) invalidate();
|
|
renderer.render(world.scene, world.camera);
|
|
};
|
|
|
|
function render(now: number) {
|
|
raf = 0;
|
|
if (!alive) return;
|
|
try {
|
|
draw(now);
|
|
} 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) {
|
|
if (next) stopPulse ??= startFramePulse(invalidate);
|
|
else {
|
|
stopPulse?.();
|
|
stopPulse = undefined;
|
|
}
|
|
},
|
|
dispose() {
|
|
alive = false;
|
|
cancelAnimationFrame(raf);
|
|
stopPulse?.();
|
|
stopListening();
|
|
disposeParts(parts);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** A cena sem React, de uma vez só: cria o renderizador dentro de `host` e começa a desenhar. Lança se o aparelho não cria um contexto WebGL ou se ele desenha por software. */
|
|
export function createField(
|
|
host: HTMLElement,
|
|
callbacks: FieldCallbacks,
|
|
): FieldEngine {
|
|
return startField(host, createParts(createRenderer(host)), callbacks);
|
|
}
|
|
|
|
/** Cede a vez ao navegador: o passo seguinte roda noutra tarefa, depois de pintar e de atender o visitante. */
|
|
const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
|
|
/**
|
|
* A mesma cena montada em tarefas separadas, para nenhuma passar de uns poucos milissegundos (criar tudo de uma vez
|
|
* era uma só tarefa de 90 a 250 ms): o renderizador (o contexto WebGL); a nuvem de pontos e o estado; a compilação do
|
|
* shader (`compileAsync`, que o navegador faz fora da thread principal); e, por fim, o tamanho e o primeiro quadro
|
|
* (que sai no requestAnimationFrame seguinte). Devolve `null` se `isAlive` virou falso no caminho; lança se o
|
|
* aparelho não cria um contexto WebGL, se ele desenha por software ou se o shader não compila.
|
|
*/
|
|
export async function mountField(
|
|
host: HTMLElement,
|
|
callbacks: FieldCallbacks,
|
|
isAlive: () => boolean,
|
|
): Promise<FieldEngine | null> {
|
|
await nextTask();
|
|
if (!isAlive()) return null;
|
|
const renderer = createRenderer(host);
|
|
let parts: Parts | undefined;
|
|
try {
|
|
await nextTask();
|
|
if (!isAlive()) {
|
|
disposeRenderer(renderer);
|
|
return null;
|
|
}
|
|
parts = createParts(renderer);
|
|
await renderer.compileAsync(parts.world.scene, parts.world.camera);
|
|
await nextTask();
|
|
} catch (error) {
|
|
if (parts) disposeParts(parts);
|
|
else disposeRenderer(renderer);
|
|
throw error;
|
|
}
|
|
if (!isAlive()) {
|
|
disposeParts(parts);
|
|
return null;
|
|
}
|
|
return startField(host, parts, callbacks);
|
|
}
|
|
|
|
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 };
|
|
});
|
|
|
|
// o que a página pediu até a cena ficar pronta (ela nasce em tarefas separadas, depois de o `visible` já ter chegado)
|
|
const wantsVisible = useRef(visible);
|
|
|
|
useEffect(() => {
|
|
const host = hostRef.current;
|
|
if (!host) return;
|
|
let alive = true;
|
|
mountField(
|
|
host,
|
|
{
|
|
progressRef,
|
|
onReady: () => callbacks.current.onReady(),
|
|
onError: () => callbacks.current.onError(),
|
|
},
|
|
() => alive,
|
|
)
|
|
.then((created) => {
|
|
if (!created) return;
|
|
if (!alive) return created.dispose();
|
|
engine.current = created;
|
|
created.setVisible(wantsVisible.current);
|
|
})
|
|
// sem WebGL (ou o contexto não abriu, o shader não compilou): o hero fica com o fundo estático
|
|
.catch(() => {
|
|
if (alive) callbacks.current.onError();
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
engine.current?.dispose();
|
|
engine.current = null;
|
|
};
|
|
}, [progressRef]);
|
|
|
|
useEffect(() => {
|
|
wantsVisible.current = visible;
|
|
engine.current?.setVisible(visible);
|
|
}, [visible]);
|
|
|
|
return (
|
|
<div
|
|
ref={hostRef}
|
|
aria-hidden="true"
|
|
style={{ width: "100%", height: "100%" }}
|
|
/>
|
|
);
|
|
}
|