perf(movimento): vitrine e 3D mais leves, WebGL por software lembrado na sessão e suporte ao WebKit
This commit is contained in:
1 parent
9fd1570426
commit
8903f3c57e
28 files changed
+854
-406
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||
import CinematicOverlays from "./CinematicOverlays";
|
||||
|
||||
/**
|
||||
@@ -6,9 +7,6 @@ import CinematicOverlays from "./CinematicOverlays";
|
||||
* com a página montada (de fora da home para a home e de volta) e as duas condições do grão juntas.
|
||||
*/
|
||||
|
||||
let mockPathname = "/";
|
||||
jest.mock("next/navigation", () => ({ usePathname: () => mockPathname }));
|
||||
|
||||
const mockLoaded = jest.fn();
|
||||
jest.mock("./FilmGrain", () => {
|
||||
mockLoaded();
|
||||
@@ -38,7 +36,7 @@ function mockMedia(active: string[] = []) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockPathname = "/";
|
||||
setNavigation({ pathname: "/" });
|
||||
mockLoaded.mockClear();
|
||||
mockMedia();
|
||||
});
|
||||
@@ -49,12 +47,12 @@ afterEach(() => {
|
||||
|
||||
describe("CinematicOverlays: troca de página", () => {
|
||||
it("path: arriving on the home from another page with a mouse -> the grain and the progress bar appear", async () => {
|
||||
mockPathname = "/cases";
|
||||
setNavigation({ pathname: "/cases" });
|
||||
const { container, rerender } = render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
mockPathname = "/";
|
||||
setNavigation({ pathname: "/" });
|
||||
rerender(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
@@ -67,12 +65,12 @@ describe("CinematicOverlays: troca de página", () => {
|
||||
await loaded();
|
||||
expect(screen.getByTestId("grain")).toBeInTheDocument();
|
||||
|
||||
mockPathname = "/servicos";
|
||||
setNavigation({ pathname: "/servicos" });
|
||||
rerender(<CinematicOverlays />);
|
||||
expect(screen.queryByTestId("grain")).toBeNull();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
mockPathname = "/";
|
||||
setNavigation({ pathname: "/" });
|
||||
rerender(<CinematicOverlays />);
|
||||
await loaded();
|
||||
expect(screen.getByTestId("grain")).toBeInTheDocument();
|
||||
@@ -80,7 +78,7 @@ describe("CinematicOverlays: troca de página", () => {
|
||||
});
|
||||
|
||||
it("path: a sub-route that merely starts like the home path -> not the home", async () => {
|
||||
mockPathname = "/cases/sev-modaintima";
|
||||
setNavigation({ pathname: "/cases/sev-modaintima" });
|
||||
const { container } = render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||
import CinematicOverlays from "./CinematicOverlays";
|
||||
|
||||
let mockPathname = "/";
|
||||
jest.mock("next/navigation", () => ({ usePathname: () => mockPathname }));
|
||||
|
||||
// o grão vem por import dinâmico (pacote próprio): aqui ele vira um marcador, e o contador diz se o pacote foi pedido
|
||||
const mockLoaded = jest.fn();
|
||||
jest.mock("./FilmGrain", () => {
|
||||
@@ -34,7 +32,7 @@ function mockMedia(active: string[] = []) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockPathname = "/";
|
||||
setNavigation({ pathname: "/" });
|
||||
mockMedia();
|
||||
});
|
||||
|
||||
@@ -68,7 +66,7 @@ describe("CinematicOverlays: where the grain package is never requested", () =>
|
||||
});
|
||||
|
||||
it("shows nothing, and requests nothing, on the other pages", async () => {
|
||||
mockPathname = "/cases";
|
||||
setNavigation({ pathname: "/cases" });
|
||||
const { container } = render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@ export type FramePulseOptions = {
|
||||
* 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.
|
||||
* quadro) e o último quadro fica na tela; qualquer movimento a religa. É o único relógio do cursor (nenhum
|
||||
* requestAnimationFrame encadeado a cada quadro da tela); quem precisa de mais (entrada, rolagem) pede o próximo quadro
|
||||
* por conta própria, dentro do quadro. Devolve a função que desliga tudo.
|
||||
*/
|
||||
export function startFramePulse(
|
||||
invalidate: () => void,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { rememberSoftwareWebGL } from "@/lib/device";
|
||||
import HeroField, { DIVE_QUERY } from "./HeroField";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de HeroField (complemento de HeroField.test.tsx): como a espera pelo WebGL acaba
|
||||
* (primeira interação, requestIdleCallback com folga ou sem folga, temporizador), os cancelamentos, as falhas
|
||||
* (contexto WebGL2, pacote que não carrega), o fundo sem a seção `.hero`, as variáveis de rolagem e a
|
||||
* (cena que não abre o contexto, pacote que não carrega), o WebGL por software lembrado na sessão, o fundo sem a seção `.hero`, as variáveis de rolagem e a
|
||||
* visibilidade (observador, aba oculta, fim do mergulho) e a limpeza ao desmontar.
|
||||
*/
|
||||
|
||||
@@ -306,25 +307,13 @@ describe("HeroField: quando o WebGL monta", () => {
|
||||
});
|
||||
|
||||
describe("HeroField: falhas voltam ao hero simples", () => {
|
||||
it("path: WebGL2 constructor exists but creating the context throws -> static background, no dive", async () => {
|
||||
setContext(() => {
|
||||
throw new Error("sem GPU");
|
||||
});
|
||||
|
||||
it("path: the scene fails to open its WebGL context (it reports an error) -> static background, no dive, no scene", async () => {
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
expect(section()).toHaveAttribute("data-dive");
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: WebGL2 constructor exists but the context is null -> static background, no dive", async () => {
|
||||
setContext(() => null);
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
act(() => mockScenes.at(-1)?.onError());
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
@@ -342,28 +331,16 @@ describe("HeroField: falhas voltam ao hero simples", () => {
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: the probe context is released right away (WEBGL2 lose_context) before the real scene mounts", async () => {
|
||||
const loseContext = jest.fn();
|
||||
setContext(() => ({
|
||||
getExtension: (name: string) =>
|
||||
name === "WEBGL_lose_context" ? { loseContext } : null,
|
||||
}));
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(loseContext).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: the lose-context extension is missing -> the probe still succeeds", async () => {
|
||||
setContext(() => ({ getExtension: () => null }));
|
||||
it("path: HeroField never touches a canvas context, on hydration or when idle (a test context cost a 150 to 500 ms task)", async () => {
|
||||
const probe = jest.fn(() => ({ getExtension: () => null }));
|
||||
setContext(probe);
|
||||
|
||||
render(<Hero />);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -601,61 +578,48 @@ describe("HeroField: desmontagem em pontos que faltavam", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Um contexto WebGL de teste que conta ao navegador o nome do renderizador (WEBGL_debug_renderer_info). */
|
||||
function contextRenderedBy(renderer: unknown, loseContext = jest.fn()) {
|
||||
const RENDERER = 37446;
|
||||
return {
|
||||
getExtension: (name: string) => {
|
||||
if (name === "WEBGL_debug_renderer_info")
|
||||
return { UNMASKED_RENDERER_WEBGL: RENDERER };
|
||||
return name === "WEBGL_lose_context" ? { loseContext } : null;
|
||||
},
|
||||
getParameter: (parameter: number) =>
|
||||
parameter === RENDERER ? renderer : undefined,
|
||||
};
|
||||
}
|
||||
describe("HeroField: WebGL por software (o veredito vem da cena e fica na sessão)", () => {
|
||||
beforeEach(() => sessionStorage.clear());
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("HeroField: WebGL2 por software (supportsWebGL2 -> isSoftwareWebGL)", () => {
|
||||
it.each([
|
||||
[
|
||||
"SwiftShader (Chrome sem GPU)",
|
||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)",
|
||||
],
|
||||
["llvmpipe (Mesa)", "llvmpipe (LLVM 15.0.7, 256 bits)"],
|
||||
[
|
||||
"Microsoft Basic Render Driver",
|
||||
"ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0)",
|
||||
],
|
||||
])(
|
||||
"path: %s -> the plain 100svh hero from hydration: no dive, no scroll listeners, no observer, no scene, and the probe context is still released",
|
||||
async (_name, renderer) => {
|
||||
const loseContext = jest.fn();
|
||||
setContext(() => contextRenderedBy(renderer, loseContext));
|
||||
const addListener = jest.spyOn(window, "addEventListener");
|
||||
it("path: software WebGL already found in this session -> the plain 100svh hero from hydration: no dive, no scroll listeners, no observer, no scene", async () => {
|
||||
rememberSoftwareWebGL();
|
||||
const addListener = jest.spyOn(window, "addEventListener");
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
await advance(3000);
|
||||
await finishLoading();
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
await advance(3000);
|
||||
await finishLoading();
|
||||
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(observers).toHaveLength(0);
|
||||
expect(addListener.mock.calls.map(([type]) => type)).not.toContain(
|
||||
"scroll",
|
||||
);
|
||||
expect(loseContext).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("path: a hardware renderer -> the scene mounts and the dive starts", async () => {
|
||||
setContext(() =>
|
||||
contextRenderedBy("ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11)"),
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(observers).toHaveLength(0);
|
||||
expect(addListener.mock.calls.map(([type]) => type)).not.toContain(
|
||||
"scroll",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: nothing known about this device yet -> the dive starts at hydration and the scene mounts", async () => {
|
||||
render(<Hero />);
|
||||
expect(section()).toHaveAttribute("data-dive");
|
||||
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: sessionStorage blocked (privacy mode) -> counts as unknown, the scene still mounts", async () => {
|
||||
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("SecurityError");
|
||||
});
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
@@ -665,24 +629,16 @@ describe("HeroField: WebGL2 por software (supportsWebGL2 -> isSoftwareWebGL)", (
|
||||
expect(section()).toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: no renderer info extension (Firefox, Safari) -> cannot tell, so the scene mounts", async () => {
|
||||
setContext(() => ({ getExtension: () => null }));
|
||||
|
||||
it("path: the scene finds software WebGL once it has opened its context (onError) -> back to the 100svh hero, no scene", async () => {
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
expect(section()).toHaveAttribute("data-dive");
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
act(() => mockScenes.at(-1)?.onError());
|
||||
|
||||
it("path: the renderer name is not a string -> cannot tell, so the scene mounts", async () => {
|
||||
setContext(() => contextRenderedBy(null));
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { rememberSoftwareWebGL } from "@/lib/device";
|
||||
import HeroField, { DIVE_QUERY } from "./HeroField";
|
||||
|
||||
type SceneProps = {
|
||||
@@ -38,18 +39,6 @@ function mockMedia(active: string[] = [DIVE_QUERY]) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Um WebGL2 que abre, e que diz na extensão de depuração em que renderizador desenha. */
|
||||
function mockWebGLRenderer(renderer: string) {
|
||||
HTMLCanvasElement.prototype.getContext = (() => ({
|
||||
getExtension: (name: string) =>
|
||||
name === "WEBGL_debug_renderer_info"
|
||||
? { UNMASKED_RENDERER_WEBGL: 37446 }
|
||||
: null,
|
||||
getParameter: (parameter: number) =>
|
||||
parameter === 37446 ? renderer : null,
|
||||
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
||||
}
|
||||
|
||||
function mockNavigator(values: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(values))
|
||||
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||
@@ -94,6 +83,7 @@ async function idle() {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
sessionStorage.clear();
|
||||
mockScenes.length = 0;
|
||||
mockPrepare.mockClear();
|
||||
mockMedia();
|
||||
@@ -111,6 +101,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
sessionStorage.clear();
|
||||
window.matchMedia = original.matchMedia;
|
||||
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
||||
original.webgl2;
|
||||
@@ -129,6 +120,19 @@ describe("HeroField (ambient background)", () => {
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("opens no WebGL context of its own, on hydration or when idle: the only one is the scene's", async () => {
|
||||
const probe = jest.fn(() => ({ getExtension: () => null }));
|
||||
HTMLCanvasElement.prototype.getContext =
|
||||
probe as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
render(<Hero />);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
await idle();
|
||||
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("brings the geometry in before the Canvas, in a task of its own", async () => {
|
||||
render(<Hero />);
|
||||
|
||||
@@ -183,17 +187,11 @@ describe("HeroField (ambient background)", () => {
|
||||
undefined;
|
||||
},
|
||||
],
|
||||
...[
|
||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
|
||||
"llvmpipe (LLVM 15.0.7, 256 bits)",
|
||||
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
].map(
|
||||
(renderer) =>
|
||||
[
|
||||
`software WebGL (${renderer.slice(0, 24)}...)`,
|
||||
() => mockWebGLRenderer(renderer),
|
||||
] as [string, () => void],
|
||||
),
|
||||
// o veredito vem do contexto da cena (HeroFieldScene), que o anota na sessão; a página seguinte já nasce estática
|
||||
[
|
||||
"a software WebGL found earlier in the session",
|
||||
() => rememberSoftwareWebGL(),
|
||||
],
|
||||
])(
|
||||
"keeps the light static background (no WebGL, no dive, no scroll work) with %s",
|
||||
async (_label, arrange) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isLowPowerDevice, isSoftwareWebGL } from "@/lib/device";
|
||||
import { isKnownSoftwareWebGL, isLowPowerDevice } from "@/lib/device";
|
||||
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. */
|
||||
@@ -44,21 +44,12 @@ const smooth = (a: number, b: number, x: number) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* O aparelho abre um contexto WebGL2 de verdade, na GPU. Um WebGL por software (SwiftShader, llvmpipe, Microsoft
|
||||
* Basic Render) abre, mas desenha a cena a poucos quadros por segundo na CPU: nesse caso vale o hero estático.
|
||||
* O navegador tem a API do WebGL2? É só uma leitura: abrir um contexto de teste aqui, na hidratação, custava uma tarefa
|
||||
* de 150 ms (500 ms por software). Se o WebGL2 abre de verdade, e se ele desenha na GPU, quem diz é a própria cena,
|
||||
* no contexto que ela abre para desenhar (HeroFieldScene.createRenderer); o veredito "por software" fica anotado na
|
||||
* sessão (isKnownSoftwareWebGL), e as páginas seguintes já nascem estáticas.
|
||||
*/
|
||||
function supportsWebGL2() {
|
||||
if (typeof window.WebGL2RenderingContext === "undefined") return false;
|
||||
try {
|
||||
const context = document.createElement("canvas").getContext("webgl2");
|
||||
if (!context) return false;
|
||||
const software = isSoftwareWebGL(context);
|
||||
context.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
return !software;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const hasWebGL2 = () => typeof window.WebGL2RenderingContext !== "undefined";
|
||||
|
||||
/** Um intervalo ocioso do navegador (sem requestIdleCallback, um instante depois). */
|
||||
function nextIdle() {
|
||||
@@ -183,7 +174,9 @@ function trackVisibility(
|
||||
* outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem
|
||||
* e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica
|
||||
* ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho
|
||||
* (trackDive). Sem legendas sobre o 3D: o espaço da direita é das molduras.
|
||||
* (trackDive). O desenho por software só se descobre no contexto que a cena abre (nunca num contexto de teste na
|
||||
* hidratação): ela avisa por `onError` e o hero volta ao palco estático. Sem legendas sobre o 3D: o espaço da direita
|
||||
* é das molduras.
|
||||
*/
|
||||
export default function HeroField() {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -202,9 +195,10 @@ export default function HeroField() {
|
||||
if (isLowPowerDevice()) return;
|
||||
const section = rootRef.current?.closest<HTMLElement>(".hero");
|
||||
if (!section) return;
|
||||
// WebGL2 só por software (ou nenhum): o hero estático de 100svh, já na hidratação, sem o bloco de rolagem do
|
||||
// mergulho que depois seria desfeito com a página já rolada
|
||||
if (!supportsWebGL2()) return;
|
||||
// sem WebGL2, ou com o desenho por software já descoberto nesta sessão: o hero estático de 100svh, já na hidratação,
|
||||
// sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai do contexto
|
||||
// da cena, ocioso, sem uma tarefa longa na hidratação); sendo software, a cena avisa e o hero volta a 100svh
|
||||
if (!hasWebGL2() || isKnownSoftwareWebGL()) return;
|
||||
let alive = true;
|
||||
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
|
||||
? trackDive(section, progressRef, setDone)
|
||||
|
||||
@@ -20,6 +20,8 @@ class MockRenderer {
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = jest.fn();
|
||||
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
|
||||
getContext = () => ({ getExtension: () => null });
|
||||
getPixelRatio = () => 1;
|
||||
constructor() {
|
||||
mockRenderers.push(this);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import {
|
||||
createField,
|
||||
type FieldEngine,
|
||||
needsFrame,
|
||||
isSettling,
|
||||
prepareField,
|
||||
type Rig,
|
||||
type Tick,
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
* - 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;
|
||||
* - quando pedir o próximo quadro (entrada e rolagem assentando) e quando deixar para a batida de 30 por segundo (cursor);
|
||||
* - 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
|
||||
@@ -48,6 +48,8 @@ class MockRenderer {
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = jest.fn();
|
||||
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
|
||||
getContext = () => ({ getExtension: () => null });
|
||||
getPixelRatio = () => this.pixelRatio;
|
||||
constructor() {
|
||||
mockRenderers.push(this);
|
||||
@@ -512,13 +514,16 @@ describe("createField: quando pedir o próximo quadro", () => {
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
});
|
||||
|
||||
it("path: settled, visible, and the mouse is moving -> frames are requested", () => {
|
||||
it("path: settled, visible, and the mouse is moving -> the frame draws but asks for no other (the pulse brings the next at 30 Hz)", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
const drawn = scene.renderer.render.mock.calls.length;
|
||||
|
||||
expect(scene.frame(50)).toBeGreaterThan(0);
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
|
||||
});
|
||||
|
||||
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
|
||||
@@ -538,16 +543,17 @@ describe("createField: quando pedir o próximo quadro", () => {
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: becoming visible later -> the next frame sees the new flag", () => {
|
||||
it("path: becoming visible later -> the pulse starts and asks for a frame right away", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
frameRequests.mockClear();
|
||||
|
||||
scene.engine.setVisible(true);
|
||||
|
||||
expect(scene.frame(50)).toBeGreaterThan(0);
|
||||
expect(frameRequests).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: several requests before a frame become ONE animation frame", () => {
|
||||
@@ -563,7 +569,7 @@ describe("createField: quando pedir o próximo quadro", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsFrame / updatePointer / updateRig (contas puras)", () => {
|
||||
describe("isSettling / updatePointer / updateRig (contas puras)", () => {
|
||||
function rig(): Rig {
|
||||
const scene = setup();
|
||||
scene.frame();
|
||||
@@ -604,23 +610,22 @@ describe("needsFrame / updatePointer / updateRig (contas puras)", () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("needsFrame: settling wins over everything, interaction only counts on screen", () => {
|
||||
it("isSettling: the intro still running keeps asking, the settled scene does not", () => {
|
||||
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);
|
||||
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
|
||||
r.uniforms.uIntro.value = 0.5;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, false)).toBe(true);
|
||||
expect(isSettling(r.uniforms, r.state, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("needsFrame: the first frames always ask for another (the cursor strength still has to settle)", () => {
|
||||
it("isSettling: the scroll target far from the shown progress keeps asking, the cursor does not (the pulse does that)", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.uniforms.uMouseAmt.value = 0;
|
||||
r.state.frames = 1;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(true);
|
||||
r.state.lastMove = 1;
|
||||
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
|
||||
expect(isSettling(r.uniforms, r.state, 0.5)).toBe(true);
|
||||
});
|
||||
|
||||
it("updatePointer: returns the strength it is heading for (0.8 with the mouse, 0.4 without, none in the dive)", () => {
|
||||
@@ -648,30 +653,12 @@ describe("needsFrame / updatePointer / updateRig (contas puras)", () => {
|
||||
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", () => {
|
||||
it("isSettling: 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;
|
||||
|
||||
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,
|
||||
);
|
||||
expect(isSettling(r.uniforms, r.state, 0.50039)).toBe(false);
|
||||
expect(isSettling(r.uniforms, r.state, 0.5005)).toBe(true);
|
||||
});
|
||||
|
||||
it("updateRig: the camera follows the choreography -> the first frame before it starts, a smoothed midpoint, the last frame after it ends", () => {
|
||||
@@ -856,12 +843,45 @@ describe("createField: o cursor sozinho não passa de ~30 quadros por segundo",
|
||||
return scene.renderer.render.mock.calls.length - before;
|
||||
}
|
||||
|
||||
function withMovingCursor() {
|
||||
/**
|
||||
* Uma tela de `refreshMs` entre quadros, com tudo assentado e o cursor mexendo: o único relógio é a batida (o
|
||||
* setInterval de 33 ms do FramePulse, aqui tocado à mão), e um quadro da tela só roda se alguém o pediu.
|
||||
*/
|
||||
function pulsedScreen(refreshMs: number) {
|
||||
let beat = () => {};
|
||||
jest.spyOn(window, "setInterval").mockImplementation(((
|
||||
callback: () => void,
|
||||
) => {
|
||||
beat = callback;
|
||||
return 7;
|
||||
}) as never);
|
||||
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
|
||||
let pending: FrameRequestCallback | undefined;
|
||||
frameRequests.mockImplementation(((callback: FrameRequestCallback) => {
|
||||
pending = callback;
|
||||
return 1;
|
||||
}) as never);
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
let clock = 0;
|
||||
let nextBeat = 33;
|
||||
const run = (seconds: number) => {
|
||||
const drawnBefore = scene.renderer.render.mock.calls.length;
|
||||
frameRequests.mockClear();
|
||||
const end = clock + seconds * 1000;
|
||||
for (clock += refreshMs; clock <= end; clock += refreshMs) {
|
||||
for (; nextBeat <= clock; nextBeat += 33) beat();
|
||||
const frame = pending;
|
||||
pending = undefined;
|
||||
frame?.(1000 + clock);
|
||||
}
|
||||
return {
|
||||
drawn: scene.renderer.render.mock.calls.length - drawnBefore,
|
||||
requested: frameRequests.mock.calls.length,
|
||||
};
|
||||
};
|
||||
run(6); // a entrada corre a cada quadro da tela e assenta
|
||||
pointerMove("mouse");
|
||||
return scene;
|
||||
return run;
|
||||
}
|
||||
|
||||
it.each([
|
||||
@@ -869,32 +889,25 @@ describe("createField: o cursor sozinho não passa de ~30 quadros por segundo",
|
||||
[120, 1000 / 120],
|
||||
[60, 1000 / 60],
|
||||
])(
|
||||
"path: a %i Hz screen with the cursor moving -> about 30 frames a second, not one per screen frame",
|
||||
"path: a %i Hz screen with the cursor moving -> about 30 frames a second drawn and asked for, never a wake-up per screen frame",
|
||||
(_hz, refreshMs) => {
|
||||
const scene = withMovingCursor();
|
||||
const run = pulsedScreen(refreshMs);
|
||||
|
||||
const drawn = drawnOn(scene, refreshMs, 2);
|
||||
const { drawn, requested } = run(2);
|
||||
|
||||
expect(drawn).toBeGreaterThanOrEqual(2 * 25);
|
||||
expect(drawn).toBeLessThanOrEqual(2 * 31);
|
||||
// quem pede é a batida: nenhum requestAnimationFrame para pular o desenho e pedir outro
|
||||
expect(requested).toBeLessThanOrEqual(drawn + 1);
|
||||
},
|
||||
);
|
||||
|
||||
it("path: a skipped frame draws nothing, keeps the clock of the last drawn one and asks for the next", () => {
|
||||
const scene = withMovingCursor();
|
||||
scene.frame(50); // o último desenhado
|
||||
const drawn = scene.renderer.render.mock.calls.length;
|
||||
|
||||
expect(scene.frame(10)).toBe(1); // 10 ms depois: pula e pede outro
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn);
|
||||
|
||||
expect(scene.frame(10)).toBe(1);
|
||||
expect(scene.frame(10)).toBe(1);
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn);
|
||||
|
||||
// 40 ms depois do último desenhado: sai (o relógio do quadro pulado não conta)
|
||||
scene.frame(10);
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
|
||||
it("path: a frame the pulse asks for soon after another is drawn, not skipped (nothing throttles it any more)", () => {
|
||||
const run = pulsedScreen(1000 / 144);
|
||||
const drawn = run(0.5).drawn;
|
||||
expect(drawn).toBeGreaterThanOrEqual(14);
|
||||
// 27,8 ms (4 quadros de 144 Hz) entre duas batidas que se alinharam mal: o desenho sai do mesmo jeito
|
||||
expect(run(0.1).drawn).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("path: the intro and the scroll are not throttled (one frame per screen frame while they settle)", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { isKnownSoftwareWebGL } from "@/lib/device";
|
||||
import HeroFieldScene from "./HeroFieldScene";
|
||||
|
||||
/*
|
||||
@@ -15,6 +16,8 @@ type RendererOptions = {
|
||||
};
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
let mockFailToCreate = false;
|
||||
/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */
|
||||
let mockRendererName: string | null = null;
|
||||
/** Quando o teste quer segurar a compilação do shader: a promessa que `compileAsync` devolve. */
|
||||
let mockCompile: () => Promise<unknown> = () => Promise.resolve();
|
||||
|
||||
@@ -31,6 +34,14 @@ class MockRenderer {
|
||||
this.pixelRatio = value;
|
||||
};
|
||||
getPixelRatio = () => this.pixelRatio;
|
||||
getContext = () => ({
|
||||
getExtension: (name: string) =>
|
||||
name === "WEBGL_debug_renderer_info" && mockRendererName !== null
|
||||
? { UNMASKED_RENDERER_WEBGL: 37446 }
|
||||
: null,
|
||||
getParameter: (parameter: number) =>
|
||||
parameter === 37446 ? mockRendererName : null,
|
||||
});
|
||||
constructor(options: RendererOptions) {
|
||||
if (mockFailToCreate) throw new Error("sem WebGL");
|
||||
this.options = options;
|
||||
@@ -64,6 +75,7 @@ beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockRenderers.length = 0;
|
||||
mockFailToCreate = false;
|
||||
mockRendererName = null;
|
||||
mockCompile = () => Promise.resolve();
|
||||
setDevicePixelRatio(1);
|
||||
});
|
||||
@@ -406,3 +418,54 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroFieldScene: WebGL por software (o teste é o contexto da própria cena)", () => {
|
||||
beforeEach(() => sessionStorage.clear());
|
||||
afterEach(() => sessionStorage.clear());
|
||||
|
||||
it.each([
|
||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
|
||||
"llvmpipe (LLVM 15.0.7, 256 bits)",
|
||||
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
])(
|
||||
"draws on the CPU (%s): no canvas, the page is told once, the context is freed and the session remembers it",
|
||||
async (name) => {
|
||||
mockRendererName = name;
|
||||
const scene = props();
|
||||
|
||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
// um só contexto: o do teste é o que a cena abriria para desenhar, sem um segundo
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
expect(isKnownSoftwareWebGL()).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("draws on the GPU: the canvas stays and the session is not marked", async () => {
|
||||
mockRendererName =
|
||||
"ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0, D3D11)";
|
||||
const scene = props();
|
||||
|
||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
expect(isKnownSoftwareWebGL()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not report an unknown renderer (no debug extension) as software", async () => {
|
||||
mockRendererName = null;
|
||||
const scene = props();
|
||||
|
||||
await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
expect(isKnownSoftwareWebGL()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
} from "three";
|
||||
import { isSoftwareWebGL, rememberSoftwareWebGL } from "@/lib/device";
|
||||
import { startFramePulse } from "./FramePulse";
|
||||
|
||||
export type HeroFieldSceneProps = {
|
||||
@@ -42,8 +43,8 @@ export type HeroFieldSceneProps = {
|
||||
* É um fundo ambiente: HeroField o deixa com pouca opacidade e uma máscara que o afasta do título e das
|
||||
* molduras da direita. O Three.js é usado direto (sem React Three Fiber, que levava junto o reconciliador do React
|
||||
* só para uma malha e um shader). O desenho é sob demanda: a cena pede um quadro a 30 por segundo (FramePulse, um
|
||||
* setInterval) e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, ou depois de uns segundos
|
||||
* sem cursor nem rolagem, não desenha nada.
|
||||
* setInterval) e no ritmo da tela só enquanto a entrada ou a rolagem assenta; sem estar à vista, ou depois de uns
|
||||
* segundos sem cursor nem rolagem, não desenha nada.
|
||||
*/
|
||||
|
||||
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
|
||||
@@ -62,15 +63,19 @@ class FieldBuilder {
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
style: PointStyle = {},
|
||||
style?: PointStyle,
|
||||
to?: [number, number, number],
|
||||
) {
|
||||
this.base.push(x, y, z);
|
||||
this.to.push(...(to ?? [x, y, z]));
|
||||
this.move.push(to ? 1 : 0);
|
||||
this.tone.push(style.tone ?? 0);
|
||||
this.size.push(style.size ?? 0.02);
|
||||
this.alpha.push(style.alpha ?? 0.7);
|
||||
this.pushStyle(style);
|
||||
}
|
||||
/** O tom, o tamanho e a opacidade do ponto, com o padrão onde o estilo não diz. */
|
||||
private pushStyle({ tone = 0, size = 0.02, alpha = 0.7 }: PointStyle = {}) {
|
||||
this.tone.push(tone);
|
||||
this.size.push(size);
|
||||
this.alpha.push(alpha);
|
||||
}
|
||||
line(
|
||||
y: Layer,
|
||||
@@ -607,31 +612,14 @@ export function isSettling(u: Uniforms, s: FieldState, target: number) {
|
||||
return u.uIntro.value < 1 || Math.abs(target - s.progress) > 4e-4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sob demanda: o próximo quadro vem do FramePulse (30 por segundo, e nenhum depois de uns segundos sem cursor nem
|
||||
* rolagem); o quadro pede o seguinte (no ritmo da tela, até 60) só enquanto algo ainda muda. A entrada e a rolagem
|
||||
* terminam de assentar mesmo depois de o hero sair de cena (o último quadro tem de ser o final); o cursor só conta
|
||||
* com o hero à vista.
|
||||
/*
|
||||
* Sob demanda, com um só relógio para o que é ambiente: o FramePulse (um setInterval de 30 por segundo, ligado só com o
|
||||
* hero à vista e parado depois de uns segundos sem cursor nem rolagem) pede o quadro do cursor e da onda dele; a tela,
|
||||
* a 60 ou 144 Hz, não pede nada por conta própria. Um quadro só pede o seguinte de imediato, no ritmo da tela, enquanto
|
||||
* a entrada ou a rolagem assenta (`isSettling`), mesmo com o hero fora de cena: o último quadro tem de ser o final.
|
||||
* Antes, o cursor também encadeava um requestAnimationFrame por quadro da tela (143 por segundo a 144 Hz) só para
|
||||
* pular o desenho e pedir de novo, e a página acordava sem desenhar nada.
|
||||
*/
|
||||
export function needsFrame(
|
||||
u: Uniforms,
|
||||
s: FieldState,
|
||||
target: number,
|
||||
amount: number,
|
||||
cursorActive: boolean,
|
||||
visible: boolean,
|
||||
) {
|
||||
const interacting =
|
||||
cursorActive || Math.abs(amount - u.uMouseAmt.value) > 0.01 || s.frames < 3;
|
||||
return isSettling(u, s, target) || (visible && interacting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Intervalo mínimo (ms) entre dois quadros quando só o cursor os pede: uns 30 por segundo, mesmo numa tela de 144 Hz
|
||||
* (que desenhava 142). Fica um pouco abaixo de 33,3 (dois quadros de uma tela de 60 Hz) para a tela de 60 Hz manter
|
||||
* 30 e a batida (um setInterval de 33 ms, com a folga do temporizador) não perder quadros.
|
||||
*/
|
||||
const CURSOR_FRAME_MS = 32;
|
||||
|
||||
/** O material de pontos: o shader, a mistura aditiva e os uniforms de partida. */
|
||||
function createMaterial() {
|
||||
@@ -695,13 +683,23 @@ export type FieldEngine = {
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
/** O renderizador transparente, sem antialias, na GPU de alto desempenho, com o canvas dentro de `host`. */
|
||||
/**
|
||||
* O renderizador transparente, sem antialias, na GPU de alto desempenho, com o canvas dentro de `host`. Lança se o
|
||||
* contexto não abre ou se ele desenha por software (SwiftShader, llvmpipe, Basic Render): abre, mas a cena sairia a
|
||||
* poucos quadros por segundo na CPU, e o hero fica estático. O contexto que acabou de abrir é o do teste (nenhum outro
|
||||
* é criado só para isso) e o veredito fica anotado na sessão (`rememberSoftwareWebGL`).
|
||||
*/
|
||||
function createRenderer(host: HTMLElement) {
|
||||
const renderer = new WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
if (isSoftwareWebGL(renderer.getContext())) {
|
||||
rememberSoftwareWebGL();
|
||||
disposeRenderer(renderer);
|
||||
throw new Error("WebGL por software");
|
||||
}
|
||||
renderer.setPixelRatio(pixelRatio());
|
||||
const canvas = renderer.domElement;
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
@@ -733,24 +731,24 @@ function buildWorld(): World {
|
||||
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. */
|
||||
/** O que o quadro lê de fora: o progresso da rolagem 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).
|
||||
* estático) e se a entrada ou a rolagem ainda assenta (então o próximo quadro é pedido de imediato; o do cursor, não:
|
||||
* vem da batida).
|
||||
*/
|
||||
function stepField(
|
||||
world: World,
|
||||
state: FieldState,
|
||||
helpers: Helpers,
|
||||
layout: Layout,
|
||||
{ target, visible, time, delta }: StepInput,
|
||||
{ target, time, delta }: StepInput,
|
||||
) {
|
||||
const dt = Math.min(delta, 1 / 20);
|
||||
const u = world.material.uniforms;
|
||||
@@ -769,11 +767,11 @@ function stepField(
|
||||
};
|
||||
const tick: Tick = { p, face, time, dt, cursorActive };
|
||||
updateRig(rig, tick);
|
||||
const amount = updatePointer(rig, tick);
|
||||
updatePointer(rig, tick);
|
||||
state.frames += 1;
|
||||
return {
|
||||
ready: state.frames === 2,
|
||||
again: needsFrame(u, state, target, amount, cursorActive, visible),
|
||||
again: isSettling(u, state, target),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -866,8 +864,9 @@ function disposeParts({ renderer, world }: Parts) {
|
||||
/**
|
||||
* A cena sem React, a partir das peças já criadas: liga o tamanho, o mouse e a perda do contexto, e desenha sob
|
||||
* demanda. `invalidate` pede um quadro no próximo requestAnimationFrame (vários pedidos viram um); cada quadro
|
||||
* atualiza os uniforms, a câmera e o cursor e pede o seguinte só se algo ainda muda. O primeiro quadro sai no
|
||||
* requestAnimationFrame que `resize` pede, numa tarefa própria.
|
||||
* atualiza os uniforms, a câmera e o cursor e pede o seguinte só se a entrada ou a rolagem ainda assenta (o resto vem
|
||||
* da batida de 30 quadros por segundo). O primeiro quadro sai no requestAnimationFrame que `resize` pede, numa
|
||||
* tarefa própria.
|
||||
*/
|
||||
function startField(
|
||||
host: HTMLElement,
|
||||
@@ -877,7 +876,6 @@ function startField(
|
||||
const { renderer, world, state, helpers } = parts;
|
||||
const started = performance.now();
|
||||
let layout = frameLayout(1, 1, world.camera.fov, renderer.getPixelRatio());
|
||||
let visible = false;
|
||||
let alive = true;
|
||||
let raf = 0;
|
||||
let last = 0;
|
||||
@@ -890,32 +888,25 @@ function startField(
|
||||
if (alive) onError();
|
||||
};
|
||||
|
||||
/** Um quadro: avança a cena, avisa a página, pede o seguinte se ainda assenta e desenha. */
|
||||
const draw = (now: number) => {
|
||||
const delta = last ? (now - last) / 1000 : 1 / 60;
|
||||
last = now;
|
||||
const step = stepField(world, state, helpers, layout, {
|
||||
target: progressRef.current ?? 0,
|
||||
time: (now - started) / 1000,
|
||||
delta,
|
||||
});
|
||||
if (step.ready) onReady();
|
||||
if (step.again) invalidate();
|
||||
renderer.render(world.scene, world.camera);
|
||||
};
|
||||
|
||||
function render(now: number) {
|
||||
raf = 0;
|
||||
if (!alive) return;
|
||||
const target = progressRef.current ?? 0;
|
||||
// só o cursor pede quadro (a entrada e a rolagem seguem a cada quadro): pula este se faz menos de ~32 ms do
|
||||
// último e pede o próximo, em vez de desenhar a cada quadro da tela
|
||||
if (
|
||||
last &&
|
||||
now - last < CURSOR_FRAME_MS &&
|
||||
!isSettling(world.material.uniforms, state, target)
|
||||
) {
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
const delta = last ? (now - last) / 1000 : 1 / 60;
|
||||
last = now;
|
||||
try {
|
||||
const step = stepField(world, state, helpers, layout, {
|
||||
target,
|
||||
visible,
|
||||
time: (now - started) / 1000,
|
||||
delta,
|
||||
});
|
||||
if (step.ready) onReady();
|
||||
if (step.again) invalidate();
|
||||
renderer.render(world.scene, world.camera);
|
||||
draw(now);
|
||||
} catch {
|
||||
fail();
|
||||
}
|
||||
@@ -941,7 +932,6 @@ function startField(
|
||||
return {
|
||||
renderAt: render,
|
||||
setVisible(next) {
|
||||
visible = next;
|
||||
if (next) stopPulse ??= startFramePulse(invalidate);
|
||||
else {
|
||||
stopPulse?.();
|
||||
@@ -958,7 +948,7 @@ function startField(
|
||||
};
|
||||
}
|
||||
|
||||
/** A cena sem React, de uma vez só: cria o renderizador dentro de `host` e começa a desenhar. Lança se o aparelho não cria um contexto WebGL. */
|
||||
/** A cena sem React, de uma vez só: cria o renderizador dentro de `host` e começa a desenhar. Lança se o aparelho não cria um contexto WebGL ou se ele desenha por software. */
|
||||
export function createField(
|
||||
host: HTMLElement,
|
||||
callbacks: FieldCallbacks,
|
||||
@@ -974,7 +964,7 @@ const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
* era uma só tarefa de 90 a 250 ms): o renderizador (o contexto WebGL); a nuvem de pontos e o estado; a compilação do
|
||||
* shader (`compileAsync`, que o navegador faz fora da thread principal); e, por fim, o tamanho e o primeiro quadro
|
||||
* (que sai no requestAnimationFrame seguinte). Devolve `null` se `isAlive` virou falso no caminho; lança se o
|
||||
* aparelho não cria um contexto WebGL ou se o shader não compila.
|
||||
* aparelho não cria um contexto WebGL, se ele desenha por software ou se o shader não compila.
|
||||
*/
|
||||
export async function mountField(
|
||||
host: HTMLElement,
|
||||
|
||||
Reference in new issue
Block a user