How to leverage the scalability of SVG in Godot

Godot Version at time of creation: 4.3

What this tutorial teaches:

Example Screenshots

This screenshot was taken with godot at it’s default resolution of 1152x648:

And this is how it looks when I upscale it to fullscreen at 2560x1440:

You can tell from the tumbnail that no quality was lost in the upscaling process, and you can verify it by opening the images.

Also in case you just want good scaling and aren’t particularly hung up on vector graphics:

An alternate way to get good scaling quality without SVG

First off, I’d like to point out, that the simplest way to achieve good scaling quality is to set the viewport width and height to the highest resolution you intend to support, and set the stretch mode to canvas_items or viewport.

Settings Example

These settings are NOT compatible with the guide, the Stretching mode must be set to disabled if you want the results I got in this guide.

This way anything below that maximum resolution is downscaled, there is some loss of quality when you downscale but it is negligible compared to the quality loss you get from upscaling.

You can further enhance the scaling by tweaking your scaler (it’s called interpolator in godot, Lanczos is generally the best one, however as far as I know there is no global interpolator setting and this must be set on an image by image basis via gdscript)

And now for the guide.

Overview


The most likely common use case for the method in this guide would be for UI icons since it allows you to reliably scale such icons relative to the window size without any loss of quality); but taking it all the way it could potentially be used to create something similar to flash games in Godot.

As everyone probably knows, SVG has infinite scalability, and godot supports this format. However, it only supports this format in the sense that you can import an SVG image, and by doing so you convert that image from Vector Graphics to a Bitmap (rasterized image). You can scale the SVG image upon importing it which will make the resulting bitmap higher resolution, but once it’s imported, it’s scale is set and it will scale the same as any PNG would (making it somewhat meaningless to use SVG in the first place).

This means that normally an SVG image in godot will not be able to leverage any of the actual benefits of the SVG format, it will not have the small filesize of SVGs after importing, and it will also not have the infinite scaleability, it will scale like any other image.

This guide is about working around that issue without using any plugins. You can even leverage the smaller filesize benefit of SVG by doing things this way, but the images will be the same size on your memory as they would be if you were using a PNG so you will unfortunately not be saving on any RAM by doing this.

The way we can work around the issue is by importing and scaling the SVG at runtime, so that we can update it’s scaling as needed also at runtime.

Scene Setup


First off you need to create a 2D scene, and add a sprite and camera2d nodes.
image

You don’t really need to do anything special to any of them, for the Node2D I like to center it (assumes your viewport size is godot default):
image

the Camera2D position must be centered as well (should just be position 0,0) and it’s anchor mode must be Drag Center (this is default).
image

The reason we need the Camera2D node with the above setting is to keep the ‘view’ centered whenever the window is scaled. Without this the ‘playable area’ (for lack of a better term) will be anchored to the top-left of the screen when the window is scaled. with the Camera2D node in place however it’ll be anchored to the center of the window instead.

GDScript


Now for the code. (This code is just for one sprite, there is a more advanced version which accounts for multiple sprites in the comments)

For the sprite2d node attach a script:
image

And here is my thoroughly commented example script which I used for the example screenshots:

extends Sprite2D

# Configurable Variables
var SVGPath:String = "res://icon.svg" # Path to SVG File to be used
var Scale:float = 1 # Size Multiplier (Increasing this makes the final image bigger)

# Buffer Variables
var DevelopmentResolution:Vector2 # Original resolution (auto-detected on _ready()); typically this is 1152x648
var ActiveResolution:Vector2 # Buffer to store the last resolution used for scaling, it is needed to check whether or not the window size has changed
var SVGBuffer:PackedByteArray # A buffer for storing the SVG
var Bitmap:Image = Image.new() # Bitmap for SVG->Bitmap conversion

func _ready() -> void:
	DevelopmentResolution = get_window().content_scale_size # Detect Original Resolution
	ActiveResolution = get_window().size # Initialize Active Resolution Variable
	SVGBuffer = FileAccess.get_file_as_bytes(SVGPath) # Load SVG to buffer
	update_scale()  # Load SVG and apply initial scaling
	anchor_bottom() # Position the sprite at the bottom of the window.

