Pixelated Quirrel face Pixelated Quirrel face that's winking Pixelated Quirrel face that's blinking

pquirrel!

Pixelart with HD Text in Godot

A small tutorial on the optimal way to get HD text in a pixel style game in Godot, as well as the caveats that come with various solutions

Published on Sunday, May 10, 2026

When I was first making my most recent game, I wanted to have it have a pixelart theme, but with very readable HD text. Searching the internet, however, a solution was hard to come by.

After a bit more searching, however, I came up with a solution. To compile my thought process as well as share it with others, I am making it a blog post!

Skip to the TL;DR if you just want to know how to do this without reading what I went through to figure it out.

Screenshot showing a pixelated character in a pixelated test scene, with two pixelated white circles on the sides of the screen. There is legible text underneath that says Hi! I'm HD Text!

The result. Each sprite you see here is stored at 1x resolution.

The Old Solution

Without much knowledge about window rendering and viewports, it was tough to even come up with an initial solution.

Initially, thinking that SubViewports had the same scaling settings as Windows, I put a SubViewport containing my pixel scene onto the screen, but I didn't find anything about scaling, so it just ended up looking blurry.

I wracked my brain a little longer, then came up with the solution that I did:

  • In Project Settings...

    • Set Display > Window > Viewport Width/Height to 1920x1080 (the "Max Resolution")
    • Set Display > Window > Stretch Mode to disabled
  • Have a Main Scene with 2 Control nodes and a background that occupies the whole screen

  • Connect get_viewport().size_changed to a function that calculates the nearest integer factor of the base 160x90 resolution to the current window size, sets the size of the Control nodes to that factor multiplied by the base resolution, and centers them on screen

Of course, this solution is suboptimal. Everything was positioned based on the max resolution, so I had to multiply all my calculated positions by 12. Additionally, it meant that all my images were scaled up by 12, making them much more inefficient and trickier to edit.

Regardless, not knowing anything better I went on with this solution.

The New Solution

After making a demo entirely with the old solution, I wanted to go back and see if there was a way to improve it.

I had noticed something very interesting in the SubViewport settings: An option to set a Default Texture Filter for Canvas Items. When I remembered that Windows are Viewports, I knew that I could use this for something.

And so, I shrank my viewport size down while still keeping things with canvas_items. I set the filter, and it just worked! The pixel art remained sharp, and the text was nice and HD (unlike just setting Stretch Mode to viewport, where it would look too pixelated)

Handling Special Cases

There were still two special cases to handle with regards to my solution, however: Movement and Shaders.

Movement

I wanted most sprites to move along the pixel grid, meaning that I didn't want them to appear "in between" pixels.

In the old solution, this was achieved by including the following snippet in a lot of places all throughout the code:

# Note: Global.PX is 12, the quotient of 1920x1080 and 160x90.
${NODE}.position = round(${POSITIONER}.position / Global.PX) * Global.PX

Fun Fact! I've known about this logic to align an item to a grid since 2015, after adapting it from some Scratch project.

This snippet aligned NODE to the 12x12 pixel grid position nearest to POSITIONER. Therefore, every frame, whenever something was moving and I wanted it to remain on the pixel grid, I used this function.

In the new solution, I was initially hoping I could just use Godot's "Snap 2D Transforms to Pixel" option. At first, this appeared to work perfectly, but after experimenting a bit more and realizing that tweened controls looked too jittery, I disabled it.

As a result, I ended up just doing the same thing as before again, just with no division & multiplication by Global.PX.

Shaders

One of the key things about my game is that there are pixelated clouds made of circles that scale up and down for dynamic animation. Of course, I wanted these to be pixelated too, but this is difficult.

Each circle has a different scale, yet they need to be pixelated identically. And ideally, I want this to be only one shader (since reusing a shader but with different uniforms sounds wasteful).

In both solutions, I use a shader adapted from This pixelation shader. This shader pixelates the 2D node which has it as a material, but in my case I draw a simple circle after applying the pixelation.

Additionally, both solutions adjust the pixelation whenever the screen size changes to match up with the current canvas size. To avoid running into the same scaling problem as before, all vertices are converted into canvas space (by multiplying them with CANVAS_MATRIX and MODEL_MATRIX).

Old Solution

In addition to adjusting the pixelation whenever the screen size changes, the old solution must also offset the pixelation in real coordinates, otherwise the pixels will look offset. This only needs to be done in integer scaling mode.

When the main scene recalculates the control positions, it also passes the screen scale (the fraction between the current scale and 1920x1080) as well as the position of the controls to a Global node like so:

# Global Screen Scale
# min_factor is the factor of 160x90 the controls are sized as currently.
# new_pos is the on-window position of the controls.
Global.screen_scale = min_factor / 12.0
Global.viewport_pos = new_pos
Global.emit_signal("screen_scale_updated")

