Vol. 07 · Dispatch2026-06-25

Real-time meteor showers in WebGL — from pixel grids to silk streaks

Why LineSegments don't work for meteors, why naive Points look like a grid of squares, and how a 64×64 canvas-generated radial-gradient sprite + a dormant-spawn lifecycle gives you a beautiful 18-meteor shower in 175 lines and one draw call.

by MetaWorldOS Engineering
WebGLThree.jsReactTypeScriptGraphics

TL;DR — Rendering meteors as THREE.LineSegments is what everyone tries first, and it’s the wrong call: WebGL’s linewidth is pinned to 1 device pixel on every consumer GPU. The right call is a single THREE.Points with a soft circular sprite. The right shape for the lifecycle is bursts with dormant gaps, not a constant stream. This post walks through both, with the actual production code from MetaWorldOS’s Birthday Sky scene.


When we built the Birthday Sky scene we wanted meteors. Not a dome of stars — a scattering of streaks slowly arcing past Earth, dramatic but quiet. The kind of thing that makes someone open the page on their birthday and forget they meant to close the tab.

The first version was bad in three specific ways, and each one taught us something. Here’s all three.


Attempt 1: LineSegments — the obvious-and-wrong answer

A meteor is a streak. A streak is a line. So:

const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.LineBasicMaterial({
    color: 0xffffff,
    linewidth: 4,        // ← lies to you
    transparent: true,
});
const meteors = new THREE.LineSegments(geometry, material);

This looks right in the docs. It does not work in your browser.

The reason is buried in the WebGL spec: LineBasicMaterial.linewidth is a request, not a guarantee. The OpenGL ES 2.0 spec (which WebGL 1 implements) says implementations may support any line width “≥ 1”, and every major browser ships with the minimum: exactly 1 device pixel. Your linewidth: 4 becomes linewidth: 1, silently. On a Retina display that’s a half-pixel hairline, on a 4K monitor it’s basically invisible at typical viewing distance.

You can verify this on your own machine in the console:

const gl = document.createElement("canvas").getContext("webgl");
console.log(gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE));
// → [1, 1] on every browser I've checked.

You cannot fix this with a material setting. You can fix it by not using lines.


Attempt 2: Points — works, but looks like a grid

Switch to THREE.Points. Each point has a configurable on-screen size that the GPU actually honors:

const material = new THREE.PointsMaterial({
    size: 0.08,
    sizeAttenuation: true,    // points get smaller with distance — correct
    transparent: true,
    vertexColors: true,
    blending: THREE.AdditiveBlending,
});

To make a streak, instead of two endpoints we lay down 8–10 points per meteor, fading from a bright head to a transparent tail, all packed into a single Points object:

const POINTS_PER_METEOR = 8;
for (let p = 0; p < POINTS_PER_METEOR; p++) {
    const u = p / (POINTS_PER_METEOR - 1);
    // p=0 is head, p=N-1 is tail
    pt.copy(head).addScaledVector(direction, -trailLen * u);
    // ... write to position + color buffers
}

This renders. It’s also visibly wrong: each point is a square sprite, the default for PointsMaterial without a texture map. Eight squares in a row don’t look like a streak, they look like an 8-bit pixel art bullet, or a row of grid cells. Users (correctly) said: “looks like little squares.”

This is where the post you’re reading was triggered.


Attempt 3: Points + a canvas-generated soft sprite

The fix is to give the material a map — a transparency texture that turns each point from a hard-edged square into a feathered round dot. With additive blending and 8–16 overlapping soft dots, the streak reads as a single smooth gradient.

You don’t need to ship a PNG. Generate the sprite in a 64×64 canvas at runtime:

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);
}

The gradient stops matter more than they look. A linear fade (0 → 1 from center to edge) gives you a soft disk with a hard boundary at the edge — visible as a faint ring. The 4-stop gradient above gives you a gaussian-ish falloff: bright core, soft halo, vanishing edge. With additive blending, 8 of them along a streak compose into something that reads as a single light source bleeding across the trail.

The material with the map attached:

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,           // skip fragments that are essentially transparent
});

depthWrite: false matters: additive sprites should not write to the depth buffer or they’ll occlude each other in glitchy ways. alphaTest: 0.01 matters: it skips fragments below 1% alpha, which is most of the sprite’s bounding box, and roughly doubles fillrate on dense scenes.

This is the version that ships. With 18 meteors × 16 points each = 288 points in a single draw call, the entire shower runs in under 0.5 ms per frame on a mid-2020s laptop.


The lifecycle: bursts, not a constant stream

Visuals fixed. But users said: “it’s too flashy and there’s always one flying.”

