This tutorial assumes basic knowledge of coding, the Godot Engine, and how to create pixel art sprites in a drawing software.
What is Sprite stacking?
Sprite stacking, stacked sprites, or “fake 3D pixel art” is a visual technique that consists of using a 2D rendering engine to render sprites on top of each other, slightly offset, in order to give the appearance of volume.
Not a single 3D model in sight! And it just looks great! And once you have a knack for it, making sprites for it is not that hard. There are some intricacies about it, though. How do I set it up easily to use sprite stacks in Godot? What if I want to rotate the camera?
This tutorial aims to showcase the way I go about making sprite stacking, and the (pretty simple) Godot code used to display them.
- Render pixels at double size, so small sprite stacks don’t look too small. You can do that by tinkering with Project Settings → Display → Window, or by creating a camera and giving it a zoom of 2.
- Remember to always deactivate “Filter” in the import settings for any pixel art asset, or they will look blurry.
Making a sprite
TIP: You can right click → Save any of the stack sprites to test. If you download the house ones, make sure to remove the black part first.
Open your preferred pixel art editor (I’m using Aseprite) and create a file with height 64 and width 64*number_of_slices. In my case, I did 20 slices. Overshooting is not much of an issue, Godot would just spawn invisible sprites (and you can always change the width and number later).
I recommend setting up the grid at a factor of the slice’s size (32, 16, or 8 in this case) so you have a clear indication of where the center of each slice is.
Now you have a very long sprite. Imagine we want to make a simple house. We would start by making a centered square in each slice’s position, basically making a cube.
If I want to add a door and some windows, I need to understand that each of those squares is a vertical slice of the house, so I need to do something like this (In red, for clarity):
I shrank the size of the door in its last two slices in order to “round” the top of it.
Be aware that mostly only the outside pixels will be visible because slices will block each other. So feel free to use thickness for clarity (as I did here – I made the door and windows a few pixels thick), even if it will not impact the final visual result.
If you’re following so far, you can imagine how would I do the roof:
Here’s how this looks with the code we’ll look at later:
This is the sprite that I showed in the introduction, which is quite a bit more complex, and made from a 2D reference:
Rendering sprite stacks in Godot
To render one of these in Godot in the cleanest way possible, I wrote a script that you can add to any Node2D:
extends Node2D # There will show up in the inspector export var texture : Texture = null export var num_slices : int = 1 # Spritestack rotation var rot = 0 func _ready(): # Extract the sprite region's width and height var w = texture.get_width()/num_slices var h = texture.get_height() # For each slice, create a new sprite, set it up so it senders the correct slice, # and add it as a child of this node for i in range(0, num_slices): var slice = Sprite.new() slice.texture = texture slice.region_enabled = true slice.region_rect = Rect2(i*w, 0, w, h) # This is the magical offset that makes the whole thing look 3D slice.position = Vector2(0, -i) add_child(slice) func _process(delta): var children = get_children() # Update each child's rotation for i in children.size(): children[i].rotation = rot # Rotating the stack, just for show rot += 0.01
Rotating the camera
Sprite stacking as it is doesn’t work well at all with rotating cameras. If we want to feel entirely 3D, we have to support those (most games won’t need to rotate the camera, so that’s why I don’t include this as part of the main sprite stacking code).
If you just spawn a Camera2D in Godot and give it some rotation, you’ll find that the sprite stacks keep offsetting themselves in the Y-axis, so everything looks awful:
Spritestacks should be offsetting themselves not in the Y-axis, buy in the “UP” vector of the camera. To achieve that, I set up an Autoload script (to have a global variable that everybody can access) with the camera’s rotation:
extends Node var camera_rot = 0
extends Camera2D func _input(ev): if ev is InputEventKey and ev.scancode == KEY_LEFT: Global.camera_rot += 0.1 if ev is InputEventKey and ev.scancode == KEY_RIGHT: Global.camera_rot -= 0.1 func _process(delta): rotation = Global.camera_rot
Spritestack code, updated for camera rotation:
extends Node2D # There will show up in the inspector export var texture : Texture = null export var num_slices : int = 1 # Spritestack rotation var rot = 0 func _ready(): # Extract the sprite region's width and height var w = texture.get_width()/num_slices var h = texture.get_height() # For each slice, create a new sprite, set it up so it senders the correct slice, # and add it as a child of this node for i in range(0, num_slices): var slice = Sprite.new() slice.texture = texture slice.region_enabled = true slice.region_rect = Rect2(i*w, 0, w, h) # This is the magical offset that makes the whole thing look 3D slice.position = Vector2(0, -i) add_child(slice) func _process(delta): # Calculate up vector direction from camera rotation var up_vector = Vector2(cos(Global.camera_rot - PI/2), sin(Global.camera_rot - PI/2)) var children = get_children(); for i in children.size(): children[i].rotation = rot # Offset along up vector instead of along Y axis children[i].position = up_vector * i
An issue that might pop up, especially if you’re using camera rotation, is depth sorting. Quickly illustrated:
This would also happen without camera rotation, but there you could just set the z-index values manually. To solve this issue, add this code to the _ready() function of the sprite stack (or in the _process() function if things are going to be moving around):
z_index = position.y
When the camera is rotating, it’s not that simple, cause things are sometimes in front or in the back depending on where the camera is, so we need some math magic. The dot product is used to check the similarity between the “up vector” and the position of the object:
z_index = -position.dot(up_vector)
Cool examples from my games
Once you understand how this works, you can go fancy with it and think of other ways to use plain 2D sprites to simulate depth. Take a look at the menu of my game Drift:
The wheel is a sprite stack that is being stacked downwards instead of upwards.
The road consists of two similar sprites (one with the white middle and one without) that are constantly being spawned at the horizon and moved downwards and scaled up.
This is some gameplay from Drift:
Here’s my game BDSM, where I made use of the camera rotation to render a puzzle game:
It looks super clean, and no one will guess that there are no 3D models at all.
A more colorful example, in my demake of -amazing indie game- Pyre, Fyre:
If you’re wondering how the outline is done, it’s very dirty (it’s a jam game…), you just render the whole stack four more times, in black, offset in each of the four directions, under the original stack.
I hope this has been useful! I discovered sprite stacking not that long ago and, as you have seen, I’ve been having a lot of fun with it.
If you have any questions or suggestions, do not hesitate to contact me!
Also, contact me, please, if you made something using this tutorial. I wanna see it!