I then listen to the Global signal and when I receive it, I adjust the circles:

...
func _pixelate_clouds():
    # Set pixelation
    get_node("CloudCircleContainer").get_child(0).material
        .set_shader_parameter("pixelation", 
            Global.screen_scale * Global.PX)
    if not Global.settings["display"]["fractional_scaling"]:
        var factor = Global.screen_scale * Global.PX
        var offset = Global.viewport_pos % Vector2i(factor, factor)
        get_node("CloudCircleContainer").get_child(0).material
            .set_shader_parameter("offset", offset)
        ...

And finally, I use this shader.

shader_type canvas_item;

uniform float pixelation:hint_range(1.0,16.0)=1.0;
uniform vec2 offset = vec2(0.0, 0.0);
uniform vec4 base_color: source_color = vec4(1.0, 1.0, 1.0, 1.0);

varying vec2 p_world;

void vertex(){
    // Get vertex in Canvas Space
    p_world=(CANVAS_MATRIX*MODEL_MATRIX*vec4(VERTEX,0.0,1.0)).xy;
}

void fragment(){
    // Pixelation
    vec2 p_snapped=floor((p_world-offset)/pixelation)
        *pixelation+pixelation/2.0;
    vec2 delta=p_snapped-p_world;

    mat2 M_p=mat2(dFdx(p_world),dFdy(p_world));
    mat2 M_uv=mat2(dFdx(UV),dFdy(UV));
    mat2 J=M_uv*inverse(M_p);

    vec2 delta_uv=J*delta;
    vec2 uv=UV+delta_uv;

    // Circle drawing
    float dc = 1.0 - floor(distance(uv, vec2(0.5, 0.5)) * 2.5);
    vec4 color = vec4(dc * base_color.r, dc * base_color.g,
        dc * base_color.b, dc * base_color.a);

    COLOR=color;
}

Of course, this is all very complex.

New Solution

Rather than doing all that, the new solution can thankfully take advantage of more built-in engine features.

Rather than having to do a whole bunch of calculations, the new solution only requires this code to get the current factor between the screen size and 160x90:

signal resized(factor: float)

func _ready() -> void:
    get_viewport().size_changed.connect(func():
        resized.emit(get_viewport().get_stretch_transform().get_scale().x))

Then, since each circle shares the same material, this is all that's needed to adjust the pixelation for all of them:

func _ready() -> void:
	Global.resized.connect(_on_screen_resized)

func _on_screen_resized(factor: float) -> void:
	$Circle.material.set_shader_parameter("pixelation", factor)

Finally, here's the shader for drawing circles. Owing to the fact that there's no offset, it's a bit simpler:

shader_type canvas_item;

uniform float pixelation:hint_range(1.0,16.0)=1.0;
uniform vec4 base_color: source_color = vec4(1.0, 1.0, 1.0, 1.0);

varying vec2 p_world;

void vertex() {
    p_world=(vec4(CANVAS_MATRIX * MODEL_MATRIX * vec4(VERTEX,0.0,1.0))).xy;
}

void fragment() {
    vec2 p_snapped=floor((p_world)/pixelation)*pixelation+pixelation/2.0;
    vec2 delta=p_snapped-p_world;

    mat2 M_p=mat2(dFdx(p_world),dFdy(p_world));
    mat2 M_uv=mat2(dFdx(UV),dFdy(UV));
    mat2 J=M_uv*inverse(M_p);

    vec2 delta_uv=J*delta;
    vec2 uv=UV+delta_uv;

    // Circle drawing
    float dc = 1.0 - floor(distance(uv, vec2(0.5, 0.5)) * 2.5);
    vec4 color = vec4(dc * base_color.r, dc * base_color.g,
        dc * base_color.b, dc * base_color.a);

    COLOR=color;
}

And there we go! Now we have the same effect, but it's much nicer.

One Big Caveat

One big caveat with this solution for shader-based pixelation is that there is "pixel shimmering" when sprites are scaled in fractional scaling modes. In my opinion, it is worse with the old solution than the new solution. If you stick to integer scaling, however, it's fine.


TL;DR

In Godot's Project Settings...

  • In Display > Window

    • Set Viewport Width/Height to resolution of pixel art (for example: 160x90)
    • Set Stretch Mode to canvas_items
  • In Rendering > Textures

    • Set Default Texture Filter to Nearest
  • In GUI > Common

    • Set Snap Controls to Pixels to Off

Optional Steps:

  • In Rendering > 2D

    • Set Snap 2D Transforms to Pixel to On. This will make nodes move pixel-perfectly, including controls.
  • In Display > Window

    • Set Stretch Aspect to Keep
    • Set Scale Mode to Integer. This will center your window on screen and make it pixel perfect.