
Simple Aseprite Importer for Godot
I've been chipping away at a project that uses pixel art, and one element involves simple Background scenes composed of multiple objects. I opted to try and write an aseprite importer for Godot to get my feet wet with the importer scripting - not something I've done in Godot yet.
This Aseprite importer is pretty simple - it generates a scene from an aseprite file, bringing in each layer as it's own Sprite2D. If you add slices to the aseprite file, it will match the slice to a layer and auto crop the asset, then position the Sprite2D appropriately when placing it in the scene. This requires the names to match - I wish the slicing wasn't necessary, but I'm not sure that I can get the trim bounds data out of aseprite when using that option in the CLI. The plugin also updates the z_index of each Sprite2D it adds to match the layer index in aseprite.
Table of Contents
The Aseprite File

There it is. You'll notice a few slices that correspond to their layers. they must match names (not case sensitive) so teh importer can associate them. This is kind of crappy, and if this were a team project I would work on smoothing out that wrinkle a bit harder. it is my own misery at stake though, so I'm pushing it off!
The Code
most of the value of the plugin is the _import method, so I'll just run it down from here, and include the entire method at the end of the blog post. If you want to follow along at home, you'll need to make your own import plugin first. After that there's some setup changes to make so that our plugin works with all the correct data types.
The Setup
once you've got an importer plugin, you'll want to store the user's aseprite executable path somewhere - on my linux box this is just aseprite, but you can make it a plugin preference with the following update to plugin.gd:
func _enter_tree() -> void:
#import plugin
import_plugin = preload("import_plugin.gd").new()
add_import_plugin(import_plugin)
# If the path doesn't exist, define it and set a default value
if not ProjectSettings.has_setting(SETTING_PATH):
ProjectSettings.set_setting(SETTING_PATH, "")
# Basic means user's can see it without flipping the advanced toggle
ProjectSettings.set_as_basic(SETTING_PATH, true)
#property hints
var property_info = {
"name": SETTING_PATH,
"type": TYPE_STRING,
"hint": PROPERTY_HINT_NONE
}
ProjectSettings.add_property_info(property_info)
#assign it to our import_plugin
import_plugin.aseprite_path = ProjectSettings.get_setting(SETTING_PATH, "")and then my import_pluign.gd looks like this - note that I tell it to expect .ase files, and it should spit out packed scenes. I called them Plases because they are Aseprite files that represent places in my project, which I''m sure I will regret.
@tool
extends EditorImportPlugin
var aseprite_path = ""
func _get_importer_name():
return "kpd.plaseimporter"
func _get_visible_name() -> String:
return "Plase"
func _get_recognized_extensions():
return ["ase"]
func _get_save_extension():
return "scn"
func _get_resource_type() -> String:
return "PackedScene"
func _get_import_options(path: String, preset_index: int) -> Array[Dictionary]:
var result:Array[Dictionary]
return resultThe Import Walkthrough
The initial part is pretty boring - Godot's _import()has a predefined signature we have to use. We generate an empty scene, because that's what our importer plugin has established we're importing. I throw an error if our aseprite path is empty.
func _import(source_file, save_path, options, r_platform_variants, r_gen_files_):
# make a new scene to return
var scene = PackedScene.new()
#early out if we've got an empty path
if aseprite_path.is_empty():
printerr("empty string provided to plaseprite importer")
return ResourceSaver.save(scene, "%s.%s" % [save_path, _get_save_extension()])Our first command gets called here, which effectively asks aseprite for the slice & layer info from the file. generally people use this for sprite sheets & animations, so they want the data delivered as a json. We are doing something simpler, so we can give the--data argument a blank path with\"\" to just print the json to console - which we capture with output. To see all of Aseprite's CLI arguments, check here.
We doa quick error check in case that failed.
var exit_code = OS.execute(aseprite_path, [
"-b",
"--list-slices",
"--list-layers",
"--data=\"\"", #empty data path means it'll spit it to output
global_path], output)
if exit_code != 0:
printerr("Error getting data from aseprite file| code %s | output is %s" % [exit_code, output])
return ResourceSaver.save(scene, "%s.%s" % [save_path, _get_save_extension()])
OK so now we've got an ugly flat string that we can parse as JSON.
var json:Dictionary = JSON.parse_string(output[0])
it should look something like the following JSON blob. Everything in "frames" we pretty much ignore for this importer, but if you felt like a keener you could validate the canvas size and other stuff here. "meta" has "layers" and "slices", which are the data we actually care about.
{
"frames":{
"test.ase":{
"frame":{
"x":0.0,
"y":0.0,
"w":384.0,
"h":216.0
},
"rotated":false,
"trimmed":false,
"spriteSourceSize":{
"x":0.0,
"y":0.0,
"w":384.0,
"h":216.0
},
"sourceSize":{
"w":384.0,
"h":216.0
},
"duration":100.0
}
},
"meta":{
"app":"http://www.aseprite.org/",
"version":"1.3.9.2-x64",
"format":"I8",
"size":{
"w":384.0,
"h":216.0
},
"scale":"1",
"layers":[
{
"name":"BG",
"opacity":255.0,
"blendMode":"normal"
},
{
"name":"Orb",
"opacity":255.0,
"blendMode":"normal",
"data":"test"
},
{
"name":"Line",
"opacity":255.0,
"blendMode":"normal"
},
{
"name":"Dots",
"opacity":255.0,
"blendMode":"normal"
},
{
"name":"rect",
"opacity":255.0,
"blendMode":"normal"
}
],
"slices":[
{
"name":"Orb",
"color":"#0000ffff",
"keys":[
{
"frame":0.0,
"bounds":{
"x":226.0,
"y":68.0,
"w":66.0,
"h":66.0
}
}
]
},
{
"name":"Line",
"color":"#0000ffff",
"keys":[
{
"frame":0.0,
"bounds":{
"x":180.0,
"y":55.0,
"w":82.0,
"h":50.0
}
}
]
},
{
"name":"Rect",
"color":"#0000ffff",
"keys":[
{
"frame":0.0,
"bounds":{
"x":138.0,
"y":105.0,
"w":61.0,
"h":50.0
}
}
]
}
]
}
}Lets get this data in a usable format. I've decided to create a Dictionary[name:String]>bound:Rect2Ibecause that's all i need, but you could map it however you want, depending on needs.
# grab the slices (just direct ref the json organization here for now)
var slices_json = json["meta"]["slices"]
var slice_bounds:Dictionary = {}
#convert slices from json to Dict[name]=bounds
for i in slices_json:
var bound:Rect2i
bound.position = Vector2i(i["keys"][0]["bounds"]["x"], i["keys"][0]["bounds"]["y"])
bound.size = Vector2i(i["keys"][0]["bounds"]["w"], i["keys"][0]["bounds"]["h"])
slice_bounds[i.name] = boundI pull the layers into a dictionary as well, with the special aseprite User Data as the value. I don't actually use this yet, but its how I might load up even more data into the aseprite file, when desired.
# direct access the layers from json
var layers_json = json["meta"]["layers"]
var layer_data:Dictionary = {}
for i in layers_json:
var data = ""
if i.has("data"):
data = i["data"]
layer_data[i.name] = dataTime to export some textures! witha big list of layers & a big list of slices, we can associate them (via name) and then export sliced (or whole!) layers. We'll start by figuring out the folder where we want these textures to live.
# get the folder path that matched the source file
var split = global_path.rsplit("/",false, 1)
var path = split[0] + "/"
Now we walk through every layer and slice combo. this is ripe for optimization! I'll post an update if I end up having to optimize, but I don't see me having too many layers or slices.
When we find a slice whose name matches a layer (to_lower() will save us from some badly name art assets), then we do another command line execution to tell aseprite exactly what to spit out. the keys here are the --layer and --slice arguments, which tell aseprite to only use a specific layer, and allow a slice to drive the cropping of the asset. we stash the path to the texture in our little texture path dictionary, keyed to layer name.
for layer_key:String in layer_data.keys():
for slice_key:String in slice_bounds.keys():
# if we find a matching slice, export the cropped layer
if slice_key.to_lower() == layer_key.to_lower():
var target_path = "%s.png" %layer_key
var code = OS.execute(aseprite_path, [
"--batch",
"--layer",layer_key,
"--slice",slice_key,
global_path,
# can't do the following:
#"--save-as %s.png" %layer_key
# need to split any spaces with a comma, as follows
"--save-as", target_path,
], output, true)
if code != 0:
print(code)
print(output)
else:
texture_paths[layer_key] = target_pathIf there's no slice for a specific layer, we export it full-size! this is desired for backgrounds probably, but who knows what else may need this. So after going through all the slices, if the layer still does not have a texture path, we do a different call to the aseprite CLI to get the full frame texture exported.
# if we did not find a matching slice, export this layer uncropped
if !texture_paths.has(layer_key):
var target_path = "%s.png" %layer_key
var code = OS.execute(aseprite_path, [
"--batch",
"--layer",layer_key,
global_path,
"--save-as", target_path,
], output, true)
if code != 0:
print(code)
print(output)
else:
texture_paths[layer_key] = target_pathNow we've got a bunch of textures on disk. Ideally, these would stick around - we'd rescan for those textures, update their godot importer settings, and then use them when we generate our scene. I did not do this, because the lifecycle there seems hairy - you can't scan() while importing, so I'd have to manually generate the import settings for each asset. For my low-fidelity project, I don't mind having these sprites localized to the scene, so I load the assets as images and assign them as local-to-scene resources in their respective Sprite2D objects.
We make a root node, name it after the aseprite file, and then make Sprite2Ds for each other layer, with the following properties:
- Sprite2D is named after the layer
- ImageTexture resource is created from the image
- ImageTexture is a local-to-scene resource
- Sprite2D's z_index matches the layer's order in the texture_path list, which matches the order they appear in the aseprite file (appears to be bottom-up)
- nearest filter for pixel art
- if it has a corresponding slice, positioned to match that
- if it has no corresponding slice, placed with (0,0) at the top left corner
- a child of the root node, owned by the root node
# now we've got lookups for slice data & textures.
# we can generate the node tree from that
# root node will be node2D for now
var root = Node2D.new()
root.name = source_file.rsplit(".")[-2].right(-6) #remove extension, then the first 6 characters (res://). sloppy
var order = 0
for name in texture_paths.keys():
# make & name node
var node = Sprite2D.new()
node.name = name
# load images from disk and save them into the scene directly
var image = Image.load_from_file(texture_paths[name])
var texture = ImageTexture.create_from_image(image)
texture.resource_local_to_scene = true
node.texture = texture
# z index should match layer order for now
node.z_index = order
order += 1
#pixel art filtering
node.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
var found_slice = false
# if node has a slice, reposition
for slice_key in slice_bounds.keys():
if slice_key.to_lower() == name.to_lower():
var bounds:Rect2i = slice_bounds[slice_key]
var pos = bounds.position + (bounds.size/2)
node.position = pos
found_slice = true
if !found_slice:
node.position = texture.get_size()*0.5
root.add_child(node)
node.owner = rootThen we delete all the textures we made! they were used to generate the textures in the imported scene, and they don't need to dirty up the project view.
We pack the scene with the root node & its subgraph, then return the saved resource. Done!
# now we should clean up (delete generated images)
for texture in texture_paths.keys():
var texture_path = texture_paths[texture]
if FileAccess.file_exists(texture_path):
var error = DirAccess.remove_absolute(texture_path)
if error != OK:
print("Failed to delete file. Error code: ", error)
# pack nodes into the scene
scene.pack(root)
#r eturn generated scene
return ResourceSaver.save(scene, "%s.%s" % [save_path, _get_save_extension()])
The Result
the generated scene matches the aseprite file perfectly, with individual objects as configured in the file. If you edit the aseprite file and save it, the generated scene immediately updates! Fun!

