Files
portifolio-evandrodevbr/app/components/cinematic/HeroFieldScene.tsx
T

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%" }}
/>
);
}