Why does my pixel-art camera drift and jitter on 120hz monitors?

What’s up everyone? I’m hacking on a little canvas pixel-art scroller and trying to keep movement smooth across 60hz and 120hz, but I keep getting tiny camera drift and subpixel jitter when panning.

let last = performance.now();
let camX = 0;
const speed = 90; // px/sec

function frame(now) {
  const dt = (now - last) / 1000;
  last = now;

  camX += speed * dt;

  // try to keep pixels crisp
  const drawX = Math.round(camX);
  ctx.setTransform(1, 0, 0, 1, -drawX, 0);
  drawWorld();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

Is rounding the camera each frame the wrong tradeoff (causing accumulated error and “micro stutter”), and what’s the practical way to keep pixel-perfect rendering without timing skew on high refresh displays?

Arthur

1 Like

Rounding isn’t what causes drift here (since you’re still integrating camX as a float), but it absolutely can cause the “hold… hold… jump” micro-stutter you’re describing on 120Hz. At 90 px/sec you’re moving ~0. 75px per frame at 120Hz, so the rounded integer camera position will repeat for a frame or two and then advance by 1. At 60Hz you’re moving ~1. 5px per frame, so you “naturally” advance most frames and it looks smoother. The tradeoff is: keep the simulation continuous, but make the render snap deterministic so you don’t bounce between two integers. Math. round can flip you back and forth around the 0. 5 boundary depending on tiny dt variation; floor/trunc won’t. Something like:

let last = performance.now();
let camX = 0;
const speed = 90; // px/sec

function frame(now) {
  const dt = (now - last) / 1000;
  last = now;

  camX += speed * dt;

  // snap only for rendering; use a consistent direction
  const drawX = Math.floor(camX); // or Math.trunc(camX) if you ever go negative

  ctx.setTransform(1, 0, 0, 1, -drawX, 0);
  drawWorld();

  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

One more thing that bites people (and looks exactly like “subpixel jitter” even when your camera is integer): make sure your canvas backing resolution and your CSS size line up so that one “game pixel” maps to an integer number of device pixels. If you’re doing any scaling with devicePixelRatio and the scale factor ends up non-integer, you’ll get shimmer no matter how perfect your camera math is.

1 Like

Snapping the camera helps, but it doesn’t stop “jitter” if anything you draw is still landing on fractional pixels — parallax layers are the usual culprit, and canvas smoothing loves to quietly come back after a resize.

I’ve had a “perfect” integer camera and still got shimmer because one layer was doing camX * 0.5, so it sat on half-pixels and looked like the whole view was wobbling. What fixed it was snapping per-layer after applying the parallax factor, and re-setting ctx.imageSmoothingEnabled = false any time canvas.width/height changes (that reset has bitten me more than once).

function drawWorld(camX) {
  // gets reset when canvas width/height changes
  ctx.imageSmoothingEnabled = false;

  const cam = Math.floor(camX);

  // base layer: snapped to camera
  drawTiles(-cam, 0);

  // parallax: apply factor, then snap
  const bg = Math.floor(camX * 0.5);
  drawBackground(-bg, 0);

  // sprites: snap their positions too
  for (const s of sprites) {
    ctx.drawImage(s.img, (Math.floor(s.x) - cam), Math.floor(s.y));
  }
}

CSS scaling can undo all your careful snapping. A 320×180 backing store shown at 853×480 means every “pixel” becomes ~2.665 screen pixels, so even integer camera moves land on fractional device pixels and shimmer shows up fast on 120hz.

I ran into this on a tiny pixel platformer mockup—locking the display size to an exact integer scale (and redoing the DPR/backing-store sizing after any resize) made the wobble disappear. Kirupa’s high‑DPI canvas write-up explains the mismatch clearly: Ensuring our Canvas Visuals Look Good on Retina/High-DPI Screens

this bug always feels like the camera’s doing an idle animation even when you swear it’s snapped lol

CSS scaling is definitely a big one, but I’ve seen the same “drift” when the source rect you pass into drawImage() isn’t landing on clean integers. Like even with integer dx/dy, if sx/sy/sw/sh end up as 31.999999 from float math (sprite atlas math, frameIndex * tileSize through some scaled path, etc), the browser starts sampling between texels and you get shimmer that reads as camera jitter — and at 120hz you just notice it constantly.

Not 100% sure it’s your case, but I’d try hard-rounding sx/sy/sw/sh to ints for a minute (same vibe as snapping the camera) and keep ctx.imageSmoothingEnabled = false. If that calms it down, it’s texture sampling pretending to be camera drift.

Ha nice

Lol same energy, I’ve seen this happen when the camera moves in tiny sub-pixel steps and the art snaps differently frame to frame on high refresh. On 120hz it just shows you the wobble more.