Smooth camera movement for pixel art games in LOVE2D!
In case you want to skip the boring stuff, here’s the full code example
Most pixel art games have a problem of jittery camera (or at least those that use real pixel rendering), which comes from the fact that pixel art assets are really small and first rendered on a virtual canvas where all the logic of the game is happening and only then scaled to actual screen size. Therefore entity movement as well as the camera movement is calculated in integer pixels or ‘scaled pixels’ to which I will refer as grid pixels.
If you are interested in reading this you are likely familiar with the issue and for you I have good news and bad news. There is no mainstream solution and it varies from engine to engine, BUT there can be an illusion of smooth movement achieved in any engine that supports rendering shaders. The goal is to use a shader to slightly shift the final image that we a rendering on a screen.
Here is a simple camera movement calculation on X axis:
camera.x = camera.x + (target_x - camera.x) * camera follow_speed * dt
Any entity’s X can be float but we floor it to render/snap it on a virtual canvas to keep the game pixel-perfect.
To fix the issue let’s separate coordinate into two parts: fractional and integer
camera.x = integer part + fractional part
We render the world with integer part, but we take the sub-pixel value and convert it into UV space for our shader:
fractional part / CAMERA_WIDTH
Let’s put it in code function:
function camera:getShaderOffset()
local sub_x = camera.x - math.floor(camera.x)
local sub_y = camera.y - math.floor(camera.y)
return {sub_x / VIEW_WIDTH, sub_y / VIEW_HEIGHT}
This value becomes a teeny tiny texture coordinate shift that we can pass to our shader:
vec4 effect(vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords)
{
return Texel(tex, texture_coords + offset);
}
So when we render the game with this shader, we are simply shifting how the final texture is sampled without doing any adjustments to game’s logic.
Send the sub-pixel offset to shader and apply shader:
local offset = camera:getShaderOffset()
smooth_shader:send("offset", offset)
love.graphics.setShader(smooth_shader)
Boom! Now we got a much better experience! Again, this is not just LOVE2D specific stuff, for example you can find many tutorials explaining this on Godot forums, but thankfully it’s just shader magic that can be achieved with any engine that supports shader rendering.
Although I am sure it is not an optimal solution, it is something that I found to immediately just work. If you find any optimization I would love to hear from you!