Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
615 lines
20 KiB
TypeScript
615 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||
import {
|
||
Component,
|
||
type ReactNode,
|
||
type RefObject,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
} from "react";
|
||
import * as THREE from "three";
|
||
import FramePulse from "./FramePulse";
|
||
|
||
export type HeroFieldSceneProps = {
|
||
progressRef: RefObject<number>;
|
||
visible: boolean;
|
||
onReady: () => void;
|
||
onError: () => void;
|
||
};
|
||
|
||
/*
|
||
* 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 Hero explicam cada camada nessa hora).
|
||
* 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.
|
||
*/
|
||
|
||
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 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++) {
|
||
const u = Math.random() * 2 - 1;
|
||
const t = Math.random() * Math.PI * 2;
|
||
const s = Math.sqrt(1 - u * u);
|
||
const d = 4 + Math.random() * 7;
|
||
scatter.set([Math.cos(t) * s * d, u * d, Math.sin(t) * s * d], n * 3);
|
||
rand.set(
|
||
[Math.random(), Math.random(), Math.random(), Math.random()],
|
||
n * 4,
|
||
);
|
||
}
|
||
const geometry = new THREE.BufferGeometry();
|
||
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.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 12);
|
||
return geometry;
|
||
}
|
||
|
||
const vertexShader = /* glsl */ `
|
||
uniform float uTime;
|
||
uniform float uIntro;
|
||
uniform float uSpread;
|
||
uniform float uScroll;
|
||
uniform float uScale;
|
||
uniform float uFocus;
|
||
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;
|
||
varying vec3 vColor;
|
||
varying float vAlpha;
|
||
varying float vSoft;
|
||
|
||
const vec3 PAPER = vec3(0.957, 0.949, 0.929);
|
||
const vec3 LIME = vec3(0.8, 1.0, 0.0);
|
||
|
||
void main() {
|
||
// 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;
|
||
|
||
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;
|
||
|
||
// 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);
|
||
float e = 1.0 - pow(1.0 - t, 4.0);
|
||
p = mix(aScatter, p, e);
|
||
|
||
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
||
gl_Position = projectionMatrix * mv;
|
||
float depth = -mv.z;
|
||
|
||
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);
|
||
|
||
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(aTone + lift * 1.6, 0.0, 1.0));
|
||
}
|
||
`;
|
||
|
||
const fragmentShader = /* glsl */ `
|
||
varying vec3 vColor;
|
||
varying float vAlpha;
|
||
varying float vSoft;
|
||
void main() {
|
||
float r = length(gl_PointCoord - 0.5) * 2.0;
|
||
float a = 1.0 - smoothstep(1.0 - vSoft, 1.0, r);
|
||
if (a <= 0.0) discard;
|
||
gl_FragColor = vec4(vColor, a * vAlpha);
|
||
}
|
||
`;
|
||
|
||
const smooth = (a: number, b: number, x: number) => {
|
||
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
|
||
return t * t * (3 - 2 * t);
|
||
};
|
||
|
||
/** 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 mínima entre as camadas no fim do mergulho: cada uma vira um "cômodo" que a câmera visita. */
|
||
const SPREAD = 5.5;
|
||
/** Distância para enquadrar uma camada inteira numa tela larga. */
|
||
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
|
||
* 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.
|
||
*/
|
||
function cameraFrames(rest: number, spread: number, frame: number) {
|
||
return [
|
||
[0, rest],
|
||
[0.28, spread + frame],
|
||
[0.44, spread + frame * 0.87],
|
||
[0.5, spread - 0.3],
|
||
[0.55, frame],
|
||
[0.69, frame * 0.87],
|
||
[0.74, -0.3],
|
||
[0.78, -spread + frame],
|
||
[0.89, -spread + frame * 0.87],
|
||
[0.95, -spread - 0.3],
|
||
[1, -spread - 2],
|
||
] as const;
|
||
}
|
||
|
||
function Field({
|
||
progressRef,
|
||
visible,
|
||
onReady,
|
||
onError,
|
||
}: 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 material = useMemo(
|
||
() =>
|
||
new THREE.ShaderMaterial({
|
||
vertexShader,
|
||
fragmentShader,
|
||
transparent: true,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
uniforms: {
|
||
uTime: { value: 0 },
|
||
uIntro: { value: 0 },
|
||
uSpread: { value: 1.05 },
|
||
uScroll: { value: 0 },
|
||
uScale: { value: 1 },
|
||
uFocus: { value: 9 },
|
||
uFade: { value: 1 },
|
||
uMouse: { value: new THREE.Vector2(9, 9) },
|
||
uMouseAmt: { value: 0 },
|
||
},
|
||
}),
|
||
[],
|
||
);
|
||
const state = useRef({
|
||
progress: 0,
|
||
pointer: new THREE.Vector2(),
|
||
lastMove: -10,
|
||
mouse: new THREE.Vector2(),
|
||
tilt: new THREE.Vector2(),
|
||
frames: 0,
|
||
});
|
||
const helpers = useMemo(
|
||
() => ({
|
||
ray: new THREE.Raycaster(),
|
||
plane: new THREE.Plane(),
|
||
hit: new THREE.Vector3(),
|
||
zero: new THREE.Vector2(),
|
||
target: new THREE.Vector2(),
|
||
}),
|
||
[],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const onMove = (event: PointerEvent) => {
|
||
if (event.pointerType !== "mouse") return;
|
||
const s = state.current;
|
||
s.pointer.set(
|
||
(event.clientX / window.innerWidth) * 2 - 1,
|
||
-(event.clientY / window.innerHeight) * 2 + 1,
|
||
);
|
||
s.lastMove = performance.now() / 1000;
|
||
};
|
||
const onLost = (event: Event) => {
|
||
event.preventDefault();
|
||
onError();
|
||
};
|
||
window.addEventListener("pointermove", onMove, { passive: true });
|
||
gl.domElement.addEventListener("webglcontextlost", onLost);
|
||
return () => {
|
||
window.removeEventListener("pointermove", onMove);
|
||
gl.domElement.removeEventListener("webglcontextlost", onLost);
|
||
geometry.dispose();
|
||
material.dispose();
|
||
};
|
||
}, [gl, geometry, material, onError]);
|
||
|
||
useFrame((frame, delta) => {
|
||
const g = group.current;
|
||
if (!g) return;
|
||
const dt = Math.min(delta, 1 / 20);
|
||
const s = state.current;
|
||
const u = material.uniforms;
|
||
const time = frame.clock.elapsedTime;
|
||
|
||
// 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));
|
||
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),
|
||
);
|
||
// 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 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();
|
||
});
|
||
|
||
return (
|
||
<group ref={group}>
|
||
<points geometry={geometry} material={material} frustumCulled={false} />
|
||
</group>
|
||
);
|
||
}
|
||
|
||
class SceneBoundary extends Component<
|
||
{ children: ReactNode; onError: () => void },
|
||
{ failed: boolean }
|
||
> {
|
||
state = { failed: false };
|
||
static getDerivedStateFromError() {
|
||
return { failed: true };
|
||
}
|
||
componentDidCatch() {
|
||
this.props.onError();
|
||
}
|
||
render() {
|
||
return this.state.failed ? null : this.props.children;
|
||
}
|
||
}
|
||
|
||
export default function HeroFieldScene(props: HeroFieldSceneProps) {
|
||
return (
|
||
<SceneBoundary onError={props.onError}>
|
||
<Canvas
|
||
aria-hidden="true"
|
||
style={{ width: "100%", height: "100%" }}
|
||
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }}
|
||
dpr={[1, 1.5]}
|
||
frameloop="demand"
|
||
gl={{
|
||
alpha: true,
|
||
antialias: false,
|
||
powerPreference: "high-performance",
|
||
}}
|
||
>
|
||
<FramePulse active={props.visible} />
|
||
<Field {...props} />
|
||
</Canvas>
|
||
</SceneBoundary>
|
||
);
|
||
}
|