Pixel art camera: integer scaling, snapping and smooth follow
Pixel art has a very particular way of going wrong when the camera moves. The sprites look lovely in a still screenshot, then the player walks and everything starts to crawl: edges flicker, single pixel lines thicken and thin, and a tile seam blinks in and out along the floor. None of that is the art's fault. It is the camera putting pixels on fractional positions and asking the GPU to do something sensible with them. This piece goes through a camera setup that keeps pixel art crisp, from the render target to the follow logic.
The examples use plain Rust for the maths and a little WGSL for the final blit. The ideas apply to any 2D renderer. If the GPU side is new to you, getting started with wgpu covers devices, textures and render passes, which is everything the blit needs.
Why pixel art shimmers
A screen pixel can only show one colour. When an art pixel lands exactly on a screen pixel, or exactly on a block of 4 by 4 screen pixels at 4x scale, the result is clean. When it lands half way between, something has to give.
With nearest filtering, the GPU picks whichever texel is closest to each screen pixel's centre. At a fractional offset, some art pixels end up covering four screen pixels and their neighbours cover three or five. As the camera drifts by fractions, the pattern changes every frame. That is the crawling. With linear filtering, the GPU blends neighbouring texels instead, so the edges stop crawling and start blurring, which is a different way of losing the look.
So a pixel art camera has two jobs. Every art pixel should map to the same whole number of screen pixels, and the mapping should change in whole steps when the camera moves.
Step 1: render the world to a low resolution target
The simplest way to guarantee both properties is to stop drawing the world at screen resolution at all. Create an offscreen texture at the game's internal resolution, say 320 by 180, draw everything into it at 1:1, then draw that texture to the screen scaled by an integer.
Inside the low resolution target, the world is drawn exactly as the art was made: one art pixel to one texel. Rotation, scaling and fractional positions still produce imperfect results inside it, but the imperfection is now at art resolution, like a real low resolution machine, rather than at screen resolution where it looks like an error.
The frame then has two passes:
pass 1: world pass
target = offscreen texture, 320 x 180, nearest sampling
camera = orthographic, 1 world unit = 1 art pixel
draw = tiles, sprites, particles
pass 2: present pass
target = swapchain, window size
draw = one quad with the offscreen texture
scale = largest integer that fits, centred, border around it
UI text is often drawn in a third pass at full screen resolution, after the present pass. Crisp fonts at native resolution sit nicely on top of chunky art, and it saves you from drawing letters in a 320 pixel buffer.
Step 2: pick the integer scale and the viewport
The present pass needs the largest whole scale that fits the window, and a centred viewport. The maths is short:
#[derive(Debug, PartialEq)]
struct Viewport { x: u32, y: u32, w: u32, h: u32, scale: u32 }
fn present_viewport(window: (u32, u32), internal: (u32, u32)) -> Viewport {
let scale = (window.0 / internal.0).min(window.1 / internal.1).max(1);
let w = internal.0 * scale;
let h = internal.1 * scale;
Viewport { x: (window.0.saturating_sub(w)) / 2, y: (window.1.saturating_sub(h)) / 2, w, h, scale }
}
A few windows worked through for a 320 by 180 target:
| Window | Scale | Drawn area | Border (each side) |
|---|---|---|---|
| 1920 x 1080 | 6 | 1920 x 1080 | 0, 0 |
| 2560 x 1440 | 8 | 2560 x 1440 | 0, 0 |
| 1366 x 768 | 4 | 1280 x 720 | 43 horizontal, 24 vertical |
| 1280 x 800 | 4 | 1280 x 720 | 0 horizontal, 40 vertical |
| 3440 x 1440 | 8 | 2560 x 1440 | 440 horizontal, 0 vertical |
The ultrawide row is the one to discuss with whoever designs the game. A border of 440 pixels on each side is honest but wasteful. The usual alternative is to make the internal width flexible: keep the height at 180 and compute the width from the window's aspect ratio, rounded to a whole number. More of the world becomes visible on wide screens, which affects level design, so it is a decision rather than a default.
Step 3: the present shader
The present pass is one textured quad. The important detail is the sampler, which must use nearest filtering for both minification and magnification. With a clean integer scale, nearest sampling gives perfect square blocks. Here is a minimal WGSL fragment shader for it:
@group(0) @binding(0) var low_res: texture_2d<f32>;
@group(0) @binding(1) var low_res_sampler: sampler; // nearest filtering
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
return textureSample(low_res, low_res_sampler, in.uv);
}
The vertex side places the quad in the viewport from step 2. You can do it by setting the render pass viewport to that rectangle and drawing a full screen triangle, or by computing the quad corners in normalised device coordinates. Either works. Clear the swapchain to the border colour first so the leftover space is a deliberate dark frame and not whatever was there before.
Step 4: snap the camera to whole art pixels
The low resolution target fixes scaling. It does not fix movement. If the camera position is 100.37 art pixels, every sprite is drawn 0.37 of a texel off, and nearest sampling inside the target makes edges jump between frames.
The fix is to keep a precise camera position for logic and round it for rendering:
#[derive(Clone, Copy)]
struct Camera { pos: (f32, f32) } // precise, in art pixels
impl Camera {
fn render_offset(&self) -> (i32, i32) {
(self.pos.0.round() as i32, self.pos.1.round() as i32)
}
}
fn world_to_target(world: (f32, f32), cam: &Camera, half: (i32, i32)) -> (i32, i32) {
let (cx, cy) = cam.render_offset();
(world.0.round() as i32 - cx + half.0, world.1.round() as i32 - cy + half.1)
}
Notice that sprite positions are rounded too, separately from the camera. If you round only the camera, a sprite at 50.5 and a camera at 10.5 produce an on-screen position of 40, but a sprite at 50.5 and a camera at 10.4 produce 40.1, which still lands off grid. Round both and subtract the integers.
There is a well known side effect. If the player moves at a speed that is not a whole number of pixels per frame, the rounded position advances in an uneven rhythm: 1, 1, 2, 1, 1, 2. At 60 frames per second this reads as a slight stutter, especially on slow moves. Two things help. Game designers can choose movement speeds that divide nicely into the frame rate, for example 60 or 90 pixels per second at 60 frames. And the camera can round the same way as the player, so both step together and the player sprite stays still relative to the screen. When the camera and the followed sprite round differently, the sprite wobbles by a pixel, and that wobble is far more visible than any background stutter.
This also interacts with the update rate. If logic runs on a fixed timestep and rendering interpolates between states, round after interpolating, at the very end, just before you build draw calls.
Step 5: smooth follow without shimmer
A camera locked to the player feels stiff. Most games ease towards the target instead, with a little lag. The standard form is exponential smoothing:
fn follow(cam: &mut Camera, target: (f32, f32), dt: f32, sharpness: f32) {
// Frame rate independent: same feel at 30, 60 or 144 frames per second.
let t = 1.0 - (-sharpness * dt).exp();
cam.pos.0 += (target.0 - cam.pos.0) * t;
cam.pos.1 += (target.1 - cam.pos.1) * t;
}
With a sharpness of about 8 to 12 the camera covers most of the gap within a quarter of a second, which suits platformers. A lower value feels floaty and suits exploration. Keep the smoothing on the precise position and round only for rendering, as in step 4. If you smooth an already rounded value, the camera gets stuck when the remaining gap is under half a pixel and then jumps.
A dead zone helps too. Let the player move inside a small rectangle in the middle of the screen without moving the camera at all. Fewer camera moves means fewer whole screen pixel steps, and the image looks calmer. For a 320 by 180 target, a dead zone of about 24 by 16 art pixels is a reasonable place to start tuning.
Smooth subpixel scrolling, if you want it
Snapping to whole art pixels gives the most authentic look, but at 6x scale one art pixel is six screen pixels, and slow pans can look steppy. A popular middle ground keeps the world snapped inside the low resolution target and moves the final quad by the fractional remainder at screen resolution.
To do it, render a target one texel larger than the visible area in each direction. Snap the camera as before, keep the remainder frac = pos - pos.round(), and offset the present quad by -frac * scale screen pixels. Since the scale is an integer, the offset can be rounded to whole screen pixels, so nothing is resampled. Every art pixel is still a perfect square block, but the whole picture glides in steps of one screen pixel instead of six.
This only shifts the world as a whole. Sprites inside it still move in whole art pixels relative to each other, which is part of the look and usually what you want.
Zoom levels
Zoom is where a lot of pixel cameras give up. The clean options are:
- Change the integer scale of the present pass: 4x to 5x, for example. Crisp, but the steps are large and the visible area jumps.
- Change the size of the low resolution target, keeping the present scale. Rendering at 256 by 144 and presenting at a bigger scale zooms in; 384 by 216 zooms out. Each target size must still divide the window well, so you get a small menu of sizes, not a continuous range.
Continuous zoom on pixel art is a compromise either way. If the design needs it, for a cinematic moment or a map view, accept some blur for the duration, sample with linear filtering while zooming, and settle back on an integer level when it ends. Players forgive a softening that lasts half a second. They notice crawling edges during ordinary play.
Screen shake and other effects
Screen shake is camera movement, so it follows the same rule: add the shake offset to the precise position, then round. A shake of 2 to 3 art pixels is a lot at low resolution. One pixel shakes often feel more satisfying than you would expect.
Effects that work on the final image, such as bloom or colour grading, can run on the low resolution target or on the scaled result. Running them at low resolution keeps them chunky and in style. For glows that should spill softly across the art, the 2D lighting and bloom piece talks about where in the chain they fit.
Checklist before shipping the camera
- World rendered into a fixed size target at 1 art pixel per texel.
- Present pass uses an integer scale, centred, with a solid border colour.
- Samplers for art and the present pass use nearest filtering.
- Camera and sprite positions rounded separately, after interpolation, before drawing.
- Smoothing applied to the precise camera position, never to the rounded one.
- Player and camera round the same way, so the player does not wobble.
- Zoom changes either the integer scale or the target size, never an arbitrary float.
A good test is to make the player walk slowly across a checkerboard floor with single pixel diagonal lines on it. Record the screen and step through the video frame by frame. If any line changes thickness between frames, one of the items above is missing, and the frame where it first happens usually tells you which.
Filed in Graphics & Shaders · 2D Craft

Written by Odalys Brisco
Odalys covers graphics programming: wgpu, shaders, lighting and the maths behind a good-looking 2D frame. She came to it from technical art and still sketches every effect on paper before writing a line of WGSL. Based in Valencia.