The first version respawned each meteor the instant it died:

if (now - m.spawnTime > m.lifespan) {
    meteors[i] = spawnMeteor(now, 0);   // immediate respawn
}

With 18 slots each cycling every ~1.5 s, the sky always had ~12 active meteors at any moment. Statistically uniform. Visually exhausting. Real meteor showers don’t look like this — they have bursts with quiet gaps.

The fix: give each slot a dormant period after death, computed at spawn time:

interface Meteor {
    start: THREE.Vector3;
    velocity: THREE.Vector3;
    spawnTime: number;
    lifespan: number;
    nextSpawnAt: number;   // earliest time this slot may respawn
    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(),
    };
}

And the render loop honors it:

const age = now - m.spawnTime;
if (age > m.lifespan) {
    if (now < m.nextSpawnAt) {
        // Slot is dormant — zero out its points and continue.
        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;
}

With RESPAWN_DELAY in [1.5 s, 5.0 s], the typical sky has 4–8 active meteors instead of 12, with periods where only one or two streak past. The shower breathes. Users stopped saying it was flashy.

The tuning matrix that landed us there:

Knob Value Effect
METEOR_COUNT 18 Slots available — most are dormant at any moment
POINTS_PER_METEOR 16 Per-streak smoothness; 8 was too granular
TRAIL_LENGTH 0.28 Scene units; ~0.5× Earth radius
SPEED_MIN/MAX (units/s) 1.0 / 1.8 Slower = more graceful; original 2.5–5.0 felt like ammo tracers
LIFESPAN_MIN/MAX (s) 1.4 / 2.2 Full-life duration of one streak
RESPAWN_DELAY_MIN/MAX (s) 1.5 / 5.0 The quiet gap that lets the eye rest
material.size 0.12 Apparent size on screen
material.opacity 0.9 Below 1.0 so additive overlaps don’t blow out

One subtle bug worth flagging

When you initialize the meteor pool in React Three Fiber, the obvious place is useEffect:

useEffect(() => {
    meteorsRef.current = Array.from({ length: METEOR_COUNT }, () => spawnMeteor(now, 0));
}, []);

This is wrong by exactly one frame. useEffect runs after the first render. The first useFrame tick fires between those two events, accesses meteorsRef.current[i], gets undefined, and throws TypeError: Cannot read properties of undefined (reading 'spawnTime'). The scene blanks out, the console screams, and your bug report says “meteor shower randomly breaks on load.”

The fix is to initialize synchronously during render, with a ref-init pattern:

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)
    );
}

The if (current === null) guard runs once per mount (refs don’t reset across re-renders), and the assignment happens before render returns, so useFrame can never see undefined. The stagger expression (agePreroll) ensures the initial 18 meteors aren’t all spawned at the same instant — without it, they all die and respawn in lockstep and the sky pulses.


What it weighs

Section Lines
Meteor lifecycle (spawn, dormant gap, respawn) ~70
Per-frame render loop (positions + colors) ~50
Soft sprite generator (canvas radial gradient) ~25
Wiring (anchor lookup, geometry/material setup) ~30
Total ~175

One file, one draw call, 18 meteors, 60 FPS. Compose with anything — we hang it off Earth’s position so it tracks Earth along its orbit.


Lessons for your past self

  1. LineBasicMaterial.linewidth is a lie. Every consumer browser pins it to 1 pixel. Don’t fight it; use Points.
  2. PointsMaterial without a map is a colored square. Generate a soft sprite once with a canvas radial gradient, cache it on the module, attach as material.map.
  3. Additive blending + soft sprites = streaks. Eight or sixteen overlapping soft dots along a direction read as a single smooth streak, no shader required.
  4. depthWrite: false for additive sprites. Or they’ll pop and z-fight.
  5. Bursts, not streams. Real-world meteor showers have quiet gaps. Add a nextSpawnAt to each slot and the sky breathes.
  6. Initialize refs synchronously, not in useEffect. Anything useFrame reads must exist before the first render. Use the if (ref.current === null) ref-init pattern, never the effect pattern.
  7. Stagger initial spawn times. Otherwise all your slots die and respawn together — the sky pulses instead of flowing.

Try it

The shower runs in the Birthday Sky scene — pick any date, the meteors anchor to Earth and stream past Earth’s night side. The same renderer works at any point in time the TimeManager is scrubbed to, so a “what did the sky look like on my birthday” view gets meteors as a free bonus.

If you’re building a particle scene and have questions — about the sprite generation, the lifecycle gap, the React Three Fiber init pattern — reach out via 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

Get an email when we publish a new post — engineering deep-dives, ~once a month, no marketing.