Ah, the scaling of user interfaces!..
A deceptively simple problem in application design that hides a bunch of tricky considerations. How do rasterized, such as fonts and images, behave? Where do on-screen elements go when an overflow happens? What kind of units should you use to describe layouts and sizes? And the list goes on.
Depending on the GUI system that you're using and your target platform, some of these questions may be resolved. Others may even be completely removed from the equation, while the remaining issues are left for you to figure out on your own. In the Godot engine it's a mix of all three options.
And, despite the title of this article, there is no one best universal solution. But there are a couple of approaches that may work for you and your situation. I'm going to explore them in the following paragraphs, but first...
This is true, Godot's own editor environment is built using the same GUI system that you use in your games and apps made with the engine. The entire editor user interface is a tree of Control nodes and many many nested containers of all sorts, with everything styled using a Theme resource.

The scaling adjustment in the editor, however, is not done in a way that you might find convenient for your game or app project. Even as an editor user one big problem is already apparent — when you change the scaling setting, a full restart is required for it to be applied. While games sometimes require a restart to correctly initialize some core settings, like the rendering API, it would be a bit silly to request it for the GUI scale. And in most apps that would be true as well, which makes the solution used by the Godot editor rather limiting.
So let's start with how it works in the editor to learn from its shortcomings.
If you look through any GUI code in the Godot editor codebase, you might start noticing a suspiciously relevant constant called EDSCALE (it's actually a macro, but that's not relevant). Indeed, this is how the current GUI scale is accessed by the editor GUI components — a global accessor with a value set during the initialization process.
Whenever an editor component or a theme constant has a fixed size value, on at least one axis, the editor scale is applied to it via multiplication. For example, the About dialog in the editor is hardcoded to pop up at the size of 780x500, and that value is then multiplied by the editor scale:
about->popup_centered(Size2(780, 500) * EDSCALE);
Effectively scaling is faked by resizing parts of the UI and then fitting them to the same space. This introduces a problem — after the scaling factor is applied to the size, it becomes an inseparable part of the size information. The scale is essentially lost, and just by looking at the control node you can no longer determine whether it has been scaled or not.
This makes it very inconvenient to update the scale on the fly. It's not enough to change the scaling factor, you need to manually adjust every control using both old and new scale. It's possible, but impractical, and is perhaps the number one reason why the Godot editor requires a restart to reflect the GUI scale setting.
The editor theme is also affected by this design. Take labels, for instance. If you change the scale of a label, you would expect it to adjust proportionally all together. Not only should the physical size of the label area change, but also the font size must be adjusted. But since the Godot editor fakes scaling with resizing, the second part doesn't happen. The label control becomes larger, but the text doesn't grow to fit the new area. (This is actually a very requested feature even outside of scaling!)
Textures and icons can be made to grow with the area, with the node configuration, but that leads to other issues. Raster images become blurry when scaled up, and can turn into a mess of pixels when scaled down. What about vector graphics and SVG? Well, the editor does use SVG images for its theme, however Godot rasterizes SVG assets on import, at a set scale. This means that all the same concerns are still valid.

To work around this problem the Godot editor code reimports all assets and regenerates the entire editor theme with the editor scale accounted for. Unlike the resizing of the GUI, this can actually happen on the fly without a hitch. Well, with a bit of a hitch since reimporting everything and then propagating the changes through the entire editor scene tree is a rather expensive operation.
But basically, it's the same general principle as the one applied to controls: every font and image is resized ahead of time using the current scaling factor. And when a resized control renders a resized font, all pieces fall into their place nicely. So in practice, this solution is proven to work, though it doesn't lead to the best user experience possible.
And it also doesn't really apply well to user projects. In your Godot game or app you will probably author most of your UI using editor tools, as scenes of nodes and not as code. This means that applying a scaling factor directly to sizes of components becomes a messy and laborious process. You can certainly use it, but perhaps we can find a better solution, with less micromanagement, that works on the fly, and, dare I say, scales everything for real?
Yep, there are at least two way we can handle this. You probably have an idea for what the first one might be, but chances are you've never heard of the second. It is, however, used by apps made with Godot, such as my own Bosca Ceoil Blue, and Material Maker by RodZill4. So you know that it's pretty robust.

