"use client"; import { type RefObject, useEffect, useRef } from "react"; import { type BufferGeometry, Euler, InstancedBufferAttribute, InstancedBufferGeometry, MathUtils, Matrix3, Matrix4, Mesh, PerspectiveCamera, Plane, Ray, Scene, ShaderMaterial, SRGBColorSpace, type Texture, TextureLoader, Vector2, Vector3, WebGLRenderer, } from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { type Blocos, caixaDosEstados, cicloAt, createFrameBudget, type Estado, type FrameVerdict, framePiece, papeis, parseBlocos, type Rect, seguir, stateAt, } from "@/lib/blocos"; import { isSoftwareWebGL, rememberSlowScene, rememberSoftwareWebGL, } from "@/lib/device"; import { startFramePulse } from "./FramePulse"; export type BlocosSceneProps = { /** Progresso do mergulho (0 a 1), escrito pela rolagem. */ progressRef: RefObject; visible: boolean; onReady: () => void; onError: () => void; /** `full` segue a rolagem e o mouse; `lite` (celular) troca de estado sozinho. Padrão `full`. */ tier?: "full" | "lite"; /** Onde a peça descansa no estado `marca` (px CSS relativos ao hospedeiro); sem ele, no hospedeiro inteiro. */ placementRef?: RefObject; /** Área útil dos outros estados (px CSS relativos ao hospedeiro); sem ela, o hospedeiro inteiro. */ stageRef?: RefObject; /** * O estado em que a peça está assentada na caixa dela (`placementRef` no `full`, a caixa toda no `lite`), para a * legenda; `null` durante uma troca e fora da caixa (no mergulho). Avisa só quando muda. */ onEstado?: (estado: number | null) => void; }; /* * Os blocos da marca, em tempo real: uma malha instanciada (um bloco chanfrado do Blender, repetido) com os quatro * estados de `public/3d/blocos.json` em atributos por instância. A troca de estado ("encaixar": cada bloco sai com um * atraso próprio, faz um arco curto para a frente e assenta sem quicar) e a entrada correm no vertex shader; o * JavaScript só atualiza uniforms, a pose e o enquadramento. A luz vem de dois matcaps renderizados no Blender com a * mesma luz do pôster, então o primeiro quadro assume por cima do pôster sem salto. * Parada num estado, a peça tem vida própria (`vida` no vertex shader), calculada pelo tempo; no `full`, os blocos * perto do ponteiro avançam para a frente. O desenho é sob demanda: no ritmo da tela só enquanto a entrada, uma troca * ou a rolagem assenta; fora disso a batida de 30 quadros por segundo enquanto há alguém mexendo na página (é ela que * faz a vida correr e parar) e, no `lite`, um quadro na próxima troca. */ type Assets = { bloco: BufferGeometry; blocos: Blocos; porcelana: Texture; lima: Texture; }; /** Duração da entrada (s): os blocos saem de perto do lugar e assentam na marca, por cima do pôster. */ const ENTRADA = 1.4; /** `lite`: tempo parado em cada estado e tempo de cada troca (s). */ const FICA = 4; const TROCA = 1.2; /** `full` parado no topo: tempo em cada estado do ciclo em repouso (s); a troca é a mesma do `lite`. */ const FICA_TOPO = 3; /** Avanço do mergulho abaixo do qual a página conta como parada no topo. */ const TOPO = 0.01; /** Quantas trocas inteiras por `TROCA` a peça anda quando segue um pedido novo (a rolagem que assume o ciclo). */ const SEGUE = 2; /** Pose da marca em repouso: a da câmera do pôster em `3d/blocos.py` (pela esquerda, 26°, e de cima, 12°). */ const REPOUSO = { yaw: 0.45, pitch: 0.21 }; /** Pose dos outros estados, quase de frente: a da câmera das prévias em `3d/blocos.py`. */ const FRENTE = { yaw: 0.24, pitch: 0.12 }; /** A câmera fica parada; a peça é que se move e muda de tamanho para caber onde `framePiece` manda. */ const DISTANCIA = 40; /** O plano da peça (z = 0, no espaço dela), onde o ponteiro é medido. */ const PLANO = new Plane(new Vector3(0, 0, 1), 0); const VERTEX = /* glsl */ ` #define ATRASO 0.35 attribute vec4 aPos0; attribute vec4 aPos1; attribute vec4 aPos2; attribute vec4 aPos3; attribute vec3 aEsc0; attribute vec3 aEsc1; attribute vec3 aEsc2; attribute vec3 aEsc3; attribute vec3 aEntrada; attribute float aSemente; // papel na vida em repouso (papeis, em lib/blocos.ts): quadrado da marca, botão da tela, linha, barra attribute vec4 aPapel; uniform float uFrom; uniform float uTo; uniform float uT; uniform float uIntro; // peso da vida (0 na troca e na entrada), estado parado em que ela corre e o relógio dela (s) uniform float uVida; uniform float uEstado; uniform float uTempo; uniform vec3 uBotao; uniform float uChao; // ponteiro no plano z = 0 da peça e o peso dele (0 sem ponteiro, no lite e fora do repouso) uniform vec2 uCursor; uniform float uCursorPeso; varying vec3 vNormal; varying float vTom; // posição (xyz) e tom (w) do bloco no estado i vec4 lugar(float i) { return i < 0.5 ? aPos0 : i < 1.5 ? aPos1 : i < 2.5 ? aPos2 : aPos3; } vec3 escala(float i) { return i < 0.5 ? aEsc0 : i < 1.5 ? aEsc1 : i < 2.5 ? aEsc2 : aEsc3; } // encaixar: cada bloco sai com um atraso próprio e assenta com ease-out, sem passar do ponto float encaixe(float t) { float k = clamp((t - aSemente * ATRASO) / (1.0 - ATRASO), 0.0, 1.0); return 1.0 - pow(1.0 - k, 3.0); } // pulo de um quadrado, u de 0 a 1: sobe rápido e assenta devagar, sem passar do lugar; 0 fora disso float pulo(float u) { if (u <= 0.0 || u >= 1.0) return 0.0; if (u < 0.35) return 1.0 - pow(1.0 - u / 0.35, 2.0); return 1.0 - smoothstep(0.0, 1.0, (u - 0.35) / 0.65); } // a vida em repouso do estado: muda posição, escala e tom do bloco void vida(inout vec3 p, inout vec3 s, inout float tom) { if (uEstado < 0.5) { // marca: os quatro quadrados lima pulam 0,25 em sequência, 0,4 s cada, um ciclo a cada 6 s; o primeiro vem 2,5 s // depois de a cena nascer, logo após a entrada (e dentro dos 4 s do lite na marca) if (aPapel.x >= 0.0) p.y += uVida * 0.25 * pulo((mod(uTempo - 2.5, 6.0) - aPapel.x * 0.3) / 0.4); } else if (uEstado < 1.5) { // tela: o botão inteiro pulsa de 1 a 1,08 em volta do centro, a cada 2,4 s float k = 1.0 + uVida * 0.08 * aPapel.y * sin(3.14159265 * min(mod(uTempo, 2.4) / 0.9, 1.0)); p = uBotao + (p - uBotao) * k; s *= k; } else if (uEstado < 2.5) { // integrações: um pulso lima corre do núcleo aos nós em 0,9 s, a cada 2,5 s float frente = mod(uTempo, 2.5) / 0.9 * 1.4 - 0.2; if (aPapel.z >= 0.0) tom = max(tom, uVida * (1.0 - smoothstep(0.0, 0.2, abs(aPapel.z - frente)))); } else if (aPapel.w >= 0.0) { // dados: cada barra respira 15% em altura a partir do chão, com a fase dela, em 3,2 s float k = 1.0 + uVida * 0.15 * (0.5 - 0.5 * cos(6.2831853 * (uTempo / 3.2 + aPapel.w))); p.z = uChao + (p.z - uChao) * k; s.z *= k; } } void main() { vec4 a = lugar(uFrom); vec4 b = lugar(uTo); vec3 ea = escala(uFrom); vec3 eb = escala(uTo); // bloco que só existe num dos estados cresce ou some no lugar, sem viajar vec3 pa = ea.x > 0.0 ? a.xyz : b.xyz; vec3 pb = eb.x > 0.0 ? b.xyz : a.xyz; float e = encaixe(uT); vec3 p = mix(pa, pb, e); // arco curto para a frente (+Z), que volta a zero ao assentar; quem não sai do lugar não faz arco p.z += sin(3.14159265 * e) * min(distance(pa, pb), 1.0) * (0.5 + 0.5 * aSemente); vec3 s = mix(ea, eb, e); vTom = mix(a.w, b.w, e); if (uVida > 0.0) vida(p, s, vTom); // cursor: os blocos a até 2 unidades do ponteiro avançam até 0,5 para a frente, com queda suave p.z += 0.5 * uCursorPeso * (1.0 - smoothstep(0.0, 2.0, distance(p.xy, uCursor))); // entrada: de perto do lugar e um pouco menor, com o mesmo encaixe float ei = encaixe(uIntro); p += aEntrada * (1.0 - ei); s *= mix(0.8, 1.0, ei); vec4 mv = modelViewMatrix * vec4(position * s + p, 1.0); // escala não uniforme: a normal vai pela inversa da escala vNormal = normalize(normalMatrix * (normal / max(s, vec3(1e-4)))); gl_Position = projectionMatrix * mv; } `; const FRAGMENT = /* glsl */ ` uniform sampler2D uPorcelana; uniform sampler2D uLima; uniform mat3 uLuz; varying vec3 vNormal; varying float vTom; void main() { // matcap pela normal em espaço de visão, desfeita da pose (uLuz): a luz fica presa à peça como no Blender, onde a // câmera do pôster girou em volta da peça e a luz não. Sem a correção de perspectiva do MeshMatcapMaterial, que // prenderia a luz à câmera de novo; só o balanço e o mouse mexem nela vec3 n = uLuz * normalize(vNormal); vec2 uv = n.xy * 0.495 + 0.5; gl_FragColor = vec4(mix(texture2D(uPorcelana, uv).rgb, texture2D(uLima, uv).rgb, vTom), 1.0); #include } `; let assets: Promise | null = null; async function loadAssets(): Promise { const textures = new TextureLoader(); const [gltf, blocos, porcelana, lima] = await Promise.all([ new GLTFLoader().loadAsync("/3d/bloco.glb"), fetch("/3d/blocos.json").then((response) => { if (!response.ok) throw new Error(`blocos.json: ${response.status}`); return response.json().then(parseBlocos); }), textures.loadAsync("/3d/matcap-porcelana.webp"), textures.loadAsync("/3d/matcap-lima.webp"), ]); let bloco: BufferGeometry | undefined; gltf.scene.traverse((object) => { if (!bloco && object instanceof Mesh) bloco = object.geometry; }); if (!bloco) throw new Error("bloco.glb sem malha"); // os matcaps estão em sRGB: o three decodifica na amostra e `colorspace_fragment` codifica de volta na saída porcelana.colorSpace = SRGBColorSpace; lima.colorSpace = SRGBColorSpace; return { bloco, blocos, porcelana, lima }; } /** Os ativos baixados uma vez por página; uma falha esvazia o cache para a próxima montagem tentar de novo. */ function loadBlocos() { assets ??= loadAssets().catch((error) => { assets = null; throw error; }); return assets; } /** * Baixa e deixa em cache o bloco, os estados e os matcaps, para quem quer adiantar o download antes de montar a cena. * Nunca rejeita: uma falha aparece ao montar (`onError`). */ export function prepareBlocos(): Promise { return loadBlocos().then( () => undefined, () => undefined, ); } /** Número de 0 a 1 fixo para cada bloco e canal: a mesma "sorte" a cada visita. */ function sorteio(bloco: number, canal: number) { let h = Math.imul(bloco * 8 + canal + 1, 0x9e3779b1); h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); h ^= h >>> 13; return (h >>> 0) / 4294967296; } /** A cena: câmera, a malha instanciada com os quatro estados e o material de matcap. */ function buildWorld({ bloco, blocos, porcelana, lima }: Assets) { const { n, estados } = blocos; const geometry = new InstancedBufferGeometry(); geometry.index = bloco.index; geometry.setAttribute("position", bloco.getAttribute("position")); geometry.setAttribute("normal", bloco.getAttribute("normal")); geometry.instanceCount = n; estados.forEach((dados, e) => { const lugar = new Float32Array(n * 4); const escala = new Float32Array(n * 3); for (let i = 0; i < n; i++) { for (let k = 0; k < 3; k++) { lugar[i * 4 + k] = dados[i * 7 + k]; escala[i * 3 + k] = dados[i * 7 + 3 + k]; } lugar[i * 4 + 3] = dados[i * 7 + 6]; } geometry.setAttribute(`aPos${e}`, new InstancedBufferAttribute(lugar, 4)); geometry.setAttribute(`aEsc${e}`, new InstancedBufferAttribute(escala, 3)); }); const semente = new Float32Array(n); const entrada = new Float32Array(n * 3); for (let i = 0; i < n; i++) { semente[i] = sorteio(i, 0); // de onde o bloco entra: até 1,2 unidade do lugar, mais para a frente que para trás const x = sorteio(i, 1) - 0.5; const y = sorteio(i, 2) - 0.5; const z = sorteio(i, 3); const k = (0.4 + 0.8 * sorteio(i, 4)) / Math.hypot(x, y, z || 1); entrada[i * 3] = x * k; entrada[i * 3 + 1] = y * k; entrada[i * 3 + 2] = z * k; } geometry.setAttribute("aSemente", new InstancedBufferAttribute(semente, 1)); geometry.setAttribute("aEntrada", new InstancedBufferAttribute(entrada, 3)); const { vida, botao, chao } = papeis(blocos); geometry.setAttribute("aPapel", new InstancedBufferAttribute(vida, 4)); // a visão desfeita da pose da peça (ver o fragment shader) const luz = new Matrix3(); const material = new ShaderMaterial({ vertexShader: VERTEX, fragmentShader: FRAGMENT, uniforms: { uFrom: { value: 0 }, uTo: { value: 0 }, uT: { value: 0 }, uIntro: { value: 0 }, uVida: { value: 0 }, uEstado: { value: 0 }, uTempo: { value: 0 }, uBotao: { value: new Vector3(...botao) }, uChao: { value: chao }, uCursor: { value: new Vector2() }, uCursorPeso: { value: 0 }, uPorcelana: { value: porcelana }, uLima: { value: lima }, uLuz: { value: luz }, }, }); const mesh = new Mesh(geometry, material); // a malha é um bloco só: a caixa dela não diz onde estão as instâncias mesh.frustumCulled = false; const scene = new Scene(); scene.add(mesh); const camera = new PerspectiveCamera(35, 1, 1, DISTANCIA * 4); camera.position.set(0, 0, DISTANCIA); // o raio do ponteiro sai dela antes do primeiro desenho camera.updateMatrixWorld(); return { scene, camera, mesh, geometry, material, porcelana, lima, luz, pose: new Euler(), rotation: new Matrix4(), // o raio do ponteiro levado para dentro da peça e onde ele fura o plano dela inversa: new Matrix4(), raio: new Ray(), mira: new Vector3(), }; } type World = ReturnType; type Tier = NonNullable; type SceneState = { /** Progresso amortecido (`full`). */ progress: number; /** Entrada, de 0 a 1. */ intro: number; /** Relógio da troca sozinha (`lite`, s), que só anda à vista e depois da entrada. */ clock: number; /** `full`: tempo (s) com a página parada no topo, sem rolagem; acima de 0, o ciclo em repouso tem o comando. */ quieto: number; /** O alvo do progresso no quadro anterior (rolagem é ele mudar). */ ultimoAlvo: number; /** O estado mostrado, quem o pede e se ele já alcançou o pedido (aí o segue sem atraso). */ mostrado: Estado | null; fonte: "ciclo" | "rolagem"; preso: boolean; pointer: Vector2; tilt: Vector2; frames: number; /** * Peso da vida em repouso (0 a 1), o estado em que ela corre e o relógio dela e do balanço (s, só anda quando há * quadro: o balanço não salta quando o desenho volta depois de parado). */ vida: number; vidaEstado: number; vidaTempo: number; /** Ponteiro amortecido no plano da peça e o peso dele (0 a 1). */ cursor: Vector2; cursorPeso: number; }; type StepInput = { tier: Tier; target: number; placement: Rect | null; stage: Rect | null; host: { width: number; height: number }; /** Tempo real desde o quadro anterior (ms). */ delta: number; /** Ponteiro em px do hospedeiro (`full`, com o mouse dentro dele); `null` sem ele. */ cursor: Vector2 | null; }; /** Peso da marca num estado: 1 parado nela, 0 nos outros, entre os dois numa troca de ou para ela. */ function pesoMarca({ from, to, t }: Estado) { return (from === 0 ? 1 - t : 0) + (to === 0 ? t : 0); } /** * Um quadro: avança a entrada, o progresso (ou o relógio da troca), a pose e o enquadramento, a vida em repouso e o * cursor, e escreve os uniforms. * Diz se ainda há algo assentando (o próximo quadro sai de imediato), em quanto tempo (s) vem a próxima troca do * ciclo parado (`lite` e `full` no topo) e o estado assentado que a legenda mostra. */ function stepBlocos(world: World, s: SceneState, input: StepInput) { const { tier, target, placement, stage, host, delta } = input; const dt = Math.min(delta / 1000, 1 / 20); s.intro = Math.min(1, s.intro + dt / ENTRADA); let settling = s.intro < 1; let wait = 0; let pedido: Estado; let fonte: SceneState["fonte"] = "ciclo"; // a troca sozinha conta tempo real (o quadro seguinte de uma espera chega só no fim dela) const real = Math.min(delta / 1000, FICA + TROCA); if (tier === "full") { // rolagem amortecida: a peça segue o dedo com um leve atraso s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5)); if (Math.abs(target - s.progress) > 4e-4) settling = true; else s.progress = target; // parada no topo, sem rolagem desde o quadro anterior: o ciclo em repouso (que começa parado na marca, `FICA_TOPO` // s) assume; qualquer rolagem devolve o comando à rolagem const noTopo = s.intro >= 1 && target < TOPO && target === s.ultimoAlvo && s.progress === target; s.ultimoAlvo = target; s.quieto = noTopo ? s.quieto + real : 0; if (s.quieto > 0) { const ciclo = cicloAt(s.quieto, FICA_TOPO, TROCA); pedido = ciclo; if (ciclo.wait === 0) settling = true; else wait = ciclo.wait; } else { pedido = stateAt(s.progress); fonte = "rolagem"; } s.tilt.lerp(s.pointer, 1 - Math.exp(-dt * 3)); } else { if (s.intro >= 1) s.clock += real; const lite = cicloAt(s.clock, FICA, TROCA); pedido = lite; if (lite.wait === 0) settling = true; else wait = lite.wait; } // quem pede mudou: a peça vai do estado em que está até o pedido, sem salto, e daí em diante o segue if (fonte !== s.fonte) s.preso = false; s.fonte = fonte; let estado = pedido; if (s.mostrado && !s.preso) { estado = seguir(s.mostrado, pedido, (dt * SEGUE) / TROCA); s.preso = estado.from === pedido.from && estado.to === pedido.to && estado.t === pedido.t; if (!s.preso) settling = true; } s.mostrado = { from: estado.from, to: estado.to, t: estado.t }; const u = world.material.uniforms; u.uFrom.value = estado.from; u.uTo.value = estado.to; u.uT.value = estado.t; u.uIntro.value = s.intro; // peso da marca: 1 parado nela, 0 nos outros estados; pose e enquadramento seguem a troca const w = pesoMarca(estado); // a marca vai na caixa da peça; os outros estados, no ciclo do topo, na caixa dos estados (dentro da da peça, longe // do texto) e, no mergulho, na área útil. `fora` diz quanto a rolagem já pede um estado fora da marca: 0 no topo, // então a rolagem assume do ciclo sem salto (o progresso amortecido parte de lá) const fora = tier === "full" && s.quieto === 0 ? 1 - pesoMarca(stateAt(s.progress)) : 0; let area = stage; if (tier === "full" && placement) { const topo = caixaDosEstados(placement, host); const fim = stage ?? { x: 0, y: 0, ...host }; area = { x: MathUtils.lerp(topo.x, fim.x, fora), y: MathUtils.lerp(topo.y, fim.y, fora), width: MathUtils.lerp(topo.width, fim.width, fora), height: MathUtils.lerp(topo.height, fim.height, fora), }; } // px por unidade no plano z = 0, para levar o enquadramento (px) para a cena const k = host.height / (2 * DISTANCIA * Math.tan(MathUtils.degToRad(world.camera.fov / 2))); const frame = framePiece(host, placement, w, area); const x = (frame.x - host.width / 2) / k; const y = (host.height / 2 - frame.y) / k; world.mesh.position.set(x, y, 0); world.mesh.scale.setScalar(frame.scale / k); // a pose vale em relação à linha de visada: fora do centro a câmera já vê a peça de lado (à direita, pela // esquerda dela), e a guinada e a elevação descontam esse ângulo para ela aparecer como no pôster const pose = world.pose.set( MathUtils.lerp(FRENTE.pitch, REPOUSO.pitch, w) + Math.atan2(y, DISTANCIA), MathUtils.lerp(FRENTE.yaw, REPOUSO.yaw, w) - Math.atan2(x, DISTANCIA), 0, ); const sway = tier === "full" ? 0.08 * Math.sin(s.vidaTempo * 0.35) : 0; world.mesh.rotation.set( pose.x - s.tilt.y * 0.06, pose.y + sway + s.tilt.x * 0.1, 0, ); // a inversa da pose (uma rotação: a transposta) leva a visão de volta à frente da peça, onde os matcaps foram feitos world.luz .setFromMatrix4(world.rotation.makeRotationFromEuler(pose)) .transpose(); // vida em repouso: só parada num estado e depois da entrada; muda de estado só depois de sumir no anterior const parado = estado.from === estado.to && s.intro >= 1; if (parado && s.vida < 0.01) s.vidaEstado = estado.to; const vida = parado && estado.to === s.vidaEstado ? 1 : 0; s.vida += (vida - s.vida) * (1 - Math.exp(-dt * 5)); s.vidaTempo += dt; u.uVida.value = s.vida; u.uEstado.value = s.vidaEstado; u.uTempo.value = s.vidaTempo; // cursor: o raio da câmera pelo ponteiro, levado para dentro da peça, fura o plano z = 0 dela let mira = 0; if (input.cursor) { world.mesh.updateMatrixWorld(); const { raio, camera } = world; raio.origin.setFromMatrixPosition(camera.matrixWorld); raio.direction .set( (input.cursor.x / host.width) * 2 - 1, 1 - (input.cursor.y / host.height) * 2, 0.5, ) .unproject(camera) .sub(raio.origin); raio.applyMatrix4(world.inversa.copy(world.mesh.matrixWorld).invert()); if (raio.intersectPlane(PLANO, world.mira)) mira = 1; } if (mira) { // chegando de fora, o ponto já nasce sob o ponteiro; dentro, segue com um leve atraso const segue = s.cursorPeso < 0.01 ? 1 : 1 - Math.exp(-dt * 12); s.cursor.x += (world.mira.x - s.cursor.x) * segue; s.cursor.y += (world.mira.y - s.cursor.y) * segue; } s.cursorPeso += (mira - s.cursorPeso) * (1 - Math.exp(-dt * 6)); u.uCursor.value.copy(s.cursor); u.uCursorPeso.value = s.cursorPeso * s.vida; s.frames += 1; // a legenda diz o estado em que a peça assentou, só com ela na caixa dela (no `lite`, sempre): nenhuma durante uma // troca, quando os blocos do estado anterior ainda ocupam o lugar dela const assentada = estado.from === estado.to && estado.t === 0; const legenda = assentada && (tier === "lite" || fora <= 0.001) ? estado.from : null; return { again: settling, wait, ready: s.frames === 2, legenda }; } /** Solta o contexto WebGL e tira o canvas da página. */ function disposeRenderer(renderer: WebGLRenderer) { renderer.dispose(); renderer.forceContextLoss(); renderer.domElement.remove(); } /** * O renderizador transparente, com 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 (o veredito fica anotado na sessão: `rememberSoftwareWebGL`). * Razão de pixels até 1,75 (`full`) ou 1,5 (`lite`). */ function createRenderer(host: HTMLElement, tier: Tier) { const renderer = new WebGLRenderer({ alpha: true, antialias: true, powerPreference: "high-performance", }); if (isSoftwareWebGL(renderer.getContext())) { rememberSoftwareWebGL(); disposeRenderer(renderer); throw new Error("WebGL por software"); } renderer.setPixelRatio( Math.min( tier === "lite" ? 1.5 : 1.75, Math.max(1, window.devicePixelRatio || 1), ), ); const canvas = renderer.domElement; canvas.setAttribute("aria-hidden", "true"); canvas.style.cssText = "display:block;width:100%;height:100%"; host.append(canvas); return renderer; } type Parts = { renderer: WebGLRenderer; world: World }; /** Solta a geometria, o material, as texturas e o contexto WebGL. */ function disposeParts({ renderer, world }: Parts) { world.geometry.dispose(); world.material.dispose(); world.porcelana.dispose(); world.lima.dispose(); disposeRenderer(renderer); } type Callbacks = Pick< BlocosSceneProps, "progressRef" | "placementRef" | "stageRef" | "onEstado" > & { onReady: () => void; onError: () => void; }; export type BlocosEngine = { setVisible: (visible: boolean) => void; dispose: () => void; }; /** Liga tamanho, mouse (`full`) e perda de contexto, e desenha sob demanda. */ function startBlocos( host: HTMLElement, parts: Parts, { progressRef, placementRef, stageRef, onReady, onError, onEstado, }: Callbacks, tier: Tier, ): BlocosEngine { const { renderer, world } = parts; const canvas = renderer.domElement; const s: SceneState = { // nasce no ponto da rolagem em que a página já está, sem varrer os estados no caminho progress: progressRef.current ?? 0, intro: 0, clock: 0, quieto: 0, ultimoAlvo: progressRef.current ?? 0, mostrado: null, fonte: "rolagem", preso: true, pointer: new Vector2(), tilt: new Vector2(), frames: 0, vida: 0, vidaEstado: 0, vidaTempo: 0, cursor: new Vector2(), cursorPeso: 0, }; /** Último ponteiro do mouse (px da janela), `null` sem mouse ou fora da janela; e ele em px do hospedeiro. */ let mouse: Vector2 | null = null; const noHost = new Vector2(); const size = { width: 1, height: 1 }; const budget = createFrameBudget(); let verdict: FrameVerdict = "pending"; let alive = true; let visible = false; let raf = 0; /** O quadro pedido veio do quadro anterior (só esses medem o orçamento). */ let chained = false; let last = 0; let wake: ReturnType | undefined; let stopPulse: (() => void) | undefined; /** O último estado avisado para a legenda. */ let legenda: number | null = null; const request = (fromFrame: boolean) => { if (raf || !alive || !visible) return; chained = fromFrame; raf = requestAnimationFrame(render); }; const invalidate = () => request(false); /** Para de desenhar: nenhum quadro, batida nem espera pendente. */ const halt = () => { cancelAnimationFrame(raf); raf = 0; clearTimeout(wake); wake = undefined; stopPulse?.(); stopPulse = undefined; last = 0; }; const fail = () => { if (!alive) return; alive = false; halt(); onError(); }; const draw = (now: number) => { const delta = last ? now - last : 1000 / 60; last = now; if (chained) verdict = budget(delta); if (verdict === "slow") { rememberSlowScene(); fail(); return; } let cursor: Vector2 | null = null; if (mouse) { const r = host.getBoundingClientRect(); noHost.set(mouse.x - r.left, mouse.y - r.top); if ( noHost.x >= 0 && noHost.y >= 0 && noHost.x <= r.width && noHost.y <= r.height ) cursor = noHost; } const step = stepBlocos(world, s, { tier, target: progressRef.current ?? 0, placement: placementRef?.current ?? null, stage: stageRef?.current ?? null, host: size, delta, cursor, }); renderer.render(world.scene, world.camera); if (step.ready) onReady(); if (step.legenda !== legenda) { legenda = step.legenda; onEstado?.(legenda); } // a entrada e os quadros seguintes correm no ritmo da tela até o orçamento ter veredito if (step.again || verdict === "pending") request(true); else if (step.wait) { clearTimeout(wake); wake = setTimeout(invalidate, step.wait * 1000); } }; const render = (now: number) => { raf = 0; if (!alive) return; try { draw(now); } catch { fail(); } }; const resize = () => { size.width = Math.max(1, host.clientWidth); size.height = Math.max(1, host.clientHeight); renderer.setSize(size.width, size.height, false); world.camera.aspect = size.width / size.height; world.camera.updateProjectionMatrix(); invalidate(); }; const onPointer = (event: PointerEvent) => { if (event.pointerType !== "mouse") return; s.pointer.set( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1, ); mouse ??= new Vector2(); mouse.set(event.clientX, event.clientY); }; // o mouse saiu da janela: os blocos voltam const onLeave = (event: PointerEvent) => { if (!event.relatedTarget) mouse = null; }; const onLost = (event: Event) => { event.preventDefault(); fail(); }; const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize); observer?.observe(host); if (!observer) window.addEventListener("resize", resize); if (tier === "full") { window.addEventListener("pointermove", onPointer, { passive: true }); window.addEventListener("pointerout", onLeave, { passive: true }); } canvas.addEventListener("webglcontextlost", onLost); resize(); return { setVisible(next) { visible = next; if (!next || !alive) return halt(); // a batida também dá vida ao `lite` parado; a troca sozinha segue com o próprio relógio stopPulse ??= startFramePulse(invalidate); }, dispose() { alive = false; halt(); observer?.disconnect(); window.removeEventListener("resize", resize); window.removeEventListener("pointermove", onPointer); window.removeEventListener("pointerout", onLeave); canvas.removeEventListener("webglcontextlost", onLost); disposeParts(parts); }, }; } /** Cede a vez ao navegador: o passo seguinte roda noutra tarefa. */ const nextTask = () => new Promise((resolve) => setTimeout(resolve, 0)); /** * Monta a cena em tarefas separadas, para nenhuma passar de uns poucos milissegundos: os ativos, o renderizador, a * cena com a compilação do shader (fora da thread principal) e o primeiro quadro. Devolve `null` se `isAlive` virou * falso no caminho; lança se os ativos não baixam, se o aparelho não cria um contexto WebGL, se ele desenha por * software ou se o shader não compila. */ export async function mountBlocos( host: HTMLElement, callbacks: Callbacks, tier: Tier, isAlive: () => boolean, ): Promise { const loaded = await loadBlocos(); await nextTask(); if (!isAlive()) return null; const renderer = createRenderer(host, tier); let parts: Parts | undefined; try { await nextTask(); if (!isAlive()) { disposeRenderer(renderer); return null; } parts = { renderer, world: buildWorld(loaded) }; 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 startBlocos(host, parts, callbacks, tier); } export default function BlocosScene({ progressRef, visible, onReady, onError, tier = "full", placementRef, stageRef, onEstado, }: BlocosSceneProps) { 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, onEstado }); useEffect(() => { callbacks.current = { onReady, onError, onEstado }; }); // o que a página pediu até a cena ficar pronta (ela nasce em tarefas separadas, depois do `visible` já ter chegado) const wantsVisible = useRef(visible); useEffect(() => { const host = hostRef.current; if (!host) return; let alive = true; mountBlocos( host, { progressRef, placementRef, stageRef, onReady: () => callbacks.current.onReady(), onEstado: (estado) => callbacks.current.onEstado?.(estado), onError: () => callbacks.current.onError(), }, tier, () => alive, ) .then((created) => { if (!created) return; if (!alive) return created.dispose(); engine.current = created; created.setVisible(wantsVisible.current); }) // sem ativos, sem WebGL, WebGL por software ou shader que não compila: o hero fica no pôster .catch(() => { if (alive) callbacks.current.onError(); }); return () => { alive = false; engine.current?.dispose(); engine.current = null; }; }, [progressRef, placementRef, stageRef, tier]); useEffect(() => { wantsVisible.current = visible; engine.current?.setVisible(visible); }, [visible]); return (