video

Fake 3D Top-Down character in Godot

Some time ago, I shared a video of a Top-Down character I created for Gdquest. People were intrigued by the inner workings of this little fella and wondered what kind of technique was used to create this kind of character.

Keep in mind that this technique detailed here works best for Top-Down characters, but you can do something similar for all types of depth effects.

All the files are available on github, you can check out the project and read along to get the gist of it :)

The basics

The whole effect boils down to Godot having a default parenting system for its nodes, any transformation applied to a parent node will be inherited by its children. The trick is to play with the parenting system of 2D nodes to obtain points on which to place sprites and give the illusion that our character is in 3D. Let's build a simple example to demonstrate this idea, which will be all we need to create more complex characters later on.

Let's create a new scene with this simple tree structure:

- SceneRoot (Node2D)
 - Visual (Node2D)
  - PointSprite (Sprite2D)
 - Rig (Node2D)
  - PointAnchor (Node2D)
   - PlaneResult (RemoteTransform2D: Targets the PointSprite node)
I always split character visuals (group of sprites) and rig (group of points that apply their transforms on the sprites) for clarity, but you can do your own structure.

Let's review the Rig node and its children and understand their role, this group dictates where sprites are placed on the screen.

The Rig node contains all the nodes in the rig structure. Because it is the parent of all the nodes in the structure, its transformation will affect all its children. Applying a scale to its Y component represents the angle of our imaginary camera; the smaller the number, the more obtuse the camera angle will be, and the more squashed the character will appear.

The PointAnchor node represent the elevation of our point (changing its Y position component)

The PlaneResult node is our point final transformation, it's this position we will use to place the Sprite node. Because it's relative to its PointAnchor parent, changing its position will change the final point position on a plane relative to the PointAnchor node.

It's important for this node to be a RemoteTransform2D updating only its remote node position, so we can apply only the final position to the PointSprite node without affecting scale and skew. We will use RemoteTransform2D nodes to escape Godot's parenting system once we are happy with a node's final transform.

Now that everything's in place, we can control our rig with these parameters:

  • The Y scale component of the Rig node to modify the PlaneResult inclination.
  • The angle value of the PointAnchor node to rotate the PlaneResult node around it.
  • The Y position component of the PointAnchor node to control the elevation of the PlaneResult node.
  • And the position of the PlaneResult node to change its position on a plane relative to the PointAnchor node.

So, with all that we obtain... "drum rolls" a point spinning around its origin... It might be little underwhelming, but this is really all we need to construct a whole character.

video

Designing our character

Now that we understand the underlying system, let's design a character on which to apply our new knowledge. Our goal is to design a character (called a 2D puppet) that can be broken down into parts (sprites).

This effect is best suited to simple, stylized characters. Round surfaces work well because they retain their shape to a greater or lesser extent, regardless of the angle from which they are viewed. It's best to avoid objects such as boxes with sharp angles, as we're not really creating a 3D model, but rather an illusion.

For this tutorial, I've chosen to create a duck, which contains fewer moving parts than the character designed for Gdquest. This step is fairly straightforward; my usual workflow is to draw a mock-up of the desired character (using vector graphics software such as Figma or Inkscape).

The model generally faces the camera from above (bearing in mind that it needs to be decomposable and simple enough to work with this technique). Once I'm happy with the general feel of the design, I can start to decompose it a save each parts as an image (.png).

No alt defined Duck mockup with all parts separated

Importing in Godot

I'll not describe how each part of the duck body works in this section as they all follow the same logic of the early example (Please refer to the project to follow along).

First we need to setup the scene and assets, let's create a folder that will contain a scene dedicated to our character and a subfolder containing all the duck's visual parts. You can find this scene in the top_down_character folder inside the project.

- top_down_character
  - parts
      - beak_sprite.png
      - body_sprite.png
      - etc...
 - duck.tscn
 - duck.gd
You might have noticed I don't use the neck sprite and didn't make legs sprites for this character. For more flexibility I draw those parts on the fly with a Line2D node with the limb_line_2d.gd attached to it. This script will draw a line between an array of node2D, this allows these body parts to stretch and deform according to the character movement.

The process is pretty straight forward, like for the early example we separate the visual and logic of the character in two sub nodes (Visual and Rig). This will allow us to separate concerns and draw things more easily without having to deal with the structure and drawing order of the Rig group.

- Duck (Node2D)
 - Visual (Node2D)
  - Sprites... (Sprite2D)
 - Rig (Node2D)
  - Anchor (Node2D)
   - PlaneResult (RemoteTransform2D: Targets a sprite node)

We then describe a succession of plane for each body parts so they can be separately moved in our imaginary 3D space. (Notice how each foot and body are setup in the same fashion). The only outlier here is the head part. As it uses a copy of the finale position of NeckRoot which is itself inherited from BodyPlaneOffset. The reason to use a RemoteTransform2D node here is to escape the parenting system and reset the transformation to only keep the position we need.

Once inside NeckPositionCopy we can set the elevation of the head by changing HeadHeightOffset's Y position component. From there, the beak and eyes parts follows the same rules as the body and feet.

From here, we computed all the position needed to place our sprites, as you can notice, they are all linked in some ways inside the Rig group with RemoteTransform2D nodes. There is one issue remaining... we didn't rotate the character yet! Let's add the finale logic to the character by creating a script.

# Export a range displayed in degrees, but which uses radians behind the curtains
# Adding a setter to this variable allows us to update everything we need to display the character properly at each angle
@export_range(0.0, 360.0, 0.1, "radians") var direction : float = 0.0 : set = _set_direction

# Declare all the sprites
@onready var foot_sprite_l = %FootSpriteL
@onready var foot_sprite_r = %FootSpriteR
@onready var body_sprite = %BodySprite
@onready var beak_sprite = %BeakSprite

# Declare all the nodes needing rotations
@onready var anchors = [%FootAnchorL, %FootAnchorR, %BodyAnchor, %EyesAnchor, %BeakAnchor, body_sprite, beak_sprite]

func _set_direction(value : float):
	if !is_inside_tree(): return
	direction = value
    
    # Wrap the direction value to an angle value which will always be between 0.0 and a full rotation (TAU)
	var angle = wrapf(direction, 0.0, TAU)
    
    # Apply the rotation value to all 2D nodes needing it (anchors and some sprites)
	for anchor in anchors:
		anchor.rotation = angle
        
    # The sprites for the feet are a bit of an outlier
    # because we also want to add a slight rotation offset so there are not fully alligned with the body
    # so let set it up directly in code while we are at it :)
	foot_sprite_l.rotation = angle + 0.5
	foot_sprite_r.rotation = angle - 0.5

From there, when we change the direction value, all the parts are rotated accordingly, positioning and rotating sprites in the desired position. There is still one remaining issue! The face and neck are not rendering properly when they are supposed to be behind the head and body.

video

To fix that, we simply need to create a logic checking for the angle value and setting the nodes z_index accordingly inside the _set_direction function.

var z_index_value = -1 if angle > HALF_PI and angle < PI + HALF_PI else 1

face_sprite_group.z_index = z_index_value
body_sprite.z_index = 1.0 - z_index_value

And there it is! A cute little duck quacking and waddling around in a Top-Down view.