Neither approach is ideal, though. There is no absolute winner, and you'll have to pick the one that works for you, or perhaps combine everything together in a truly bespoke blend of hacks. So, it might be obvious at this point, but...
Indeed we can! The GUI in your project probably starts with a single root node, and you can definitely apply some scaling directly to it. After all, it conveniently has a scale property, and that property does what it says on the tin. When you scale a GUI node, its scale affects all of the node's descendants (as long as the chain of nodes is not interrupted by non-UI nodes). Problem solved?
Not quite. One big limiting factor of this solution is that it affects control's own anchors too. If your UI component needs to align to opposing sides of the screen, you will quickly notice that the farthest side (in the direction of the node's growth) runs off the screen as the scale changes. And as you resize the window, it still tries to align itself to some point beyond the visible area.
Still, this can be fine in some scenarios where the runoff is the only reasonable behavior remaining when nodes stop fitting the screen. This will do in a lot of games, probably. And a huge benefit of this approach is that the scale is limited to the control node and its branch. Nothing else is affected! If you're making a 2D game, then your game world is safe from any undesirable side effects. Just put the UI into a canvas layer so it doesn't move with the camera, and you're set!
Then we get to the option B. Every game or app starts with a Window node. Actually, one is always implicitly created for you at the top of the scene tree when you run your project. And every Window has the content_scale_factor property. Sounds appealing, doesn't it? What this does is it applies an additional scaling transformation to every bit of 2D content inside of this window.
Unlike scaling of control nodes, this doesn't affect anchors directly. It's more like the fake behavior that the Godot editor uses, but it works automatically, is reversable, and is controlled by one property change. As long as there is space for scaled elements within the window, they will not run off the screen. Just make sure to enable the wrap_controls property and to call child_controls_changed() to update the minimal size of the window when the scale changes:
get_window().wrap_controls = true
# ...
get_window().content_scale_factor = 1.25
get_window().child_controls_changed()
Seems pretty simple and elegant, right? It is actually a pretty great solution for apps made with Godot, because apps are made out of GUI nodes entirely. And it can also work in 3D projects, but you will face issues adapting it for 2D games. As I mentioned above, the content_scale_factor property affects all 2D content inside of the window. This means that your 2D world will also be subjected to this scaling.
Furthermore, you might want to use other related content_* properties to adjust the scaling behavior for your entire game. After all, they are responsible for scaling, stretching, adding black bars, — all the things that you do to adapt your content to the window or screen resolution. The effect of the content_scale_factor property cannot be isolated or counteracted within the window, canvas layers or sub-viewports won't help here. (It would've been nice to have this as a viewport property, though!)
One workaround that might work for you is to adjust the zoom of your 2D camera proportionally to the scale of the window. Zoom out the more scaled up the window becomes. There might also be a way to overlay multiple windows on top of each other and put your GUI into another layer like that. I think this would be more trouble than it is worth though.
In 3D none of this shouldn't be an issue. The resolution of the 3D camera is going to be affected by the window size, if that changes, but otherwise there is no direct effect from the content scale factor.
Whichever approach you decide to use in your project, there are going to be some common caveats to consider. I'll focus on these three:
We already touched on the locality aspect of GUI scaling. Either approach is tied to a node: a control or a window. A scaled control node only affects itself and its descendants, which is good if you don't want to accidentally mess up your 2D game world. But if you have multiple UI roots, each needs to be scaled individually. In turn, the window scaling affects the entire window and all controls inside of it, but if you have multiple windows, you need to pass the information onto them manually and explicitly. This includes non-native file dialogs and other popups.
Then there is the matter of assets...
All image assets in Godot are rasterized on import, even if their source files are SVG. This means that each image, texture, or icon has a fixed resolution. For raster image formats, like PNG and JPEG, this is the same exact resolution as the source file has. SVG files have an import setting to control their scale (e.g. a 32x32 file at the 2x scale will be imported as a 64x64 texture).

Images imported at native (1x) scale.
In simple terms, there are only so many pixels in each texture. So scaling the GUI up inevitably leads to interpolation issues where images become blurry and muddy, as each existing pixel gets stretched to cover more area, with neighboring pixels being blended together. Depending on your art style, this can be countered by changing filtering settings in the project from linear to nearest neighbor. But in most cases this is unlikely to be the acceptable solution.
What you can do instead is to author your assets at a higher scale to begin with. If your icon is supposed to be rendered at 32x32 pixels, create the image that is 64x64 in size instead, for example. With SVG assets you can simply adjust the import setting, and re-import your images at a higher scale. Then give your controls, such as TextureRect, the appropriate size. Similarly provide the desired target size when doing custom drawing, if you use that.

