Ren'Py Tutorial: Layering in Isometric Perspective


Last week I announced that Employee A was switching to an isometric view because it would allow me to generate much nicer art on a much shorter time scale. And while I definitely think this is true, an isometric perspective added its own challenges. Backgrounds now had movable elements, they being the characters, which necessitated that we would be rendering multiple images on screen at once. But that sounds pretty easy, right? Well...

Consider this screenshot from a pre-release version of The Train to Redamancy, my first complete game and entry into the Spooktober visual novel jam.


In this screenshot, three characters are seated at this table. In order to make this happen, we need to draw the floor furthest away from the screen, then the chairs, then the characters, then the table. Right?


They call me No-Leg Alouette, because I've got no legs.

This is `alouette_front_chairsit.png`. In The Train to Redamancy, the isometric train background is all one image. With the time constraints imposed by the game jam, we didn't have time to divide the train background into multiple images and deal with displaying different parts of the image in different orders. So we just made versions of all the main character sprites that had no legs and tucked them neatly into the table. This was fine because this was the only time any of the characters would be occluded by anything. This solution worked great for The Train to Redamancy, which had a thoroughly limited scope thanks to being part of a jam.

But what if we wanted to find a way to implement this "properly", so we only ever have to draw one set of sprites for each character, and so that they could be partially occluded by an object of any shape in any scene?

Occluders

First things first: if we want a character to appear behind an object, there needs to be an object for them to appear behind. This will be very difficult if the entire background is just a single image. Let's look at a concrete example of how I wanted this to look in Employee A.

Just the background

This is the current working version of the cafe. I want a character to appear behind the counter. But as it stands, rendering a character on top of this image will cause them to sort of float in front of the counter rather than standing behind it. If we want a character to appear behind the counter instead, we need to be able to put the counter on top of them. And that means we need a drawing of just the counter.


Countertop occluder

Here I've made an "occluder" of the countertop - a cutout of just the countertop, cropped directly from the background in Aseprite. This image is overlaid onto the background, using the drawing of the countertop that's already in the background as a guide to ensure it's perfectly lined up (unfortunately I haven't yet found a good way to automate this process). But this gives us an important step in our goal of putting someone behind the counter: now there's a counter for them to be behind!

Ren'Py Z indices

Now all we need is a way to ensure that our character is rendered in between the background and the countertop occluder. So we need to manipulate the front-to-back ordering of our images. In general, the front-to-back order of image display in Ren'Py is determined by one of two ways:

1. Layer: images on lower layers are displayed further back from the screen when compared to images on higher layers.

2. Ordering: images displayed earlier are behind images displayed later.

In general, this is fine. There is one more way to control the order in which images are displayed, though. It's the `zorder` property.

The integer specifies the relative ordering of images within a layer, with larger numbers being closer to the user. This isn't generally used by Ren'Py games, but can be useful when porting visual novels from other engines.

- Ren'Py documentation

I opted to use zorder because it allows me to define an effectively unlimited number of front-to-back positions without needing to define new layers (which must be done at config time). So now we've narrowed the problem down quite a lot: set the zorder property of each character and occluder in the scene such that characters and occluders can both appear behind one another when appropriate.

In that problem description we can see there's still one ambiguity. When is it appropriate for one item to appear behind another one? Let's think about this. Object A is behind object B if it's further away from the camera. Where is the camera in our isometric scene? In effect, the camera is actually at the corner of the room that appears closest to the bottom of the screen. This may not be intuitive right off the bat, but construct some examples for yourself: objects that are closer to that tile should always appear in front of objects that are further from that tile.

Now let's see if we can calculate that. First we'll need to know the point at the centre of that lowest tile - I'll refer to this as the map origin. Now with the origin, we can calculate the distance from an object using the basic Euclidean distance formula.

coords_point = coordinate_to_point(self.origin, *coords)
xd = (coords_point[0] - self.origin[0]) ** 2
yd = (coords_point[1] - self.origin[1]) ** 2
d = int(math.ceil(math.sqrt(xd + yd)))

In order to make placing objects on the isometric map easier, I use a coordinates system that lets me place things directly on a specific tile rather than having to place objects at individual pixels. On line 1, `coordinate_to_point` simply undoes this transformation, giving me the actual pixel values at which an object is located. Then in the next 3 lines we compute the distance from the origin to that point, then turn the distance result into an integer.

Now all we need to do is make objects that are further away have a lower index than objects that are closer. This is actually pretty simple: the z-index doesn't need to be a non-negative integer, so we can use `-1 * d` as the z-index. Here's the full code for computing the z-index:

def compute_zorder(self, coords):
  coords_point = coordinate_to_point(self.origin, *coords)
  xd = (coords_point[0] - self.origin[0]) ** 2
  yd = (coords_point[1] - self.origin[1]) ** 2
  d = int(math.ceil(math.sqrt(xd + yd)))
  return -1 * d

Now we can draw the images using the `zorder` parameter of `renpy.show`, and they'll appear in order!

Loose ends

We have just one loose end to tie up. Our characters and occluders now have a negative z-index, meaning they'll appear behind the background. We can fix this simply by defining one more layer above the `master` layer and putting all of our characters and occluders on that. This ensures they're always above the background image.

Let's take a look at the results!


In the left side of the image, you can see Charlotte standing behind the counter. This isn't a fudged sprite, she's really being partially covered up by the counter in-engine! We did it!

Thanks for reading, and don't forget to follow me on Twitter @cross_couloir!

Get Employee A

Comments

Log in with itch.io to leave a comment.

(+1)

More complex trick... Use item masks that apply to the things which you need drawn behind them. So, the counter would have a black and white mask image, which is applied to the character. That would simply erase the character parts which should be unseen. (However, RenPy only has one mask option per image. So you would be limited to only masking off one item, per item. I believe. You couldn't have a separate coffee cup on the counter and have that ALSO mask-off the undesired parts of the character.) You would have to create a custom "mask image" from all masks in front of the character. Then apply that merged mask to the character as one mask image.

But now I am getting into some more complex stuff. However, for complex scenes... That would be needed, for fast drawing of the scene. RenPy isn't exactly a graphical speed daemon!

(+1)

Old trick... For movement, you only need to redraw the things that changed. Though RenPy doesn't have a back-buffer which you have access to... You can draw all static items, which everything sits on-top of or can only be walked in front of, first. (All on one layer.)

Then, draw the the rest on the second layer, in z-order, devoid of characters. That makes less to re-draw, as the character moves.