Hey folks, I’m working on a little canvas pixel-art game and I’m trying to make camera + sprite motion feel smooth without the art turning into blurry mush on high refresh monitors, and the tradeoff is jittery snapping vs smeary filtering.
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
let camX = 0;
let last = performance.now();
function frame(t) {
const dt = (t - last) / 1000;
last = t;
camX += 60 * dt; // pixels per second
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
// draw at subpixel camera positions
ctx.setTransform(1, 0, 0, 1, -camX, 0);
ctx.drawImage(spriteSheet, 32, 0, 16, 16, 100, 50, 16, 16);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Should I be rounding the camera/sprite positions to integers every frame (accepting tiny stutter), or is there a better pattern like rendering to an integer-scaled offscreen buffer and compositing, so motion stays smooth but pixels stay crisp?
@Quelly, Keep your camera in floats for smooth motion, but snap only at the final draw by rounding the translation (or baking it into the draw positions) so every sprite lands on whole pixels. Canvas will still “blend” when the destination x/y is fractional even with imageSmoothingEnabled = false, because it’s sampling between device pixels; integer-aligning the transform avoids that. One gotcha: if you’re scaling the canvas (CSS size ≠ width/height, or DPR handling), you need to snap in backbuffer pixels (after multiplying by devicePixelRatio), otherwise you’ll still get shimmer.
Your blur is coming from -camX landing on fractional device pixels, so the browser has to blend even with smoothing off. Keep camX as a float for simulation, but snap the translation you actually render with to the device pixel grid:```
const dpr = devicePixelRatio || 1;
const renderX = Math.round(camX * dpr) / dpr;
ctx.setTransform(1, 0, 0, 1, -renderX, 0);
That gives you crisp sprites without forcing your whole game state onto integers, and it behaves nicely on 120 Hz where tiny subpixel offsets show up constantly.
Keep the simulation and camera as floats, but round what you feed into the actual sprite draw so the art lands on whole pixels instead of getting resampled into mush. The clean split is float for game logic, integer for the crisp layer, and smooth motion only for things that can tolerate it, like parallax, particles, or a separate offscreen buffer. On a 120hz display, that usually feels better than letting the main art drift on subpixels, because the tiny stutter from snapping is way less noticeable than blurry edges.
That offscreen buffer pattern is usually the cleanest way to do it.
Keep your camera and sim in floats, but render the actual game world to a low-res canvas at 1:1 pixel units and snap there. Then scale that canvas up to the display canvas by an integer factor with smoothing off. The one thing that bites people is the final blit. The destination size and x/y need to land on whole pixels too, or you’ll get shimmer back even though the art itself is pixel-perfect. If the window size doesn’t divide evenly, letterbox or crop. Fractional scaling is where the blur sneaks in.
Yeah, the “final blit” is where I’ve screwed this up before — even with nearest-neighbor, a half-pixel offset in the upscale pass will shimmer like crazy at 120hz. What fixed it for me was snapping the scaled viewport rect (x/y and width/height) to integers in the final framebuffer and just accepting letterbox bars when the math doesn’t land cleanly.
Nearest-neighbor still looks like trash if your final blit rect lands on a half-pixel, because 120hz just makes that shimmer impossible to ignore.
Snapping the scaled viewport x/y and w/h to integers fixed it for me too, and I keep the camera locked to the 320x180 grid even when it forces letterbox bars.