"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; 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 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; /** 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: 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 (no ritmo da tela, até 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 interacting = cursorActive || Math.abs(amount - u.uMouseAmt.value) > 0.01 || s.frames < 3; return isSettling(u, s, target) || (visible && interacting); } /** * Intervalo mínimo (ms) entre dois quadros quando só o cursor os pede: uns 30 por segundo, mesmo numa tela de 144 Hz * (que desenhava 142). Fica um pouco abaixo de 33,3 (dois quadros de uma tela de 60 Hz) para a tela de 60 Hz manter * 30 e a batida (um setInterval de 33 ms, com a folga do temporizador) não perder quadros. */ const CURSOR_FRAME_MS = 32; /** 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; /** 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); }; } /** * 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; /** 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 algo ainda muda. 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 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 target = progressRef.current ?? 0; // só o cursor pede quadro (a entrada e a rolagem seguem a cada quadro): pula este se faz menos de ~32 ms do // último e pede o próximo, em vez de desenhar a cada quadro da tela if ( last && now - last < CURSOR_FRAME_MS && !isSettling(world.material.uniforms, state, target) ) { invalidate(); return; } const delta = last ? (now - last) / 1000 : 1 / 60; last = now; try { const step = stepField(world, state, helpers, layout, { target, 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(); 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. */ 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((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 ou se o shader não compila. */ export async function mountField( host: HTMLElement, callbacks: FieldCallbacks, isAlive: () => boolean, ): Promise { 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(null); const engine = useRef(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 (