Select Page

Indie game development tutorials

Implement low-resolution pixel-perfect rendering and a sub-pixel camera with Godot 4

TL;DR

The solution is to simulate and render the game in low resolution, pixel-perfect, in a SubViewport. This SubViewport is then resized by an integer factor and displayed in high resolution in a SubViewportContainer, itself rendered by a second camera. This camera can move freely and fluidly according to the player's velocity, while being limited to a certain distance so as never to deviate from the initial rendering.

When I started assembling the first foundations of The Reaping Company, my 2D pixel art platform game, I quickly encountered a major problem when I wanted to tackle the camera.

I wanted to obtain a pixel-perfect world, That is to say, a faithful rendering of the old games where every pixel displayed on the screen actually corresponds to the "playable" pixel on the grid. However, I also wanted a modern camera which moves smoothly, not linked to those pixels precisely, because I find the "notched" movement too rigid and unaesthetic for a modern game.

The problems encountered in such a project are more numerous than they appear:

  • High resolution: Today, our screens are high resolution (1080p, 4K), while a pixel art game is low resolution. How can we guarantee a clean and consistent rendering?
  • Portability: How can I be sure that the image I see on my machine will be identical on another screen, with a different resolution or refresh rate?
  • Sub-pixel fluidity: If the game is locked onto a pixel grid, how can the camera be made more fluid than simply rigid tracking?
  • Synchronization: How to avoid desynchronization issues between the game's physics (the calculations) and the visual rendering (what we see)?

