fix(navegacao): foco em âncora de outra página, vitrine acessível e 3D mais leve

Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 19:14:48 -03:00
1 parent 9e2d65a926
commit 204ebd5fa5
49 files changed
+2197 -606

No files matched your search

+137 -29
View File
@@ -382,9 +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) 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).
* imagem, uma tabela de dados). As legendas do Hero (HERO_CAPTIONS, em hero-captions.ts) entram um pouco antes de
* a câmera chegar a cada camada e saem um pouco depois (a última fica até Projetos subir e cobri-la); se os
* quadros abaixo mudarem, os trechos delas mudam junto.
* 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.
*/
@@ -602,11 +602,16 @@ export function updatePointer(rig: Rig, tick: Tick) {
return targetAmt;
}
/** A entrada ou a rolagem ainda não assentou (o progresso mostrado ainda não alcançou o da página). */
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 (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.
* 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.
*/
export function needsFrame(
u: Uniforms,
@@ -616,12 +621,18 @@ export function needsFrame(
cursorActive: boolean,
visible: boolean,
) {
const settling = u.uIntro.value < 1 || Math.abs(target - s.progress) > 4e-4;
const interacting =
cursorActive || Math.abs(amount - u.uMouseAmt.value) > 0.01 || s.frames < 3;
return settling || (visible && interacting);
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() {
return new ShaderMaterial({
@@ -824,18 +835,46 @@ function listenToField({
}
/**
* 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.
* Completa o renderizador com o que o quadro desenha: a nuvem de pontos e o estado. Separado de `createRenderer` (que
* abre o contexto WebGL) para a montagem em tarefas poder pôr um passo em cada uma.
*/
export function createField(
function createParts(renderer: WebGLRenderer) {
return {
renderer,
world: buildWorld(),
state: createState(),
helpers: createHelpers(),
};
}
type Parts = ReturnType<typeof createParts>;
/** Solta o contexto WebGL e tira o canvas da página. */
function disposeRenderer(renderer: WebGLRenderer) {
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
}
/** Solta o que `createParts` criou: a geometria, o material e o contexto WebGL. */
function disposeParts({ renderer, world }: Parts) {
world.geometry.dispose();
world.material.dispose();
disposeRenderer(renderer);
}
/**
* 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.
*/
function startField(
host: HTMLElement,
parts: Parts,
{ progressRef, onReady, onError }: FieldCallbacks,
): FieldEngine {
const renderer = createRenderer(host);
const world = buildWorld();
const state = createState();
const helpers = createHelpers();
const { renderer, world, state, helpers } = parts;
const started = performance.now();
let layout = frameLayout(1, 1, world.camera.fov, renderer.getPixelRatio());
let visible = false;
@@ -854,11 +893,22 @@ export function createField(
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: progressRef.current ?? 0,
target,
visible,
time: (now - started) / 1000,
delta,
@@ -903,15 +953,59 @@ export function createField(
cancelAnimationFrame(raf);
stopPulse?.();
stopListening();
world.geometry.dispose();
world.material.dispose();
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
disposeParts(parts);
},
};
}
/** 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. */
export function createField(
host: HTMLElement,
callbacks: FieldCallbacks,
): FieldEngine {
return startField(host, createParts(createRenderer(host)), callbacks);
}
/** Cede a vez ao navegador: o passo seguinte roda noutra tarefa, depois de pintar e de atender o visitante. */
const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
/**
* A mesma cena montada em tarefas separadas, para nenhuma passar de uns poucos milissegundos (criar tudo de uma vez
* 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.
*/
export async function mountField(
host: HTMLElement,
callbacks: FieldCallbacks,
isAlive: () => boolean,
): Promise<FieldEngine | null> {
await nextTask();
if (!isAlive()) return null;
const renderer = createRenderer(host);
let parts: Parts | undefined;
try {
await nextTask();
if (!isAlive()) {
disposeRenderer(renderer);
return null;
}
parts = createParts(renderer);
await renderer.compileAsync(parts.world.scene, parts.world.camera);
await nextTask();
} catch (error) {
if (parts) disposeParts(parts);
else disposeRenderer(renderer);
throw error;
}
if (!isAlive()) {
disposeParts(parts);
return null;
}
return startField(host, parts, callbacks);
}
export default function HeroFieldScene({
progressRef,
visible,
@@ -926,27 +1020,41 @@ export default function HeroFieldScene({
callbacks.current = { onReady, onError };
});
// o que a página pediu até a cena ficar pronta (ela nasce em tarefas separadas, depois de o `visible` já ter chegado)
const wantsVisible = useRef(visible);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
try {
engine.current = createField(host, {
let alive = true;
mountField(
host,
{
progressRef,
onReady: () => callbacks.current.onReady(),
onError: () => callbacks.current.onError(),
},
() => alive,
)
.then((created) => {
if (!created) return;
if (!alive) return created.dispose();
engine.current = created;
created.setVisible(wantsVisible.current);
})
// sem WebGL (ou o contexto não abriu, o shader não compilou): o hero fica com o fundo estático
.catch(() => {
if (alive) 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 () => {
alive = false;
engine.current?.dispose();
engine.current = null;
};
}, [progressRef]);
useEffect(() => {
wantsVisible.current = visible;
engine.current?.setVisible(visible);
}, [visible]);