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.
TL;DR — Rendering meteors as
THREE.LineSegmentsis what everyone tries first, and it’s the wrong call: WebGL’slinewidthis pinned to 1 device pixel on every consumer GPU. The right call is a singleTHREE.Pointswith 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
LineBasicMaterial.linewidthis a lie. Every consumer browser pins it to 1 pixel. Don’t fight it; usePoints.PointsMaterialwithout a map is a colored square. Generate a soft sprite once with a canvas radial gradient, cache it on the module, attach asmaterial.map.- Additive blending + soft sprites = streaks. Eight or sixteen overlapping soft dots along a direction read as a single smooth streak, no shader required.
depthWrite: falsefor additive sprites. Or they’ll pop and z-fight.- Bursts, not streams. Real-world meteor showers have quiet gaps. Add a
nextSpawnAtto each slot and the sky breathes. - Initialize refs synchronously, not in
useEffect. AnythinguseFramereads must exist before the first render. Use theif (ref.current === null)ref-init pattern, never the effect pattern. - 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.