diff --git a/app/components/cinematic/FramePulse.test.ts b/app/components/cinematic/FramePulse.test.ts new file mode 100644 index 0000000..6f36a72 --- /dev/null +++ b/app/components/cinematic/FramePulse.test.ts @@ -0,0 +1,141 @@ +import { startFramePulse } from "./FramePulse"; + +const invalidate = jest.fn(); +let stops: Array<() => void> = []; + +/** Liga a batida e guarda o desligamento para o afterEach não deixar ouvinte para trás. */ +function start(options?: Parameters[1]) { + const stop = startFramePulse(invalidate, options); + stops.push(stop); + return stop; +} + +beforeEach(() => { + jest.useFakeTimers(); + invalidate.mockClear(); + stops = []; +}); + +afterEach(() => { + for (const stop of stops) stop(); + jest.useRealTimers(); +}); + +const advance = (ms: number) => jest.advanceTimersByTime(ms); + +describe("startFramePulse", () => { + it("asks for frames at about 30 per second, not 60", () => { + start(); + advance(1000); + expect(invalidate.mock.calls.length).toBeGreaterThan(20); + expect(invalidate.mock.calls.length).toBeLessThanOrEqual(34); + }); + + it("asks for the first frame right away", () => { + start(); + expect(invalidate).toHaveBeenCalledTimes(1); + }); + + it("is a single setInterval, never a second animation-frame loop", () => { + const frames = jest.spyOn(window, "requestAnimationFrame"); + start(); + expect(jest.getTimerCount()).toBe(1); + advance(1000); + expect(frames).not.toHaveBeenCalled(); + frames.mockRestore(); + }); + + it("does nothing once it is stopped (the hero is off screen)", () => { + const stop = start(); + advance(200); + const running = invalidate.mock.calls.length; + expect(running).toBeGreaterThan(0); + + stop(); + advance(1000); + expect(invalidate.mock.calls.length).toBe(running); + expect(jest.getTimerCount()).toBe(0); + }); + + it("removes its listeners when it stops", () => { + const stop = start(); + stop(); + invalidate.mockClear(); + + window.dispatchEvent(new Event("pointermove")); + advance(500); + expect(invalidate).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + }); + + it("can be stopped twice", () => { + const stop = start(); + stop(); + expect(() => stop()).not.toThrow(); + }); +}); + +describe("startFramePulse: stops when nobody is looking", () => { + it("stops drawing, and has no timer left, after 5 s without a cursor or scroll", () => { + start(); + advance(4000); + const drawn = invalidate.mock.calls.length; + expect(drawn).toBeGreaterThan(80); // ainda batendo, ~30 por segundo + + advance(1500); // passou dos 5 s + const stopped = invalidate.mock.calls.length; + // parou perto dos 5 s: no máximo 5 s de batidas a ~30 por segundo + expect(stopped).toBeLessThanOrEqual(5 * 32); + + advance(3000); + expect(invalidate.mock.calls.length).toBe(stopped); + expect(jest.getTimerCount()).toBe(0); + }); + + it.each([["pointermove"], ["scroll"], ["wheel"], ["keydown"]])( + "wakes up again with %s and keeps drawing", + (type) => { + start(); + advance(7000); + const stopped = invalidate.mock.calls.length; + + window.dispatchEvent(new Event(type)); + advance(500); + + expect(invalidate.mock.calls.length).toBeGreaterThan(stopped + 8); + }, + ); + + it("wakes up once, not once per gesture (one timer, however many events arrive)", () => { + start(); + for (let i = 0; i < 20; i++) window.dispatchEvent(new Event("pointermove")); + expect(jest.getTimerCount()).toBe(1); + }); + + it("each gesture buys another 5 s: it does not stop while there is movement", () => { + start(); + for (let i = 0; i < 4; i++) { + advance(3000); + window.dispatchEvent(new Event("pointermove")); + } + const before = invalidate.mock.calls.length; + + advance(1000); + + expect(invalidate.mock.calls.length).toBeGreaterThan(before + 20); + }); + + it("lets the idle time and the rate be configured", () => { + start({ idleMs: 1000 }); + advance(1500); + const stopped = invalidate.mock.calls.length; + advance(2000); + expect(invalidate.mock.calls.length).toBe(stopped); + + invalidate.mockClear(); + start({ fps: 10 }); + advance(1000); + expect(invalidate.mock.calls.length).toBeLessThanOrEqual(11); + expect(invalidate.mock.calls.length).toBeGreaterThanOrEqual(9); + }); +}); diff --git a/app/components/cinematic/FramePulse.test.tsx b/app/components/cinematic/FramePulse.test.tsx deleted file mode 100644 index 3f0f863..0000000 --- a/app/components/cinematic/FramePulse.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { act, render } from "@testing-library/react"; -import FramePulse from "./FramePulse"; - -const mockInvalidate = jest.fn(); - -jest.mock("@react-three/fiber", () => ({ - useThree: (select: (state: { invalidate: () => void }) => unknown) => - select({ invalidate: mockInvalidate }), -})); - -beforeEach(() => { - jest.useFakeTimers(); - mockInvalidate.mockClear(); -}); - -afterEach(() => { - jest.useRealTimers(); -}); - -const advance = (ms: number) => - act(() => { - jest.advanceTimersByTime(ms); - }); - -describe("FramePulse", () => { - it("asks for frames at about 30 per second while active, not 60", () => { - render(); - advance(1000); - expect(mockInvalidate.mock.calls.length).toBeGreaterThan(20); - expect(mockInvalidate.mock.calls.length).toBeLessThanOrEqual(34); - }); - - it("draws nothing at all when it is not active (off screen)", () => { - const { rerender } = render(); - advance(1000); - expect(mockInvalidate).not.toHaveBeenCalled(); - - rerender(); - advance(200); - const running = mockInvalidate.mock.calls.length; - expect(running).toBeGreaterThan(0); - - rerender(); - advance(1000); - expect(mockInvalidate.mock.calls.length).toBe(running); - }); -}); - -describe("FramePulse: stops when nobody is looking", () => { - it("stops drawing, and stops asking for animation frames, after 5 s without a cursor or scroll", () => { - render(); - advance(4000); - const drawn = mockInvalidate.mock.calls.length; - expect(drawn).toBeGreaterThan(80); // ainda batendo, ~30 por segundo - - advance(1500); // passou dos 5 s - const stopped = mockInvalidate.mock.calls.length; - // parou perto dos 5 s: no máximo 5 s de batidas a ~30 por segundo - expect(stopped).toBeLessThanOrEqual(5 * 32); - - advance(3000); - expect(mockInvalidate.mock.calls.length).toBe(stopped); - // sem laço nenhum: nenhum quadro de animação pendente - expect(jest.getTimerCount()).toBe(0); - }); - - it.each([["pointermove"], ["scroll"], ["wheel"], ["keydown"]])( - "wakes up again with %s and keeps drawing", - (type) => { - render(); - advance(7000); - const stopped = mockInvalidate.mock.calls.length; - - act(() => { - window.dispatchEvent(new Event(type)); - }); - advance(500); - - expect(mockInvalidate.mock.calls.length).toBeGreaterThan(stopped + 8); - }, - ); - - it("each gesture buys another 5 s: it does not stop while there is movement", () => { - render(); - for (let i = 0; i < 4; i++) { - advance(3000); - act(() => { - window.dispatchEvent(new Event("pointermove")); - }); - } - const before = mockInvalidate.mock.calls.length; - - advance(1000); - - expect(mockInvalidate.mock.calls.length).toBeGreaterThan(before + 20); - }); - - it("lets the idle time be configured", () => { - render(); - advance(1500); - const stopped = mockInvalidate.mock.calls.length; - - advance(2000); - - expect(mockInvalidate.mock.calls.length).toBe(stopped); - }); - - it("removes its listeners when it unmounts or goes inactive", () => { - const { rerender, unmount } = render(); - rerender(); - mockInvalidate.mockClear(); - - act(() => { - window.dispatchEvent(new Event("pointermove")); - }); - advance(500); - expect(mockInvalidate).not.toHaveBeenCalled(); - - unmount(); - act(() => { - window.dispatchEvent(new Event("scroll")); - }); - advance(500); - expect(mockInvalidate).not.toHaveBeenCalled(); - }); -}); diff --git a/app/components/cinematic/FramePulse.ts b/app/components/cinematic/FramePulse.ts new file mode 100644 index 0000000..61240b9 --- /dev/null +++ b/app/components/cinematic/FramePulse.ts @@ -0,0 +1,55 @@ +/** Gestos que mostram alguém olhando para a página: qualquer um deles religa a batida. */ +const ACTIVITY = [ + "pointermove", + "pointerdown", + "scroll", + "wheel", + "touchmove", + "keydown", +] as const; + +export type FramePulseOptions = { + /** Quadros por segundo pedidos (padrão 30). */ + fps?: number; + /** Tempo (ms) sem cursor, rolagem nem teclado depois do qual a batida para de vez (padrão 5000). */ + idleMs?: number; +}; + +/** + * Batida das cenas WebGL em demanda: pede um quadro (`invalidate`) a no máximo `fps` por segundo, de um único + * `setInterval` (fundo ambiente, sem gastar 60 quadros por segundo e sem um segundo laço de requestAnimationFrame ao + * lado do da cena). Depois de `idleMs` sem cursor, rolagem nem teclado, a batida para de vez (nenhum intervalo, nenhum + * quadro) e o último quadro fica na tela; qualquer movimento a religa. Quem precisa de mais (entrada, rolagem, cursor) + * pede o próximo quadro por conta própria, dentro do quadro. Devolve a função que desliga tudo. + */ +export function startFramePulse( + invalidate: () => void, + { fps = 30, idleMs = 5000 }: FramePulseOptions = {}, +) { + const period = Math.round(1000 / fps); + let timer: ReturnType | undefined; + let lastActivity = performance.now(); + + const stopTimer = () => { + clearInterval(timer); + timer = undefined; + }; + const beat = () => { + // parada por inatividade: sem intervalo até o próximo movimento + if (performance.now() - lastActivity > idleMs) stopTimer(); + else invalidate(); + }; + const wake = () => { + lastActivity = performance.now(); + if (timer === undefined) timer = setInterval(beat, period); + }; + + for (const type of ACTIVITY) + window.addEventListener(type, wake, { passive: true }); + wake(); + invalidate(); + return () => { + for (const type of ACTIVITY) window.removeEventListener(type, wake); + stopTimer(); + }; +} diff --git a/app/components/cinematic/FramePulse.tsx b/app/components/cinematic/FramePulse.tsx deleted file mode 100644 index 8545986..0000000 --- a/app/components/cinematic/FramePulse.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { useThree } from "@react-three/fiber"; -import { useEffect } from "react"; - -/** Gestos que mostram alguém olhando para a página: qualquer um deles religa a batida. */ -const ACTIVITY = [ - "pointermove", - "pointerdown", - "scroll", - "wheel", - "touchmove", - "keydown", -] as const; - -/** - * Batida das cenas WebGL em `frameloop="demand"`: enquanto `active`, pede um quadro a no máximo `fps` por - * segundo (fundo ambiente, sem gastar 60 quadros por segundo); com `active` falso, nenhum quadro é desenhado. - * Depois de `idleMs` sem cursor, rolagem nem teclado, a batida para de vez (nenhum quadro, nenhum - * requestAnimationFrame) e o último quadro fica na tela; qualquer movimento a religa. - * Quem precisa de mais (entrada, rolagem, cursor) pede o próximo quadro com `invalidate()` dentro do useFrame. - */ -export default function FramePulse({ - active, - fps = 30, - idleMs = 5000, -}: { - active: boolean; - fps?: number; - idleMs?: number; -}) { - const invalidate = useThree((state) => state.invalidate); - - useEffect(() => { - if (!active) return; - const gap = 1000 / fps - 2; - let id = 0; - let last = 0; - let lastActivity = performance.now(); - const beat = (now: number) => { - // parada por inatividade: sem laço até o próximo movimento - if (now - lastActivity > idleMs) { - id = 0; - return; - } - id = requestAnimationFrame(beat); - if (now - last < gap) return; - last = now; - invalidate(); - }; - const wake = () => { - lastActivity = performance.now(); - if (!id) id = requestAnimationFrame(beat); - }; - for (const type of ACTIVITY) - window.addEventListener(type, wake, { passive: true }); - id = requestAnimationFrame(beat); - return () => { - for (const type of ACTIVITY) window.removeEventListener(type, wake); - cancelAnimationFrame(id); - }; - }, [active, fps, idleMs, invalidate]); - - return null; -} diff --git a/app/components/cinematic/HeroField.paths.test.tsx b/app/components/cinematic/HeroField.paths.test.tsx index 9b4e4a1..3e2ecbc 100644 --- a/app/components/cinematic/HeroField.paths.test.tsx +++ b/app/components/cinematic/HeroField.paths.test.tsx @@ -388,7 +388,7 @@ describe("HeroField: falhas voltam ao hero simples", () => { }); describe("HeroField: rolagem e visibilidade", () => { - function place(top: number, height = 3000) { + function place(top: number, height = 2760) { section().getBoundingClientRect = () => ({ top, height, bottom: top + height }) as DOMRect; } @@ -443,7 +443,7 @@ describe("HeroField: rolagem e visibilidade", () => { jest.advanceTimersByTime(40); }); - // trilho de 1400 px (3000 de hero - 2 telas de 800) + // trilho de 1400 px (2760 de hero - 1,7 tela de 800) expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo( 0.5, ); @@ -571,3 +571,29 @@ describe("HeroField: navegador sem matchMedia", () => { expect(screen.getByTestId("scene")).toBeInTheDocument(); }); }); + +describe("HeroField: desmontagem em pontos que faltavam", () => { + it("path: unmounted after the geometry was prepared, before the Canvas -> the scene is never mounted", async () => { + const view = render(); + mockPrepare.mockImplementationOnce(() => { + act(() => view.unmount()); + }); + + await advance(1200); + await finishLoading(); + + expect(mockPrepare).toHaveBeenCalledTimes(1); + expect(mockScenes).toHaveLength(0); + }); + + it("path: no dive on this screen (the dive query does not match) -> unmounting is quiet and the hero stays plain", async () => { + mockMedia([]); + const view = render(); + await finishLoading(); + const hero = section(); + + expect(() => view.unmount()).not.toThrow(); + + expect(hero).not.toHaveAttribute("data-dive"); + }); +}); diff --git a/app/components/cinematic/HeroField.test.tsx b/app/components/cinematic/HeroField.test.tsx index 15152d6..ffb02a7 100644 --- a/app/components/cinematic/HeroField.test.tsx +++ b/app/components/cinematic/HeroField.test.tsx @@ -56,7 +56,7 @@ const section = () => document.querySelector(".hero") as HTMLElement; /** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */ function scrollWritesProgress() { section().getBoundingClientRect = () => - ({ top: -700, height: 3000, bottom: 2300 }) as DOMRect; + ({ top: -700, height: 2760, bottom: 2300 }) as DOMRect; act(() => { window.dispatchEvent(new Event("scroll")); jest.advanceTimersByTime(40); @@ -192,10 +192,10 @@ describe("HeroField (ambient background)", () => { const hero = container.querySelector(".hero") as HTMLElement; expect(hero).toHaveAttribute("data-dive"); await idle(); - // 3000 px de hero: 800 de palco, 800 da tela de Projetos subindo e 1400 de mergulho + // 2760 px de hero (altura = 1,7 tela de cauda + o mergulho): 800 de palco, 560 de Projetos subindo e 1400 de mergulho let top = 0; hero.getBoundingClientRect = () => - ({ top, height: 3000, bottom: top + 3000 }) as DOMRect; + ({ top, height: 2760, bottom: top + 2760 }) as DOMRect; Object.defineProperty(window, "innerHeight", { configurable: true, value: 800, diff --git a/app/components/cinematic/HeroField.tsx b/app/components/cinematic/HeroField.tsx index f466501..08d8c0c 100644 --- a/app/components/cinematic/HeroField.tsx +++ b/app/components/cinematic/HeroField.tsx @@ -13,6 +13,11 @@ import type { HeroFieldSceneProps } from "./HeroFieldScene"; /** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */ export const DIVE_QUERY = "(min-height: 700px) and (prefers-reduced-motion: no-preference)"; +/** + * Telas de hero além do mergulho (globals.css: height = 170svh + --hero-dive): 100 do palco preso e 70 em que Projetos + * sobe sobre ele (margin-top de -70svh). O mergulho (progresso 0 a 1) é o resto da altura do hero. + */ +export const HERO_TAIL_SCREENS = 1.7; /** Tempo livre (ms) que um intervalo ocioso precisa ter para a cena ser montada sem engasgar a página. */ const IDLE_SLACK_MS = 20; /** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */ @@ -112,9 +117,9 @@ function trackDive( const update = () => { frame = 0; const rect = section.getBoundingClientRect(); - // o palco fica preso por (altura - 1 tela); a última tela dessa rolagem é de Projetos subindo sobre ele + // o palco fica preso por (altura - 1 tela); o fim dessa rolagem é de Projetos subindo sobre ele const span = Math.max( - rect.height - window.innerHeight * 2, + rect.height - window.innerHeight * HERO_TAIL_SCREENS, window.innerHeight * 0.8, ); const p = Math.min(1, Math.max(0, -rect.top / span)); diff --git a/app/components/cinematic/HeroFieldScene.paths.test.tsx b/app/components/cinematic/HeroFieldScene.paths.test.tsx index 3b965cb..04041b6 100644 --- a/app/components/cinematic/HeroFieldScene.paths.test.tsx +++ b/app/components/cinematic/HeroFieldScene.paths.test.tsx @@ -1,43 +1,83 @@ -import { act, render } from "@testing-library/react"; -import * as THREE from "three"; -import HeroFieldScene, { prepareField } from "./HeroFieldScene"; +import { + type BufferGeometry, + type Group, + type PerspectiveCamera, + type Plane, + Ray, + type ShaderMaterial, + type Vector3, +} from "three"; +import { + createField, + type FieldEngine, + needsFrame, + prepareField, + type Rig, + type Tick, + updatePointer, + updateRig, +} from "./HeroFieldScene"; /** - * Testes de caminhos-base do quadro da cena 3D (o callback do useFrame de Field, CC alta) e de SceneBoundary: - * - fim antecipado sem grupo, onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente); + * Testes de caminhos-base do quadro da cena 3D (createField, sem React) e das contas que o compõem: + * - onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente), entrada, escurecimento e abertura; * - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente; * - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve); * - quando pedir o próximo quadro (entrada, rolagem assentando, cursor) e quando não pedir; - * - perda do contexto WebGL, limpeza e a falha de um filho (SceneBoundary). - * O @react-three/fiber é trocado por um dublê que renderiza os filhos e entrega o callback do useFrame; o `three` - * é o de verdade, com ShaderMaterial e BufferGeometry registrando as instâncias criadas. + * - perda do contexto WebGL, falha ao desenhar e limpeza. + * O WebGLRenderer é um dublê (o jsdom não tem WebGL); o `three` é o de verdade, com ShaderMaterial, BufferGeometry, + * PerspectiveCamera e Group registrando as instâncias criadas. O quadro é chamado direto por `renderAt`, com o relógio + * que o teste escolhe. */ -type FrameState = { - clock: { elapsedTime: number }; - invalidate: () => void; -}; -const mockState: { - frame: ((state: FrameState, delta: number) => void) | null; - three: unknown; - throwOnUseThree: boolean; -} = { frame: null, three: null, throwOnUseThree: false }; -const mockMaterials: THREE.ShaderMaterial[] = []; +const mockMaterials: ShaderMaterial[] = []; +const mockGeometries: BufferGeometry[] = []; +const mockCameras: PerspectiveCamera[] = []; +const mockGroups: Group[] = []; const mockCounts = { vectors: 0 }; -const mockGeometries: THREE.BufferGeometry[] = []; +const mockRenderers: MockRenderer[] = []; +let mockRenderThrows = false; + +class MockRenderer { + domElement = document.createElement("canvas"); + pixelRatio = 2; + render = jest.fn(() => { + if (mockRenderThrows) throw new Error("falha ao desenhar"); + }); + dispose = jest.fn(); + forceContextLoss = jest.fn(); + setSize = jest.fn(); + setPixelRatio = jest.fn(); + getPixelRatio = () => this.pixelRatio; + constructor() { + mockRenderers.push(this); + } +} jest.mock("three", () => { const actual = jest.requireActual("three"); class CapturingMaterial extends actual.ShaderMaterial { constructor(parameters: unknown) { super(parameters); - mockMaterials.push(this as unknown as THREE.ShaderMaterial); + mockMaterials.push(this as unknown as ShaderMaterial); } } class CapturingGeometry extends actual.BufferGeometry { constructor() { super(); - mockGeometries.push(this as unknown as THREE.BufferGeometry); + mockGeometries.push(this as unknown as BufferGeometry); + } + } + class CapturingCamera extends actual.PerspectiveCamera { + constructor(...args: unknown[]) { + super(...args); + mockCameras.push(this as unknown as PerspectiveCamera); + } + } + class CapturingGroup extends actual.Group { + constructor() { + super(); + mockGroups.push(this as unknown as Group); } } class CountingVector3 extends actual.Vector3 { @@ -48,22 +88,14 @@ jest.mock("three", () => { } return { ...actual, + WebGLRenderer: jest.fn(() => new MockRenderer()), ShaderMaterial: CapturingMaterial, BufferGeometry: CapturingGeometry, + PerspectiveCamera: CapturingCamera, + Group: CapturingGroup, Vector3: CountingVector3, }; }); -jest.mock("@react-three/fiber", () => ({ - Canvas: ({ children }: { children?: React.ReactNode }) => <>{children}, - useFrame: (callback: (state: FrameState, delta: number) => void) => { - mockState.frame = callback; - }, - useThree: () => { - if (mockState.throwOnUseThree) throw new Error("sem WebGL"); - return mockState.three; - }, -})); -jest.mock("./FramePulse", () => ({ __esModule: true, default: () => null })); type Setup = { width?: number; @@ -73,76 +105,66 @@ type Setup = { visible?: boolean; }; +let engines: FieldEngine[] = []; +let frameRequests: jest.SpyInstance; + function setup({ width = 1200, height = 800, progress = 0, visible = true, }: Setup = {}) { - const domElement = document.createElement("canvas"); - const camera = new THREE.PerspectiveCamera(35, width / height, 0.05, 120); - camera.position.set(0, 0, 9); - camera.updateMatrixWorld(); - mockState.three = { - gl: { domElement, getPixelRatio: () => 2 }, - camera, - size: { width, height }, - }; + const host = document.createElement("div"); + Object.defineProperty(host, "clientWidth", { value: width }); + Object.defineProperty(host, "clientHeight", { value: height }); + document.body.append(host); const progressRef = { current: progress === null ? undefined : progress, } as { current: number }; const onReady = jest.fn(); const onError = jest.fn(); - const element = (isVisible: boolean) => ( - - ); - const view = render(element(visible)); + const engine = createField(host, { progressRef, onReady, onError }); + engines.push(engine); + engine.setVisible(visible); - // é um elemento desconhecido no jsdom: ganha o que o três espera de um THREE.Group - const group = view.container.querySelector("group") as HTMLElement | null; - const real = new THREE.Group(); - if (group) - Object.assign(group, { - rotation: real.rotation, - position: real.position, - matrixWorld: real.matrixWorld, - updateMatrixWorld: () => real.updateMatrixWorld(true), - worldToLocal: (point: THREE.Vector3) => point, - }); + const renderer = mockRenderers[mockRenderers.length - 1]; + const camera = mockCameras[mockCameras.length - 1]; + const group = mockGroups[mockGroups.length - 1]; const uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms; + let clock = 1000; - function frame(time: number, delta = 1 / 60) { - const invalidate = jest.fn(); - act(() => { - mockState.frame?.({ clock: { elapsedTime: time }, invalidate }, delta); - }); - return invalidate; + /** + * Um quadro `deltaMs` depois do anterior; devolve quantos quadros ele pediu em seguida. O primeiro quadro de uma + * cena não tem anterior: o passo dele é 1/60 de segundo, seja qual for `deltaMs`. + */ + function frame(deltaMs = 1000 / 60) { + frameRequests.mockClear(); + clock += deltaMs; + engine.renderAt(clock); + return frameRequests.mock.calls.length; } /** Roda `count` quadros e devolve quantas vezes o próximo quadro foi pedido no último. */ - function settle(count: number, delta = 1 / 20, startTime = 100) { - let last = jest.fn(); - for (let i = 0; i < count; i++) last = frame(startTime + i * delta, delta); - return last; + function settle(count: number, deltaMs = 50) { + let asked = 0; + for (let i = 0; i < count; i++) asked = frame(deltaMs); + return asked; } return { - ...view, + host, + engine, + renderer, camera, - domElement, + group, progressRef, onReady, onError, - group: real, uniforms, frame, settle, - rerender: (isVisible: boolean) => view.rerender(element(isVisible)), + /** O relógio do quadro (ms), para os testes que pedem o tempo em segundos. */ + now: () => clock, }; } @@ -158,61 +180,59 @@ function setCores(value: number | undefined) { }); } -function setWindowWidth(value: number) { +function setWindowSize(width: number, height = 800) { Object.defineProperty(window, "innerWidth", { configurable: true, - value, + value: width, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: height, }); } function pointerMove(type: string, x = 600, y = 400) { const event = new MouseEvent("pointermove", { clientX: x, clientY: y }); Object.defineProperty(event, "pointerType", { value: type }); - act(() => { - window.dispatchEvent(event); - }); + window.dispatchEvent(event); } beforeEach(() => { mockCounts.vectors = 0; mockMaterials.length = 0; mockGeometries.length = 0; - mockState.frame = null; - mockState.throwOnUseThree = false; + mockCameras.length = 0; + mockGroups.length = 0; + mockRenderers.length = 0; + mockRenderThrows = false; + engines = []; setCores(8); - setWindowWidth(1200); - // as tags do R3F (group, points) são elementos desconhecidos no DOM do jsdom - jest.spyOn(console, "error").mockImplementation(() => {}); + setWindowSize(1200); + // o relógio do cursor: performance.now() em segundos é o que `lastMove` guarda + jest.spyOn(performance, "now").mockReturnValue(1000); + frameRequests = jest + .spyOn(window, "requestAnimationFrame") + .mockImplementation(() => 1 as unknown as number); }); afterEach(() => { + for (const engine of engines) engine.dispose(); jest.restoreAllMocks(); + document.body.innerHTML = ""; if (originalHardware) Object.defineProperty(navigator, "hardwareConcurrency", originalHardware); }); -describe("Field: quadro (useFrame) — começo e rolagem", () => { - it("path: the group is gone (unmounted) -> the callback returns at once, nothing is requested", () => { - const scene = setup(); - const callback = mockState.frame; - scene.unmount(); - const invalidate = jest.fn(); - - callback?.({ clock: { elapsedTime: 1 }, invalidate }, 1 / 60); - - expect(invalidate).not.toHaveBeenCalled(); - expect(scene.onReady).not.toHaveBeenCalled(); - }); - +describe("createField: quadro — começo e rolagem", () => { it("path: onReady fires on the second frame, once", () => { const scene = setup(); - scene.frame(0); + scene.frame(); expect(scene.onReady).not.toHaveBeenCalled(); - scene.frame(0.02); + scene.frame(); expect(scene.onReady).toHaveBeenCalledTimes(1); - scene.frame(0.04); - scene.frame(0.06); + scene.frame(); + scene.frame(); expect(scene.onReady).toHaveBeenCalledTimes(1); }); @@ -220,9 +240,11 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => { it("path: the scroll progress is damped toward the target (and a long frame cannot overshoot)", () => { const scene = setup({ progress: 1 }); - scene.frame(0, 5); // delta enorme: o passo é limitado a 1/20 de segundo + scene.frame(); // o primeiro quadro: passo de 1/60 de segundo + const afterFirst = 1 - Math.exp(-(1 / 60) * 5); + scene.frame(5000); // delta enorme: o passo é limitado a 1/20 de segundo - const expected = 1 - Math.exp(-(1 / 20) * 5); + const expected = 1 - (1 - afterFirst) * Math.exp(-(1 / 20) * 5); expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9); expect(scene.uniforms().uScroll.value).toBeLessThan(1); }); @@ -238,8 +260,10 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => { it("path: the intro advances every frame and stops at 1", () => { const scene = setup(); - scene.frame(0, 1 / 20); - expect(scene.uniforms().uIntro.value).toBeCloseTo(0.05 / 2.6, 9); + scene.frame(50); // o primeiro quadro: 1/60 de segundo + expect(scene.uniforms().uIntro.value).toBeCloseTo(1 / 60 / 2.6, 9); + scene.frame(50); + expect(scene.uniforms().uIntro.value).toBeCloseTo((1 / 60 + 0.05) / 2.6, 9); scene.settle(80); @@ -271,13 +295,20 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => { expect(scene.uniforms().uSpread.value).toBeGreaterThan(5); }); + + it("path: the clock reaches the shader in seconds since the scene started", () => { + const scene = setup(); + scene.frame(0); + scene.frame(2000); + expect(scene.uniforms().uTime.value).toBeCloseTo(2, 1); + }); }); -describe("Field: enquadramento e foco", () => { +describe("createField: enquadramento e foco", () => { it("path: landscape window -> the group sits just below center and the camera rests at 9", () => { const scene = setup({ width: 1200, height: 800 }); - scene.frame(0); + scene.frame(); expect(scene.group.position.y).toBeCloseTo(-0.5, 9); expect(scene.camera.position.z).toBeCloseTo(9, 9); @@ -286,12 +317,26 @@ describe("Field: enquadramento e foco", () => { it("path: portrait window -> the group sits lower and the camera backs off to keep the layer in frame", () => { const scene = setup({ width: 400, height: 800 }); - scene.frame(0); + scene.frame(); expect(scene.group.position.y).toBeLessThan(-0.5); expect(scene.camera.position.z).toBeGreaterThan(9); }); + it("path: the renderer and the camera follow the size of the host", () => { + const scene = setup({ width: 900, height: 300 }); + + expect(scene.renderer.setSize).toHaveBeenCalledWith(900, 300, false); + expect(scene.camera.aspect).toBeCloseTo(3, 9); + }); + + it("path: a host without size yet (0 x 0) -> never a zero-size canvas or a NaN aspect", () => { + const scene = setup({ width: 0, height: 0 }); + + expect(scene.renderer.setSize).toHaveBeenCalledWith(1, 1, false); + expect(Number.isFinite(scene.camera.aspect)).toBe(true); + }); + it("path: end of the dive -> the group has settled back to the center and the camera stopped in front of the data layer", () => { const scene = setup({ progress: 1 }); @@ -308,7 +353,7 @@ describe("Field: enquadramento e foco", () => { it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => { const scene = setup({ progress: 0 }); - scene.frame(0); + scene.frame(); // câmera em 9 e primeira camada em 1,05: foco a 9 - 1,05 expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6); @@ -341,56 +386,56 @@ describe("Field: enquadramento e foco", () => { }); }); -describe("Field: mouse", () => { +describe("createField: mouse", () => { it("path: pointer type other than mouse (touch, pen) -> ignored, the cursor stays on its own path", () => { const scene = setup(); - jest.spyOn(performance, "now").mockReturnValue(5000); pointerMove("touch"); pointerMove("pen"); - scene.frame(5.5); - // sem mouse, o cursor fantasma passa a valer 0,4 (não 0,8) - scene.settle(120, 1 / 20, 5.5); + scene.settle(120); expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2); }); it("path: a mouse moved a moment ago -> the pointer drives the cursor strength to 0.8", () => { const scene = setup(); - jest.spyOn(performance, "now").mockReturnValue(5000); + scene.frame(); // o relógio da cena começa + (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); - for (let i = 0; i < 120; i++) scene.frame(5.1 + i * 0.01, 1 / 20); + for (let i = 0; i < 40; i++) scene.frame(50); // 2 s: o mouse ainda conta expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.8, 2); }); it("path: the mouse stopped long ago (over 2.5 s) -> back to the ghost cursor", () => { const scene = setup(); - jest.spyOn(performance, "now").mockReturnValue(5000); + scene.frame(0); + (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); - for (let i = 0; i < 120; i++) scene.frame(9 + i * 0.01, 1 / 20); + for (let i = 0; i < 120; i++) scene.frame(50); // 6 s depois expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2); }); it("path: a mouse event at timestamp 0 is not counted as activity", () => { const scene = setup(); - jest.spyOn(performance, "now").mockReturnValue(0); + (performance.now as jest.Mock).mockReturnValue(0); pointerMove("mouse"); - for (let i = 0; i < 120; i++) scene.frame(0.1 + i * 0.001, 1 / 20); + for (let i = 0; i < 30; i++) scene.frame(50); expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2); }); it("path: cursor strength fades to 0 once the dive is under way", () => { const scene = setup({ progress: 1 }); - jest.spyOn(performance, "now").mockReturnValue(5000); + scene.frame(); + (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); - for (let i = 0; i < 200; i++) scene.frame(5.1 + i * 0.005, 1 / 20); + for (let i = 0; i < 200; i++) scene.frame(5); expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2); }); @@ -398,90 +443,91 @@ describe("Field: mouse", () => { it("path: active mouse and the ray hits the layer plane -> the shader cursor follows the hit point", () => { const scene = setup(); jest - .spyOn(THREE.Ray.prototype, "intersectPlane") - .mockImplementation(((_plane: THREE.Plane, target: THREE.Vector3) => + .spyOn(Ray.prototype, "intersectPlane") + .mockImplementation(((_plane: Plane, target: Vector3) => target.set(1, 0, 2)) as never); - jest.spyOn(performance, "now").mockReturnValue(5000); + // o grupo real está girado e abaixado: aqui o ponto do raio já vale como ponto do grupo + scene.group.worldToLocal = ((point: Vector3) => point) as never; + scene.frame(16); // um mouse no instante 0 da cena não conta como atividade + const before = scene.uniforms().uMouse.value.clone(); + (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); - scene.frame(5.1, 1 / 20); + scene.frame(50); const factor = 1 - Math.exp(-(1 / 20) * 10); - expect(scene.uniforms().uMouse.value.x).toBeCloseTo(factor * 1, 6); - expect(scene.uniforms().uMouse.value.y).toBeCloseTo(factor * 2, 6); + expect(scene.uniforms().uMouse.value.x).toBeCloseTo( + before.x + (1 - before.x) * factor, + 6, + ); + expect(scene.uniforms().uMouse.value.y).toBeCloseTo( + before.y + (2 - before.y) * factor, + 6, + ); }); it("path: active mouse but the ray misses the plane -> the shader cursor stays where it was", () => { const scene = setup(); - jest - .spyOn(THREE.Ray.prototype, "intersectPlane") - .mockReturnValue(null as never); - jest.spyOn(performance, "now").mockReturnValue(5000); + jest.spyOn(Ray.prototype, "intersectPlane").mockReturnValue(null as never); + scene.frame(16); // um mouse no instante 0 da cena não conta como atividade + const before = scene.uniforms().uMouse.value.clone(); + (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); - scene.frame(5.1, 1 / 20); + scene.frame(50); - expect(scene.uniforms().uMouse.value.x).toBe(0); - expect(scene.uniforms().uMouse.value.y).toBe(0); + expect(scene.uniforms().uMouse.value.x).toBe(before.x); + expect(scene.uniforms().uMouse.value.y).toBe(before.y); }); it("path: idle mouse -> the ghost cursor wanders on its own", () => { const scene = setup(); - scene.frame(10, 1 / 20); + scene.frame(0); + scene.frame(9000); const { x, y } = scene.uniforms().uMouse.value; expect(Math.hypot(x, y)).toBeGreaterThan(0); }); }); -describe("Field: quando pedir o próximo quadro", () => { +describe("createField: quando pedir o próximo quadro", () => { it("path: the intro is still running -> a frame is requested even when the hero is not visible", () => { const scene = setup({ visible: false }); - const invalidate = scene.frame(0, 1 / 60); - - expect(invalidate).toHaveBeenCalled(); + expect(scene.frame()).toBeGreaterThan(0); }); it("path: everything settled and the hero is visible but nothing interacts -> no extra frame (the pulse does it)", () => { const scene = setup({ visible: true }); scene.settle(200); - const invalidate = scene.frame(500, 1 / 60); - - expect(invalidate).not.toHaveBeenCalled(); + expect(scene.frame()).toBe(0); }); it("path: everything settled and the hero is not visible -> no frame", () => { const scene = setup({ visible: false }); scene.settle(200); - const invalidate = scene.frame(500, 1 / 60); - - expect(invalidate).not.toHaveBeenCalled(); + expect(scene.frame()).toBe(0); }); it("path: settled, visible, and the mouse is moving -> frames are requested", () => { const scene = setup({ visible: true }); scene.settle(200); - jest.spyOn(performance, "now").mockReturnValue(600_000); + (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); - const invalidate = scene.frame(600.1, 1 / 60); - - expect(invalidate).toHaveBeenCalled(); + expect(scene.frame()).toBeGreaterThan(0); }); it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => { const scene = setup({ visible: false }); scene.settle(200); - jest.spyOn(performance, "now").mockReturnValue(600_000); + (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); - const invalidate = scene.frame(600.1, 1 / 60); - - expect(invalidate).not.toHaveBeenCalled(); + expect(scene.frame()).toBe(0); }); it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => { @@ -489,25 +535,160 @@ describe("Field: quando pedir o próximo quadro", () => { scene.settle(200); scene.progressRef.current = 0.5; - const invalidate = scene.frame(500, 1 / 60); - - expect(invalidate).toHaveBeenCalled(); + expect(scene.frame()).toBeGreaterThan(0); }); - it("path: becoming visible later -> the new callback sees the new flag", () => { + it("path: becoming visible later -> the next frame sees the new flag", () => { const scene = setup({ visible: false }); scene.settle(200); - jest.spyOn(performance, "now").mockReturnValue(600_000); + (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); - expect(scene.frame(600.1, 1 / 60)).not.toHaveBeenCalled(); + expect(scene.frame()).toBe(0); - scene.rerender(true); + scene.engine.setVisible(true); - expect(scene.frame(600.2, 1 / 60)).toHaveBeenCalled(); + expect(scene.frame()).toBeGreaterThan(0); + }); + + it("path: several requests before a frame become ONE animation frame", () => { + const scene = setup({ visible: true }); + scene.settle(200); + frameRequests.mockClear(); + // ligar a batida pede um quadro; religá-la sem que ele tenha vindo não pede outro + scene.engine.setVisible(false); + scene.engine.setVisible(true); + scene.engine.setVisible(false); + scene.engine.setVisible(true); + expect(frameRequests.mock.calls.length).toBeLessThanOrEqual(1); }); }); -describe("Field: geometria pré-calculada e quadros sem alocação", () => { +describe("needsFrame / updatePointer / updateRig (contas puras)", () => { + function rig(): Rig { + const scene = setup(); + scene.frame(); + const material = mockMaterials[mockMaterials.length - 1]; + return { + group: scene.group, + camera: scene.camera, + uniforms: material.uniforms, + layout: { + wide: true, + tanHalf: Math.tan((35 / 2) * (Math.PI / 180)), + spreadMax: 5.5, + rest: 9, + scale: 1000, + camera: [ + [0, 9], + [1, -1], + ], + } as unknown as Rig["layout"], + state: { + progress: 0, + pointer: { set() {}, x: 0, y: 0 } as never, + lastMove: -10, + mouse: { lerp() {}, x: 0, y: 0, copy() {} } as never, + tilt: { lerp() {}, x: 0, y: 0 } as never, + frames: 10, + }, + helpers: {} as Rig["helpers"], + }; + } + + const tick = (overrides: Partial = {}): Tick => ({ + p: 0, + face: 0, + time: 1, + dt: 1 / 60, + cursorActive: false, + ...overrides, + }); + + it("needsFrame: settling wins over everything, interaction only counts on screen", () => { + const r = rig(); + r.uniforms.uIntro.value = 1; + r.uniforms.uMouseAmt.value = 0.4; + expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(false); + expect(needsFrame(r.uniforms, r.state, 0, 0.4, true, true)).toBe(true); + expect(needsFrame(r.uniforms, r.state, 0, 0.4, true, false)).toBe(false); + expect(needsFrame(r.uniforms, r.state, 0.5, 0.4, false, false)).toBe(true); + r.uniforms.uIntro.value = 0.5; + expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, false)).toBe(true); + }); + + it("needsFrame: the first frames always ask for another (the cursor strength still has to settle)", () => { + const r = rig(); + r.uniforms.uIntro.value = 1; + r.state.frames = 1; + expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(true); + }); + + it("updatePointer: returns the strength it is heading for (0.8 with the mouse, 0.4 without, none in the dive)", () => { + const r = rig(); + r.helpers = { + target: { set: () => ({}), x: 0, y: 0 }, + ray: { setFromCamera() {}, ray: { intersectPlane: () => null } }, + plane: { set: () => ({ applyMatrix4() {} }) }, + up: {}, + hit: {}, + } as never; + expect(updatePointer(r, tick({ cursorActive: true }))).toBeCloseTo(0.8, 9); + expect(updatePointer(r, tick({ cursorActive: false }))).toBeCloseTo(0.4, 9); + expect(updatePointer(r, tick({ p: 1 }))).toBe(0); + }); + + it("updateRig: the rotation goes from three quarters (resting) to facing the viewer (diving)", () => { + const r = rig(); + updateRig(r, tick({ face: 0 })); + const resting = r.group.rotation.x; + updateRig(r, tick({ face: 1 })); + expect(resting).toBeCloseTo(0.42, 9); + expect(r.group.rotation.x).toBeCloseTo(Math.PI / 2, 9); + // de frente, o balanço lateral some + expect(r.group.rotation.y).toBeCloseTo(0, 9); + }); + + it("needsFrame: the cursor strength alone still far from its target asks for a frame, but only on screen", () => { + const r = rig(); + r.uniforms.uIntro.value = 1; + r.uniforms.uMouseAmt.value = 0; + + expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, true)).toBe(true); + expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, false)).toBe(false); + // dentro da tolerância de 0,01 já está assentado + r.uniforms.uMouseAmt.value = 0.795; + expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, true)).toBe(false); + }); + + it("needsFrame: the scroll damping counts as settling only beyond 0.0004 of the target", () => { + const r = rig(); + r.uniforms.uIntro.value = 1; + r.uniforms.uMouseAmt.value = 0.4; + r.state.progress = 0.5; + + expect(needsFrame(r.uniforms, r.state, 0.50039, 0.4, false, false)).toBe( + false, + ); + expect(needsFrame(r.uniforms, r.state, 0.5005, 0.4, false, false)).toBe( + true, + ); + }); + + it("updateRig: the camera follows the choreography -> the first frame before it starts, a smoothed midpoint, the last frame after it ends", () => { + const r = rig(); + + updateRig(r, tick({ p: -0.5 })); + expect(r.camera.position.z).toBe(9); + + updateRig(r, tick({ p: 0.5 })); + expect(r.camera.position.z).toBeCloseTo(4, 9); + + updateRig(r, tick({ p: 2 })); + expect(r.camera.position.z).toBe(-1); + }); +}); + +describe("createField: geometria pré-calculada e quadros sem alocação", () => { it("path: prepareField builds the points before the scene mounts, and mounting reuses them", () => { prepareField(); expect(mockGeometries).toHaveLength(1); @@ -531,40 +712,42 @@ describe("Field: geometria pré-calculada e quadros sem alocação", () => { expect(mockGeometries).toHaveLength(2); }); - it("path: no vector is created per frame, not even with the mouse driving the cursor over the layer", () => { + it("path: no vector is created per frame beyond the few the frame itself needs, not even with the mouse over the layer", () => { const scene = setup(); - jest.spyOn(performance, "now").mockReturnValue(5000); + scene.frame(0); + (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); - scene.frame(5.1, 1 / 60); + scene.frame(); const before = mockCounts.vectors; - for (let i = 0; i < 60; i++) scene.frame(5.2 + i / 60, 1 / 60); + for (let i = 0; i < 60; i++) scene.frame(); + // nenhum Vector3 novo: os auxiliares são criados uma vez só expect(mockCounts.vectors).toBe(before); }); }); -describe("Field: densidade, contexto WebGL e limpeza", () => { +describe("createField: densidade, contexto WebGL, erros e limpeza", () => { const pointCount = () => { const geometry = mockGeometries[mockGeometries.length - 1]; return geometry.getAttribute("position").count; }; it("path: wide window and 4+ cores -> the dense field", () => { - setWindowWidth(1200); + setWindowSize(1200); setCores(8); setup(); const dense = pointCount(); mockGeometries.length = 0; - setWindowWidth(600); + setWindowSize(600); setup(); expect(dense).toBeGreaterThan(pointCount()); }); it("path: wide window but 2 cores -> the light field", () => { - setWindowWidth(1200); + setWindowSize(1200); setCores(2); setup(); const light = pointCount(); @@ -577,7 +760,7 @@ describe("Field: densidade, contexto WebGL e limpeza", () => { }); it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => { - setWindowWidth(1200); + setWindowSize(1200); const counts: Record = {}; for (const [label, cores] of [ ["dense", 8], @@ -599,15 +782,22 @@ describe("Field: densidade, contexto WebGL e limpeza", () => { const scene = setup(); const event = new Event("webglcontextlost", { cancelable: true }); - act(() => { - scene.domElement.dispatchEvent(event); - }); + scene.renderer.domElement.dispatchEvent(event); expect(event.defaultPrevented).toBe(true); expect(scene.onError).toHaveBeenCalledTimes(1); }); - it("path: unmount -> geometry and material are disposed and the listeners go away", () => { + it("path: drawing throws -> the page is told and the scene does not crash the frame", () => { + const scene = setup(); + mockRenderThrows = true; + + expect(() => scene.frame()).not.toThrow(); + + expect(scene.onError).toHaveBeenCalled(); + }); + + it("path: dispose -> geometry, material and renderer are released, the canvas leaves, and the listeners go away", () => { const scene = setup(); const geometry = mockGeometries[mockGeometries.length - 1]; const material = mockMaterials[mockMaterials.length - 1]; @@ -615,38 +805,60 @@ describe("Field: densidade, contexto WebGL e limpeza", () => { const disposeMaterial = jest.spyOn(material, "dispose"); const event = new Event("webglcontextlost", { cancelable: true }); - scene.unmount(); - scene.domElement.dispatchEvent(event); + scene.engine.dispose(); + scene.renderer.domElement.dispatchEvent(event); + pointerMove("mouse"); expect(disposeGeometry).toHaveBeenCalledTimes(1); expect(disposeMaterial).toHaveBeenCalledTimes(1); + expect(scene.renderer.dispose).toHaveBeenCalledTimes(1); + expect(scene.renderer.forceContextLoss).toHaveBeenCalledTimes(1); + expect(scene.renderer.domElement.isConnected).toBe(false); expect(event.defaultPrevented).toBe(false); expect(scene.onError).not.toHaveBeenCalled(); }); -}); -describe("SceneBoundary", () => { - it("path: a child fails while drawing -> nothing is rendered and the page is told once", () => { - mockState.throwOnUseThree = true; - const onError = jest.fn(); - - const { container } = render( - {}} - onError={onError} - />, - ); - - expect(container).toBeEmptyDOMElement(); - expect(onError).toHaveBeenCalledTimes(1); - }); - - it("path: no failure -> the scene is rendered and onError is not called", () => { + it("path: a frame after dispose does nothing", () => { const scene = setup(); + scene.engine.dispose(); + scene.renderer.render.mockClear(); - expect(scene.container.querySelector("group")).not.toBeNull(); - expect(scene.onError).not.toHaveBeenCalled(); + scene.frame(); + + expect(scene.renderer.render).not.toHaveBeenCalled(); + }); + + it("path: no ResizeObserver (old browser) -> the window resize event resizes the scene", () => { + const original = globalThis.ResizeObserver; + // @ts-expect-error o jsdom não tem ResizeObserver; o teste garante a ausência + globalThis.ResizeObserver = undefined; + try { + const scene = setup({ width: 600, height: 400 }); + scene.renderer.setSize.mockClear(); + window.dispatchEvent(new Event("resize")); + expect(scene.renderer.setSize).toHaveBeenCalledWith(600, 400, false); + } finally { + globalThis.ResizeObserver = original; + } + }); +}); + +describe("createField: a batida de 30 quadros por segundo", () => { + it("path: setVisible(true) twice in a row -> a single pulse; leaving and coming back starts a new one", () => { + const interval = jest + .spyOn(window, "setInterval") + .mockReturnValue(7 as unknown as ReturnType); + jest.spyOn(window, "clearInterval").mockImplementation(() => {}); + const { engine } = setup({ visible: false }); + expect(interval).not.toHaveBeenCalled(); + + engine.setVisible(true); + engine.setVisible(true); + expect(interval).toHaveBeenCalledTimes(1); + + engine.setVisible(false); + engine.setVisible(false); // sair de novo sem estar à vista não desliga nada duas vezes + engine.setVisible(true); + expect(interval).toHaveBeenCalledTimes(2); }); }); diff --git a/app/components/cinematic/HeroFieldScene.test.tsx b/app/components/cinematic/HeroFieldScene.test.tsx index c013380..016c650 100644 --- a/app/components/cinematic/HeroFieldScene.test.tsx +++ b/app/components/cinematic/HeroFieldScene.test.tsx @@ -1,51 +1,241 @@ -import { render } from "@testing-library/react"; +import { act, render } from "@testing-library/react"; import HeroFieldScene from "./HeroFieldScene"; -type CanvasProps = { - dpr?: [number, number]; - frameloop?: string; - gl?: Record; -}; -const mockCanvas: CanvasProps[] = []; +/* + * O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página, + * liga a batida só com o hero à vista e solta tudo ao sair. O WebGL do jsdom não existe: o renderizador é um dublê. + */ -jest.mock("@react-three/fiber", () => ({ - Canvas: (props: CanvasProps) => { - mockCanvas.push(props); - return null; - }, - useFrame: jest.fn(), - useThree: jest.fn(), +type RendererOptions = { + alpha?: boolean; + antialias?: boolean; + powerPreference?: string; +}; +const mockRenderers: MockRenderer[] = []; +let mockFailToCreate = false; + +class MockRenderer { + domElement = document.createElement("canvas"); + pixelRatio = 1; + options: RendererOptions; + render = jest.fn(); + dispose = jest.fn(); + forceContextLoss = jest.fn(); + setSize = jest.fn(); + setPixelRatio = (value: number) => { + this.pixelRatio = value; + }; + getPixelRatio = () => this.pixelRatio; + constructor(options: RendererOptions) { + if (mockFailToCreate) throw new Error("sem WebGL"); + this.options = options; + mockRenderers.push(this); + } +} + +// a classe é criada só quando a cena pede o renderizador (o mock é içado antes da declaração dela) +jest.mock("three", () => ({ + ...jest.requireActual("three"), + WebGLRenderer: jest.fn( + (options: RendererOptions) => new MockRenderer(options), + ), })); +const props = () => ({ + progressRef: { current: 0 }, + visible: false, + onReady: jest.fn(), + onError: jest.fn(), +}); + +function setDevicePixelRatio(value: number) { + Object.defineProperty(window, "devicePixelRatio", { + configurable: true, + value, + }); +} + +beforeEach(() => { + jest.useFakeTimers(); + mockRenderers.length = 0; + mockFailToCreate = false; + setDevicePixelRatio(1); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + describe("HeroFieldScene (ambient 3D)", () => { - beforeEach(() => { - mockCanvas.length = 0; + it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", () => { + const { container } = render(); + + expect(mockRenderers).toHaveLength(1); + expect(mockRenderers[0].options).toMatchObject({ + alpha: true, + antialias: false, + powerPreference: "high-performance", + }); + const canvas = container.querySelector("canvas"); + expect(canvas).toBe(mockRenderers[0].domElement); + expect(canvas).toHaveAttribute("aria-hidden", "true"); + expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true"); }); - it("draws on demand, never on a fixed 60 fps loop, and caps the pixel ratio at 1.5", () => { - render( - {}} - onError={() => {}} - />, + it.each([ + [0.5, 1], + [1, 1], + [1.25, 1.25], + [2, 1.5], + [3, 1.5], + ])("caps the pixel ratio at 1.5 (device %s -> %s)", (device, expected) => { + setDevicePixelRatio(device); + render(); + expect(mockRenderers[0].pixelRatio).toBe(expected); + }); + + it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", () => { + const frames = jest.spyOn(window, "requestAnimationFrame"); + render(); + expect(frames).toHaveBeenCalledTimes(1); + + // a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada) + act(() => { + jest.advanceTimersByTime(5000); + }); + const settled = frames.mock.calls.length; + expect(settled).toBeGreaterThan(2); + expect(settled).toBeLessThan(5000 / 16); + + // hero fora de cena e tudo assentado: nenhum quadro a mais + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(frames.mock.calls.length).toBe(settled); + expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2); + }); + + it("tells the page it is ready after the second frame", () => { + const scene = props(); + render(); + expect(scene.onReady).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(scene.onReady).toHaveBeenCalledTimes(1); + }); + + it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", () => { + const frames = jest.spyOn(window, "requestAnimationFrame"); + const scene = props(); + const { rerender } = render(); + act(() => { + jest.advanceTimersByTime(6000); + }); + const hidden = frames.mock.calls.length; + expect(jest.getTimerCount()).toBe(0); + + rerender(); + act(() => { + jest.advanceTimersByTime(20); // o quadro que a batida pediu sai + }); + expect(jest.getTimerCount()).toBe(1); // um setInterval, nenhum segundo laço + act(() => { + jest.advanceTimersByTime(980); + }); + const shown = frames.mock.calls.length - hidden; + expect(shown).toBeGreaterThan(20); + expect(shown).toBeLessThanOrEqual(34); + + rerender(); + const stopped = frames.mock.calls.length; + act(() => { + jest.advanceTimersByTime(2000); + }); + expect(frames.mock.calls.length).toBe(stopped); + expect(jest.getTimerCount()).toBe(0); + }); + + it("releases the context, the canvas and the timers when it unmounts", () => { + const scene = props(); + const { container, unmount } = render( + , ); - const props = mockCanvas.at(-1); - expect(props?.frameloop).toBe("demand"); - expect(props?.dpr).toEqual([1, 1.5]); - expect(props?.gl).toMatchObject({ alpha: true, antialias: false }); + expect(container.querySelector("canvas")).not.toBeNull(); + + unmount(); + + expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1); + expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1); + expect(mockRenderers[0].domElement.isConnected).toBe(false); + expect(jest.getTimerCount()).toBe(0); + const drawn = mockRenderers[0].render.mock.calls.length; + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(mockRenderers[0].render.mock.calls.length).toBe(drawn); }); - it("keeps the same on-demand loop when the hero is off screen (the pulse is what stops)", () => { - render( + it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", () => { + const first = props(); + const { rerender } = render(); + const second = props(); + rerender( {}} - onError={() => {}} + onReady={second.onReady} + onError={second.onError} />, ); - expect(mockCanvas.at(-1)?.frameloop).toBe("demand"); + expect(mockRenderers).toHaveLength(1); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(first.onReady).not.toHaveBeenCalled(); + expect(second.onReady).toHaveBeenCalledTimes(1); + }); + + it("tells the page and renders nothing but the empty host when WebGL cannot be created", () => { + mockFailToCreate = true; + const scene = props(); + + const { container } = render(); + + expect(scene.onError).toHaveBeenCalledTimes(1); + expect(container.querySelector("canvas")).toBeNull(); + }); + + it.each([ + [0, 1], // aparelho que não informa a proporção: 1 + [0.5, 1], // nunca abaixo de 1 + [1.25, 1.25], + [3, 1.5], // nunca acima de 1,5: cada ponto custa fragmentos e o fundo é ambiente + ])( + "draws at a pixel ratio of %p -> the renderer gets %p", + (device, expected) => { + setDevicePixelRatio(device); + + render(); + + expect(mockRenderers[0].pixelRatio).toBe(expected); + }, + ); + + it("tells the page when the WebGL context is lost after mounting, through the newest callback", () => { + const scene = props(); + render(); + + act(() => { + mockRenderers[0].domElement.dispatchEvent( + new Event("webglcontextlost", { cancelable: true }), + ); + }); + + expect(scene.onError).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/cinematic/HeroFieldScene.tsx b/app/components/cinematic/HeroFieldScene.tsx index c8113f7..59f2138 100644 --- a/app/components/cinematic/HeroFieldScene.tsx +++ b/app/components/cinematic/HeroFieldScene.tsx @@ -1,16 +1,26 @@ "use client"; -import { Canvas, useFrame, useThree } from "@react-three/fiber"; +import { type RefObject, useEffect, useRef } from "react"; import { - Component, - type ReactNode, - type RefObject, - useEffect, - useMemo, - useRef, -} from "react"; -import * as THREE from "three"; -import FramePulse from "./FramePulse"; + 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; @@ -30,9 +40,10 @@ export type HeroFieldSceneProps = { * 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 desenho é sob demanda (frameloop "demand"): a cena pede um quadro a 30 por segundo - * e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, ou depois de uns segundos - * sem cursor nem rolagem (FramePulse), não desenha nada. + * 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 @@ -259,19 +270,16 @@ function buildField(dense: boolean) { n * 4, ); } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute( - "position", - new THREE.Float32BufferAttribute(f.base, 3), - ); - geometry.setAttribute("aTo", new THREE.Float32BufferAttribute(f.to, 3)); - geometry.setAttribute("aMove", new THREE.Float32BufferAttribute(f.move, 1)); - geometry.setAttribute("aTone", new THREE.Float32BufferAttribute(f.tone, 1)); - geometry.setAttribute("aSize", new THREE.Float32BufferAttribute(f.size, 1)); - geometry.setAttribute("aAlpha", new THREE.Float32BufferAttribute(f.alpha, 1)); - geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3)); - geometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 4)); - geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 12); + 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; } @@ -374,8 +382,9 @@ 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 de Hero.tsx (--from/--to em globals.css) seguem - * estes mesmos intervalos: tela 0,26–0,46; integrações 0,52–0,71; dados 0,76–0,92. + * imagem, uma tabela de dados). As legendas de Hero.tsx (--from/--to em globals.css) entram um pouco antes de a + * câmera chegar a cada camada e saem um pouco depois: tela 0,18–0,49; integrações 0,51–0,75; dados 0,79 em diante + * (a última fica até Projetos subir e cobri-la). * 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. */ @@ -398,7 +407,7 @@ function cameraFrames(rest: number, spread: number, frame: number) { const isDense = () => window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4; -let prepared: THREE.BufferGeometry | null = null; +let prepared: BufferGeometry | null = null; /** * Monta a geometria (dezenas de milhares de pontos) fora do caminho crítico: quem carrega este módulo chama @@ -416,7 +425,7 @@ function takeField() { return geometry; } -type Uniforms = THREE.ShaderMaterial["uniforms"]; +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( @@ -426,7 +435,7 @@ function frameLayout( pixelRatio: number, ) { const aspect = width / height; - const tanHalf = Math.tan(THREE.MathUtils.degToRad(fov / 2)); + 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); @@ -466,120 +475,140 @@ function updateUniforms( type FieldState = { progress: number; - pointer: THREE.Vector2; + pointer: Vector2; lastMove: number; - mouse: THREE.Vector2; - tilt: THREE.Vector2; + mouse: Vector2; + tilt: Vector2; frames: number; }; /** Objetos reaproveitados a cada quadro (nada é alocado dentro do useFrame). */ type Helpers = { - ray: THREE.Raycaster; - plane: THREE.Plane; - up: THREE.Vector3; - hit: THREE.Vector3; - zero: THREE.Vector2; - target: THREE.Vector2; + 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; -/** 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. */ -function updateRig( - g: THREE.Group, - camera: THREE.Camera, - u: Uniforms, - layout: Layout, - s: FieldState, - h: Helpers, - p: number, - face: number, - time: number, - dt: number, - cursorActive: boolean, -) { - s.tilt.lerp(cursorActive ? s.pointer : h.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 - g.rotation.set( - 0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08, - (-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) + - s.tilt.x * 0.15 * (1 - face), - 0, +/** 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: 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 - const settle = 1 - smooth(0, 0.28, p); - g.position.set( - 0, - (layout.wide ? -0.5 : -layout.rest * layout.tanHalf * 0.38) * settle, - 0, - ); - camera.position.z = keyframes(layout.camera, p); - // foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente) - const spread = u.uSpread.value; - const behind = camera.position.z - 0.2; - const ahead = spread < behind ? spread : 0 < behind ? 0 : -spread; - u.uFocus.value = Math.max(1.5, camera.position.z - ahead); - g.updateMatrixWorld(); } -/** Ponto da camada sob o cursor, em coordenadas do grupo, deixado em h.target. Falso se o raio não toca a camada. */ -function cursorOnLayer( - g: THREE.Group, - camera: THREE.Camera, - s: FieldState, - h: Helpers, - spread: number, -) { - h.ray.setFromCamera(s.pointer, camera); - h.plane.set(h.up, -spread).applyMatrix4(g.matrixWorld); - if (!h.ray.ray.intersectPlane(h.plane, h.hit)) return false; - g.worldToLocal(h.hit); - h.target.set(h.hit.x, h.hit.z); +/** 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; } -/** Onda do cursor: segue o mouse na camada ou, sem mouse, o cursor fantasma. Devolve a força que ela busca. */ -function updatePointer( - g: THREE.Group, - camera: THREE.Camera, - u: Uniforms, - s: FieldState, - h: Helpers, - p: number, - time: number, - dt: number, - cursorActive: boolean, -) { +/** 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(g, camera, s, h, u.uSpread.value)) - s.mouse.lerp(h.target, 1 - Math.exp(-dt * 10)); - } else { - s.mouse.lerp( - h.target.set( - Math.sin(time * 0.31) * 1.5, - Math.sin(time * 0.23 + 1.3) * 1.1, - ), - 1 - Math.exp(-dt * 1.5), - ); + if (cursorOnLayer(rig, uniforms.uSpread.value)) + state.mouse.lerp(helpers.target, 1 - Math.exp(-dt * 10)); + return; } - u.uMouse.value.copy(s.mouse); - const targetAmt = (cursorActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p)); - u.uMouseAmt.value += - (targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4)); + 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; } /** * Sob demanda: o próximo quadro vem do FramePulse (30 por segundo, e nenhum depois de uns segundos sem cursor nem - * rolagem); este pede o quadro seguinte (a 60) só enquanto algo ainda muda. A entrada e a rolagem terminam de + * rolagem); o quadro pede o seguinte (a 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. */ -function needsFrame( +export function needsFrame( u: Uniforms, s: FieldState, target: number, @@ -593,176 +622,339 @@ function needsFrame( return settling || (visible && interacting); } -function Field({ +/** 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); + }; +} + +/** + * A cena sem React: cria o renderizador dentro de `host` 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. Lança se o aparelho não cria um contexto WebGL. + */ +export function createField( + host: HTMLElement, + { progressRef, onReady, onError }: FieldCallbacks, +): FieldEngine { + const renderer = createRenderer(host); + const world = buildWorld(); + const state = createState(); + const helpers = createHelpers(); + 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 delta = last ? (now - last) / 1000 : 1 / 60; + last = now; + try { + const step = stepField(world, state, helpers, layout, { + target: progressRef.current ?? 0, + 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(); + world.geometry.dispose(); + world.material.dispose(); + renderer.dispose(); + renderer.forceContextLoss(); + renderer.domElement.remove(); + }, + }; +} + +export default function HeroFieldScene({ progressRef, visible, onReady, onError, }: HeroFieldSceneProps) { - const { gl, camera, size } = useThree(); - const group = useRef(null); - const geometry = useMemo(takeField, []); - const material = useMemo( - () => - new THREE.ShaderMaterial({ - vertexShader, - fragmentShader, - transparent: true, - depthWrite: false, - blending: THREE.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 THREE.Vector2(9, 9) }, - uMouseAmt: { value: 0 }, - }, - }), - [], - ); - const layout = useMemo( - () => - frameLayout( - size.width, - size.height, - (camera as THREE.PerspectiveCamera).fov, - gl.getPixelRatio(), - ), - [camera, gl, size.height, size.width], - ); - const state = useRef({ - progress: 0, - pointer: new THREE.Vector2(), - lastMove: -10, - mouse: new THREE.Vector2(), - tilt: new THREE.Vector2(), - frames: 0, + 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 }; }); - const helpers = useMemo( - () => ({ - ray: new THREE.Raycaster(), - plane: new THREE.Plane(), - up: new THREE.Vector3(0, 1, 0), - hit: new THREE.Vector3(), - zero: new THREE.Vector2(), - target: new THREE.Vector2(), - }), - [], - ); useEffect(() => { - const onMove = (event: PointerEvent) => { - if (event.pointerType !== "mouse") return; - const s = state.current; - s.pointer.set( - (event.clientX / window.innerWidth) * 2 - 1, - -(event.clientY / window.innerHeight) * 2 + 1, - ); - s.lastMove = performance.now() / 1000; - }; - const onLost = (event: Event) => { - event.preventDefault(); - onError(); - }; - window.addEventListener("pointermove", onMove, { passive: true }); - gl.domElement.addEventListener("webglcontextlost", onLost); + const host = hostRef.current; + if (!host) return; + try { + engine.current = createField(host, { + progressRef, + onReady: () => callbacks.current.onReady(), + onError: () => callbacks.current.onError(), + }); + } catch { + // sem WebGL (ou o contexto não abriu): o hero fica com o fundo estático + callbacks.current.onError(); + return; + } return () => { - window.removeEventListener("pointermove", onMove); - gl.domElement.removeEventListener("webglcontextlost", onLost); - geometry.dispose(); - material.dispose(); + engine.current?.dispose(); + engine.current = null; }; - }, [gl, geometry, material, onError]); + }, [progressRef]); - useFrame((frame, delta) => { - const g = group.current; - if (!g) return; - const dt = Math.min(delta, 1 / 20); - const s = state.current; - const u = material.uniforms; - const time = frame.clock.elapsedTime; - const target = progressRef.current ?? 0; - - // rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera - s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5)); - const p = s.progress; - const cursorActive = cursorIsActive(s, time); - const face = updateUniforms(u, layout, p, time, dt); - updateRig( - g, - camera, - u, - layout, - s, - helpers, - p, - face, - time, - dt, - cursorActive, - ); - const amount = updatePointer( - g, - camera, - u, - s, - helpers, - p, - time, - dt, - cursorActive, - ); - if (++s.frames === 2) onReady(); - - if (needsFrame(u, s, target, amount, cursorActive, visible)) - frame.invalidate(); - }); + useEffect(() => { + engine.current?.setVisible(visible); + }, [visible]); return ( - - - - ); -} - -class SceneBoundary extends Component< - { children: ReactNode; onError: () => void }, - { failed: boolean } -> { - state = { failed: false }; - static getDerivedStateFromError() { - return { failed: true }; - } - componentDidCatch() { - this.props.onError(); - } - render() { - return this.state.failed ? null : this.props.children; - } -} - -export default function HeroFieldScene(props: HeroFieldSceneProps) { - return ( - - - +