3 Tricks to Improve Pixel Art Rendering in UE4
- Posted by Francesco
- 6 Min. read
- 0 Comments
The pixelated look of the games of the past was largely the result of the severe constraints that graphics hardware imposed to developers back then. Obtaining the same look on modern game engines, however, can be quite difficult and requires some setup.
In a previous post we’ve seen how the game camera can be setup in Unreal Engine 4 to make pixel art look crisp when viewed on most screens. Today, I’d like to go over a series of tricks that we use inside the engine to enforce a more authentic pixelated look in Guntastic.
1. Everything Should Be Aligned to the Pixel Grid
On a screen there is no such thing as an half-pixel, but in a game world it’s common for sprites to end up in positions that aren’t aligned to the pixel grid. This introduces some noticeable inconsistencies in the way sprites are aligned.
The simplest solution to prevent this from happening is to snap the sprites to the pixel art grid just before rendering. Most implementations I could find online perform the snapping directly in the world, by conforming object locations to the grid just before rendering. The original location is then restored on the objects at the beginning of the next frame.
Unfortunately, this is cumbersome to do in Unreal Engine 4 (the resources I found were for Unity), and might have unwanted side-effects (like triggering overlaps, for example). As such, in Guntastic we ended up using a simpler approach: snapping is applied directly in the vertex shader by offsetting the sprite geometry vertices.
This works on the assumption that the vertices of the rendering geometry of the sprites are aligned to the pixels of the art itself, so special care should be taken to generate valid rendering geometry.
Prevent Jittering When the Camera Moves
It’s also important for the camera to be aligned to the pixel grid. Otherwise, when the camera moves, we’ll probably end up looking at the sprites from locations that are not on the grid, which will make the sprites jitter out from their expected positions.
In our implementation we apply the snapping at the end of the camera update logic, after everything else (including special effects like screen shakes, etc.) have been calculated. This is how the actual code looks like:
void AFolliesPlayerCameraManager::DoUpdateCamera(float DeltaTime)
{
// Update the camera
Super::DoUpdateCamera(DeltaTime);
// Snap the final camera location to the pixel grid
{
const float PixelsPerUnits = 0.24f;
const float UnitsPerPixel = 1.0f / PixelsPerUnits;
FMinimalViewInfo CameraCachePOV = GetCameraCachePOV();
CameraCachePOV.Location.X = FMath::GridSnap(CameraCachePOV.Location.X, UnitsPerPixel);
CameraCachePOV.Location.Z = FMath::GridSnap(CameraCachePOV.Location.Z, UnitsPerPixel);
FillCameraCache(CameraCachePOV);
}
}
2. Sprites Shouldn’t Rotate
Pixels in a grid can’t rotate, and so shouldn’t your sprites. Furthermore, rotating textures that use nearest-neighbor filtering introduces evident aliasing between pixels.
The simplest solution here it to avoid to physically rotate the objects in the world, and use hand-drawn rotated versions of a sprite where rotation is actually needed.
While developing Guntastic, however, we encountered some edge cases that still required to handle in-world rotations. An example of such cases are guided missiles, which need to track a target by pointing towards it: here the amount of rotated sprites to draw was simply too much to handle for a team with a single artist.
To handle these (sporadic) cases, we fell back to an antialiasing technique, created by Cláudio Fernandes, called Manual Texture Filtering. This technique works by:
[…] performing linear interpolation between texels on the edges of each texel in the fragment shader, but sampling the nearest texel everywhere else.
In other terms, it smooths out jaggies between pixels, while keeping the overall result crisp. The only caveat when working with this technique is that linear filtering should be applied to the sprite texture (instead of nearest-neighbor filtering). Here’s how the shader looks like when implemented in an UE4 material function:
This almost completely eliminated the problem:
3. Maintain Pixel Density
Finally, it’s important for the sprites in the game to have the same pixel density: this means they should be created using the same reference grid, and never scaled up.
Luckily, applying this inside the engine is straightforward as it only takes some discipline to never scale the sprites.
Taking It Further
The final step to ensure a true pixel-perfect look would be to render the game at the original art resolution, and upscale the rendered frame to match the actual screen resolution. This would automatically eliminate any possibility of inconsistent pixel sizes, because it’s impossible to draw anything smaller than a pixel.
Unfortunately we hit a couple of major road blocks when trying to implement it, and decided to abandon it (at least for now):
- Rendering at a very low resolution would make the handful of post processing effects we use in the game (such as bloom) look distorted and aliased once upscaled.
- Implementing this solution in Unreal Engine 4 requires changes at the engine level. This is something we’re trying to avoid as it can soon become a nightmare to manage for a very small team such as ours, with no full-time programmer/engineer.
If you’re interested in seeing this technique in action, I highly encourage you to take a look at the implementation available on Alex Ocias’ blog for Unity.