Images imported at 4x scale.
Given the appropriate size (according to your maximum supported GUI scale), with this setup you will be always downsampling textures, instead of interpolating them. This gives the engine more pixels to work with, and reduction is always more reliable than making up data where there is none. Now, though, the opposite problem becomes more apparent. When the texture is shrunk, it becomes too jagged, with individual pixels being very present even if the original artwork didn't intend it.
To address that problem you need to enable mipmaps. Mipmaps can be described as level-of-detail (LOD), but for textures. In essence, they provide a smaller version of your texture, properly adapted to the size. All jaggedness is smoothed, and it should look pretty much as good as intended. You need to enable mipmapping in the import settings, but also adjust texture filtering options for the project, or individual controls. There are counterparts for every filtering variant with mipmapping enabled.

Images imported at 4x scale with mipmapping enabled.
With all this done, your textures are ready for rendering at higher scales when used with TextureRect or when custom drawing is used. Built-in controls and their theme properties are another story, however.
We need one thing to successfully downsample upscaled textures — direct control over the target size. We take a 64x64 texture and tell the engine to render it at 32x32 with certain filtering settings applied, and the engine does the rest. If you try to extend the same approach to standard GUI widgets in Godot, you will immediately find that there is no way for us to dictate the render size.
Most textures in controls are taken as is and rendered at their native size. So our higher-scale textures just get bigger and bigger, on top of the scaling applied to the GUI. Not only do they look bad, they also mess with the size of the control nodes they are applied to.

Images imported at 4x scale, used for styling of built-in controls.
Good news is that Godot is highly scriptable. Native types, such as textures, can be extended with custom implementations created specifically for your project. This fact can be used to design a solution for our problem: we can create a new texture type that renders any given image at the size that we want. You could achieve that by setting the scaling factor, but in this example I opted for setting the target size directly:
# ScaledTexture.gd
@tool
class_name ScaledTexture extends Texture2D
@export var texture: Texture2D = null:
set = set_texture
@export var target_size: Vector2 = Vector2.ZERO:
set = set_target_size
func _draw(to_canvas_item: RID, pos: Vector2, modulate: Color, transpose: bool) -> void:
if not texture:
return
var scaled_rect := Rect2(pos, target_size)
texture.draw_rect(to_canvas_item, scaled_rect, false, modulate, transpose)
func _draw_rect(to_canvas_item: RID, rect: Rect2, tile: bool, modulate: Color, transpose: bool) -> void:
if not texture:
return
texture.draw_rect(to_canvas_item, rect, tile, modulate, transpose)
func _draw_rect_region(to_canvas_item: RID, rect: Rect2, src_rect: Rect2, modulate: Color, transpose: bool, clip_uv: bool) -> void:
if not texture:
return
texture.draw_rect_region(to_canvas_item, rect, src_rect, modulate, transpose, clip_uv)
func _get_width() -> int:
return int(target_size.x)
func _get_height() -> int:
return int(target_size.y)
# Properties.
func set_texture(value: Texture2D) -> void:
if texture == value:
return
if texture:
texture.changed.disconnect(emit_changed)
texture = value
if texture:
texture.changed.connect(emit_changed)
emit_changed()
func set_target_size(value: Vector2) -> void:
if target_size == value:
return
target_size = value
emit_changed()
Make sure that the script is in tool mode, so it works in the editor, and use setters to trigger and pass through changed notifications. The main trick is in the _draw method where we enforce the size that we want. Other methods are just proxies for what is already implemented by the underlying texture. And it just works! But not for 9-patch textures and not for textured styleboxes...

Images imported at 4x scale, used for styling of built-in controls, with a custom script applied.
Unfortunately, rendering of 9-patch textures is very low level and cannot be customized through scripting. You might be able to create your own custom stylebox that works around the issue to replace StyleBoxTexture in the same way we replaced standard textures. But that is getting pretty involved at this point, and whether it is worth it would depend on how deep you want to go down that rabbit hole. There is also room for a proposal that gives users more control over the 9-patch rendering.
Perhaps making a custom control would be a better option at this point! Luckily, you can create completely custom GUI nodes with custom drawing, or use composition to replace built-in nodes. It's more work than simply relying on what is already given to you by the engine, but at the scale of a real project it's not as hard as it might look! (btw, open for collaboration on your Godot games 👀)
Quite similar to how SVGs are handled by the Godot, fonts must also be converted to a format that the engine can render. By default, that means that the font is rasterized, each character from the character set converted into an image, all of them forming an atlas. I'm not entirely familiar with how exactly fonts are imported in Godot. However, starting with Godot 4.0 the font size is independent from the font resource, and can be adjusted on individual controls (or via the theme).
This means that the rasterization must be performed for every font size found in the project. And it even works at runtime: as you change the font size, you can see the text adjusting without losing quality or clarity. But it doesn't work with GUI scaling. Evidently, by the time the scaling is applied, the font is already rasterized at 100% scale, and all you have is a texture. And thus the same limitations of textures apply as we discussed above: blurriness at higher scales, crunchiness at lower scales.