func _process(delta: float) -> void:
	if Vector2i(ActiveResolution) != get_window().size: # If the window size has changed
		position.x /=ActiveResolution.x / DevelopmentResolution.x # Unscale X Position using the X scaling of the last resolution (needed for the anchor X position scaling logic)
		ActiveResolution = get_window().size # Update Active Resolution
		update_scale() # Rescale SVG
		anchor_bottom() # Update sprite position based on new screen resolution

func update_scale(desired_scale: float = ActiveResolution.y / DevelopmentResolution.y) -> void: # Accepts scale parameter, defaults to scaling based on difference between development resolution and currently active resolution.
	Bitmap.load_svg_from_buffer(SVGBuffer, desired_scale * Scale) # Scale SVG and convert from buffer to bitmap
	texture = ImageTexture.create_from_image(Bitmap) # Apply the bitmap as a texture

func anchor_bottom() -> void: 
	position.y = ActiveResolution.y * 0.5 - texture.get_height() * 0.5 # Calculates the coordinate for the bottom of the screen offset by the height of the bitmap
	position.x *= ActiveResolution.x / DevelopmentResolution.x # Scale X Position according to X window scaling

Copy-Pasting it should work just fine.

And we’re done, this is a basic way to use SVG scaling in Godot.

Performance


After the SVG has been scaled and imported it will perform as fast as any PNG would, however the scaling process (load_svg_from_buffer) itself can be very costly.

If you need the scaling process to be faster, use multithreading

The time it takes to scale an SVG depends strongly on the final image size, you could say the time increase based on output image resolution is exponential.

Just to reiterate: these performance tests are strictly just for the scaling process. (You could say I’m measuring how much the game ‘could’ stutter when an image or images are being initially loaded or re-scaled), generally speaking there isn’t any actual performance cost after this scaling happens (it’ll perform the same as any other image would).

Some very rough performance testing:

50 Sprites * Scale 1 (128x128) = ~170fps
50 Sprites * Scale 2 (256x256) = ~70fps
1 Sprite * Scale 10 (1280x1280) = ~100fps
50 Sprites * Scale 10 (1280x1280) = <1fps (about 3 seconds)
1 Sprite * Scale 30 (3840x3840) = ~7fps
50 Sprites * Scale 30 (3840x3840) = <1fps (about 7 seconds)
1 Sprite * Scale 100 (12800x12800) = <1fps (about 8 seconds)

Multithreading tests:

50 Sprites * Scale 1 (128x128) = ~300fps
50 Sprites * Scale 2 (256x256) = ~270fps
50 Sprites * Scale 10 (1280x1280) = 140fps
50 Sprites * Scale 30 (3840x3840) = 140fps (I checked multiple times, there might be a delay before the images actually get resized, but at least it doesn’t block the game thread)

Multithreading code changes:

var thread: Thread = Thread.new() # Added this line at the top with the other variables.

thread.start(update_scale) # Replaced update_scale() under _process() with this line.

func update_scale(desired_scale: float = ActiveResolution.y / DevelopmentResolution.y): # Removed: -> void

Native Vector Graphics Support In Godot


There is a pull request on github with code to implement native vector graphics support in godot.

It’s creator also made a plugin which can be used to load SVG files natively (without all the shenanigans in this guide) for potentially better performance and simpler scaling mechanisms.

5 Likes

Thanks for the detailed writeup!

