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
@@ -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