From 957c58b69afb28b21f0935b2caf484ccb61a8efe Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 27 Aug 2026 19:29:38 -0300 Subject: [PATCH] fix: starfield visibility on load and WebGL fail-soft guard - ParticleBuildText: self-scheduling rAF loop (previous one-shot tick left the canvas blank until scroll), deterministic sampleUntilBuilt warm-up, ambient star layer spread across the whole card with twinkle, random drift and a birth/death lifecycle (4-10s TTL with 800ms fades and respawn) - ControlRoomGlobe: fail-soft WebGL guard (webgl2/webgl check + try/catch around createGlobe) so an unavailable or poisoned context renders the fallback instead of crashing the hero tree --- app/components/cinematic/ControlRoomGlobe.tsx | 64 +++++-- .../cinematic/ParticleBuildText.tsx | 171 ++++++++++++++---- 2 files changed, 177 insertions(+), 58 deletions(-) diff --git a/app/components/cinematic/ControlRoomGlobe.tsx b/app/components/cinematic/ControlRoomGlobe.tsx index fe6b472..2d2ae1a 100644 --- a/app/components/cinematic/ControlRoomGlobe.tsx +++ b/app/components/cinematic/ControlRoomGlobe.tsx @@ -125,26 +125,50 @@ export function ControlRoomGlobe({ observer.disconnect(); }; } - const globe = createGlobe(canvas, { - ...config, - width: width * 2, - height: width * 2, - onRender: (state) => { - if (!isVisibleRef.current || document.hidden) return; - if (autoRotate && !pointerInteracting.current) { - phiRef.current += rotationSpeed; - thetaPhaseRef.current += rotationSpeed * 0.9; - } - state.phi = phiRef.current + rs.get(); - // Rotação livre em todos os eixos: spin (phi) + guinada senoidal (theta) + inclinação por arraste vertical - state.theta = - (config.theta ?? TROPIC_OF_CAPRICORN_RAD) + - Math.sin(thetaPhaseRef.current) * 0.35 + - thetaDragRef.current; - state.width = width * 2; - state.height = width * 2; - }, - }); + // Falha suave: sem WebGL (ou canvas já vinculado a outro tipo de contexto) o phenomenon + // lança "reading 'enable' of null" e derrubaria a árvore React inteira — card ficaria preto. + let globe: ReturnType | null = null; + try { + const supportsWebGL = + canvas.getContext("webgl2") ?? canvas.getContext("webgl"); + if (supportsWebGL) { + globe = createGlobe(canvas, { + ...config, + width: width * 2, + height: width * 2, + onRender: (state) => { + if (!isVisibleRef.current || document.hidden) return; + if (autoRotate && !pointerInteracting.current) { + phiRef.current += rotationSpeed; + thetaPhaseRef.current += rotationSpeed * 0.9; + } + state.phi = phiRef.current + rs.get(); + // Rotação livre em todos os eixos: spin (phi) + guinada senoidal (theta) + inclinação por arraste vertical + state.theta = + (config.theta ?? TROPIC_OF_CAPRICORN_RAD) + + Math.sin(thetaPhaseRef.current) * 0.35 + + thetaDragRef.current; + state.width = width * 2; + state.height = width * 2; + }, + }); + } + } catch { + globe = null; + } + + if (!globe) { + // Sem globo: fallback visível (gradiente do card permanece), página viva. + canvas.style.opacity = "1"; + return () => { + window.removeEventListener("resize", onResize); + document.removeEventListener( + "visibilitychange", + handleVisibilityChange, + ); + observer.disconnect(); + }; + } setTimeout(() => { if (canvasRef.current) { diff --git a/app/components/cinematic/ParticleBuildText.tsx b/app/components/cinematic/ParticleBuildText.tsx index f29fcc8..a153589 100644 --- a/app/components/cinematic/ParticleBuildText.tsx +++ b/app/components/cinematic/ParticleBuildText.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { useEffect, useRef } from "react"; @@ -20,11 +20,15 @@ type Particle = { ox: number; oy: number; delay: number; - color: string; + fillStyle: string; + baseAlpha: number; + ambient: boolean; size: number; phase: number; speed: number; amp: number; + ttl: number; + birth: number; }; type TrackingCapableContext = CanvasRenderingContext2D & { @@ -97,6 +101,8 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { let running = false; let cancelled = false; let resampleTimer = 0; + let sampleRetryTimer = 0; + let sampleAttempts = 0; let particles: Particle[] = []; let hasAssembled = false; let elapsed = 0; @@ -112,7 +118,7 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { const paperRgb = hexToRgb(rootStyles.getPropertyValue("--paper")) ?? FALLBACK_PAPER; - // next/font gera nome de família com hash: resolver pela classe font-display real + // next/font gera nome de família com hash: resolver pela classe font-display real const probe = document.createElement("span"); probe.className = "font-display"; probe.style.position = "absolute"; @@ -141,7 +147,7 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { offscreen.height = canvas.height; const offCtx = offscreen.getContext("2d"); if (!offCtx) return; - // Backing store é device-pixel: escala p/ que coordenadas CSS preencham o painel inteiro + // Backing store é device-pixel: escala p/ que coordenadas CSS preencham o painel inteiro offCtx.scale(dpr, dpr); const maxWidth = width * 0.92; @@ -187,7 +193,46 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { } } } - if (targets.length === 0) return; + // Ambient stars cover the whole card so the background reads as a living + // starfield even where the globe covers the headline text shape + const makeParticle = ( + tx: number, + ty: number, + ambient: boolean, + ): Particle => { + const useSlash = Math.random() < (ambient ? 0.45 : 0.72); + const rgb = useSlash ? slashRgb : paperRgb; + return { + tx, + ty, + sx: hasAssembled ? tx : (Math.random() * 1.3 - 0.15) * canvas.width, + sy: hasAssembled ? ty : (Math.random() * 1.3 - 0.15) * canvas.height, + ox: 0, + oy: 0, + delay: Math.random() * 0.35, + fillStyle: `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`, + baseAlpha: ambient + ? 0.6 + Math.random() * 0.4 + : useSlash + ? 0.45 + Math.random() * 0.3 + : 0.4 + Math.random() * 0.2, + ambient, + size: + (ambient + ? Math.random() < 0.7 + ? 2 + : 3 + : Math.random() < 0.85 + ? 1 + : 2) * dpr, + phase: Math.random() * Math.PI * 2, + speed: 0.0005 + Math.random() * 0.0007, + amp: (ambient ? 2 + Math.random() * 4 : 0.6 + Math.random()) * dpr, + ttl: 4000 + Math.random() * 6000, + // birth negativo: estagia os ciclos de vida para o spawn/death ja rodar disperso no load + birth: -Math.random() * 10000, + }; + }; const desired = Math.round( Math.min( @@ -196,32 +241,21 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { ), ); - particles = Array.from({ length: desired }, () => { + const textParticles = Array.from({ length: desired }, () => { const target = targets[Math.floor(Math.random() * targets.length)]; - const useSlash = Math.random() < 0.72; - const rgb = useSlash ? slashRgb : paperRgb; - const alpha = useSlash - ? 0.28 + Math.random() * 0.22 - : 0.25 + Math.random() * 0.15; - return { - tx: target[0], - ty: target[1], - sx: hasAssembled - ? target[0] - : (Math.random() * 1.3 - 0.15) * canvas.width, - sy: hasAssembled - ? target[1] - : (Math.random() * 1.3 - 0.15) * canvas.height, - ox: 0, - oy: 0, - delay: Math.random() * 0.35, - color: `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha.toFixed(2)})`, - size: (Math.random() < 0.85 ? 1 : 2) * dpr, - phase: Math.random() * Math.PI * 2, - speed: 0.0005 + Math.random() * 0.0007, - amp: (0.6 + Math.random()) * dpr, - }; + return makeParticle(target[0], target[1], false); }); + + const ambientCount = Math.round( + Math.min(520, Math.max(200, (width * height) / 2500)), + ); + const ambient: Particle[] = Array.from({ length: ambientCount }, () => { + const tx = Math.random() * canvas.width; + const ty = Math.random() * canvas.height; + return makeParticle(tx, ty, true); + }); + + particles = [...textParticles, ...ambient]; }; const render = () => { @@ -267,7 +301,33 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { particle.ox *= 0.86; particle.oy *= 0.86; - ctx.fillStyle = particle.color; + const fadeIn = Math.min(elapsed / 1200, 1); + const twinkle = particle.ambient + ? 0.55 + 0.45 * Math.sin(elapsed * 0.004 + particle.phase) + : 0.82 + 0.18 * Math.sin(elapsed * particle.speed + particle.phase); + let lifeFade = 1; + if (particle.ambient) { + let life = elapsed - particle.birth; + if (life > particle.ttl) { + particle.tx = Math.random() * canvas.width; + particle.ty = Math.random() * canvas.height; + particle.sx = particle.tx; + particle.sy = particle.ty; + particle.phase = Math.random() * Math.PI * 2; + particle.baseAlpha = 0.6 + Math.random() * 0.4; + particle.size = (Math.random() < 0.7 ? 2 : 3) * dpr; + particle.ttl = 4000 + Math.random() * 6000; + particle.birth = elapsed; + life = 0; + } + // fade-in de 800ms ao nascer, fade-out nos ultimos 800ms de vida + lifeFade = Math.min(life / 800, (particle.ttl - life) / 800, 1); + } + ctx.globalAlpha = Math.min( + 1, + particle.baseAlpha * fadeIn * twinkle * lifeFade, + ); + ctx.fillStyle = particle.fillStyle; ctx.fillRect( baseX + driftX + particle.ox, baseY + driftY + particle.oy, @@ -276,21 +336,27 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { ); } + ctx.globalAlpha = 1; + if (allSettled && particles.length > 0) hasAssembled = true; }; const drawStaticFrame = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); for (const particle of particles) { - ctx.fillStyle = particle.color; + ctx.fillStyle = particle.fillStyle; + ctx.globalAlpha = particle.baseAlpha; ctx.fillRect(particle.tx, particle.ty, particle.size, particle.size); } + ctx.globalAlpha = 1; }; const tick = (now: number) => { + if (!running) return; elapsed += Math.min(now - lastTime, 50); lastTime = now; render(); + rafId = window.requestAnimationFrame(tick); }; const syncLoop = () => { @@ -305,11 +371,27 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { } }; + // Warm-up determinístico: corrida entre fontes/layout pode deixar o + // container com 0px no primeiro sample; re-tenta até existirem partículas + const sampleUntilBuilt = (onBuilt?: () => void) => { + sample(); + if (particles.length > 0 || cancelled) { + onBuilt?.(); + return; + } + if (sampleAttempts++ > 50) return; + sampleRetryTimer = window.setTimeout( + () => sampleUntilBuilt(onBuilt), + 120, + ); + }; + const scheduleResample = () => { window.clearTimeout(resampleTimer); resampleTimer = window.setTimeout(() => { - sample(); - if (reducedMotion) drawStaticFrame(); + sampleUntilBuilt(() => { + if (reducedMotion) drawStaticFrame(); + }); }, 150); }; @@ -332,21 +414,34 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { resizeObserver.observe(container); if (reducedMotion) { - // Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro + // Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro fontsReady.then(() => { if (cancelled) return; hasAssembled = true; - sample(); - drawStaticFrame(); + sampleUntilBuilt(() => drawStaticFrame()); }); return () => { cancelled = true; window.clearTimeout(resampleTimer); + window.clearTimeout(sampleRetryTimer); resizeObserver.disconnect(); }; } + // Start determinístico: intersecting derivado de geometry no setup (o callback do IO + // pode chegar tarde/fora de ordem com fontsReady e deixar o loop rAF morto na carga) + const syncIntersecting = () => { + const current = container.getBoundingClientRect(); + intersecting = + current.bottom > 0 && + current.right > 0 && + current.top < window.innerHeight && + current.left < window.innerWidth; + }; + syncIntersecting(); + syncLoop(); + const intersectionObserver = new IntersectionObserver((entries) => { intersecting = entries.some((entry) => entry.isIntersecting); syncLoop(); @@ -365,14 +460,14 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { fontsReady.then(() => { if (cancelled) return; - sample(); - syncLoop(); + sampleUntilBuilt(() => syncLoop()); }); return () => { cancelled = true; window.cancelAnimationFrame(rafId); window.clearTimeout(resampleTimer); + window.clearTimeout(sampleRetryTimer); resizeObserver.disconnect(); intersectionObserver.disconnect(); window.removeEventListener("pointermove", handlePointerMove);