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

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.