perf(movimento): vitrine e 3D mais leves, WebGL por software lembrado na sessão e suporte ao WebKit

This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 22:54:27 -03:00
1 parent 9fd1570426
commit 8903f3c57e
28 files changed
+854 -406

No files matched your search

+55 -65
View File
@@ -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,