"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"; 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; }; /* * 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. */ 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; } // 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( () => 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(), anchor: 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: à direita do texto na tela larga; no celular, mais abaixo, atrás dos botões g.position.set( wide ? 1.7 * settle : 0, (wide ? -0.1 : -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)); // 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(); }); return ( ); } 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 ( ); }