perf(movimento): sem tela vazia, passagens em CSS e vitrine leve
Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
1 parent
10f170abf4
commit
318a764190
77 files changed
+4293
-4951
No files matched your search
@@ -25,12 +25,14 @@ export type HeroFieldSceneProps = {
|
||||
* ligados a uma central, com mensagens correndo pelas linhas); embaixo, os DADOS (uma tabela).
|
||||
* Colunas de partículas sobem da tabela até a tela: a informação chegando a quem usa.
|
||||
* Na entrada, os pontos saem de uma nuvem e se organizam; ao rolar, as camadas se afastam, ficam de
|
||||
* frente e a câmera atravessa uma a uma (as legendas de Hero explicam cada camada nessa hora).
|
||||
* frente e a câmera atravessa uma a uma, até parar diante dos dados (as legendas de Hero explicam cada
|
||||
* camada nessa hora; no fim, a seção seguinte sobe e cobre o palco).
|
||||
* 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, não desenha nada.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
|
||||
@@ -371,7 +373,8 @@ const FRAME = 5.2;
|
||||
|
||||
/*
|
||||
* Coreografia da câmera (z): afasta para enquadrar a tela inteira, atravessa, enquadra as integrações,
|
||||
* atravessa, enquadra os dados, atravessa. As legendas de Hero.tsx (--from/--to em globals.css) seguem
|
||||
* 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.
|
||||
* 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.
|
||||
@@ -387,11 +390,209 @@ function cameraFrames(rest: number, spread: number, frame: number) {
|
||||
[0.74, -0.3],
|
||||
[0.78, -spread + frame],
|
||||
[0.89, -spread + frame * 0.87],
|
||||
[0.95, -spread - 0.3],
|
||||
[1, -spread - 2],
|
||||
[1, -spread + frame * 0.8],
|
||||
] as const;
|
||||
}
|
||||
|
||||
/** Tela larga o bastante (e CPU suficiente) para a densidade cheia de pontos. */
|
||||
const isDense = () =>
|
||||
window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4;
|
||||
|
||||
let prepared: THREE.BufferGeometry | null = null;
|
||||
|
||||
/**
|
||||
* Monta a geometria (dezenas de milhares de pontos) fora do caminho crítico: quem carrega este módulo chama
|
||||
* isto num intervalo ocioso, antes de montar o Canvas, para a criação do contexto WebGL não carregar também
|
||||
* essa conta. Chamar de novo, sem a cena ter montado, não refaz nada.
|
||||
*/
|
||||
export function prepareField() {
|
||||
prepared ??= buildField(isDense());
|
||||
}
|
||||
|
||||
/** A geometria pronta (que a montagem consome), ou uma nova se ninguém a preparou antes. */
|
||||
function takeField() {
|
||||
const geometry = prepared ?? buildField(isDense());
|
||||
prepared = null;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
type Uniforms = THREE.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(
|
||||
width: number,
|
||||
height: number,
|
||||
fov: number,
|
||||
pixelRatio: number,
|
||||
) {
|
||||
const aspect = width / height;
|
||||
const tanHalf = Math.tan(THREE.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);
|
||||
const rest = Math.max(9, frameDist * 1.1);
|
||||
return {
|
||||
wide: aspect > 1.1,
|
||||
tanHalf,
|
||||
spreadMax,
|
||||
rest,
|
||||
// com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir
|
||||
scale:
|
||||
((pixelRatio * height * 0.5) / tanHalf) * Math.sqrt(frameDist / FRAME),
|
||||
camera: cameraFrames(rest, spreadMax, frameDist),
|
||||
};
|
||||
}
|
||||
|
||||
type Layout = ReturnType<typeof frameLayout>;
|
||||
|
||||
/** Uniforms do tempo, da entrada e do mergulho. Devolve o quanto as camadas já estão de frente (0 a 1). */
|
||||
function updateUniforms(
|
||||
u: Uniforms,
|
||||
layout: Layout,
|
||||
p: number,
|
||||
time: number,
|
||||
dt: number,
|
||||
) {
|
||||
u.uTime.value = time;
|
||||
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
|
||||
u.uScroll.value = p;
|
||||
// as camadas só se afastam enquanto ficam de frente: inclinadas e afastadas sairiam do quadro
|
||||
const face = smooth(0.02, 0.28, p);
|
||||
u.uSpread.value = 1.05 + (layout.spreadMax - 1.05) * face * face;
|
||||
u.uFade.value = 1 - 0.4 * smooth(0.93, 1, p);
|
||||
u.uScale.value = layout.scale;
|
||||
return face;
|
||||
}
|
||||
|
||||
type FieldState = {
|
||||
progress: number;
|
||||
pointer: THREE.Vector2;
|
||||
lastMove: number;
|
||||
mouse: THREE.Vector2;
|
||||
tilt: THREE.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;
|
||||
};
|
||||
|
||||
/** 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,
|
||||
);
|
||||
// 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);
|
||||
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,
|
||||
) {
|
||||
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),
|
||||
);
|
||||
}
|
||||
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));
|
||||
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
|
||||
* 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(
|
||||
u: Uniforms,
|
||||
s: FieldState,
|
||||
target: number,
|
||||
amount: number,
|
||||
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);
|
||||
}
|
||||
|
||||
function Field({
|
||||
progressRef,
|
||||
visible,
|
||||
@@ -400,11 +601,7 @@ function Field({
|
||||
}: HeroFieldSceneProps) {
|
||||
const { gl, camera, size } = useThree();
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const dense = useMemo(
|
||||
() => window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4,
|
||||
[],
|
||||
);
|
||||
const geometry = useMemo(() => buildField(dense), [dense]);
|
||||
const geometry = useMemo(takeField, []);
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.ShaderMaterial({
|
||||
@@ -427,7 +624,17 @@ function Field({
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const state = useRef({
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
frameLayout(
|
||||
size.width,
|
||||
size.height,
|
||||
(camera as THREE.PerspectiveCamera).fov,
|
||||
gl.getPixelRatio(),
|
||||
),
|
||||
[camera, gl, size.height, size.width],
|
||||
);
|
||||
const state = useRef<FieldState>({
|
||||
progress: 0,
|
||||
pointer: new THREE.Vector2(),
|
||||
lastMove: -10,
|
||||
@@ -435,10 +642,11 @@ function Field({
|
||||
tilt: new THREE.Vector2(),
|
||||
frames: 0,
|
||||
});
|
||||
const helpers = useMemo(
|
||||
const helpers = useMemo<Helpers>(
|
||||
() => ({
|
||||
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(),
|
||||
@@ -477,95 +685,41 @@ function Field({
|
||||
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 +=
|
||||
((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5));
|
||||
s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5));
|
||||
const p = s.progress;
|
||||
const aspect = size.width / size.height;
|
||||
const wide = aspect > 1.1;
|
||||
const tanHalf = Math.tan(
|
||||
THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2),
|
||||
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,
|
||||
);
|
||||
// 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);
|
||||
const rest = Math.max(9, frameDist * 1.1);
|
||||
|
||||
u.uTime.value = time;
|
||||
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
|
||||
u.uScroll.value = p;
|
||||
// as camadas só se afastam enquanto ficam de frente: inclinadas e afastadas sairiam do quadro
|
||||
const face = smooth(0.02, 0.28, p);
|
||||
u.uSpread.value = 1.05 + (spreadMax - 1.05) * face * face;
|
||||
u.uFade.value = 1 - smooth(0.93, 1, p);
|
||||
// com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir
|
||||
u.uScale.value =
|
||||
((gl.getPixelRatio() * size.height * 0.5) / tanHalf) *
|
||||
Math.sqrt(frameDist / FRAME);
|
||||
|
||||
// sem mouse (toque, ou mouse parado), um cursor fantasma percorre a tela
|
||||
const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0;
|
||||
s.tilt.lerp(mouseActive ? s.pointer : helpers.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,
|
||||
const amount = updatePointer(
|
||||
g,
|
||||
camera,
|
||||
u,
|
||||
s,
|
||||
helpers,
|
||||
p,
|
||||
time,
|
||||
dt,
|
||||
cursorActive,
|
||||
);
|
||||
const settle = 1 - smooth(0, 0.28, p);
|
||||
// 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
|
||||
g.position.set(0, (wide ? -0.5 : -rest * tanHalf * 0.38) * settle, 0);
|
||||
camera.position.z = keyframes(cameraFrames(rest, spreadMax, frameDist), 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 ahead =
|
||||
[spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread;
|
||||
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
|
||||
g.updateMatrixWorld();
|
||||
|
||||
if (mouseActive) {
|
||||
helpers.ray.setFromCamera(s.pointer, camera);
|
||||
helpers.plane
|
||||
.set(new THREE.Vector3(0, 1, 0), -u.uSpread.value)
|
||||
.applyMatrix4(g.matrixWorld);
|
||||
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
|
||||
g.worldToLocal(helpers.hit);
|
||||
s.mouse.lerp(
|
||||
helpers.target.set(helpers.hit.x, helpers.hit.z),
|
||||
1 - Math.exp(-dt * 10),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
s.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),
|
||||
);
|
||||
}
|
||||
u.uMouse.value.copy(s.mouse);
|
||||
const targetAmt = (mouseActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
|
||||
u.uMouseAmt.value +=
|
||||
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
||||
|
||||
if (++s.frames === 2) onReady();
|
||||
|
||||
// sob demanda: o próximo quadro vem do FramePulse (30 por segundo); 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, com tudo já apagado); o cursor só conta com o hero à vista.
|
||||
const settling =
|
||||
u.uIntro.value < 1 ||
|
||||
Math.abs((progressRef.current ?? 0) - s.progress) > 4e-4;
|
||||
const interacting =
|
||||
mouseActive === 1 ||
|
||||
Math.abs(targetAmt - u.uMouseAmt.value) > 0.01 ||
|
||||
s.frames < 3;
|
||||
if (settling || (visible && interacting)) frame.invalidate();
|
||||
if (needsFrame(u, s, target, amount, cursorActive, visible))
|
||||
frame.invalidate();
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user