@4onen
: Blog2023-12-20
This post will cover how Ren’Py can crop and scale image assets to provide slick and accessible image hints for the speaking character without exporting hundreds of copies of what’s already in the game. We do this using Ren’Py’s “Side Images” system in collaboration display.im
image combinators that provides near-infinite possibilities for cropping, scaling, and flipping assets.1
First, let’s start with some history. Shortly after publishing my first mod in the Angels with Scaly Wings (AwSW) modding community, I was informed of the Side Faces mod made by CalamityLime, now removed from the internet. This mod was made by manually cutting every character’s face out of their different sprite expression files using an image editor, then aligning them to be used as face boxes in the lower left corner of the screen when the character is talking. The ability to place boxes in that corner, known as the “side image” system was a native feature of Ren’Py, but required specifically placed and named assets, which were a pain and a half to create I’m sure.
With the original mod already removed by that time, and a dyslexic friend struggling with keeping track of the speaking character, I felt there had to be a better way to do side faces in the Ren’Py engine that wouldn’t require replicating that effort. In the end, I did have to write a small amount of code for every character expression, though it was far less work than cutting out every face by hand.
The result was the Side Images mod, which I published on the AwSW Steam Workshop and GitHub
That’s right! Not only do we have the speaking character, we also have their expression provided automatically!
In Ren’Py 6.99, as used by AwSW, a side image is any image where the tag (the first word of the image name) is the word side
2. Ren’Py will append the tag and expression of the currently speaking character (e.g. emera laugh b flip
) to create the full image specifier it searches for (e.g. side emera laugh b flip
.) Taking this image specifier, it loads the associated displayable for that image specifier, which is made accessible to the Ren’Py Say screen (where the character text appears) with the SideImage()
displayable object.
Thus, to add expressions to the AwSW characters, all I had to do was define the side image specifiers to point at ready-to-use cutouts of the character faces. Great! But how do we cut out the faces?
One nice part of AwSW’s development is that every character image has the exact same size across all expressions, and almost always puts the character in the same spot across those expressions.3 This is likely an artefact of the artistry process, as the different expressions appear to be small changes from some base. It may also be intentional, as it has the benefit that changing the character’s expression doesn’t cause the character to move. Either way, for us, the fact that the expressions for a given character are the same mean we can use the same bounding box for every expression of a character, saving us lots of effort!
The space we need to put the character’s face into, next to the text, is 250 pixels wide by 300 pixels tall.4 All we need to do is load up a given character’s sprite in an image editor, choose the box we want, and…
… Oh. The box is smaller than her face.
As it turns out, cutting the faces out isn’t all we need to do. Most characters’ faces are simply larger than the space we want to insert them into, meaning we’ll get them strangely cropped down to a rectangle and may only get a fraction of their emotional impact. We need to scale the faces down to fit the box. But if we do that first, then we’ll wind up dealing with a coordinate space that doesn’t match our editor, and what if the scaling isn’t right, and, and, and.
What if we took the box, then scaled it down? If we use a box that’s twice the target size, we should have a perfect linear interpolation of two pixels for every pixel in the result, preserving as much detail as we can with as few artifacts as possible.
Much better! Scaling up the crop box, then scaling down the result was actually the trick I used with every single dragon. Interestingly, the humans were all small enough that I could just use the 250x300 box directly.
But now that we’ve chosen the box, how do we explain that to Ren’Py? Those who remember the first part will also notice she’s facing the wrong way.
Ren’Py’s display.im
module provides a number of helpful ImageBase
objects, which are either a directly loaded image themselves, or take one as input and perform some operation on it. To tell Ren’Py we only need some rectangle out of an image, we use the Crop()
displayable, which takes an image and a rectangle and returns a new image that is only the rectangle.
I’ll demonstrate this in parallel with two different images: one of Emera and one that’s a placeholder image with placeholder variable names.
from renpy.display import im
= im.Crop("image.png", (x, y, width, height))
cropped_image = im.Crop("cr/emera_normal.png", (4, 9, 500, 600)) cropped_emera
The Crop()
displayable is a subclass of ImageBase
, so it can be used anywhere an image can be used. We can give the filename to it because its constructor will try to convert its first argument into an ImageBase
, including by loading the image provided. The rectangle we provide after that is a tuple of four numbers: The x
and y
coordinates of the top-left corner of the rectangle, and the width
and height
of the rectangle. The Crop()
displayable will then represent a new image that is only the rectangle we specified. This image can also be passed to other ImageBase
objects, such as Scale()
.
= im.Scale(cropped_image, new_width, new_height)
scaled_image = im.Scale(cropped_emera, 250, 300) scaled_emera
The Scale()
displayable takes an image and two numbers, the width
and height
of the new image, and returns a new image that is the original image scaled to the new size. This has brought our Emera face down to the right size, but she’s still facing the wrong way. We can fix that with the Flip()
displayable.
= im.Flip(image, horizontal=True, vertical=False)
flipped_image = im.Flip(scaled_emera, horizontal=True) flipped_emera
The Flip()
displayable takes an image and two booleans, horizontal
and vertical
, and returns a new image that is the original image flipped horizontally and/or vertically. At least one of them must be True
, or the Flip()
displayable will raise an error. (They default to False, so you may leave them off as I’ve done in the Emera example.)
Putting all of these steps together, we can create a displayable that will take an image, crop it to the right size, scale it down, and flip it horizontally. That looks like this:
= im.Flip(im.Scale(im.Crop(image, (x, y, width, height)), new_width, new_height), horizontal=True)
image_result = im.Flip(im.Scale(im.Crop("cr/emera_normal.png", (4, 9, 500, 600)), 250, 300), horizontal=True) emera_result
The final step is to register this displayable with Ren’Py so that it knows the name side emera normal
refers to this displayable. There are two ways to do this, one in Ren’Py code and one in Python code:
# Ren'Py
# If you are writing your own mod, don't use this one, use the Python one below.
= im.Flip(im.Scale(im.Crop(image, (x, y, width, height)), new_width, new_height), horizontal=True)
image side image = im.Flip(im.Scale(im.Crop("cr/emera_normal.png", (4, 9, 500, 600)), 250, 300), horizontal=True) image side emera normal
# ... inside your mod's python file ...
= ["?Side Images"] # Add Side Images as an optional dependency
dependencies
def mod_load(self):
# ... other code ...
if mod_info.has_mod("Side Images"):
import renpy.exports
# ... other image definitions ...
"side emera normal",
renpy.exports.image(
im.Flip(
im.Scale("cr/emera_normal.png", (4, 9, 500, 600)),
im.Crop(250, 300,
), =True,
horizontal
)
)# ... other image definitions ...
# ... the rest of your mod ...
That is quite a chunk of code to write for every single expression of every single character. Fortunately, looking at the Ren’Py code, you cans see it has a very regular structure with only a few numbers changing. When I wrote the base Side Images mod, I wrote a script that would generate the code for me from a simple list of all the expressions which is still available on the Side Images GitHub. (You do not need to use this script to make your own mod; it’s a convenience tool for me and utterly unhelpful for anyone else.)
In the python example here, I’ve also included a check to see if the Side Images mod is installed. This is to allow other mods to use my mod as a flag to indicate that the user wants side images, and then they can install their own specific side images for their mod, and the whole system works together.
A great thing about Ren’Py’s displayable lookup is that, after the tag, choice of an image is split into two parts: required and optional. (See renpy/display/image.py:654
.) Side image lookup considers all attributes to be optional. The image lookup algorithm will then select the image with the longest set of attributes that all fall into optional. This means we can leave attributes off our side images (e.g. not creating flip
variants) and the system will still work (it will find the unflipped side image.)
This extends further to a smooth fallback under modded expressions (or simply expressions I forgot to add.) For example, imagine someone adds emera blush b
and emera blush b flip
with a mod. By adding the image side emera
with the same displayable as side emera normal
, we can imagine the lookup algorithm as trying side emera blush b flip
, falling back to side emera blush b
, falling back again to side emera blush
, then finally selecting our side emera
for which we do have a side image. We lose Emera’s true expression in the side image, but hopefully the spritework onscreen will fill that in – we’re at least still indicating the speaking character and not showing an error to the user.
Finally, this fallback does not override side images added by mods, allowing seamless interoperation to be implemented from their side if they so choose, but without requiring it to still achieve a good user experience.
If all you want to do is make your own side images, you can stop here. If you want to know more about how the engine works, read on!
Crop
s and Scale
s and Flip
s lag my game?The answer to this may be quite surprising to you, but all the Crop
s and Scale
s and Flip
s are actually very fast. This is because Ren’Py has a very powerful image cache that stores the results of all of these operations. When you use a Crop()
displayable, Ren’Py will check its cache to see if it already has a cropped version of the image you’re asking for. If it does, it will return that image instead of creating a new one. This means that, if you use the same cropped image in multiple places, Ren’Py will only have to crop it once, and then it will be able to use the cached version everywhere else. This is also true for Scale()
and Flip()
.
ImageBase
vs Displayable
Throughout this article I’ve been using the term displayable for things, even though I specified explicitly that Crop()
, Scale()
, and Flip()
are all ImageBase
s. This is because Displayable
is the base class of ImageBase
; all ImageBase
s are Displayable
s, they just explicitly represent an image.
Displayable
s are the core of Ren’Py’s rendering system, and are responsible for all of the rendering that happens onscreen. Displayable
s are also responsible for the layout of the screen, and can be used to create complex interfaces or animations of images, text, and other elements. ImageBase
, however, focuses solely on high-efficiency rendering of images and their loading and rendering.
The distinction may sound trivial, but it’s important to understand that ImageBase
is a much lower-level facility. Once something has left the subset that is ImageBase
, it can no longer be manipulated with things like Crop()
, Scale()
, or Flip()
, nor any of the other useful transforms in renpy.display.im
. While an ImageBase
can be used anywhere a Displayable
is used, the reverse is not true.
Fundamentally, ImageBase
s are considered to be static images, while Displayable
s are considered to be dynamic. This is why ImageBase
s are so much more efficient: they never change, so the game knows they can always be pre-rendered and cached, while Displayable
s usually must be re-rendered every time they are used. This is also why ImageBase
s are so much more limited: they can’t be changed once they’re created, while Displayable
s can be changed at any time so long as their cache entry is invalidated (handled automatically by all of the combinators Ren’Py provides.)
We already went over Ren’Py’s image lookup algorithm, but how does it know which character is speaking? The answer is that Ren’Py keeps track of the speaking character’s side image in a variable called _side_image
. This variable is set every interaction (of which the Say
statement is one example) and is used by the SideImage()
displayable to choose which side image to display.
A lot at once? Don’t worry. We’ll keep breaking it down.
Whenever a character speaks, that line is known as a Say
statement. (This includes characters that don’t have names like the narrators m
and NVL-narrator n
.) The Say
statement is a bunch of code that finishes any unfinished transitions, sets up the game to write out the text of your line, and then pause until the player clicks. One of its key responsibilities, though, is to actually show the text of the line. To do this, it asks the say
screen to display the text.
The say
screen is a Screen
object that represents the layout of the textbox that appears whenever a character is speaking. It is responsible for displaying the text of the line, the name of the character speaking, and the side image of the character speaking. It receives the text of the line and the Character
object of the speaking character from the Say
statement, and then gets to choose what to display.
When Side Images fails in AwSW, it’s almost always because the line is intentionally not supposed to show a character’s name, so the associated Character
object is not provided. This leads to not having an image tag to look up, which leads to not having a side image to show.
say
screen know what to display?The AwSW say
screen is actually a complex, so we’ll only look at the part that’s relevant to side images. In AwSW’s screens.rpy
, on line 55 (the 2nd to last of the say
screen) we see the following code:
if side_image:
add side_imageelse:
0.0 yalign 1.0 add SideImage() xalign
This tells us that if the screen was given a side_image
by the Say
statement, it will use that one. Otherwise, it will use the SideImage()
displayable.
In all of AwSW, only the SideImage()
path is taken.
SideImage()
displayable know what to display?SideImage()
is a special displayable that will show whatever displayable is stored in the Ren’Py default variable store’s _side_image
variable. If you try setting this variable yourself, however, you may wind up confused that it doesn’t ever show what you put in it. This is because, every interaction (every Say
statement, every pause) Ren’Py will set the _side_image
variable to the side image of the speaking character. This means that, if you set it yourself, it will be overwritten the next time a character speaks.
The side image engine is called every time an interaction begins and chooses a new side image based on the speaker of that interaction (See renpy/common/00sideimage.rpy:63
which calls renpy/exports.py:2720
which then uses the choose_image
algorithm from renpy/display/image.py:654
.)
And that’s it! By passing information from the Say
statement of the current line, Ren’Py can store away the speaking character’s side image every single time someone speaks, making sure we always have the right side image for the right character.
If you look closely in the function at renpy/exports.py:2720
, you’ll see that, if given a speaking character’s image_tag
, it will run the lines
= default_layer(layer, image_tag)
image_layer = (image_tag,) + images.get_attributes(image_layer, image_tag) attrs
AwSW never uses layers, so we can ignore the first line. The second line asks the layer (always the default) to tell us what attributes the image has. This is what tells us the character’s expression! These attributes are then used to choose the side image, through means discussed before.
_side_image
variable come from?renpy/common/00sideimage.rpy
is loaded by the engine during init stage -1650, so before the init blocks of the game, during the Ren’Py-only initialization. It also performs some initialization (by setting some variables) at init +1650. This side image system keeps track of the side image sprite for the speaking character, so that the SideImage()
displayable in the default store provides the side images to the speaking menu.
As far as the Side Images system goes, that’s all there is to it. The rest of the implementation is just the SideImage()
displayable and the side
image tag.
If you’d like to understand more about how Ren’Py works, I recommend starting with the Ren’Py documentation. While a bit sparse and increasingly less relevant to AwSW’s version of Ren’Py, it still has clear descriptions of many of the objects and tools you’ll run across as you begin to delve even deeper into the engine. Good luck!
While technically infinite because you’re able to use floating-point numbers for position selection, you’ll find it tends to use very fast bilinear interpolation to scale images, which can quickly cause artifacts for subpixel work. This is why I’ve opted almost universally to use integer pixel coordinates when I define my displayables.↩︎
Ren’Py allows changing this tag at init time by setting config.side_image_prefix_tag
, in case you ever want to develop your own game and change it. Fellow modders, please don’t change this in AwSW as it will break my mod. 😅↩︎
Some exceptions:
Reza pointing a gun to the side, as he is offset when he does so. We can manually adjust the bounding box for that one case.
Bryce has multiple issues:
Bryce has no angry or sad right-facing image wearing a badge, so we have to use the badgeless image to prevent him suddenly gaining a scar in the side image.
Multiple “Old” (scar-less) images are marked as “flip”ped but actually facing the other way. We have to manually flip these back before cropping.
AwSW uses a 1920x1080 screen resolution as its base, then Ren’Py scales up or down all of its rendering to fit the window size the game is actually played with. This rebuilding of the image processing pipeline can result in some wild corruptions. Check out what happens if you click quickly between different resolutions in the AwSW settings menu! (At least, that was the case for Windows 10, and MacOS didn’t even need you to click quickly, just click once.)↩︎