Vol. 07 · Dispatch2026-06-25

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.

by MetaWorldOS Engineering
WebGLThree.jsReactTypeScriptGraphics

TL;DR — Renderizar meteoros como THREE.LineSegments é o que todo mundo tenta primeiro, e é a escolha errada: o linewidth do WebGL está fixado em 1 pixel de dispositivo em toda GPU de consumidor. A escolha certa é um único THREE.Points com 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

  1. LineBasicMaterial.linewidth é uma mentira. Todo navegador de consumidor fixa em 1 pixel. Não brigue; use Points.
  2. PointsMaterial sem mapa é um quadrado colorido. Gere um sprite suave uma vez com um gradiente radial em canvas, cacheie no módulo, anexe como material.map.
  3. 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.
  4. depthWrite: false para sprites aditivos. Senão eles pulam e fazem z-fight.
  5. Rajadas, não fluxos. Chuvas de meteoros reais têm pausas silenciosas. Adicione um nextSpawnAt a cada slot e o céu respira.
  6. Inicialize refs sincronamente, não em useEffect. Qualquer coisa que o useFrame lê precisa existir antes do primeiro render. Use o padrão if (ref.current === null), nunca o padrão de effect.
  7. 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.

— Read Next —

Recommended Dispatches

More engineering deep-dives into 3D rendering, physics simulation, and game architecture

WebGL

We built a Keplerian solar system in 700 lines of WebGL

How a generic Newton–Raphson solver and React Three Fiber gave us 20+ moons, 9 planets, and a "see the sky on the day you were born" feature for free — plus the refactor that took us from 6,470 → 1,754 lines.

Read dispatch
Three.js

Zero art assets — building every polygon, texture, and shader in code

Pokémon of the Forest ships no GLB files, no PNG textures, no baked normal maps. Every creature is marching-cubes-meshed from Wyvill metaballs at boot; every leaf is a 2D-canvas Bézier fill baked into a CanvasTexture; every terrain patch is an analytic heightfield sampled onto a PlaneGeometry; every skin material is a MeshPhysicalMaterial with a custom subsurface-wrap term injected via onBeforeCompile. This post walks the seven techniques that let a browser game with ~24k lines of TypeScript render Pallet Town without downloading a single texture.

Read dispatch
Three.js

How Infinitown fakes an infinite city with 81 chunks and mod 9

The infinite-scrolling town in our Infinitown gamecenter port isn't procedurally generated — it's a Möbius carpet. A 9×9 pool of pre-built chunks maps onto a 9×9 grid of fixed container slots through modulo arithmetic, and camera drags rebind slots to different pool entries instead of spawning new geometry. This post walks the four moving parts (pool, containers, mapping, drag event) and explains why the whole system holds together with zero allocations at runtime.

Read dispatch

Receba um email quando publicarmos um novo artigo — análises técnicas, ~uma vez por mês.