"use client"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import type { Material, Object3D, Texture } from "three"; export type ResultadoDaBancada = | { p50: number; p95: number; max: number; quadros: number; chamadas: number; triangulos: number; heapMB: number | null; zumbis: number; largura: number; altura: number; pixelRatio: number; gpu: string | null; trabalhoP50: number; trabalhoP95: number; geometrias: number; texturas: number; } | { erro: "sem-webgl" }; declare global { interface Window { __bancada?: ResultadoDaBancada; } } type Estado = | { fase: "carregando" } | { fase: "medindo"; faltam: number } | { fase: "pronto"; resultado: ResultadoDaBancada }; const AQUECIMENTO_MS = 2000; const VERDE = "#c5f04a"; /** Velocidade do zumbi comum (m/s), a mesma da lógica do jogo. */ const PASSO = 1.2; function umaCasa(valor: number) { return Math.round(valor * 10) / 10; } /** Percentil pelo posto mais próximo, sobre a lista já ordenada. */ function percentil(ordenados: number[], p: number) { const indice = Math.ceil((p / 100) * ordenados.length) - 1; return ordenados[Math.min(ordenados.length - 1, Math.max(0, indice))]; } /** Descarta geometrias, materiais e as texturas deles em tudo o que está na cena. */ function descartarCena(raiz: Object3D) { const texturas = new Set(); const materiais = new Set(); raiz.traverse((objeto) => { const malha = objeto as Object3D & { geometry?: { dispose(): void }; material?: Material | Material[]; }; malha.geometry?.dispose(); const lista = Array.isArray(malha.material) ? malha.material : malha.material ? [malha.material] : []; for (const material of lista) materiais.add(material); }); for (const material of materiais) { for (const valor of Object.values(material)) if ((valor as Texture | null)?.isTexture) texturas.add(valor as Texture); material.dispose(); } for (const textura of texturas) textura.dispose(); } /** * Monta a sala, os zumbis e os drones em `host`, aquece 2 s, mede `seg` segundos e chama `aoTerminar`. Devolve a * função que desfaz tudo, ou `null` se `vivo()` virou falso no meio da carga. */ async function rodarBancada( host: HTMLElement, zumbis: number, seg: number, vivo: () => boolean, aoMedir: (faltam: number) => void, aoTerminar: (resultado: ResultadoDaBancada) => void, ) { // carga sob demanda: o three e o modelo só baixam quando a bancada monta, fora do pacote da página const [THREE, { carregarZumbi, criarZumbi }, { GLTFLoader }] = await Promise.all([ import("three"), import("./zumbis3d"), import("three/examples/jsm/loaders/GLTFLoader.js"), ]); if (!vivo()) return null; let renderer: InstanceType; const teste = document.createElement("canvas").getContext("webgl2"); if (!teste) { aoTerminar({ erro: "sem-webgl" }); return () => {}; } teste.getExtension("WEBGL_lose_context")?.loseContext(); try { renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance", }); } catch { aoTerminar({ erro: "sem-webgl" }); return () => {}; } const pixelRatio = Math.min(window.devicePixelRatio || 1, 2); renderer.setPixelRatio(pixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; const canvas = renderer.domElement; canvas.setAttribute("aria-hidden", "true"); canvas.style.cssText = "display:block;width:100%;height:100%"; const [modelo, bloco] = await Promise.all([ carregarZumbi(), new GLTFLoader().loadAsync("/3d/bloco.glb"), ]); if (!vivo()) { renderer.dispose(); renderer.forceContextLoss(); return null; } host.append(canvas); const scene = new THREE.Scene(); const escuro = new THREE.Color("#0b0c0e"); scene.background = escuro; scene.fog = new THREE.Fog(escuro, 6, 24); // a sala: uma caixa vista por dentro, 28 × 3 × 17 m, piso no y = 0 const parede = new THREE.MeshStandardMaterial({ color: "#3a3d42", roughness: 0.95, side: THREE.BackSide, }); const piso = new THREE.MeshStandardMaterial({ color: "#2a2b2d", roughness: 1, side: THREE.BackSide, }); const sala = new THREE.Mesh(new THREE.BoxGeometry(28, 3, 17), [ parede, parede, parede, piso, parede, parede, ]); sala.position.y = 1.5; sala.receiveShadow = true; scene.add(sala); scene.add(new THREE.AmbientLight("#ffffff", 0.25)); const sol = new THREE.DirectionalLight("#fff3e0", 1.6); sol.position.set(4, 8, 3); sol.castShadow = true; sol.shadow.mapSize.set(1024, 1024); Object.assign(sol.shadow.camera, { left: -15, right: 15, top: 10, bottom: -10, near: 1, far: 20, }); sol.shadow.camera.updateProjectionMatrix(); scene.add(sol); const camera = new THREE.PerspectiveCamera( 70, window.innerWidth / window.innerHeight, 0.1, 40, ); camera.position.set(0, 1.6, 0); const andantes = Array.from({ length: zumbis }, (_, i) => { const zumbi = criarZumbi(modelo); zumbi.raiz.traverse((objeto) => { objeto.castShadow = true; }); zumbi.tocar("andar"); zumbi.avancar(i * 0.37); scene.add(zumbi.raiz); return { zumbi, raio: 3 + (6 * i) / Math.max(1, zumbis - 1), angulo: i * 2.39996, sentido: i % 2 ? -1 : 1, }; }); const geometriaDoBloco = ( bloco.scene.children[0] as InstanceType ).geometry; const verde = new THREE.MeshStandardMaterial({ color: VERDE, roughness: 0.6, }); const drones = Array.from({ length: 3 }, () => { const drone = new THREE.Group(); for (let i = 0; i < 8; i += 1) { const peca = new THREE.Mesh(geometriaDoBloco, verde); peca.scale.setScalar(0.12); peca.position.set( (i & 1 ? 1 : -1) * 0.07, (i & 2 ? 1 : -1) * 0.07, (i & 4 ? 1 : -1) * 0.07, ); peca.castShadow = true; drone.add(peca); } scene.add(drone); return drone; }); const redimensionar = () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }; window.addEventListener("resize", redimensionar); const duracoes: number[] = []; const trabalhosBrutos: number[] = []; let inicio = -1; let anterior = -1; let ultimoAviso = -1; const quadro = (agora: number) => { const comecoDoQuadro = performance.now(); if (inicio < 0) inicio = agora; const dt = anterior < 0 ? 0 : Math.min(0.1, (agora - anterior) / 1000); const decorrido = agora - inicio; if (decorrido > AQUECIMENTO_MS && anterior >= 0) duracoes.push(agora - anterior); anterior = agora; const t = decorrido / 1000; camera.rotation.y = t * 0.15; for (const andante of andantes) { andante.angulo += (andante.sentido * PASSO * dt) / andante.raio; const { raiz } = andante.zumbi; raiz.position.set( Math.sin(andante.angulo) * andante.raio, 0, Math.cos(andante.angulo) * andante.raio, ); raiz.rotation.y = andante.angulo + (andante.sentido * Math.PI) / 2; andante.zumbi.avancar(dt); } drones.forEach((drone, i) => { const angulo = camera.rotation.y + Math.PI + (i - 1) * 0.5; drone.position.set( Math.sin(angulo) * 2.5, 2.1 + Math.sin(t * 2 + i) * 0.1, Math.cos(angulo) * 2.5, ); drone.rotation.y = t + i; }); renderer.render(scene, camera); if (decorrido > AQUECIMENTO_MS) trabalhosBrutos.push(performance.now() - comecoDoQuadro); const medido = Math.max(0, decorrido - AQUECIMENTO_MS) / 1000; const faltam = Math.max(0, Math.ceil(seg - medido)); if (faltam !== ultimoAviso) { ultimoAviso = faltam; aoMedir(faltam); } if (medido < seg) return; renderer.setAnimationLoop(null); const ordenados = [...duracoes].sort((a, b) => a - b); const trabalhos = [...trabalhosBrutos].sort((a, b) => a - b); const tamanho = renderer.getSize(new THREE.Vector2()); const gl = renderer.getContext(); const depuracao = gl.getExtension("WEBGL_debug_renderer_info"); const memoria = ( performance as Performance & { memory?: { usedJSHeapSize: number } } ).memory; aoTerminar({ p50: umaCasa(percentil(ordenados, 50)), p95: umaCasa(percentil(ordenados, 95)), max: umaCasa(ordenados[ordenados.length - 1] ?? 0), quadros: duracoes.length, chamadas: renderer.info.render.calls, triangulos: renderer.info.render.triangles, heapMB: memoria ? umaCasa(memoria.usedJSHeapSize / 1048576) : null, zumbis, largura: tamanho.x, altura: tamanho.y, pixelRatio, gpu: depuracao ? String(gl.getParameter(depuracao.UNMASKED_RENDERER_WEBGL)) : null, trabalhoP50: umaCasa(percentil(trabalhos, 50)), trabalhoP95: umaCasa(percentil(trabalhos, 95)), geometrias: renderer.info.memory.geometries, texturas: renderer.info.memory.textures, }); }; renderer.setAnimationLoop(quadro); return () => { renderer.setAnimationLoop(null); window.removeEventListener("resize", redimensionar); descartarCena(scene); for (const andante of andantes) andante.zumbi.descartar(); sol.dispose(); renderer.dispose(); renderer.forceContextLoss(); canvas.remove(); }; } /** A bancada de desempenho: `zumbis` e `seg` já vêm conferidos e dentro dos limites pela página. */ export default function Bancada({ zumbis, seg, }: { zumbis: number; seg: number; }) { const palco = useRef(null); const [rodada, setRodada] = useState(0); const [estado, setEstado] = useState({ fase: "carregando" }); // biome-ignore lint/correctness/useExhaustiveDependencies: `rodada` só existe para medir de novo useEffect(() => { const host = palco.current; if (!host) return; let ativo = true; let desfazer: (() => void) | null = null; delete window.__bancada; setEstado({ fase: "carregando" }); rodarBancada( host, zumbis, seg, () => ativo, (faltam) => { if (ativo) setEstado({ fase: "medindo", faltam }); }, (resultado) => { if (!ativo) return; window.__bancada = resultado; setEstado({ fase: "pronto", resultado }); }, ).then((parar) => { if (ativo) desfazer = parar; else parar?.(); }); return () => { ativo = false; desfazer?.(); }; }, [zumbis, seg, rodada]); // acima do cabeçalho (z-9000) e do WhatsApp, abaixo da barra de cookies (z-9980) return (

Bancada do jogo

{estado.fase === "carregando" && (

Carregando…

)} {estado.fase === "medindo" && (

Medindo… {estado.faltam} s

)}
{estado.fase === "pronto" && ( setRodada((n) => n + 1)} /> )}
); } function Resultado({ resultado, seg, medirDeNovo, }: { resultado: ResultadoDaBancada; seg: number; medirDeNovo: () => void; }) { if ("erro" in resultado) return (

Este navegador não desenha 3D.

); const linhas: Array<[string, string]> = [ ["p50", `${resultado.p50} ms`], ["p95", `${resultado.p95} ms`], ["máx", `${resultado.max} ms`], ["quadros", String(resultado.quadros)], ["zumbis", String(resultado.zumbis)], ["chamadas", String(resultado.chamadas)], ["triângulos", resultado.triangulos.toLocaleString("pt-BR")], ["memória", resultado.heapMB === null ? "—" : `${resultado.heapMB} MB`], [ "tela", `${resultado.largura}×${resultado.altura} @${resultado.pixelRatio}`, ], ["GPU", resultado.gpu ?? "—"], ["Trabalho por quadro", `p95 ${resultado.trabalhoP95} ms`], ["trabalho p50", `${resultado.trabalhoP50} ms`], ["monitor (estimado)", `${Math.round(1000 / resultado.p50)} Hz`], ]; return (
{linhas.map(([nome, valor]) => (
{nome}
{valor}
))}
{[8, 16, 24].map((n) => ( {n} zumbis ))}
); }