diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx
index af8125b..2d6de7f 100644
--- a/app/components/Hero.tsx
+++ b/app/components/Hero.tsx
@@ -37,6 +37,13 @@ function statItems(stats?: HeroStats) {
const TITLE = "Sistemas sob medida para operações que não cabem em soluções prontas.".split(" ");
const ACCENT = new Set([1, 2]);
+/** O que cada camada da cena significa, em linguagem simples. */
+const CAPTIONS = [
+ ["A tela", "O que sua equipe e seus clientes usam no dia a dia."],
+ ["As integrações", "Os programas que você já usa conversando entre si, sem retrabalho."],
+ ["Os dados", "Suas informações guardadas, organizadas e seguras."],
+] as const;
+
export default function Hero({ stats }: { stats?: HeroStats }) {
const items = statItems(stats);
@@ -49,6 +56,15 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
+ {/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
+
+ {CAPTIONS.map(([title, text], index) => (
+ -
+ {title}
+ {text}
+
+ ))}
+
diff --git a/app/components/cinematic/HeroField.tsx b/app/components/cinematic/HeroField.tsx
index 8d74cc3..f18b378 100644
--- a/app/components/cinematic/HeroField.tsx
+++ b/app/components/cinematic/HeroField.tsx
@@ -4,6 +4,13 @@ import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import type { HeroFieldSceneProps } from "./HeroFieldScene";
+/** Etiquetas presas às camadas da cena (a cena calcula a posição de cada uma a cada quadro). */
+const TAGS = [
+ ["A tela", "o que sua equipe e seus clientes usam"],
+ ["As integrações", "seus sistemas conversando entre si"],
+ ["Os dados", "suas informações organizadas"],
+] as const;
+
const Scene = dynamic(() => import("./HeroFieldScene"), {
ssr: false,
});
@@ -26,6 +33,7 @@ function supportsWebGL2() {
export default function HeroField() {
const rootRef = useRef(null);
const progressRef = useRef(0);
+ const labelsRef = useRef<(HTMLElement | null)[]>([]);
const [mount, setMount] = useState(false);
const [visible, setVisible] = useState(false);
const [ready, setReady] = useState(false);
@@ -95,11 +103,26 @@ export default function HeroField() {
{mount && !failed && (
)}
+ {mount &&
+ !failed &&
+ TAGS.map(([title, text], index) => (
+ {
+ labelsRef.current[index] = element;
+ }}
+ className="hero-tag"
+ >
+ {title}
+ {text}
+
+ ))}
);
}
diff --git a/app/components/cinematic/HeroFieldScene.tsx b/app/components/cinematic/HeroFieldScene.tsx
index 179a5eb..0146057 100644
--- a/app/components/cinematic/HeroFieldScene.tsx
+++ b/app/components/cinematic/HeroFieldScene.tsx
@@ -13,54 +13,158 @@ import * as THREE from "three";
export type HeroFieldSceneProps = {
progressRef: RefObject;
+ /** etiquetas em HTML presas às camadas (tela, integrações, dados), posicionadas a cada quadro */
+ labelsRef: RefObject<(HTMLElement | null)[]>;
visible: boolean;
onReady: () => void;
onError: () => void;
};
/*
- * Três camadas de pontos (interface, integrações, dados) ligadas por colunas de partículas que sobem
- * como dados. 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 as três até o escuro da próxima seção.
- * Tudo acontece no vertex shader: o JavaScript só atualiza meia dúzia de uniforms por quadro.
+ * Um sistema desenhado com pontos, em três camadas que um leigo reconhece:
+ * em cima, a TELA de um aplicativo (janela, título, botão, cards); no meio, as INTEGRAÇÕES (sistemas
+ * 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 HeroField explicam cada camada nessa hora).
+ * Tudo se move no vertex shader; o JavaScript só atualiza alguns uniforms por quadro.
*/
-const RADIUS = 2.35;
+type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
+type PointStyle = { tone?: number; size?: number; alpha?: number };
+
+class FieldBuilder {
+ base: number[] = [];
+ to: number[] = [];
+ move: number[] = [];
+ tone: number[] = [];
+ size: number[] = [];
+ alpha: number[] = [];
+ constructor(private density: number) {}
+
+ point(x: number, y: number, z: number, 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);
+ }
+ line(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
+ const s = step * this.density;
+ const n = Math.max(1, Math.round(Math.hypot(x1 - x0, z1 - z0) / s));
+ for (let i = 0; i <= n; i++) this.point(x0 + ((x1 - x0) * i) / n, y, z0 + ((z1 - z0) * i) / n, style);
+ }
+ rect(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
+ this.line(y, x0, z0, x1, z0, step, style);
+ this.line(y, x1, z0, x1, z1, step, style);
+ this.line(y, x1, z1, x0, z1, step, style);
+ this.line(y, x0, z1, x0, z0, step, style);
+ }
+ fill(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
+ const s = step * this.density;
+ for (let x = x0; x <= x1 + 1e-6; x += s)
+ for (let z = z0; z <= z1 + 1e-6; z += s)
+ this.point(x + (Math.random() - 0.5) * s * 0.3, y, z + (Math.random() - 0.5) * s * 0.3, style);
+ }
+ ring(y: Layer, cx: number, cz: number, r: number, step: number, style?: PointStyle) {
+ const n = Math.max(8, Math.round((Math.PI * 2 * r) / (step * this.density)));
+ for (let i = 0; i < n; i++) {
+ const a = (i / n) * Math.PI * 2;
+ this.point(cx + Math.cos(a) * r, y, cz + Math.sin(a) * r, style);
+ }
+ }
+ disc(y: Layer, cx: number, cz: number, r: number, step: number, style?: PointStyle) {
+ for (let rr = r; rr > 0.005; rr -= step * this.density) this.ring(y, cx, cz, rr, step, style);
+ this.point(cx, y, cz, style);
+ }
+ /** partículas que correm de a até b (a posição é calculada no shader) */
+ flow(a: [number, number, number], b: [number, number, number], count: number, style?: PointStyle) {
+ const n = Math.max(2, Math.round(count / this.density));
+ for (let i = 0; i < n; i++) this.point(...a, style, b);
+ }
+}
+
+// z negativo = topo da tela quando a camada fica de frente para a câmera
+function drawScreen(f: FieldBuilder) {
+ const y: Layer = 1;
+ f.fill(y, -2.1, -1.4, 2.1, 1.4, 0.1, { alpha: 0.1 }); // superfície da tela
+ f.rect(y, -2.1, -1.4, 2.1, 1.4, 0.026, { alpha: 0.95 });
+ f.line(y, -2.1, -1.05, 2.1, -1.05, 0.03, { alpha: 0.6 }); // barra do navegador
+ for (const x of [-1.9, -1.72, -1.54]) f.disc(y, x, -1.23, 0.05, 0.025, { alpha: 0.8 });
+ f.rect(y, -1.1, -1.32, 1.3, -1.14, 0.035, { alpha: 0.45 }); // endereço
+ f.fill(y, -1.8, -0.82, 0.25, -0.58, 0.03, { alpha: 0.85 }); // título
+ f.line(y, -1.8, -0.38, 0.75, -0.38, 0.04, { alpha: 0.5 }); // texto
+ f.line(y, -1.8, -0.24, 0.35, -0.24, 0.04, { alpha: 0.5 });
+ f.fill(y, -1.8, -0.02, -0.75, 0.2, 0.028, { tone: 1, alpha: 0.95, size: 0.022 }); // botão
+ f.rect(y, 0.6, -0.82, 1.8, 0.2, 0.035, { alpha: 0.6 }); // imagem
+ f.fill(y, 0.6, -0.82, 1.8, 0.2, 0.08, { alpha: 0.25 });
+ for (const [x0, x1] of [
+ [-1.8, -0.7],
+ [-0.55, 0.55],
+ [0.7, 1.8],
+ ]) {
+ f.rect(y, x0, 0.45, x1, 1.2, 0.035, { alpha: 0.55 }); // cards
+ f.line(y, x0 + 0.12, 0.62, x1 - 0.3, 0.62, 0.045, { alpha: 0.5 });
+ f.line(y, x0 + 0.12, 0.76, x1 - 0.5, 0.76, 0.045, { alpha: 0.35 });
+ }
+}
+
+/** posições dos sistemas ligados à central; as colunas de dados sobem por eles */
+const NODES: [number, number][] = Array.from({ length: 6 }, (_, k) => {
+ const a = (k / 6) * Math.PI * 2 + Math.PI / 6;
+ return [Math.cos(a) * 1.7, Math.sin(a) * 1.05];
+});
+
+function drawIntegrations(f: FieldBuilder) {
+ const y: Layer = 0;
+ f.ring(y, 0, 0, 0.34, 0.02, { tone: 1, alpha: 0.95, size: 0.024 }); // central
+ f.disc(y, 0, 0, 0.2, 0.035, { tone: 1, alpha: 0.5 });
+ NODES.forEach(([x, z], k) => {
+ f.ring(y, x, z, 0.2, 0.02, { tone: 1, alpha: 0.9, size: 0.022 });
+ f.disc(y, x, z, 0.1, 0.03, { alpha: 0.6 });
+ const len = Math.hypot(x, z);
+ const ux = x / len;
+ const uz = z / len;
+ // linha da central até o sistema, e mensagens correndo nos dois sentidos
+ f.line(y, ux * 0.38, uz * 0.38, x - ux * 0.24, z - uz * 0.24, 0.06, { alpha: 0.35 });
+ f.flow([ux * 0.38, y, uz * 0.38], [x - ux * 0.24, y, z - uz * 0.24], 10, { tone: 1, alpha: 0.95, size: 0.026 });
+ f.flow([x - ux * 0.24, y, z - uz * 0.24], [ux * 0.38, y, uz * 0.38], 6, { alpha: 0.9, size: 0.024 });
+ const [nx, nz] = NODES[(k + 1) % NODES.length];
+ f.line(y, x, z, nx, nz, 0.08, { alpha: 0.15 });
+ });
+}
+
+function drawData(f: FieldBuilder) {
+ const y: Layer = -1;
+ const x0 = -2;
+ const x1 = 2;
+ const z0 = -1.3;
+ const z1 = 1.3;
+ const cols = [x0, -0.9, 0.2, 1.1, x1];
+ f.rect(y, x0, z0, x1, z1, 0.03, { alpha: 0.8 });
+ f.fill(y, x0, z0, x1, z0 + 0.32, 0.045, { tone: 0.6, alpha: 0.45 }); // cabeçalho da tabela
+ for (let z = z0 + 0.32; z < z1; z += 0.33) f.line(y, x0, z, x1, z, 0.04, { alpha: 0.4 });
+ for (const x of cols.slice(1, -1)) f.line(y, x, z0, x, z1, 0.04, { alpha: 0.4 });
+ for (let z = z0 + 0.48; z < z1; z += 0.33) {
+ for (let c = 0; c < cols.length - 1; c++) {
+ const start = cols[c] + 0.12;
+ const width = (cols[c + 1] - cols[c] - 0.24) * (0.35 + Math.random() * 0.6);
+ f.line(y, start, z, start + width, z, 0.045, { alpha: 0.55 }); // valores nas células
+ }
+ }
+}
function buildField(dense: boolean) {
- const grid = dense ? 76 : 46;
- const columns = dense ? 18 : 10;
- const perColumn = dense ? 64 : 40;
- const base: number[] = [];
- const kind: number[] = [];
- const step = (RADIUS * 2) / (grid - 1);
- for (const layer of [-1, 0, 1]) {
- for (let i = 0; i < grid; i++) {
- for (let j = 0; j < grid; j++) {
- // leve desalinhamento: quebra o moiré da grade perfeita
- const x = -RADIUS + (i + (Math.random() - 0.5) * 0.35) * step;
- const z = -RADIUS + (j + (Math.random() - 0.5) * 0.35) * step;
- if (Math.hypot(x, z) > RADIUS) continue;
- base.push(x, layer, z);
- kind.push(0);
- }
- }
- }
- for (let c = 0; c < columns; c++) {
- const angle = c * 2.39996 + 0.4; // ângulo áureo: colunas espalhadas sem se amontoar
- const r = Math.sqrt((c + 0.5) / columns) * RADIUS * 0.78;
- const x = Math.round((Math.cos(angle) * r) / step) * step;
- const z = Math.round((Math.sin(angle) * r) / step) * step;
- for (let k = 0; k < perColumn; k++) {
- base.push(x, 0, z);
- kind.push(1);
- }
- for (const layer of [-1, 0, 1]) {
- base.push(x, layer, z);
- kind.push(2);
- }
- }
- const count = kind.length;
+ const f = new FieldBuilder(dense ? 1 : 1.5);
+ drawScreen(f);
+ drawIntegrations(f);
+ drawData(f);
+ // colunas: a informação sobe dos dados, passa pelos sistemas e chega à tela
+ for (const [x, z] of [[0, 0] as [number, number], ...NODES])
+ f.flow([x, -1, z], [x, 1, z], 40, { tone: 1, alpha: 0.6, size: 0.022 });
+
+ const count = f.move.length;
const scatter = new Float32Array(count * 3);
const rand = new Float32Array(count * 4);
for (let n = 0; n < count; n++) {
@@ -72,10 +176,14 @@ function buildField(dense: boolean) {
rand.set([Math.random(), Math.random(), Math.random(), Math.random()], n * 4);
}
const geometry = new THREE.BufferGeometry();
- geometry.setAttribute("position", new THREE.Float32BufferAttribute(base, 3));
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(f.base, 3));
+ geometry.setAttribute("aTo", new THREE.Float32BufferAttribute(f.to, 3));
+ geometry.setAttribute("aMove", new THREE.Float32BufferAttribute(f.move, 1));
+ geometry.setAttribute("aTone", new THREE.Float32BufferAttribute(f.tone, 1));
+ geometry.setAttribute("aSize", new THREE.Float32BufferAttribute(f.size, 1));
+ geometry.setAttribute("aAlpha", new THREE.Float32BufferAttribute(f.alpha, 1));
geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3));
geometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 4));
- geometry.setAttribute("aKind", new THREE.Float32BufferAttribute(kind, 1));
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 12);
return geometry;
}
@@ -90,9 +198,13 @@ const vertexShader = /* glsl */ `
uniform float uFade;
uniform vec2 uMouse;
uniform float uMouseAmt;
+ attribute vec3 aTo;
+ attribute float aMove;
+ attribute float aTone;
+ attribute float aSize;
+ attribute float aAlpha;
attribute vec3 aScatter;
attribute vec4 aRand;
- attribute float aKind;
varying vec3 vColor;
varying float vAlpha;
varying float vSoft;
@@ -101,24 +213,19 @@ const vertexShader = /* glsl */ `
const vec3 LIME = vec3(0.8, 1.0, 0.0);
void main() {
- float layer = position.y;
- float isPlane = 1.0 - step(0.5, aKind);
- float isStream = step(0.5, aKind) * (1.0 - step(1.5, aKind));
- float isNode = step(1.5, aKind);
+ // partículas em movimento correm de position até aTo e recomeçam
+ float f = fract(uTime * (0.1 + aRand.x * 0.08) + aRand.y);
+ vec3 b = mix(position, aTo, aMove * f);
+ vec3 p = vec3(b.x, b.y * uSpread, b.z);
+ float still = 1.0 - aMove;
- // colunas: cada partícula sobe da camada de dados até a interface e recomeça
- float flow = fract(uTime * (0.08 + aRand.x * 0.07) + aRand.y);
- layer = mix(layer, flow * 2.0 - 1.0, isStream);
+ float wave = sin(b.x * 1.7 + uTime * 0.55 + b.y * 2.1) * cos(b.z * 1.3 - uTime * 0.4);
+ p.y += wave * (0.02 + uScroll * 0.2) * still;
- vec3 p = vec3(position.x, layer * uSpread, position.z);
- float wave = sin(position.x * 1.7 + uTime * 0.55 + layer * 2.1) * cos(position.z * 1.3 - uTime * 0.4);
- p.y += wave * (0.045 + uScroll * 0.25) * isPlane;
- p.xz += (aRand.zw - 0.5) * 0.05 * isStream;
-
- // cursor: uma onda que levanta a malha e acende os pontos em verde
- float d = length(position.xz - uMouse);
- float lift = exp(-d * d * 2.2) * uMouseAmt;
- p.y += (lift * 0.32 + sin(d * 7.0 - uTime * 3.5) * lift * 0.05) * isPlane;
+ // cursor: uma onda que levanta a camada e acende os pontos em verde
+ float d = length(b.xz - uMouse);
+ float lift = exp(-d * d * 2.2) * uMouseAmt * still;
+ p.y += lift * 0.3 + sin(d * 7.0 - uTime * 3.5) * lift * 0.05;
// entrada: cada ponto sai da nuvem com atraso próprio
float t = clamp((uIntro - aRand.z * 0.4) / 0.6, 0.0, 1.0);
@@ -129,24 +236,18 @@ const vertexShader = /* glsl */ `
gl_Position = projectionMatrix * mv;
float depth = -mv.z;
- float size = 0.021 * isPlane * (0.7 + aRand.w * 0.6) + 0.024 * isStream + 0.07 * isNode;
- float blur = clamp(abs(depth - uFocus) * 0.32, 0.0, 1.6);
- gl_PointSize = min(size * uScale / max(depth, 0.05) * (1.0 + blur * 1.4), 64.0);
+ float blur = clamp(abs(depth - uFocus) * 0.3, 0.0, 1.6);
+ float size = aSize * (0.8 + aRand.w * 0.4);
+ gl_PointSize = min(size * uScale / max(depth, 0.05) * (1.0 + blur * 1.3), 64.0);
- // cada camada é um disco: miolo que esmaece para a borda e um aro fino que desenha o contorno
- float radial = length(position.xz);
- float edge = 1.0 - smoothstep(RADIUS_IN, RADIUS_OUT, radial);
- float rim = smoothstep(RADIUS_OUT - 0.16, RADIUS_OUT - 0.04, radial);
- float alpha = isPlane * ((0.5 + 0.2 * step(0.5, position.y)) * edge + rim * 0.75) + isStream * 0.55 + isNode;
- alpha *= (0.25 + 0.75 * e) / (1.0 + blur * 1.6);
+ float alpha = aAlpha * mix(1.0, sin(f * 3.14159), aMove);
+ alpha *= (0.25 + 0.75 * e) / (1.0 + blur * 1.5);
alpha *= smoothstep(0.25, 1.6, depth) * uFade;
vAlpha = alpha;
vSoft = clamp(0.35 + blur * 0.45, 0.35, 1.0);
- vColor = mix(PAPER, LIME, clamp(isStream + isNode + (lift * 1.6 + rim * 0.55) * isPlane, 0.0, 1.0));
+ vColor = mix(PAPER, LIME, clamp(aTone + lift * 1.6, 0.0, 1.0));
}
-`
- .replaceAll("RADIUS_IN", (RADIUS * 0.62).toFixed(2))
- .replaceAll("RADIUS_OUT", RADIUS.toFixed(2));
+`;
const fragmentShader = /* glsl */ `
varying vec3 vColor;
@@ -165,7 +266,48 @@ const smooth = (a: number, b: number, x: number) => {
return t * t * (3 - 2 * t);
};
-function Field({ progressRef, onReady, onError }: HeroFieldSceneProps) {
+/** Interpola entre quadros-chave [progresso, valor] com suavização em cada trecho. */
+function keyframes(frames: readonly (readonly [number, number])[], p: number) {
+ if (p <= frames[0][0]) return frames[0][1];
+ for (let i = 1; i < frames.length; i++) {
+ const [p1, v1] = frames[i];
+ if (p <= p1) {
+ const [p0, v0] = frames[i - 1];
+ return v0 + (v1 - v0) * smooth(p0, p1, p);
+ }
+ }
+ return frames[frames.length - 1][1];
+}
+
+/** Distância entre as camadas no fim do mergulho: cada uma vira um "cômodo" que a câmera visita. */
+const SPREAD = 5.5;
+/*
+ * 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
+ * estes mesmos intervalos: tela 0,26–0,46; integrações 0,52–0,71; dados 0,76–0,92.
+ */
+const CAMERA_Z = [
+ [0, 9],
+ [0.28, SPREAD + 5.2],
+ [0.44, SPREAD + 4.5],
+ [0.5, SPREAD - 0.3],
+ [0.55, 5.2],
+ [0.69, 4.5],
+ [0.74, -0.3],
+ [0.78, -SPREAD + 5.2],
+ [0.89, -SPREAD + 4.5],
+ [0.95, -SPREAD - 0.3],
+ [1, -SPREAD - 2],
+] as const;
+
+// ponto de cada camada onde a etiqueta se prende (canto direito da frente), na ordem tela, integrações, dados
+const LABEL_ANCHORS: [number, Layer, number][] = [
+ [2.1, 1, 1.4],
+ [1.95, 0, 0.9],
+ [2, -1, 1.3],
+];
+
+function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps) {
const { gl, camera, size } = useThree();
const group = useRef(null);
const dense = useMemo(
@@ -208,6 +350,9 @@ function Field({ progressRef, onReady, onError }: HeroFieldSceneProps) {
ray: new THREE.Raycaster(),
plane: new THREE.Plane(),
hit: new THREE.Vector3(),
+ anchor: new THREE.Vector3(),
+ zero: new THREE.Vector2(),
+ target: new THREE.Vector2(),
}),
[],
);
@@ -252,27 +397,31 @@ function Field({ progressRef, onReady, onError }: HeroFieldSceneProps) {
u.uTime.value = time;
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
u.uScroll.value = p;
- u.uSpread.value = 1.05 + 1.3 * smooth(0, 0.55, p);
- u.uFade.value = 1 - smooth(0.94, 1, 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 + (SPREAD - 1.05) * face * face;
+ u.uFade.value = 1 - smooth(0.93, 1, p);
u.uScale.value =
(gl.getPixelRatio() * size.height * 0.5) /
Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2));
- // sem mouse (toque, ou mouse parado), um cursor fantasma percorre a malha
+ // 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 : new THREE.Vector2(), 1 - Math.exp(-dt * 3));
+ s.tilt.lerp(mouseActive ? s.pointer : helpers.zero, 1 - Math.exp(-dt * 3));
- const face = smooth(0.04, 0.6, p);
+ // 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,
- time * 0.045 + s.tilt.x * 0.18,
+ (-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) + s.tilt.x * 0.15 * (1 - face),
0,
);
- const settle = 1 - smooth(0, 0.4, p);
- g.position.set(wide ? 1.85 * settle : 0, wide ? -0.15 * settle : -1.25 * settle, 0);
- const dive = smooth(0.22, 1, p);
- camera.position.z = 9 - 12.5 * dive * dive;
- u.uFocus.value = Math.max(1.5, camera.position.z - g.position.z);
+ const settle = 1 - smooth(0, 0.28, p);
+ g.position.set(wide ? 1.7 * settle : 0, wide ? -0.1 * settle : -1.25 * settle, 0);
+ camera.position.z = keyframes(CAMERA_Z, 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) {
@@ -282,18 +431,31 @@ function Field({ progressRef, onReady, onError }: HeroFieldSceneProps) {
.applyMatrix4(g.matrixWorld);
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
g.worldToLocal(helpers.hit);
- s.mouse.lerp(new THREE.Vector2(helpers.hit.x, helpers.hit.z), 1 - Math.exp(-dt * 10));
+ s.mouse.lerp(helpers.target.set(helpers.hit.x, helpers.hit.z), 1 - Math.exp(-dt * 10));
}
} else {
s.mouse.lerp(
- new THREE.Vector2(Math.sin(time * 0.31) * 1.5, Math.sin(time * 0.23 + 1.3) * 1.3),
+ 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 ? 1 : 0.55) * (1 - smooth(0.05, 0.3, p));
+ 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));
+ // etiquetas: projeta o canto de cada camada na tela e as esconde quando o mergulho começa
+ const labels = labelsRef.current ?? [];
+ const shown = smooth(0.75, 1, u.uIntro.value) * (1 - smooth(0.04, 0.16, p));
+ LABEL_ANCHORS.forEach(([x, layer, z], index) => {
+ const label = labels[index];
+ if (!label) return;
+ helpers.anchor.set(x, layer * u.uSpread.value, z).applyMatrix4(g.matrixWorld).project(camera);
+ const px = ((helpers.anchor.x + 1) / 2) * size.width;
+ const py = ((1 - helpers.anchor.y) / 2) * size.height;
+ label.style.transform = `translate3d(${px.toFixed(1)}px, ${py.toFixed(1)}px, 0)`;
+ label.style.opacity = shown.toFixed(3);
+ });
+
if (++s.frames === 2) onReady();
});
diff --git a/app/globals.css b/app/globals.css
index 055c35c..d8db6e4 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1093,11 +1093,107 @@ textarea {
}
}
+/* Etiquetas presas às camadas da cena: a posição vem da cena a cada quadro (transform), o recuo para a
+ esquerda do ponto de ancoragem fica no translate */
+.hero-tag {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: none;
+ max-width: 230px;
+ translate: calc(-100% - 14px) -50%;
+ border: 1px solid rgba(244, 242, 237, 0.14);
+ background: rgba(10, 10, 10, 0.62);
+ backdrop-filter: blur(4px);
+ padding: 7px 11px;
+ color: rgba(244, 242, 237, 0.78);
+ font: 13px / 1.35 var(--font-body);
+ text-align: right;
+ opacity: 0;
+ pointer-events: none;
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ right: -14px;
+ width: 14px;
+ height: 1px;
+ background: var(--slash);
+ }
+
+ @media (min-width: 1024px) {
+ display: block;
+ }
+}
+
+.hero-tag-title,
+.hero-caption-title {
+ display: block;
+ color: var(--slash);
+ font: 600 11px / 1.4 var(--font-mono);
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.hero-captions {
+ display: none;
+}
+
@media (min-width: 1024px) and (min-height: 700px) and (prefers-reduced-motion: no-preference) {
.hero {
height: 220svh;
}
+ /* Legendas do mergulho: cada uma aparece enquanto a câmera atravessa a sua camada (--hero-p) */
+ .hero-captions {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: end center;
+ margin: 0;
+ padding: 0 var(--sp-6) 11svh;
+ list-style: none;
+ pointer-events: none;
+ /* faixa escura atrás da legenda, como em legenda de cinema, sem cobrir o centro da cena */
+ background: linear-gradient(0deg, rgba(10, 10, 10, 0.75), transparent 38%);
+ opacity: clamp(0, min((var(--hero-p) - 0.24) * 14, (0.94 - var(--hero-p)) * 14), 1);
+
+ & li {
+ --mid: calc((var(--from) + var(--to)) / 2);
+ grid-area: 1 / 1;
+ max-width: 26ch;
+ color: #f4f2ed;
+ font: 600 clamp(28px, 2.8vw, 48px) / 1.1 var(--font-display);
+ letter-spacing: -0.035em;
+ text-align: center;
+ text-shadow: 0 2px 40px rgba(10, 10, 10, 0.9);
+ opacity: clamp(0, min((var(--hero-p) - var(--from)) * 14, (var(--to) - var(--hero-p)) * 14), 1);
+ translate: 0 calc((var(--hero-p) - var(--mid)) * -160px);
+ }
+
+ /* mesmos intervalos em que a câmera enquadra cada camada (CAMERA_Z em HeroFieldScene.tsx) */
+ & li:nth-child(1) {
+ --from: 0.26;
+ --to: 0.46;
+ }
+
+ & li:nth-child(2) {
+ --from: 0.52;
+ --to: 0.71;
+ }
+
+ & li:nth-child(3) {
+ --from: 0.76;
+ --to: 0.92;
+ }
+
+ & .hero-caption-title {
+ margin-bottom: var(--sp-3);
+ font-size: 12px;
+ }
+ }
+
.hero-stage {
position: sticky;
top: 0;
@@ -1108,7 +1204,7 @@ textarea {
.hero-content {
transform: translate3d(0, calc(var(--hero-p) * -16vh), 0) scale(calc(1 - var(--hero-p) * 0.08));
transform-origin: 0 50%;
- opacity: calc(1 - var(--hero-p) * 2.6);
+ opacity: calc(1 - var(--hero-p) * 4.2);
filter: blur(calc(var(--hero-p) * 18px));
}