Chuvas de meteoros em tempo real no WebGL — de grades de pixels a riscos de seda
Por que LineSegments não funciona para meteoros, por que Points ingênuo parece uma grade de quadradinhos, e como um sprite de gradiente radial gerado em canvas 64×64 + um ciclo de vida com pausas dormentes dá uma linda chuva de 18 meteoros em 175 linhas e uma draw call.
TL;DR — Renderizar meteoros como
THREE.LineSegmentsé o que todo mundo tenta primeiro, e é a escolha errada: olinewidthdo WebGL está fixado em 1 pixel de dispositivo em toda GPU de consumidor. A escolha certa é um únicoTHREE.Pointscom um sprite circular suave. A forma certa para o ciclo de vida são rajadas com pausas dormentes, não um fluxo constante. Este post passa pelos dois, com o código de produção real da cena Birthday Sky do MetaWorldOS.
Quando construímos a cena Birthday Sky, queríamos meteoros. Não uma cúpula de estrelas — um espalhamento de riscos arqueando lentamente pela Terra, dramáticos mas silenciosos. O tipo de coisa que faz alguém abrir a página no dia do aniversário e esquecer que ia fechar a aba.
A primeira versão estava ruim em três formas específicas, e cada uma nos ensinou algo. Aqui estão as três.
Tentativa 1: LineSegments — a resposta óbvia-e-errada
Um meteoro é um risco. Um risco é uma linha. Então:
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.LineBasicMaterial({
color: 0xffffff,
linewidth: 4, // ← mente para você
transparent: true,
});
const meteors = new THREE.LineSegments(geometry, material);
Isso parece certo na documentação. Não funciona no seu navegador.
A razão está enterrada na spec do WebGL: LineBasicMaterial.linewidth é um pedido, não uma garantia. A spec do OpenGL ES 2.0 (que o WebGL 1 implementa) diz que implementações podem suportar qualquer largura de linha “≥ 1”, e todo navegador major distribui o mínimo: exatamente 1 pixel de dispositivo. Seu linewidth: 4 vira linewidth: 1, silenciosamente. Num display Retina é um fiozinho de meio pixel; num monitor 4K é basicamente invisível em distância típica.
Você pode verificar isso na sua própria máquina no console:
const gl = document.createElement("canvas").getContext("webgl");
console.log(gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE));
// → [1, 1] em todo navegador que verifiquei.
Você não conserta isso com uma configuração de material. Conserta não usando linhas.
Tentativa 2: Points — funciona, mas parece uma grade
Mude para THREE.Points. Cada ponto tem um tamanho de tela configurável que a GPU efetivamente respeita:
const material = new THREE.PointsMaterial({
size: 0.08,
sizeAttenuation: true, // pontos ficam menores com distância — correto
transparent: true,
vertexColors: true,
blending: THREE.AdditiveBlending,
});
Para fazer um risco, em vez de dois endpoints, colocamos 8–10 pontos por meteoro, esmaecendo de uma cabeça brilhante até uma cauda transparente, todos empacotados num único objeto Points:
const POINTS_PER_METEOR = 8;
for (let p = 0; p < POINTS_PER_METEOR; p++) {
const u = p / (POINTS_PER_METEOR - 1);
// p=0 é a cabeça, p=N-1 é a cauda
pt.copy(head).addScaledVector(direction, -trailLen * u);
// ... escreve nos buffers de position + color
}
Isso renderiza. Também está visivelmente errado: cada ponto é um sprite quadrado, o padrão do PointsMaterial sem mapa de textura. Oito quadrados em fila não parecem um risco, parecem uma bala de pixel art 8-bit, ou uma fileira de células de grade. Usuários (corretamente) disseram: “parece quadradinhos.”
É aqui que o post que você está lendo foi disparado.
Tentativa 3: Points + um sprite suave gerado em canvas
A correção é dar ao material um map — uma textura de transparência que transforma cada ponto de um quadrado com borda dura em um pontinho redondo com bordas suaves. Com additive blending e 8–16 pontinhos sobrepostos, o risco se lê como um único gradiente suave.
Você não precisa empacotar um PNG. Gere o sprite num canvas 64×64 em tempo de execução:
function makeSoftCircleTexture(): THREE.Texture {
const size = 64;
const canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
const ctx = canvas.getContext("2d")!;
const cx = size / 2;
const gradient = ctx.createRadialGradient(cx, cx, 0, cx, cx, cx);
gradient.addColorStop(0.00, "rgba(255,255,255,1.0)");
gradient.addColorStop(0.25, "rgba(255,255,255,0.85)");
gradient.addColorStop(0.55, "rgba(255,255,255,0.35)");
gradient.addColorStop(1.00, "rgba(255,255,255,0.0)");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
return new THREE.CanvasTexture(canvas);
}
Os pontos de parada do gradiente importam mais do que parecem. Um fade linear (0 → 1 do centro para a borda) te dá um disco suave com fronteira dura na borda — visível como um anel fraco. O gradiente de 4 paradas acima te dá uma queda quase gaussiana: núcleo brilhante, halo suave, borda evanescente. Com additive blending, 8 deles ao longo de um risco compõem algo que se lê como uma única fonte de luz sangrando ao longo da trilha.
O material com o map anexado:
const material = new THREE.PointsMaterial({
vertexColors: true,
size: 0.12,
sizeAttenuation: true,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false,
map: makeSoftCircleTexture(),
alphaTest: 0.01, // pula fragments essencialmente transparentes
});
depthWrite: false importa: sprites aditivos não devem escrever no depth buffer ou vão se ocluir de forma esquisita. alphaTest: 0.01 importa: pula fragments abaixo de 1% de alpha, o que cobre a maior parte da bounding box do sprite, e roughly dobra o fillrate em cenas densas.
Essa é a versão que vai para produção. Com 18 meteoros × 16 pontos cada = 288 pontos numa única draw call, a chuva inteira roda em menos de 0,5 ms por frame num laptop dos anos 2020.
O ciclo de vida: rajadas, não um fluxo constante
Visual resolvido. Mas usuários disseram: “está espalhafatoso demais, sempre tem um voando.”
A primeira versão respawnava cada meteoro no instante que ele morria:
if (now - m.spawnTime > m.lifespan) {
meteors[i] = spawnMeteor(now, 0); // respawn imediato
}
Com 18 slots, cada um ciclando a cada ~1,5 s, o céu sempre tinha ~12 meteoros ativos a cada momento. Estatisticamente uniforme. Visualmente exaustivo. Chuvas de meteoros reais não parecem isso — elas têm rajadas com pausas silenciosas.
A correção: dê a cada slot um período dormente após a morte, calculado no momento do spawn:
interface Meteor {
start: THREE.Vector3;
velocity: THREE.Vector3;
spawnTime: number;
lifespan: number;
nextSpawnAt: number; // mais cedo que esse slot pode respawnar
hueWarm: number;
}
function spawnMeteor(now: number, agePreroll: number): Meteor {
const lifespan = LIFESPAN_MIN_S + Math.random() * (LIFESPAN_MAX_S - LIFESPAN_MIN_S);
const respawnDelay =
RESPAWN_DELAY_MIN_S + Math.random() * (RESPAWN_DELAY_MAX_S - RESPAWN_DELAY_MIN_S);
return {
// ...
spawnTime: now - agePreroll,
lifespan,
nextSpawnAt: now - agePreroll + lifespan + respawnDelay,
hueWarm: Math.random(),
};
}
E o render loop respeita isso:
const age = now - m.spawnTime;
if (age > m.lifespan) {
if (now < m.nextSpawnAt) {
// Slot está dormente — zera seus pontos e continua.
for (let p = 0; p < POINTS_PER_METEOR; p++) {
const vi = (i * POINTS_PER_METEOR + p) * 3;
colors[vi] = colors[vi + 1] = colors[vi + 2] = 0;
}
continue;
}
m = spawnMeteor(now, 0);
meteors[i] = m;
}
Com RESPAWN_DELAY em [1,5 s, 5,0 s], o céu típico tem 4–8 meteoros ativos em vez de 12, com períodos onde apenas um ou dois cruzam. A chuva respira. Usuários pararam de dizer que estava espalhafatosa.
A matriz de ajustes que nos levou até lá:
| Botão | Valor | Efeito |
|---|---|---|
METEOR_COUNT |
18 | Slots disponíveis — a maioria está dormente a qualquer momento |
POINTS_PER_METEOR |
16 | Suavidade por risco; 8 era granular demais |
TRAIL_LENGTH |
0,28 | Unidades de cena; ~0,5× raio da Terra |
SPEED_MIN/MAX (unid/s) |
1,0 / 1,8 | Mais lento = mais gracioso; o original 2,5–5,0 parecia traçante de munição |
LIFESPAN_MIN/MAX (s) |
1,4 / 2,2 | Duração de vida completa de um risco |
RESPAWN_DELAY_MIN/MAX (s) |
1,5 / 5,0 | A pausa silenciosa que deixa o olho descansar |
material.size |
0,12 | Tamanho aparente na tela |
material.opacity |
0,9 | Abaixo de 1,0 para que sobreposições aditivas não estourem |
Um bug sutil que vale a pena sinalizar
Quando você inicializa o pool de meteoros no React Three Fiber, o lugar óbvio é o useEffect:
useEffect(() => {
meteorsRef.current = Array.from({ length: METEOR_COUNT }, () => spawnMeteor(now, 0));
}, []);
Isso está errado por exatamente um frame. O useEffect roda depois do primeiro render. O primeiro tick do useFrame dispara entre esses dois eventos, acessa meteorsRef.current[i], recebe undefined e joga TypeError: Cannot read properties of undefined (reading 'spawnTime'). A cena some, o console grita, e seu bug report diz “a chuva de meteoros quebra aleatoriamente no load.”
A correção é inicializar sincronamente durante o render, com um padrão de ref-init:
const meteorsRef = useRef<Meteor[] | null>(null);
if (meteorsRef.current === null) {
const now = performance.now() / 1000;
meteorsRef.current = Array.from({ length: METEOR_COUNT }, (_, i) =>
spawnMeteor(now, (i / METEOR_COUNT) * LIFESPAN_MAX_S * 2 + Math.random() * 2)
);
}
A guarda if (current === null) roda uma vez por mount (refs não resetam entre re-renders), e a atribuição acontece antes do return do render, então o useFrame nunca vai ver undefined. A expressão de stagger (agePreroll) garante que os 18 meteoros iniciais não sejam todos spawnados no mesmo instante — sem isso, todos morrem e respawnam em lockstep e o céu pulsa.
Quanto pesa
| Seção | Linhas |
|---|---|
| Ciclo de vida do meteoro (spawn, pausa dormente, respawn) | ~70 |
| Render loop por frame (positions + colors) | ~50 |
| Gerador de sprite suave (canvas radial gradient) | ~25 |
| Cola (lookup de âncora, setup de geometry/material) | ~30 |
| Total | ~175 |
Um arquivo, uma draw call, 18 meteoros, 60 FPS. Compõe com qualquer coisa — penduramos da posição da Terra para que acompanhe a Terra ao longo da órbita.
Lições para o seu eu do passado
LineBasicMaterial.linewidthé uma mentira. Todo navegador de consumidor fixa em 1 pixel. Não brigue; usePoints.PointsMaterialsem mapa é um quadrado colorido. Gere um sprite suave uma vez com um gradiente radial em canvas, cacheie no módulo, anexe comomaterial.map.- Additive blending + sprites suaves = riscos. Oito ou dezesseis pontinhos suaves sobrepostos ao longo de uma direção se leem como um único risco suave, sem precisar de shader.
depthWrite: falsepara sprites aditivos. Senão eles pulam e fazem z-fight.- Rajadas, não fluxos. Chuvas de meteoros reais têm pausas silenciosas. Adicione um
nextSpawnAta cada slot e o céu respira. - Inicialize refs sincronamente, não em
useEffect. Qualquer coisa que ouseFramelê precisa existir antes do primeiro render. Use o padrãoif (ref.current === null), nunca o padrão de effect. - Distribua os tempos iniciais de spawn. Senão todos os seus slots morrem e respawnam juntos — o céu pulsa em vez de fluir.
Experimente
A chuva roda na cena Birthday Sky — escolha qualquer data, os meteoros se ancoram na Terra e cruzam o lado noturno do planeta. O mesmo renderer funciona em qualquer instante para o qual o TimeManager seja arrastado, então uma visualização do tipo “como o céu estava no meu aniversário” ganha meteoros como bônus de graça.
Se você está construindo uma cena de partículas e tem dúvidas — sobre a geração do sprite, a pausa do ciclo de vida, o padrão de init do React Three Fiber — entre em contato pelo Contact.