obOB STUDIO
Web Tech

Drawing Orbital Trails That Don't Kill Performance

5 min readcosmic-gravity-sandbox

My framerate dropped to 4 FPS and I deserved it

I had just finished wiring up the N-body physics for Cosmic Gravity Sandbox. Bodies were orbiting, gravity was working, collisions were merging masses — everything felt right. Then I thought, "It'd look sick if each body left a glowing trail behind it."

So I did the most obvious thing. Every frame, I pushed the body's position into an array. Then I drew a circle at every stored position. With 20 bodies and trails 500 points long, that's 10,000 arc() calls per frame.

The framerate cratered. My laptop fan sounded like a jet engine. I deserved it.

The first fix: cap the trail length

The simplest optimisation is also the most effective. Don't keep infinite trail history. I settled on a maximum of 300 positions per body. When the array hits the limit, I shift the oldest one off:

body.trail.push({ x: body.x, y: body.y });
if (body.trail.length > MAX_TRAIL_LENGTH) {
  body.trail.shift();
}

This alone cut the draw calls dramatically. But I was still calling ctx.arc() for every point. That's still too many individual draw operations.

Batch the draw: one path, one stroke

The real trick is to stop drawing individual dots and instead draw the trail as a single continuous path. Canvas 2D is fast when you batch operations into one beginPath() → moveTo → lineTo → stroke() sequence. It's agonisingly slow when you call beginPath() hundreds of times per frame.

function drawTrail(ctx, trail, color) {
  if (trail.length < 2) return;

  ctx.beginPath();
  ctx.moveTo(trail[0].x, trail[0].y);

  for (let i = 1; i < trail.length; i++) {
    ctx.lineTo(trail[i].x, trail[i].y);
  }

  ctx.strokeStyle = color;
  ctx.lineWidth = 1.5;
  ctx.stroke();
}

One beginPath(), one stroke(). The GPU-backed rasteriser chews through a single path of 300 segments like nothing. This was the biggest win — framerate went from "slideshow" to buttery smooth.

Fading the tail with alpha

A solid-colour trail looks flat and boring. You want the tail to fade out, so the oldest positions are nearly invisible and the newest ones are vivid. The cheap way to do this is to break the trail into segments and set a different strokeStyle alpha for each one:

function drawFadingTrail(ctx, trail, r, g, b) {
  if (trail.length < 2) return;

  const len = trail.length;
  const segmentSize = Math.max(1, Math.floor(len / 8));

  for (let s = 0; s < len - 1; s += segmentSize) {
    const end = Math.min(s + segmentSize + 1, len);
    const alpha = (s / len) * 0.8 + 0.05; // 0.05 → 0.85

    ctx.beginPath();
    ctx.moveTo(trail[s].x, trail[s].y);
    for (let i = s + 1; i < end; i++) {
      ctx.lineTo(trail[i].x, trail[i].y);
    }

    ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
    ctx.lineWidth = 1.5;
    ctx.stroke();
  }
}

I group the trail into 8 segments and set a different alpha for each. This gives the illusion of a smooth fade with only 8 beginPath/stroke pairs instead of 300. The eye can't tell the difference.

The glow effect: stacked strokes

This is the part that made people say "whoa" when they first opened the sandbox. The glow effect is surprisingly simple — it's just multiple overlapping strokes of the same path, each one wider and more transparent:

function drawGlowTrail(ctx, trail, r, g, b) {
  if (trail.length < 2) return;

  // Build the path once
  const path = new Path2D();
  path.moveTo(trail[0].x, trail[0].y);
  for (let i = 1; i < trail.length; i++) {
    path.lineTo(trail[i].x, trail[i].y);
  }

  // Draw multiple layers: wide + faint on the outside, narrow + bright inside
  const layers = [
    { width: 8, alpha: 0.04 },
    { width: 5, alpha: 0.08 },
    { width: 3, alpha: 0.15 },
    { width: 1.5, alpha: 0.6 },
  ];

  for (const layer of layers) {
    ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${layer.alpha})`;
    ctx.lineWidth = layer.width;
    ctx.stroke(path);
  }
}

Four strokes per trail. The outermost layer is 8px wide at 4% opacity — barely visible, but it gives that soft halo. The innermost is 1.5px at 60% — the bright core. Layer them up and you get a neon glow effect that looks like it's running through a GPU shader, but it's just Canvas 2D being clever.

Using Path2D here is a nice optimisation — you build the path geometry once and stroke it multiple times. Without it, you'd need to call moveTo and lineTo four times over.

Other tricks that helped

Skip frames for trail recording. I don't push a position every frame. I push one every 3 frames. The trail looks just as smooth visually, but the array grows 3× slower. This means the trail covers more history before hitting the length cap.

Round coordinates. Sub-pixel rendering is expensive. Rounding trail positions to the nearest integer gave me a measurable FPS boost:

body.trail.push({
  x: Math.round(body.x),
  y: Math.round(body.y),
});

Use `globalCompositeOperation = 'lighter'`. This additive blending mode makes overlapping trails glow brighter where they cross. Two blue trails crossing look white-hot at the intersection. It's free — one line of code, zero performance cost.

The summary

  • Cap trail length (300 points max)
  • Draw as a single Path2D, not individual arcs
  • Fade by splitting into ~8 alpha-graded segments
  • Glow = 4 overlapping strokes at decreasing alpha / increasing width
  • Record every 3rd frame, round coordinates, use additive blending

The Cosmic Gravity Sandbox now draws 40+ bodies with full glowing trails at a locked 60 FPS on my mid-range laptop. The whole trail system is about 80 lines of code. Sometimes the cheap trick is the right trick.

ob

Written by Oliver

Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.