To improve performance with runtime scale changes, you can also generate specific sizes at load, cache them and switch them as needed, or generate a large texture once with mipmaps enabled (Image.generate_mipmaps().

1 Like

Yeah there are many ways to optimize this.

Personally I think the best way would be something along these lines:

  1. Create a folder/folders for your SVGs and a cache directory for their corresponding Bitmaps
  2. Use a ‘master’ SVG scaling script that is always running which:
    1. Detects when images need to be loaded (or re-scaled), and keeps track of which images are currently loaded and what.
    2. Using the code in this guide or something similar, scale the target SVG and save it as bitmap (png) files in aforementioned cache directory.
    3. Signal all sprites that are using the modified bitmap to re-load it.
    4. When an image is no longer going to be needed, delete the cached bitmap to save disk space (only do this in the released version of the game, not in the development build because you need those cached bitmaps to be able to preview the sprites in the editor)
  3. The sprites load their imges from the cached bitmaps and don’t need any special scripts.

There’s a lot of room for adjustment for that plan, but the main goal is to prevent duplication by letting multiple sprites share the same bitmap instead of each having their own unique ones, thus saving potentially a lot of memory and processing power (since each bitmap is only scaled once now no matter if you have 50 sprites using it or just one; because they all use the same bitmap instead of unique ones).

I tried to run with the above idea, and this is my result (for some reason I couldn’t upload a video, so it’s a gif):
170924-182523

I tried to create a setup utilizing the above philosophy. Caching the scaled images to files failed because it seemed that no matter how I tried to re-load the files (besides loading them as bytes, which defeats the point) they would always come out as they had been originally imported in the editor. Godot apparently completely lacks logic for re-loading modified files (maybe if I used the importer it might work, but by that point it is not worth the hassle anymore).

What I did instead was: I have a master scaling script, and some ‘generic’ svg sprite script code that would need to be contained within all sprites that are going to use SVGs (nothing is hardcoded so it can just be copy-pasted to all such sprites, but if a sprite does not use an SVG as it’s default texture your game will crash so careful with that).

In the master script I have a dictionary that’s set up so that the Key is always the path to the SVG, and it’s value is an array that at index 0 contains the SVG as bytes, at index 1 it contains the scale at which the SVG was imported by the editor (so it respects that), and the remaining array items are the nodes that are using the SVG that the Key refers to.

Upon the sprite’s creation, it creates a key for it’s svg in the svg tracker dictionary of the master scaler if it doesn’t already exist and then it adds itself to the tracking list. (So the creation of dictionary keys and values, and the removal of values as well is generally called by the sprite, not the master script, even if the code for it is mostly in the scaler script.)

The master script then keeps track of whether or not the window size has changed, if it has then it triggers the rescaling logic which is more or less the same as in the original post (biggest exception is that it’s positional adjustment is keeping it’s position relative to the window’s edges rather than just relative to the bottom edge) and it sends a signal to all the sprites to inform them that they need to update their position and texture.

Master SVG Scaler script (it’s my root node2d running it)

extends Node2D

signal rescale(File:String,OutTexture:ImageTexture,LastRes:Vector2, LastScale:Vector2) # Create a signal to be used to tell sprites when they need to update their texture and position, and send them the data to do so.

var SVGTracker: Dictionary = {} # A dictionary to keep track of which SVGs are in use, at what scale, and what nodes are using them.

@onready var MainWindow: Window = get_window() # Store window reference in variable because we will use it a lot.
@onready var DevelopmentResolution:Vector2 = Vector2(MainWindow.content_scale_size) # Original resolution of the project
@onready var ActiveResolution:Vector2 =  Vector2(MainWindow.size) # Buffer to store the last resolution used for scaling
var LastScale:Vector2 # Buffer to store last used scale value for both axis
var Bitmap: Image = Image.new() # Bitmap for SVG->Bitmap conversion

func _ready() -> void:
	get_tree().get_root().connect("size_changed",window_size_changed) # Hook up the size_changed event for the root to the window_size_changed function

func window_size_changed():
	LastScale = ActiveResolution / DevelopmentResolution # Store the last used scale for both axis
	ActiveResolution = MainWindow.size # Update Active Resolution
	for key in SVGTracker: # For every SVG being tracked by this script
		Bitmap.load_svg_from_buffer(SVGTracker[key][0],(ActiveResolution.y / DevelopmentResolution.y)*SVGTracker[key][1]) # Convert SVG to Bitmap with updated scaling settings
		rescale.emit(key,ImageTexture.create_from_image(Bitmap),ActiveResolution,LastScale) # Forward the SVG File Path, Bitmap(as texture), Window Resolution and Last Scale values to all nodes connected to the rescale signal.
		'''
		# Alternate way which does not require a signal connection, i'm not sure which is more efficient.
		for node in SVGTracker[key]: # For each node using SVG
			if node is Object: # Make sure it's actually a node (first 2 items in the array are never nodes, which is why this is needed)
				node._on_rescale(key,ImageTexture.create_from_image(Bitmap),ActiveResolution,LastScale) # Activate rescale function with all the relevant data (doing this actually does not require the signal to be connected)
		'''

func add_svg(file: String): # Adds a new SVG file to the tracking dictionary (Generally needs to be called from the child)
	if !SVGTracker.has(file): # Only run if the key does not already exist (svg not already being tracked)
		SVGTracker[file] = [FileAccess.get_file_as_bytes(file), get_import_scale(file)] # create dictionary Key:SVG File Path, Value: [SVG as bytes, Importer Scale Setting]


func remove_svg(file: String, node: Object): # Removes node from tracking dictionary, and deletes the svg from tracking if no nodes are using it.  (Generally needs to be called from the child)
	SVGTracker[file].remove_at(SVGTracker[file].find(node,2)) # Remove the target node from the list of nodes using the target SVG 
	if SVGTracker[file].size() < 3: # If no nodes are using this SVG anymore
		SVGTracker.erase(file) # Delete the SVG file from the tracking dictionary


func get_import_scale(file: String): # Gets the scale at which the SVG was imported into the editor.
	var ImportSettings: ConfigFile = ConfigFile.new() # Blank config file buffer
	ImportSettings.load(file+".import")	# Load import settings to config file buffer
	return ImportSettings.get_value("params","svg/scale") # Read import scale value

And a sprite that uses the master scaler:

extends Sprite2D

var SourcePath: String = texture.resource_path # Store the path of the SVG this sprite is using.
@onready var SVGScaleMaster: Node2D = $".."  # A reference to the node containing the SVG Scaling script, in this case it is the root node

func _ready() -> void:
	SVGScaleMaster.connect("rescale",_on_rescale) # Connect to the root node's rescale signal
	SVGScaleMaster.add_svg(SourcePath) # Add a new entry to the SVG Tracker in the SVG Scale Master script if one does not already exist for this SVG.
	SVGScaleMaster.SVGTracker[SourcePath] += [self] # Add self to list in the root node that keeps track of which nodes are using this SVG

func _on_rescale(SVG:String,TEX:ImageTexture,AR:Vector2,LS:Vector2) -> void:
	if SourcePath == SVG: # If the modified SVG is the same as the one this sprite is using
		texture = TEX # Update the displayed texture with the re-scaled one
		#position.y = position.y/ LS.y * AR.y / get_window().content_scale_size.y # Keep relative sprite positioning on Y axis only
		position = position / LS * AR / Vector2(get_window().content_scale_size) # Keep relative sprite positioning (works best if aspect ratio is locked)

func _on_death() -> void:
	SVGScaleMaster.remove_svg(SourcePath,self) # Remove self from tracking for the SVG Scaling script
	call_deferred("free") # Mark for deletion at the next opportunity (safer than queue_free())

A big benefit imo of this approach is that once you’ve created and applied this code, using SVGs with sprites becomes fully intuitive, for instance let’s say you want to make a new sprite that uses an SVG.

The process is to add the sprite code above as it’s script (this can actually in many cases probably just be one shared script if they don’t need any logic other than the scaling logic).

Then just set the SVG you want as this sprite’s texture in the editor.

That’s it. That’s the whole process, you now have a sprite using an SVG that’ll scale like in my OP and the gif at the top of this post.

The biggest downside is that scaling is shared for each sprite using an identical SVG, there’s no easy way around this so the simplest solution to make a sprite using the same SVG operate at a different scale is to duplicate the SVG and reimport the duplicate at the scale you want for that particular sprite, then use that duplicate as that sprite’s texture.

Edit: Cleaned up the scripts a bit by moving more code to the master scaler script