I was faced with all these problems at once, and despite the documentation available online and numerous suggested solutions, none of the resources worked perfectly for me, or for many others. However, I finally came up with a solution that seems to solve everything (at least I haven't found any limitations yet)! Through this misadventure, I'll explain why traditional methods can fail and provide a step-by-step solution. achieve a retro look while maintaining a smooth camera on Godot.

Confronting the problem: why traditional solutions failed

At the beginning of the project, I naturally used a Camera2D classic, attached to the character. The camera followed the player smoothly without requiring a single line of code. This solution was obviously temporary, because I knew that I ultimately wanted to achieve that famous blend: strict pixel-perfect and a fluid camera.

I then turned to the many tutorials available, which generally offer a general approach: The use of a low-resolution SubViewport in which the game runs, with a shader or a script that takes care of pixel-prefect alignment.

On my work MacBook, everything seemed to work correctly. But as soon as I tested the game on my Windows desktop PC, the rendering became catastrophic: glitches appeared ghosting (trails), visual duplications And desynchronizations blatant. The character sometimes seemed to glitch during its movements, giving a very unpleasant impression on screen.

After numerous tests, I finally identified one of the causes of the problem: the refresh rate difference between my screens. My MacBook runs at 60 Hz, while my PC screen is at 165 Hz.

Godot runs its physics engine and rendering engine independently. By default, the physics engine runs at 60 ticks per second. On a 60Hz screen, everything is perfectly synchronized. However, on a 165Hz screen, the rendering engine attempts to display images before the physics engine has been updated, causing these visual artifacts.

I tried several approaches:

  • modifying the number of ticks per second of the physics engine (which caused collision bugs and various glitches),
  • moving the camera into the _physics_process (which eliminated fluidity and introduced jitters),
  • adjust various parameters related to the SubViewport.

None of these solutions has proven to be truly robust or universal.. The SubViewport solution stubbornly refused to work reliably on all machines.

The videos on this topic (aarthificial, Barry's Dev Hell, Nesi, Picster) are excellent and very educational. They help to better understand the issues, but as soon as you try to reproduce these systems at home, the differences in setup mean that the result is no longer the same.

There was clearly a viable solution in Godot 3 (notably Picster's), but it ended up broken during the upgrade to Godot 4.0. Many developers have experienced exactly the same thing, as evidenced by the numerous comments under these videos.

Looking further, I also immersed myself in lengthy discussions on forums and GitHub. The conclusion that emerged from these exchanges was rather discouraging: there would be no universal solution, capable of operating under all conditions without jitter, flickering, or visual artifacts. From a mathematical perspective, the proposed approaches still seem involve compromises, and do not allow for achieving the perfect result that everyone hopes for.

Just when I was almost ready to give up, and after seeing many different theories, I finally came up with a different idea from anything I had seen before, an approach I hadn't encountered anywhere else.

Not being a mathematician, physicist, or expert developer, all solutions relying on complex shaders, vector calculations, or intricate mathematical adjustments seemed obscure and difficult to devise on my own. Rather than trying to understand or reinvent these ingeniously designed systems, I decided to reason more simply, almost naively, using only the elements of Godot that I already knew.

I am not claiming that this technique is perfect, nor that it is without limitations. However, at present, it is the only solution that allowed me to obtain :

  • a low-resolution pixel art game pixel-perfect,
  • a smooth camera without jitter or trembling,
  • a compatibility with all screen refresh rates,
  • and one resizing suitable for all high resolutions.

If you are also looking to achieve this result, I suggest we look at how to concretely implement this whole system, but first let's talk a little about the theory and the problems related to all of this.

Understanding the fundamental problems of pixel-perfect rendering

Pixel grid (sub-pixel) management

In a modern game engine, object positions are calculated using floating-point numbers (float). A character can be located at position x=10.52. This fraction corresponds to a sub-pixel position, which exists mathematically in the engine, but not on a pixel art screen: pixel 10.52 does not exist, either the pixel is at position 10, or at position 11.

That is why it is recommended to render your pixel art game in low resolution to perfectly match the pixels of the sprites, then to resize by integer in high resolution. However, be aware that this isn't always so simple; I strongly encourage you to do some research on... How to choose the right resolution for pixel art games.

If we let the engine handle these sub-pixel positions naively, it will try to display them by blending the colors of neighboring pixels (anti-aliasing) or by distorting the sprite unevenly. For a strict pixel art game, like The Reaping Company, this is unacceptable: each pixel must fall precisely on the grid to remain sharp. This is what we call... Pixel Snapping. The challenge is to apply this snapping visually without breaking the accuracy of the underlying calculations.

Godot does have an option to manage Pixel Snapping, accessible via: Project Settings → Rendering → 2D → Snap → Snap 2D Transforms to Pixel. However, using this option in combination with the fluid camera did not work correctly in my case.

The challenge of the refresh rate (Hz)

This is where my switch from Mac to PC revealed everything. On a 60Hz screen, The image is refreshed every 16.6 ms. If your physics engine is also running at 60 ticks/sec as default, everything is synchronous.

But on a 165Hz screen, The rendering engine requests an image every 6 ms. Since the physics hasn't changed in the meantime, the rendering engine tries to interpolate a "ghost" position to fill the gap. If this interpolation isn't perfectly aligned with our pixel art logic, we get... ghosting : a blurry trail behind the moving character. The game seems to "bleed" because the screen displays positions that the game's logic has never actually validated.

I don't know if the difference between the physics engine's ticks per second and the screen's refresh rate poses a problem in many cases, but in the configurations I tested to combine low-resolution rendering and sub-pixel camera, it caused bugs.

The paradox of camera fluidity in pixel art

There is a major aesthetic conflict:

  • If we lock the camera onto the pixel grid (pure snapping like in retro games) at the same time as the character and the game, its movement becomes jerky, especially during slow movements. The camera can be seen "jumping" from pixel to pixel, which gives it a stiff and potentially "cheap" look. While this could be a deliberate choice to maintain a retro aesthetic I accepted it, but in my case I didn't want that effect.
  • If the camera is left completely free, the world's sprites appear to vibrate or deform slightly during movement, because the camera is never perfectly aligned with the textures of the scenery.

The ideal solution must therefore allow the camera to glide with infinite mathematical precision, while ensuring that during game rendering the pixels are properly aligned.

Physical/Rendering Desynchronization

Godot separates the _physics_process (fixed calculations, 60 FPS by default) of _process (Variable rendering, as fast as possible). If you move your camera in the _process To ensure smooth movement, it may overshoot the player's actual position calculated by the physics engine. This results in micro-stuttering (jitterThe player appears to vibrate inside the camera because the two systems are not "communicating" at the same frequency. Therefore, a method is needed to tell the engine: "Calculate the position stably within the physics, but smooth the visual display without creating a lag.".

Implement pixel-perfect rendering with a smooth camera in Godot 4

Here is the solution that allowed me to resolve all the problems mentioned previously. It is based on a specific tree structure, designed to clearly separate low-resolution pixel-perfect rendering from high-resolution smooth camera work.

The approach may seem unusual at first glance, but it remains relatively simple to understand and above all easy to maintain once it is in place.

Determine the resolutions of the pixel art game and the final rendering

This step is absolutely essential and should not be neglected, as it directly conditions the resizing and quality of the final rendering.

The first thing to do is to define the internal rendering resolution of the pixel art game, That is, the low resolution that represents the game's true pixel grid, as if it were running on an older machine. This resolution determines the Actual size of logical pixels of the game.

It is essential to pay attention to:

  • in proportion to this resolution,
  • as well as to the possible integer enlargement factors towards modern resolutions.

A poor choice at this stage can lead to inaccurate resizing or visual artifacts. I highly recommend that you learn how to choosing the right resolution for a pixel art game in order to make your choice.

In our case, we will start with a resolution of 320×180px.

The final rendering resolution, on the other hand, corresponds to the actual resolution at which the game will be displayed on modern screens. It is this resolution that allows for smooth camera movement while maintaining a sharp image. We are using a Full HD resolution (1920×1080 px), which corresponds exactly to a magnification factor x6 compared to pixel art resolution (1920 / 320 = 6).

Stage architecture in Godot 4

The fundamental principle of this solution rests on a clear separation between:

  • the game rendered in low resolution, pixel-perfect,
  • and the fluid camera responsible for the final display.

 

To achieve this, the tree structure is organized as follows:

  • Root The main scene that launches at the start of the game.
  • SubViewportContainer The node responsible for displaying and positioning the rendering from the SubViewport.
  • SubViewport : The node that allows the game to be rendered in low resolution, in a fixed and controlled manner.
  • LowResGame : A grouping node containing all the elements of the game.
  • LowResCamera The camera used to render the game in low resolution. It follows the player in a strict and rigid manner.
  • Player The player character.
  • TileMapLayer The world of gaming.
  • HighResCamera The camera is responsible for producing the effect of fluidity. It renders what is displayed in the SubViewportContainer.

This architecture is key to the system: the game is rendered in a way perfectly aligned in low resolution, Then displayed and moved smoothly at a higher level.

Godot project settings for low-resolution pixel art rendering

Before going any further, it is essential to properly configure certain global project parameters.

In Project Settings → Rendering → Textures → Canvas Texture:

  • Default Texture Filter: Nearest

This setting is very important; it prevents any texture interpolation and preserves the raw sharpness of pixel contours.

In Project Settings → Display → Window:

  • Size → Viewport Width: 1920 (width determined previously)
  • Size → Viewport Width: 1080 (height determined previously)

These values correspond to the final resolution of the game. Unlike other techniques based on a low native resolution, here the project starts directly in high resolution.

  • Size → Mode: Windowed (optional)
  • Stretch → Mode: viewport
  • Stretch → Appearance: keep
  • Stretch → Scale Mode: integer

These parameters guarantee: scaling by integer factors only, the complete absence of fractional pixels, and a rendered consistent regardless of screen resolution.

The Player character script (Player – CharacterBody2D)

In addition to its own behavior (movements, jumps, collisions, etc.), the player simply needs an extra line at the end of its _physics_process :

This instruction forces the player's position to remain within integer coordinates. It allows:

  • to maintain perfect alignment on the pixel grid,
  • and to prevent ghosting once the complete system is in place.

 

The game's camera script (LowResCamera – camera2D)

This camera must be as simple and rigid as possible. Its sole purpose is to follow the player without delay, interpolation, or smoothing effects.

All you need to do is assign the node Player à target. The camera then takes the exact position of the player, which guarantees a rendered strictly pixel-perfect. We could also have placed this directly Camera2D as the player's child.

The use of global_position.round() is an additional safety measure, even if the player's position is already rounded in their own script. It is also at this level that a possible vertical offset (Y-axis), depending on gameplay needs. No other special settings are necessary: the camera must remain as neutral as possible.

At this stage, you can already test the LowResGame scene on its own. You should get:

  • a character perfectly aligned with the grid,
  • a rigid, instant camera,
  • and a very zoomed-in rendering (depending on the size of your sprites and the resolution chosen).

 

The Subviewport and the subviewportcontainer

This is where the system really starts to take shape, but also where it's easiest to make mistakes. The parameters of these two nodes are crucial.

The SubViewport This allows you to "freeze" the low-resolution game rendering so that it displays correctly in the SubViewportContainer. The parameters to adjust are as follows:

  • Size → 1920 x 1080 px (i.e., the final resolution defined previously).
  • Render Target → Update Mode → Always
  • Viewport → Disable 3D → On
  • Canvas Items → Default Texture Filter → Nearest
  • Audio Listener → Enable 2D → On

SubViewportContainer The SubViewportContainer simply displays the rendering provided by the SubViewport. It must occupy the entire available area and be centered.

  • Layout → Anchors Preset → Full Rect

 

the smooth camera (highResCamera – camera2D)

This camera is responsible for the’game fluidity effect. Its settings must be configured carefully. The first step is to adjust the zoom. This must correspond exactly to the magnification factor determined previously. In our case, a 6x zoom:

  • Zoom → x → 6.0
  • Zoom → y → 6.0

At this stage, if you launch the game, it displays correctly in 1920×1080px, but with the’appearance of a 320×180px game. The character moves correctly, the rendering is pixel-perfect, but the camera is still rigid, which is perfectly normal for now.

We now need to add a script to this camera to introduce the smooth movement:

Let's now explain in detail how this script works and its purpose:

  • The first step is to retrieve the central point of SubViewportContainer. This point represents the center of the low-resolution rendering displayed on the screen, and therefore the position towards which the high-resolution camera should naturally point.
  • We then calculate a target offset based on player velocity. This choice is fundamental. We absolutely do not base our rendering on the player's position, as they move within their own playing area and can travel very far in all directions. Conversely, our rendering is strictly limited to the frame of the SubViewportContainer : the camera should never try to follow the player directly in this space, otherwise it will go out of frame and reveal the limitations of the rendering.
  • Velocity only gives us information about the direction and intensity of movement, which is exactly what we need to create a natural camera anticipation, without ever losing the frame of reference.
  • This offset is then clamped, in order to define a maximum distance to which the camera can move away from the center. This limit is important: without it, the camera could move too far, to the point of no longer displaying the character correctly or revealing the edges of the rendering. SubViewportContainer, thus revealing the deception.
  • Once the offset is limited, we calculate the final target by adding the center of the SubViewportContainer and this clamped offset. This position is then reached progressively through interpolation (lerp), which allows us to obtain a perfectly smooth movement.
  • Finally, all this calculation is performed in the _physics_process, in order to avoid any desynchronization with the character's physics. I must admit I'm not entirely sure this is the best practice for a camera, using the _process This could also work. In my tests, both approaches work identically, but placing the logic in the physical loop seemed more consistent with the rest of the system.

And that's all!

From there, it becomes very simple to adjust the camera's behavior to suit your needs.

Going further, possible improvements

One of the great advantages of this system is its simplicity. As long as certain basic rules are followed, it is easy to maintain both strict pixel-perfect rendering and smooth camera operation, without imposing excessive constraints.

The fundamental principles to be respected are as follows:

  • Use a whole magnification factor between low resolution and high resolution.
  • Round systematically the overall position of the character with global_position.round() in order to maintain perfect alignment on the grid.
  • Employ a rigid low-resolution camera which directly follows the character.
  • Move the low-resolution rendering to a SubViewportContainer.
  • Use a high-resolution camera in floating space, zoomed in by an integer value, based on the character's speed to get around, with a limit well defined.

With these relatively simple criteria, numerous extensions become possible:

  • Set up a high-definition HUD or user interface, for example attached to the HighResCamera, in order to have a perfectly clear and fixed interface on the screen.
  • Dynamically adapt the final resolution based on the user's screen or settings chosen via an interface.
  • Adding non-pixel-perfect elements on top of the pixel art rendering, such as particles, visual effects, or post-processing.
  • Improve the camera so that it can zoom, shake, rotate, or apply any modern effect, without ever compromising the pixel-perfect rendering of the game.

 

To conclude

By implementing this system in The Reaping Company, I finally managed to achieve the compromise I had been seeking from the beginning: a perfectly crisp retro rendering, combined with a decidedly modern feeling. The pixels remain strictly aligned on the grid, while the camera maintains total fluidity and remains very simple to adjust for gameplay needs.

To be perfectly honest, I don't know if this approach is the most conventional. In any case, I've never seen it presented this way elsewhere. It probably has its own limitations, but based on my tests and the logic behind it, I don't think it will hinder further development.

It's mainly the the only method that allowed me to concretely correct the numerous problems and artifacts encountered with classical approaches.

If you're also embarking on creating a pixel art game, don't underestimate the importance of these technical details from the outset, nor your ability to find solutions on your own. This experience allowed me to better understand the inner workings of Godot and the intricacies of its rendering pipeline!

Thank you for reading, and I sincerely hope that this article can help some of you.

See you soon for another devlog or a new tutorial!