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
This commit is contained in:
Evandro Fonseca Junior committed 2026-08-27 19:29:38 -03:00
1 parent b791b7de9e
commit 957c58b69a
2 files changed
+177 -58

No files matched your search

+44 -20
View File
@@ -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<typeof createGlobe> | 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) {
+133 -38
View File
@@ -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);