Hero e movimento - cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py): marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao cursor e, parada no topo, passa sozinha pelos estados com legenda - três níveis de aparelho (full, lite, static); sem WebGL fica o pôster - título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha." - faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções - rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa compartilhada entre a lista de cases e o case Ofertas - atendimento no WhatsApp com IA e nota fiscal para a reforma tributária (páginas de serviço, cartões da home, formulário e aviso com data de validade) - página /ia atualizada, com fontes - peças 3D em loop nos cartões e topos de página (3d/pecas.py) Design - hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta - um destino por rótulo: "Falar sobre meu projeto" leva ao formulário, "Chamar no WhatsApp" abre o WhatsApp - logo montado em blocos, 404 redesenhado, grades sem célula vazia - ícones do PWA e manifesto novos Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
920 lines
32 KiB
TypeScript
920 lines
32 KiB
TypeScript
"use client";
|
|
|
|
import { type RefObject, useEffect, useRef } from "react";
|
|
import {
|
|
type BufferGeometry,
|
|
Euler,
|
|
InstancedBufferAttribute,
|
|
InstancedBufferGeometry,
|
|
MathUtils,
|
|
Matrix3,
|
|
Matrix4,
|
|
Mesh,
|
|
PerspectiveCamera,
|
|
Plane,
|
|
Ray,
|
|
Scene,
|
|
ShaderMaterial,
|
|
SRGBColorSpace,
|
|
type Texture,
|
|
TextureLoader,
|
|
Vector2,
|
|
Vector3,
|
|
WebGLRenderer,
|
|
} from "three";
|
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
import {
|
|
type Blocos,
|
|
caixaDosEstados,
|
|
cicloAt,
|
|
createFrameBudget,
|
|
type Estado,
|
|
type FrameVerdict,
|
|
framePiece,
|
|
papeis,
|
|
parseBlocos,
|
|
type Rect,
|
|
seguir,
|
|
stateAt,
|
|
} from "@/lib/blocos";
|
|
import {
|
|
isSoftwareWebGL,
|
|
rememberSlowScene,
|
|
rememberSoftwareWebGL,
|
|
} from "@/lib/device";
|
|
import { startFramePulse } from "./FramePulse";
|
|
|
|
export type BlocosSceneProps = {
|
|
/** Progresso do mergulho (0 a 1), escrito pela rolagem. */
|
|
progressRef: RefObject<number>;
|
|
visible: boolean;
|
|
onReady: () => void;
|
|
onError: () => void;
|
|
/** `full` segue a rolagem e o mouse; `lite` (celular) troca de estado sozinho. Padrão `full`. */
|
|
tier?: "full" | "lite";
|
|
/** Onde a peça descansa no estado `marca` (px CSS relativos ao hospedeiro); sem ele, no hospedeiro inteiro. */
|
|
placementRef?: RefObject<Rect | null>;
|
|
/** Área útil dos outros estados (px CSS relativos ao hospedeiro); sem ela, o hospedeiro inteiro. */
|
|
stageRef?: RefObject<Rect | null>;
|
|
/**
|
|
* O estado em que a peça está assentada na caixa dela (`placementRef` no `full`, a caixa toda no `lite`), para a
|
|
* legenda; `null` durante uma troca e fora da caixa (no mergulho). Avisa só quando muda.
|
|
*/
|
|
onEstado?: (estado: number | null) => void;
|
|
};
|
|
|
|
/*
|
|
* Os blocos da marca, em tempo real: uma malha instanciada (um bloco chanfrado do Blender, repetido) com os quatro
|
|
* estados de `public/3d/blocos.json` em atributos por instância. A troca de estado ("encaixar": cada bloco sai com um
|
|
* atraso próprio, faz um arco curto para a frente e assenta sem quicar) e a entrada correm no vertex shader; o
|
|
* JavaScript só atualiza uniforms, a pose e o enquadramento. A luz vem de dois matcaps renderizados no Blender com a
|
|
* mesma luz do pôster, então o primeiro quadro assume por cima do pôster sem salto.
|
|
* Parada num estado, a peça tem vida própria (`vida` no vertex shader), calculada pelo tempo; no `full`, os blocos
|
|
* perto do ponteiro avançam para a frente. O desenho é sob demanda: no ritmo da tela só enquanto a entrada, uma troca
|
|
* ou a rolagem assenta; fora disso a batida de 30 quadros por segundo enquanto há alguém mexendo na página (é ela que
|
|
* faz a vida correr e parar) e, no `lite`, um quadro na próxima troca.
|
|
*/
|
|
|
|
type Assets = {
|
|
bloco: BufferGeometry;
|
|
blocos: Blocos;
|
|
porcelana: Texture;
|
|
lima: Texture;
|
|
};
|
|
|
|
/** Duração da entrada (s): os blocos saem de perto do lugar e assentam na marca, por cima do pôster. */
|
|
const ENTRADA = 1.4;
|
|
/** `lite`: tempo parado em cada estado e tempo de cada troca (s). */
|
|
const FICA = 4;
|
|
const TROCA = 1.2;
|
|
/** `full` parado no topo: tempo em cada estado do ciclo em repouso (s); a troca é a mesma do `lite`. */
|
|
const FICA_TOPO = 3;
|
|
/** Avanço do mergulho abaixo do qual a página conta como parada no topo. */
|
|
const TOPO = 0.01;
|
|
/** Quantas trocas inteiras por `TROCA` a peça anda quando segue um pedido novo (a rolagem que assume o ciclo). */
|
|
const SEGUE = 2;
|
|
/** Pose da marca em repouso: a da câmera do pôster em `3d/blocos.py` (pela esquerda, 26°, e de cima, 12°). */
|
|
const REPOUSO = { yaw: 0.45, pitch: 0.21 };
|
|
/** Pose dos outros estados, quase de frente: a da câmera das prévias em `3d/blocos.py`. */
|
|
const FRENTE = { yaw: 0.24, pitch: 0.12 };
|
|
/** A câmera fica parada; a peça é que se move e muda de tamanho para caber onde `framePiece` manda. */
|
|
const DISTANCIA = 40;
|
|
/** O plano da peça (z = 0, no espaço dela), onde o ponteiro é medido. */
|
|
const PLANO = new Plane(new Vector3(0, 0, 1), 0);
|
|
|
|
const VERTEX = /* glsl */ `
|
|
#define ATRASO 0.35
|
|
attribute vec4 aPos0;
|
|
attribute vec4 aPos1;
|
|
attribute vec4 aPos2;
|
|
attribute vec4 aPos3;
|
|
attribute vec3 aEsc0;
|
|
attribute vec3 aEsc1;
|
|
attribute vec3 aEsc2;
|
|
attribute vec3 aEsc3;
|
|
attribute vec3 aEntrada;
|
|
attribute float aSemente;
|
|
// papel na vida em repouso (papeis, em lib/blocos.ts): quadrado da marca, botão da tela, linha, barra
|
|
attribute vec4 aPapel;
|
|
uniform float uFrom;
|
|
uniform float uTo;
|
|
uniform float uT;
|
|
uniform float uIntro;
|
|
// peso da vida (0 na troca e na entrada), estado parado em que ela corre e o relógio dela (s)
|
|
uniform float uVida;
|
|
uniform float uEstado;
|
|
uniform float uTempo;
|
|
uniform vec3 uBotao;
|
|
uniform float uChao;
|
|
// ponteiro no plano z = 0 da peça e o peso dele (0 sem ponteiro, no lite e fora do repouso)
|
|
uniform vec2 uCursor;
|
|
uniform float uCursorPeso;
|
|
varying vec3 vNormal;
|
|
varying float vTom;
|
|
|
|
// posição (xyz) e tom (w) do bloco no estado i
|
|
vec4 lugar(float i) { return i < 0.5 ? aPos0 : i < 1.5 ? aPos1 : i < 2.5 ? aPos2 : aPos3; }
|
|
vec3 escala(float i) { return i < 0.5 ? aEsc0 : i < 1.5 ? aEsc1 : i < 2.5 ? aEsc2 : aEsc3; }
|
|
|
|
// encaixar: cada bloco sai com um atraso próprio e assenta com ease-out, sem passar do ponto
|
|
float encaixe(float t) {
|
|
float k = clamp((t - aSemente * ATRASO) / (1.0 - ATRASO), 0.0, 1.0);
|
|
return 1.0 - pow(1.0 - k, 3.0);
|
|
}
|
|
|
|
// pulo de um quadrado, u de 0 a 1: sobe rápido e assenta devagar, sem passar do lugar; 0 fora disso
|
|
float pulo(float u) {
|
|
if (u <= 0.0 || u >= 1.0) return 0.0;
|
|
if (u < 0.35) return 1.0 - pow(1.0 - u / 0.35, 2.0);
|
|
return 1.0 - smoothstep(0.0, 1.0, (u - 0.35) / 0.65);
|
|
}
|
|
|
|
// a vida em repouso do estado: muda posição, escala e tom do bloco
|
|
void vida(inout vec3 p, inout vec3 s, inout float tom) {
|
|
if (uEstado < 0.5) {
|
|
// marca: os quatro quadrados lima pulam 0,25 em sequência, 0,4 s cada, um ciclo a cada 6 s; o primeiro vem 2,5 s
|
|
// depois de a cena nascer, logo após a entrada (e dentro dos 4 s do lite na marca)
|
|
if (aPapel.x >= 0.0) p.y += uVida * 0.25 * pulo((mod(uTempo - 2.5, 6.0) - aPapel.x * 0.3) / 0.4);
|
|
} else if (uEstado < 1.5) {
|
|
// tela: o botão inteiro pulsa de 1 a 1,08 em volta do centro, a cada 2,4 s
|
|
float k = 1.0 + uVida * 0.08 * aPapel.y * sin(3.14159265 * min(mod(uTempo, 2.4) / 0.9, 1.0));
|
|
p = uBotao + (p - uBotao) * k;
|
|
s *= k;
|
|
} else if (uEstado < 2.5) {
|
|
// integrações: um pulso lima corre do núcleo aos nós em 0,9 s, a cada 2,5 s
|
|
float frente = mod(uTempo, 2.5) / 0.9 * 1.4 - 0.2;
|
|
if (aPapel.z >= 0.0) tom = max(tom, uVida * (1.0 - smoothstep(0.0, 0.2, abs(aPapel.z - frente))));
|
|
} else if (aPapel.w >= 0.0) {
|
|
// dados: cada barra respira 15% em altura a partir do chão, com a fase dela, em 3,2 s
|
|
float k = 1.0 + uVida * 0.15 * (0.5 - 0.5 * cos(6.2831853 * (uTempo / 3.2 + aPapel.w)));
|
|
p.z = uChao + (p.z - uChao) * k;
|
|
s.z *= k;
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
vec4 a = lugar(uFrom);
|
|
vec4 b = lugar(uTo);
|
|
vec3 ea = escala(uFrom);
|
|
vec3 eb = escala(uTo);
|
|
// bloco que só existe num dos estados cresce ou some no lugar, sem viajar
|
|
vec3 pa = ea.x > 0.0 ? a.xyz : b.xyz;
|
|
vec3 pb = eb.x > 0.0 ? b.xyz : a.xyz;
|
|
float e = encaixe(uT);
|
|
vec3 p = mix(pa, pb, e);
|
|
// arco curto para a frente (+Z), que volta a zero ao assentar; quem não sai do lugar não faz arco
|
|
p.z += sin(3.14159265 * e) * min(distance(pa, pb), 1.0) * (0.5 + 0.5 * aSemente);
|
|
vec3 s = mix(ea, eb, e);
|
|
vTom = mix(a.w, b.w, e);
|
|
if (uVida > 0.0) vida(p, s, vTom);
|
|
// cursor: os blocos a até 2 unidades do ponteiro avançam até 0,5 para a frente, com queda suave
|
|
p.z += 0.5 * uCursorPeso * (1.0 - smoothstep(0.0, 2.0, distance(p.xy, uCursor)));
|
|
// entrada: de perto do lugar e um pouco menor, com o mesmo encaixe
|
|
float ei = encaixe(uIntro);
|
|
p += aEntrada * (1.0 - ei);
|
|
s *= mix(0.8, 1.0, ei);
|
|
vec4 mv = modelViewMatrix * vec4(position * s + p, 1.0);
|
|
// escala não uniforme: a normal vai pela inversa da escala
|
|
vNormal = normalize(normalMatrix * (normal / max(s, vec3(1e-4))));
|
|
gl_Position = projectionMatrix * mv;
|
|
}
|
|
`;
|
|
|
|
const FRAGMENT = /* glsl */ `
|
|
uniform sampler2D uPorcelana;
|
|
uniform sampler2D uLima;
|
|
uniform mat3 uLuz;
|
|
varying vec3 vNormal;
|
|
varying float vTom;
|
|
|
|
void main() {
|
|
// matcap pela normal em espaço de visão, desfeita da pose (uLuz): a luz fica presa à peça como no Blender, onde a
|
|
// câmera do pôster girou em volta da peça e a luz não. Sem a correção de perspectiva do MeshMatcapMaterial, que
|
|
// prenderia a luz à câmera de novo; só o balanço e o mouse mexem nela
|
|
vec3 n = uLuz * normalize(vNormal);
|
|
vec2 uv = n.xy * 0.495 + 0.5;
|
|
gl_FragColor = vec4(mix(texture2D(uPorcelana, uv).rgb, texture2D(uLima, uv).rgb, vTom), 1.0);
|
|
#include <colorspace_fragment>
|
|
}
|
|
`;
|
|
|
|
let assets: Promise<Assets> | null = null;
|
|
|
|
async function loadAssets(): Promise<Assets> {
|
|
const textures = new TextureLoader();
|
|
const [gltf, blocos, porcelana, lima] = await Promise.all([
|
|
new GLTFLoader().loadAsync("/3d/bloco.glb"),
|
|
fetch("/3d/blocos.json").then((response) => {
|
|
if (!response.ok) throw new Error(`blocos.json: ${response.status}`);
|
|
return response.json().then(parseBlocos);
|
|
}),
|
|
textures.loadAsync("/3d/matcap-porcelana.webp"),
|
|
textures.loadAsync("/3d/matcap-lima.webp"),
|
|
]);
|
|
let bloco: BufferGeometry | undefined;
|
|
gltf.scene.traverse((object) => {
|
|
if (!bloco && object instanceof Mesh) bloco = object.geometry;
|
|
});
|
|
if (!bloco) throw new Error("bloco.glb sem malha");
|
|
// os matcaps estão em sRGB: o three decodifica na amostra e `colorspace_fragment` codifica de volta na saída
|
|
porcelana.colorSpace = SRGBColorSpace;
|
|
lima.colorSpace = SRGBColorSpace;
|
|
return { bloco, blocos, porcelana, lima };
|
|
}
|
|
|
|
/** Os ativos baixados uma vez por página; uma falha esvazia o cache para a próxima montagem tentar de novo. */
|
|
function loadBlocos() {
|
|
assets ??= loadAssets().catch((error) => {
|
|
assets = null;
|
|
throw error;
|
|
});
|
|
return assets;
|
|
}
|
|
|
|
/**
|
|
* Baixa e deixa em cache o bloco, os estados e os matcaps, para quem quer adiantar o download antes de montar a cena.
|
|
* Nunca rejeita: uma falha aparece ao montar (`onError`).
|
|
*/
|
|
export function prepareBlocos(): Promise<void> {
|
|
return loadBlocos().then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
}
|
|
|
|
/** Número de 0 a 1 fixo para cada bloco e canal: a mesma "sorte" a cada visita. */
|
|
function sorteio(bloco: number, canal: number) {
|
|
let h = Math.imul(bloco * 8 + canal + 1, 0x9e3779b1);
|
|
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
|
|
h ^= h >>> 13;
|
|
return (h >>> 0) / 4294967296;
|
|
}
|
|
|
|
/** A cena: câmera, a malha instanciada com os quatro estados e o material de matcap. */
|
|
function buildWorld({ bloco, blocos, porcelana, lima }: Assets) {
|
|
const { n, estados } = blocos;
|
|
const geometry = new InstancedBufferGeometry();
|
|
geometry.index = bloco.index;
|
|
geometry.setAttribute("position", bloco.getAttribute("position"));
|
|
geometry.setAttribute("normal", bloco.getAttribute("normal"));
|
|
geometry.instanceCount = n;
|
|
estados.forEach((dados, e) => {
|
|
const lugar = new Float32Array(n * 4);
|
|
const escala = new Float32Array(n * 3);
|
|
for (let i = 0; i < n; i++) {
|
|
for (let k = 0; k < 3; k++) {
|
|
lugar[i * 4 + k] = dados[i * 7 + k];
|
|
escala[i * 3 + k] = dados[i * 7 + 3 + k];
|
|
}
|
|
lugar[i * 4 + 3] = dados[i * 7 + 6];
|
|
}
|
|
geometry.setAttribute(`aPos${e}`, new InstancedBufferAttribute(lugar, 4));
|
|
geometry.setAttribute(`aEsc${e}`, new InstancedBufferAttribute(escala, 3));
|
|
});
|
|
const semente = new Float32Array(n);
|
|
const entrada = new Float32Array(n * 3);
|
|
for (let i = 0; i < n; i++) {
|
|
semente[i] = sorteio(i, 0);
|
|
// de onde o bloco entra: até 1,2 unidade do lugar, mais para a frente que para trás
|
|
const x = sorteio(i, 1) - 0.5;
|
|
const y = sorteio(i, 2) - 0.5;
|
|
const z = sorteio(i, 3);
|
|
const k = (0.4 + 0.8 * sorteio(i, 4)) / Math.hypot(x, y, z || 1);
|
|
entrada[i * 3] = x * k;
|
|
entrada[i * 3 + 1] = y * k;
|
|
entrada[i * 3 + 2] = z * k;
|
|
}
|
|
geometry.setAttribute("aSemente", new InstancedBufferAttribute(semente, 1));
|
|
geometry.setAttribute("aEntrada", new InstancedBufferAttribute(entrada, 3));
|
|
const { vida, botao, chao } = papeis(blocos);
|
|
geometry.setAttribute("aPapel", new InstancedBufferAttribute(vida, 4));
|
|
|
|
// a visão desfeita da pose da peça (ver o fragment shader)
|
|
const luz = new Matrix3();
|
|
const material = new ShaderMaterial({
|
|
vertexShader: VERTEX,
|
|
fragmentShader: FRAGMENT,
|
|
uniforms: {
|
|
uFrom: { value: 0 },
|
|
uTo: { value: 0 },
|
|
uT: { value: 0 },
|
|
uIntro: { value: 0 },
|
|
uVida: { value: 0 },
|
|
uEstado: { value: 0 },
|
|
uTempo: { value: 0 },
|
|
uBotao: { value: new Vector3(...botao) },
|
|
uChao: { value: chao },
|
|
uCursor: { value: new Vector2() },
|
|
uCursorPeso: { value: 0 },
|
|
uPorcelana: { value: porcelana },
|
|
uLima: { value: lima },
|
|
uLuz: { value: luz },
|
|
},
|
|
});
|
|
const mesh = new Mesh(geometry, material);
|
|
// a malha é um bloco só: a caixa dela não diz onde estão as instâncias
|
|
mesh.frustumCulled = false;
|
|
const scene = new Scene();
|
|
scene.add(mesh);
|
|
const camera = new PerspectiveCamera(35, 1, 1, DISTANCIA * 4);
|
|
camera.position.set(0, 0, DISTANCIA);
|
|
// o raio do ponteiro sai dela antes do primeiro desenho
|
|
camera.updateMatrixWorld();
|
|
return {
|
|
scene,
|
|
camera,
|
|
mesh,
|
|
geometry,
|
|
material,
|
|
porcelana,
|
|
lima,
|
|
luz,
|
|
pose: new Euler(),
|
|
rotation: new Matrix4(),
|
|
// o raio do ponteiro levado para dentro da peça e onde ele fura o plano dela
|
|
inversa: new Matrix4(),
|
|
raio: new Ray(),
|
|
mira: new Vector3(),
|
|
};
|
|
}
|
|
|
|
type World = ReturnType<typeof buildWorld>;
|
|
type Tier = NonNullable<BlocosSceneProps["tier"]>;
|
|
|
|
type SceneState = {
|
|
/** Progresso amortecido (`full`). */
|
|
progress: number;
|
|
/** Entrada, de 0 a 1. */
|
|
intro: number;
|
|
/** Relógio da troca sozinha (`lite`, s), que só anda à vista e depois da entrada. */
|
|
clock: number;
|
|
/** `full`: tempo (s) com a página parada no topo, sem rolagem; acima de 0, o ciclo em repouso tem o comando. */
|
|
quieto: number;
|
|
/** O alvo do progresso no quadro anterior (rolagem é ele mudar). */
|
|
ultimoAlvo: number;
|
|
/** O estado mostrado, quem o pede e se ele já alcançou o pedido (aí o segue sem atraso). */
|
|
mostrado: Estado | null;
|
|
fonte: "ciclo" | "rolagem";
|
|
preso: boolean;
|
|
pointer: Vector2;
|
|
tilt: Vector2;
|
|
frames: number;
|
|
/**
|
|
* Peso da vida em repouso (0 a 1), o estado em que ela corre e o relógio dela e do balanço (s, só anda quando há
|
|
* quadro: o balanço não salta quando o desenho volta depois de parado).
|
|
*/
|
|
vida: number;
|
|
vidaEstado: number;
|
|
vidaTempo: number;
|
|
/** Ponteiro amortecido no plano da peça e o peso dele (0 a 1). */
|
|
cursor: Vector2;
|
|
cursorPeso: number;
|
|
};
|
|
|
|
type StepInput = {
|
|
tier: Tier;
|
|
target: number;
|
|
placement: Rect | null;
|
|
stage: Rect | null;
|
|
host: { width: number; height: number };
|
|
/** Tempo real desde o quadro anterior (ms). */
|
|
delta: number;
|
|
/** Ponteiro em px do hospedeiro (`full`, com o mouse dentro dele); `null` sem ele. */
|
|
cursor: Vector2 | null;
|
|
};
|
|
|
|
/** Peso da marca num estado: 1 parado nela, 0 nos outros, entre os dois numa troca de ou para ela. */
|
|
function pesoMarca({ from, to, t }: Estado) {
|
|
return (from === 0 ? 1 - t : 0) + (to === 0 ? t : 0);
|
|
}
|
|
|
|
/**
|
|
* Um quadro: avança a entrada, o progresso (ou o relógio da troca), a pose e o enquadramento, a vida em repouso e o
|
|
* cursor, e escreve os uniforms.
|
|
* Diz se ainda há algo assentando (o próximo quadro sai de imediato), em quanto tempo (s) vem a próxima troca do
|
|
* ciclo parado (`lite` e `full` no topo) e o estado assentado que a legenda mostra.
|
|
*/
|
|
function stepBlocos(world: World, s: SceneState, input: StepInput) {
|
|
const { tier, target, placement, stage, host, delta } = input;
|
|
const dt = Math.min(delta / 1000, 1 / 20);
|
|
s.intro = Math.min(1, s.intro + dt / ENTRADA);
|
|
let settling = s.intro < 1;
|
|
let wait = 0;
|
|
let pedido: Estado;
|
|
let fonte: SceneState["fonte"] = "ciclo";
|
|
// a troca sozinha conta tempo real (o quadro seguinte de uma espera chega só no fim dela)
|
|
const real = Math.min(delta / 1000, FICA + TROCA);
|
|
if (tier === "full") {
|
|
// rolagem amortecida: a peça segue o dedo com um leve atraso
|
|
s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5));
|
|
if (Math.abs(target - s.progress) > 4e-4) settling = true;
|
|
else s.progress = target;
|
|
// parada no topo, sem rolagem desde o quadro anterior: o ciclo em repouso (que começa parado na marca, `FICA_TOPO`
|
|
// s) assume; qualquer rolagem devolve o comando à rolagem
|
|
const noTopo =
|
|
s.intro >= 1 &&
|
|
target < TOPO &&
|
|
target === s.ultimoAlvo &&
|
|
s.progress === target;
|
|
s.ultimoAlvo = target;
|
|
s.quieto = noTopo ? s.quieto + real : 0;
|
|
if (s.quieto > 0) {
|
|
const ciclo = cicloAt(s.quieto, FICA_TOPO, TROCA);
|
|
pedido = ciclo;
|
|
if (ciclo.wait === 0) settling = true;
|
|
else wait = ciclo.wait;
|
|
} else {
|
|
pedido = stateAt(s.progress);
|
|
fonte = "rolagem";
|
|
}
|
|
s.tilt.lerp(s.pointer, 1 - Math.exp(-dt * 3));
|
|
} else {
|
|
if (s.intro >= 1) s.clock += real;
|
|
const lite = cicloAt(s.clock, FICA, TROCA);
|
|
pedido = lite;
|
|
if (lite.wait === 0) settling = true;
|
|
else wait = lite.wait;
|
|
}
|
|
// quem pede mudou: a peça vai do estado em que está até o pedido, sem salto, e daí em diante o segue
|
|
if (fonte !== s.fonte) s.preso = false;
|
|
s.fonte = fonte;
|
|
let estado = pedido;
|
|
if (s.mostrado && !s.preso) {
|
|
estado = seguir(s.mostrado, pedido, (dt * SEGUE) / TROCA);
|
|
s.preso =
|
|
estado.from === pedido.from &&
|
|
estado.to === pedido.to &&
|
|
estado.t === pedido.t;
|
|
if (!s.preso) settling = true;
|
|
}
|
|
s.mostrado = { from: estado.from, to: estado.to, t: estado.t };
|
|
const u = world.material.uniforms;
|
|
u.uFrom.value = estado.from;
|
|
u.uTo.value = estado.to;
|
|
u.uT.value = estado.t;
|
|
u.uIntro.value = s.intro;
|
|
|
|
// peso da marca: 1 parado nela, 0 nos outros estados; pose e enquadramento seguem a troca
|
|
const w = pesoMarca(estado);
|
|
// a marca vai na caixa da peça; os outros estados, no ciclo do topo, na caixa dos estados (dentro da da peça, longe
|
|
// do texto) e, no mergulho, na área útil. `fora` diz quanto a rolagem já pede um estado fora da marca: 0 no topo,
|
|
// então a rolagem assume do ciclo sem salto (o progresso amortecido parte de lá)
|
|
const fora =
|
|
tier === "full" && s.quieto === 0 ? 1 - pesoMarca(stateAt(s.progress)) : 0;
|
|
let area = stage;
|
|
if (tier === "full" && placement) {
|
|
const topo = caixaDosEstados(placement, host);
|
|
const fim = stage ?? { x: 0, y: 0, ...host };
|
|
area = {
|
|
x: MathUtils.lerp(topo.x, fim.x, fora),
|
|
y: MathUtils.lerp(topo.y, fim.y, fora),
|
|
width: MathUtils.lerp(topo.width, fim.width, fora),
|
|
height: MathUtils.lerp(topo.height, fim.height, fora),
|
|
};
|
|
}
|
|
// px por unidade no plano z = 0, para levar o enquadramento (px) para a cena
|
|
const k =
|
|
host.height /
|
|
(2 * DISTANCIA * Math.tan(MathUtils.degToRad(world.camera.fov / 2)));
|
|
const frame = framePiece(host, placement, w, area);
|
|
const x = (frame.x - host.width / 2) / k;
|
|
const y = (host.height / 2 - frame.y) / k;
|
|
world.mesh.position.set(x, y, 0);
|
|
world.mesh.scale.setScalar(frame.scale / k);
|
|
// a pose vale em relação à linha de visada: fora do centro a câmera já vê a peça de lado (à direita, pela
|
|
// esquerda dela), e a guinada e a elevação descontam esse ângulo para ela aparecer como no pôster
|
|
const pose = world.pose.set(
|
|
MathUtils.lerp(FRENTE.pitch, REPOUSO.pitch, w) + Math.atan2(y, DISTANCIA),
|
|
MathUtils.lerp(FRENTE.yaw, REPOUSO.yaw, w) - Math.atan2(x, DISTANCIA),
|
|
0,
|
|
);
|
|
const sway = tier === "full" ? 0.08 * Math.sin(s.vidaTempo * 0.35) : 0;
|
|
world.mesh.rotation.set(
|
|
pose.x - s.tilt.y * 0.06,
|
|
pose.y + sway + s.tilt.x * 0.1,
|
|
0,
|
|
);
|
|
// a inversa da pose (uma rotação: a transposta) leva a visão de volta à frente da peça, onde os matcaps foram feitos
|
|
world.luz
|
|
.setFromMatrix4(world.rotation.makeRotationFromEuler(pose))
|
|
.transpose();
|
|
|
|
// vida em repouso: só parada num estado e depois da entrada; muda de estado só depois de sumir no anterior
|
|
const parado = estado.from === estado.to && s.intro >= 1;
|
|
if (parado && s.vida < 0.01) s.vidaEstado = estado.to;
|
|
const vida = parado && estado.to === s.vidaEstado ? 1 : 0;
|
|
s.vida += (vida - s.vida) * (1 - Math.exp(-dt * 5));
|
|
s.vidaTempo += dt;
|
|
u.uVida.value = s.vida;
|
|
u.uEstado.value = s.vidaEstado;
|
|
u.uTempo.value = s.vidaTempo;
|
|
|
|
// cursor: o raio da câmera pelo ponteiro, levado para dentro da peça, fura o plano z = 0 dela
|
|
let mira = 0;
|
|
if (input.cursor) {
|
|
world.mesh.updateMatrixWorld();
|
|
const { raio, camera } = world;
|
|
raio.origin.setFromMatrixPosition(camera.matrixWorld);
|
|
raio.direction
|
|
.set(
|
|
(input.cursor.x / host.width) * 2 - 1,
|
|
1 - (input.cursor.y / host.height) * 2,
|
|
0.5,
|
|
)
|
|
.unproject(camera)
|
|
.sub(raio.origin);
|
|
raio.applyMatrix4(world.inversa.copy(world.mesh.matrixWorld).invert());
|
|
if (raio.intersectPlane(PLANO, world.mira)) mira = 1;
|
|
}
|
|
if (mira) {
|
|
// chegando de fora, o ponto já nasce sob o ponteiro; dentro, segue com um leve atraso
|
|
const segue = s.cursorPeso < 0.01 ? 1 : 1 - Math.exp(-dt * 12);
|
|
s.cursor.x += (world.mira.x - s.cursor.x) * segue;
|
|
s.cursor.y += (world.mira.y - s.cursor.y) * segue;
|
|
}
|
|
s.cursorPeso += (mira - s.cursorPeso) * (1 - Math.exp(-dt * 6));
|
|
u.uCursor.value.copy(s.cursor);
|
|
u.uCursorPeso.value = s.cursorPeso * s.vida;
|
|
s.frames += 1;
|
|
// a legenda diz o estado em que a peça assentou, só com ela na caixa dela (no `lite`, sempre): nenhuma durante uma
|
|
// troca, quando os blocos do estado anterior ainda ocupam o lugar dela
|
|
const assentada = estado.from === estado.to && estado.t === 0;
|
|
const legenda =
|
|
assentada && (tier === "lite" || fora <= 0.001) ? estado.from : null;
|
|
return { again: settling, wait, ready: s.frames === 2, legenda };
|
|
}
|
|
|
|
/** Solta o contexto WebGL e tira o canvas da página. */
|
|
function disposeRenderer(renderer: WebGLRenderer) {
|
|
renderer.dispose();
|
|
renderer.forceContextLoss();
|
|
renderer.domElement.remove();
|
|
}
|
|
|
|
/**
|
|
* O renderizador transparente, com 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 (o veredito fica anotado na sessão: `rememberSoftwareWebGL`).
|
|
* Razão de pixels até 1,75 (`full`) ou 1,5 (`lite`).
|
|
*/
|
|
function createRenderer(host: HTMLElement, tier: Tier) {
|
|
const renderer = new WebGLRenderer({
|
|
alpha: true,
|
|
antialias: true,
|
|
powerPreference: "high-performance",
|
|
});
|
|
if (isSoftwareWebGL(renderer.getContext())) {
|
|
rememberSoftwareWebGL();
|
|
disposeRenderer(renderer);
|
|
throw new Error("WebGL por software");
|
|
}
|
|
renderer.setPixelRatio(
|
|
Math.min(
|
|
tier === "lite" ? 1.5 : 1.75,
|
|
Math.max(1, window.devicePixelRatio || 1),
|
|
),
|
|
);
|
|
const canvas = renderer.domElement;
|
|
canvas.setAttribute("aria-hidden", "true");
|
|
canvas.style.cssText = "display:block;width:100%;height:100%";
|
|
host.append(canvas);
|
|
return renderer;
|
|
}
|
|
|
|
type Parts = { renderer: WebGLRenderer; world: World };
|
|
|
|
/** Solta a geometria, o material, as texturas e o contexto WebGL. */
|
|
function disposeParts({ renderer, world }: Parts) {
|
|
world.geometry.dispose();
|
|
world.material.dispose();
|
|
world.porcelana.dispose();
|
|
world.lima.dispose();
|
|
disposeRenderer(renderer);
|
|
}
|
|
|
|
type Callbacks = Pick<
|
|
BlocosSceneProps,
|
|
"progressRef" | "placementRef" | "stageRef" | "onEstado"
|
|
> & {
|
|
onReady: () => void;
|
|
onError: () => void;
|
|
};
|
|
|
|
export type BlocosEngine = {
|
|
setVisible: (visible: boolean) => void;
|
|
dispose: () => void;
|
|
};
|
|
|
|
/** Liga tamanho, mouse (`full`) e perda de contexto, e desenha sob demanda. */
|
|
function startBlocos(
|
|
host: HTMLElement,
|
|
parts: Parts,
|
|
{
|
|
progressRef,
|
|
placementRef,
|
|
stageRef,
|
|
onReady,
|
|
onError,
|
|
onEstado,
|
|
}: Callbacks,
|
|
tier: Tier,
|
|
): BlocosEngine {
|
|
const { renderer, world } = parts;
|
|
const canvas = renderer.domElement;
|
|
const s: SceneState = {
|
|
// nasce no ponto da rolagem em que a página já está, sem varrer os estados no caminho
|
|
progress: progressRef.current ?? 0,
|
|
intro: 0,
|
|
clock: 0,
|
|
quieto: 0,
|
|
ultimoAlvo: progressRef.current ?? 0,
|
|
mostrado: null,
|
|
fonte: "rolagem",
|
|
preso: true,
|
|
pointer: new Vector2(),
|
|
tilt: new Vector2(),
|
|
frames: 0,
|
|
vida: 0,
|
|
vidaEstado: 0,
|
|
vidaTempo: 0,
|
|
cursor: new Vector2(),
|
|
cursorPeso: 0,
|
|
};
|
|
/** Último ponteiro do mouse (px da janela), `null` sem mouse ou fora da janela; e ele em px do hospedeiro. */
|
|
let mouse: Vector2 | null = null;
|
|
const noHost = new Vector2();
|
|
const size = { width: 1, height: 1 };
|
|
const budget = createFrameBudget();
|
|
let verdict: FrameVerdict = "pending";
|
|
let alive = true;
|
|
let visible = false;
|
|
let raf = 0;
|
|
/** O quadro pedido veio do quadro anterior (só esses medem o orçamento). */
|
|
let chained = false;
|
|
let last = 0;
|
|
let wake: ReturnType<typeof setTimeout> | undefined;
|
|
let stopPulse: (() => void) | undefined;
|
|
/** O último estado avisado para a legenda. */
|
|
let legenda: number | null = null;
|
|
|
|
const request = (fromFrame: boolean) => {
|
|
if (raf || !alive || !visible) return;
|
|
chained = fromFrame;
|
|
raf = requestAnimationFrame(render);
|
|
};
|
|
const invalidate = () => request(false);
|
|
|
|
/** Para de desenhar: nenhum quadro, batida nem espera pendente. */
|
|
const halt = () => {
|
|
cancelAnimationFrame(raf);
|
|
raf = 0;
|
|
clearTimeout(wake);
|
|
wake = undefined;
|
|
stopPulse?.();
|
|
stopPulse = undefined;
|
|
last = 0;
|
|
};
|
|
const fail = () => {
|
|
if (!alive) return;
|
|
alive = false;
|
|
halt();
|
|
onError();
|
|
};
|
|
|
|
const draw = (now: number) => {
|
|
const delta = last ? now - last : 1000 / 60;
|
|
last = now;
|
|
if (chained) verdict = budget(delta);
|
|
if (verdict === "slow") {
|
|
rememberSlowScene();
|
|
fail();
|
|
return;
|
|
}
|
|
let cursor: Vector2 | null = null;
|
|
if (mouse) {
|
|
const r = host.getBoundingClientRect();
|
|
noHost.set(mouse.x - r.left, mouse.y - r.top);
|
|
if (
|
|
noHost.x >= 0 &&
|
|
noHost.y >= 0 &&
|
|
noHost.x <= r.width &&
|
|
noHost.y <= r.height
|
|
)
|
|
cursor = noHost;
|
|
}
|
|
const step = stepBlocos(world, s, {
|
|
tier,
|
|
target: progressRef.current ?? 0,
|
|
placement: placementRef?.current ?? null,
|
|
stage: stageRef?.current ?? null,
|
|
host: size,
|
|
delta,
|
|
cursor,
|
|
});
|
|
renderer.render(world.scene, world.camera);
|
|
if (step.ready) onReady();
|
|
if (step.legenda !== legenda) {
|
|
legenda = step.legenda;
|
|
onEstado?.(legenda);
|
|
}
|
|
// a entrada e os quadros seguintes correm no ritmo da tela até o orçamento ter veredito
|
|
if (step.again || verdict === "pending") request(true);
|
|
else if (step.wait) {
|
|
clearTimeout(wake);
|
|
wake = setTimeout(invalidate, step.wait * 1000);
|
|
}
|
|
};
|
|
const render = (now: number) => {
|
|
raf = 0;
|
|
if (!alive) return;
|
|
try {
|
|
draw(now);
|
|
} catch {
|
|
fail();
|
|
}
|
|
};
|
|
|
|
const resize = () => {
|
|
size.width = Math.max(1, host.clientWidth);
|
|
size.height = Math.max(1, host.clientHeight);
|
|
renderer.setSize(size.width, size.height, false);
|
|
world.camera.aspect = size.width / size.height;
|
|
world.camera.updateProjectionMatrix();
|
|
invalidate();
|
|
};
|
|
const onPointer = (event: PointerEvent) => {
|
|
if (event.pointerType !== "mouse") return;
|
|
s.pointer.set(
|
|
(event.clientX / window.innerWidth) * 2 - 1,
|
|
-(event.clientY / window.innerHeight) * 2 + 1,
|
|
);
|
|
mouse ??= new Vector2();
|
|
mouse.set(event.clientX, event.clientY);
|
|
};
|
|
// o mouse saiu da janela: os blocos voltam
|
|
const onLeave = (event: PointerEvent) => {
|
|
if (!event.relatedTarget) mouse = null;
|
|
};
|
|
const onLost = (event: Event) => {
|
|
event.preventDefault();
|
|
fail();
|
|
};
|
|
const observer =
|
|
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
|
|
observer?.observe(host);
|
|
if (!observer) window.addEventListener("resize", resize);
|
|
if (tier === "full") {
|
|
window.addEventListener("pointermove", onPointer, { passive: true });
|
|
window.addEventListener("pointerout", onLeave, { passive: true });
|
|
}
|
|
canvas.addEventListener("webglcontextlost", onLost);
|
|
resize();
|
|
|
|
return {
|
|
setVisible(next) {
|
|
visible = next;
|
|
if (!next || !alive) return halt();
|
|
// a batida também dá vida ao `lite` parado; a troca sozinha segue com o próprio relógio
|
|
stopPulse ??= startFramePulse(invalidate);
|
|
},
|
|
dispose() {
|
|
alive = false;
|
|
halt();
|
|
observer?.disconnect();
|
|
window.removeEventListener("resize", resize);
|
|
window.removeEventListener("pointermove", onPointer);
|
|
window.removeEventListener("pointerout", onLeave);
|
|
canvas.removeEventListener("webglcontextlost", onLost);
|
|
disposeParts(parts);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Cede a vez ao navegador: o passo seguinte roda noutra tarefa. */
|
|
const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
|
|
/**
|
|
* Monta a cena em tarefas separadas, para nenhuma passar de uns poucos milissegundos: os ativos, o renderizador, a
|
|
* cena com a compilação do shader (fora da thread principal) e o primeiro quadro. Devolve `null` se `isAlive` virou
|
|
* falso no caminho; lança se os ativos não baixam, se o aparelho não cria um contexto WebGL, se ele desenha por
|
|
* software ou se o shader não compila.
|
|
*/
|
|
export async function mountBlocos(
|
|
host: HTMLElement,
|
|
callbacks: Callbacks,
|
|
tier: Tier,
|
|
isAlive: () => boolean,
|
|
): Promise<BlocosEngine | null> {
|
|
const loaded = await loadBlocos();
|
|
await nextTask();
|
|
if (!isAlive()) return null;
|
|
const renderer = createRenderer(host, tier);
|
|
let parts: Parts | undefined;
|
|
try {
|
|
await nextTask();
|
|
if (!isAlive()) {
|
|
disposeRenderer(renderer);
|
|
return null;
|
|
}
|
|
parts = { renderer, world: buildWorld(loaded) };
|
|
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 startBlocos(host, parts, callbacks, tier);
|
|
}
|
|
|
|
export default function BlocosScene({
|
|
progressRef,
|
|
visible,
|
|
onReady,
|
|
onError,
|
|
tier = "full",
|
|
placementRef,
|
|
stageRef,
|
|
onEstado,
|
|
}: BlocosSceneProps) {
|
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
const engine = useRef<BlocosEngine | null>(null);
|
|
// sempre a última versão dos avisos, sem refazer a cena quando o pai troca de função
|
|
const callbacks = useRef({ onReady, onError, onEstado });
|
|
useEffect(() => {
|
|
callbacks.current = { onReady, onError, onEstado };
|
|
});
|
|
// o que a página pediu até a cena ficar pronta (ela nasce em tarefas separadas, depois do `visible` já ter chegado)
|
|
const wantsVisible = useRef(visible);
|
|
|
|
useEffect(() => {
|
|
const host = hostRef.current;
|
|
if (!host) return;
|
|
let alive = true;
|
|
mountBlocos(
|
|
host,
|
|
{
|
|
progressRef,
|
|
placementRef,
|
|
stageRef,
|
|
onReady: () => callbacks.current.onReady(),
|
|
onEstado: (estado) => callbacks.current.onEstado?.(estado),
|
|
onError: () => callbacks.current.onError(),
|
|
},
|
|
tier,
|
|
() => alive,
|
|
)
|
|
.then((created) => {
|
|
if (!created) return;
|
|
if (!alive) return created.dispose();
|
|
engine.current = created;
|
|
created.setVisible(wantsVisible.current);
|
|
})
|
|
// sem ativos, sem WebGL, WebGL por software ou shader que não compila: o hero fica no pôster
|
|
.catch(() => {
|
|
if (alive) callbacks.current.onError();
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
engine.current?.dispose();
|
|
engine.current = null;
|
|
};
|
|
}, [progressRef, placementRef, stageRef, tier]);
|
|
|
|
useEffect(() => {
|
|
wantsVisible.current = visible;
|
|
engine.current?.setVisible(visible);
|
|
}, [visible]);
|
|
|
|
return (
|
|
<div
|
|
ref={hostRef}
|
|
aria-hidden="true"
|
|
style={{ width: "100%", height: "100%" }}
|
|
/>
|
|
);
|
|
}
|