The Import function in it's Entirety
func _import(source_file, save_path, options, r_platform_variants, r_gen_files_):
# make a new scene to return
var scene = PackedScene.new()
#early out if we've got an empty path
if aseprite_path.is_empty():
printerr("empty string provided to plaseprite importer")
return ResourceSaver.save(scene, "%s.%s" % [save_path, _get_save_extension()])
var output = []
var global_path = ProjectSettings.globalize_path(source_file)
var exit_code = OS.execute(aseprite_path, [
"-b",
"--list-slices",
"--list-layers",
"--data=\"\"", #empty data path means it'll spit it to output
global_path], output)
if exit_code != 0:
printerr("Error getting data from aseprite file| code %s | output is %s" % [exit_code, output])
return ResourceSaver.save(scene, "%s.%s" % [save_path, _get_save_extension()])
var json:Dictionary = JSON.parse_string(output[0])
# grab the slices (just direct ref the json organization here for now)
var slices_json = json["meta"]["slices"]
var slice_bounds:Dictionary = {}
#convert slices from json to Dict[name]=bounds
for i in slices_json:
var bound:Rect2i
bound.position = Vector2i(i["keys"][0]["bounds"]["x"], i["keys"][0]["bounds"]["y"])
bound.size = Vector2i(i["keys"][0]["bounds"]["w"], i["keys"][0]["bounds"]["h"])
slice_bounds[i.name] = bound
# direct access the layers from json
var layers_json = json["meta"]["layers"]
var layer_data:Dictionary = {}
for i in layers_json:
var data = ""
if i.has("data"):
data = i["data"]
layer_data[i.name] = data
# inside of you there are 2 dictionaries
# for each slice A, we want to retrieve Layer B that shares a name
# then we want to export Layer B in isolation, trimmed to the bounds of slice A
# and save it in the same global path. (to be read then deleted)
# get the folder path that matched the source file
var split = global_path.rsplit("/",false, 1)
var path = split[0] + "/"
var texture_paths:Dictionary = {}
for layer_key:String in layer_data.keys():
for slice_key:String in slice_bounds.keys():
# if we find a matching slice, export the cropped layer
if slice_key.to_lower() == layer_key.to_lower():
var target_path = "%s.png" %layer_key
var code = OS.execute(aseprite_path, [
"--batch",
"--layer",layer_key,
"--slice",slice_key,
global_path,
# can't do the following:
#"--save-as %s.png" %layer_key
# need to split any spaces with a comma, as follows
"--save-as", target_path,
], output, true)
if code != 0:
print(code)
print(output)
else:
texture_paths[layer_key] = target_path
# if we did not find a matching slice, export this layer uncropped
if !texture_paths.has(layer_key):
var target_path = "%s.png" %layer_key
var code = OS.execute(aseprite_path, [
"--batch",
"--layer",layer_key,
global_path,
"--save-as", target_path,
], output, true)
if code != 0:
print(code)
print(output)
else:
texture_paths[layer_key] = target_path
# now we've got lookups for slice data & textures.
# we can generate the node tree from that
# root node will be node2D for now
var root = Node2D.new()
root.name = source_file.rsplit(".")[-2].right(-6) #remove extension, then the first 6 characters (res://). sloppy
var order = 0
for name in texture_paths.keys():
# make & name node
var node = Sprite2D.new()
node.name = name
# load images from disk and save them into the scene directly
var image = Image.load_from_file(texture_paths[name])
var texture = ImageTexture.create_from_image(image)
texture.resource_local_to_scene = true
node.texture = texture
# z index should match layer order for now
node.z_index = order
order += 1
#pixel art filtering
node.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
var found_slice = false
# if node has a slice, reposition
for slice_key in slice_bounds.keys():
if slice_key.to_lower() == name.to_lower():
var bounds:Rect2i = slice_bounds[slice_key]
var pos = bounds.position + (bounds.size/2)
node.position = pos
found_slice = true
if !found_slice:
node.position = texture.get_size()*0.5
root.add_child(node)
node.owner = root
# now we should clean up (delete generated images)
for texture in texture_paths.keys():
var texture_path = texture_paths[texture]
if FileAccess.file_exists(texture_path):
var error = DirAccess.remove_absolute(texture_path)
if error != OK:
print("Failed to delete file. Error code: ", error)
# pack nodes into the scene
scene.pack(root)
#r eturn generated scene
return ResourceSaver.save(scene, "%s.%s" % [save_path, _get_save_extension()])