Font imported with default settings.
With fonts, we cannot control filtering applied to the texture, although we can enable mipmaps. Fonts also benefit from subpixel adjustments and antialiasing applied by the engine, although they don't help with scaling up. There are two ways to make fonts more flexible: oversampling and MSDF. Neither is objectively better and your choice will depend on the specific font and usage context.
Oversampling is the most straightforward option. It boils down to rasterizing the font at a higher resolution and then downsampling it to the required size. Once again, this is similar to changing the scale of imported textures. This works quite well on scales over 100%, but when the target size is too small compared to the rasterized resolution it can lead to aliased looks. Going too strongly into oversampling will also reduce the quality of the results.

Font imported with 2x oversampling enabled.
Multi-channel signed distance fields, or MSDF for short, is an approach that uses signed distance fields to approximate the shape of each character. Instead of being rasterized to an image, the font is converted to, well, a signed distance field. In theory, this solution is infinitely scalable. Its downside is being imprecise, which can cause artifacts in character glyphs, such as their shape and position on the line.

Font imported with MSDF enabled.
It's worth pointing out that many fonts available on the internet are also rather "broken", leading to gaps and shape breaks, specifically caused by self-intersections. This requires expensive processing to be fixed. There are external tools that can help you with that, as this is not something you can do within Godot. There is this PR though, if you're into custom engine builds.
There is one more thing to tackle, and then we're done. By default, though perhaps not by design, a window node does not account for the GUI scale. It respects neither its own content_scale_factor, nor does it respect scales of individual controls even when wrap_controls is enabled. In some situations this may be desirable to let excessively scaled content to overflow and disappear outside of the window boundaries.
This is definitely not the case for apps made with Godot, though. You generally want the window to always fit the content, unless the window itself no longer fits the screen. There is also a matter of anchors and grow direction that window's default contents minimum size computation doesn't handle well. GUI components designed to grow in both directions when resized (on either axis) can go outside of the window area straight into negative numbers (to the top/left). And this is not accounted for correctly, leading to the computed size being smaller than expected even with the scale correctly applied.
Both problems are solvable with short custom Window script. By the way, yes, you can attach a script to the main project window. There is no project setting for that, you have to do it from a script:
# Main.gd
const WINDOW_SCRIPT := preload("res://WindowScript.gd")
func _ready() -> void:
get_window().set_script(WINDOW_SCRIPT)
In the script itself we want to override the _get_contents_minimum_size method to customize the behavior. For the content scale factor the solution is straightforward: simply apply it at the end. For individual controls we need to do a bit more, and create a bounding box that encompasses both the control and the origin of the coordinate system (i.e. (0, 0)). The size of this bounding box is always exactly enough to include the control no matter in which direction it grows and where its anchors end up. This size can then be multiplied by the control's scale and used to further computations.
# WindowScript.gd
extends Window
func _get_contents_minimum_size() -> Vector2:
var content_min_size := Vector2.ZERO
for child in get_children():
if child is Control:
var control_size := _get_control_minimum_size_with_offset(child)
content_min_size = content_min_size.max(control_size )
return content_min_size * content_scale_factor
func _get_control_minimum_size_with_offset(control: Control) -> Vector2:
# In the engine code invisible controls are also included in
# the computation, but that doesn't seem correct.
if not control.visible:
return Vector2.ZERO
var control_min_size := control.get_combined_minimum_size()
var control_rect := Rect2(control.position, control_min_size)
control_rect = control_rect.expand(Vector2.ZERO)
return control_rect.size * control.scale
That should solve the problems with the window size in the engine when scaling is applied. For fun you can also try adding support for CanvasLayer, since those are often used to house the GUI root, instead of it being added to the window root directly.
To help you and me to understand these aspects of GUI scaling better, I made a little demo project which you can find on GitHub:
It has a configuration window with every option explained in great detail, and a demo window with a live preview. Please feel free to experiment with it and refer to it when you have scaling issues in need of fixing!
And if you've enjoyed the article and found it helpful, please consider donating to support my work!
Loading comments and feedback...