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

Screenshot of aseprite with a test file open

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 result

The 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] = bound

I 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] = data

Time 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_path

If 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_path

Now 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 = root

Then 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!

Screenshot of the Godot editor with a recreation of the aseprite file previewed in the scene view

 

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()])

 

Mastodon

This article was updated on

Related post