Game Developer Deep Dives are an ongoing series with a goal of shedding light on specific design, art, or technical features within a video game in order to show how seemingly simple, fundamental design decisions aren't really that simple at all.
This article was written by Ernestas Norvaišas, the solo developer behind Sweet Transit, a strategic city building game set in a world where the railway is king and trains are the sole means of transportation and expansion.
When designing a game, working with 2D rather than 3D graphics can often seem the easier choice. With more components, variables, and things that can go wrong, 3D can appear the more complex space and challenging option in which to build a world. Sweet Transit is an upcoming 2D isometric transport sim and city builder, and I thought I'd share the reality of working within this space, as well as some of the challenges unique to 2D programming that I've encountered since the project's inception.
3D Limitations & Graphics Sorting
To begin, let's look at 3D. In 3D games, most process sorting is done within the GPU and measured by the depth of each pixel and its proximity to the game's virtual camera. The CPU assists in optimisation and sorting transparent objects, but the majority of the heavy lifting is done by the graphics card. In 2D games, it's quite the opposite -- most sprites are transparent, giving the GPU very little to render and meaning the limitations imposed on 2D programming are those from the CPU rather than the GPU. This means that working within 2D space can come alongside a unique set of challenges you'd never encounter in 3D.
Unlike 3D, 2D graphics sorting isn't done per pixel but holistically, by individual in-game images or sprites. For the designer, this leaves a lot less space to work within. A common example of these limitations can be seen in something like a 2D side-scrolling game, where the foreground will always appear to sit in front of the background. Isometric 2D games, like Sweet Transit, use the position of the screen to determine the order of images rendered. Funnily enough, those on top are actually behind those on the bottom and the rendering process is executed in that order, one graphic at a time.
Another limitation of 3D is that graphical fidelity is often dictated by the power of the end user's graphics card. There is a limit to how many polygons can simultaneously pass through a GPU and produce a consistent image of 60 frames-per-second. In 2D, graphical power is rarely an issue. There are cases where over-rendering and pairing a relatively slow GPU with a 4K monitor can result in drop in framerate, but these are quite rare.
2D Graphics Sorting
In 2D, the CPU is the limiting factor. Before sending everything you want to render to the GPU, you first need to gather your sprites and sort them into your desired display order. This is no easy task, even with modern CPUs. Currently, in Sweet Transit, there are an average of 40,000-60,000 sprites being rendered within each frame of the game. To mitigate a 2D, CPU-heavy workload, one effective tool is ‘layering'. In 2D isometric games, individual image layers house individual objects. For example, tiles and water exist on their own layers, as do trees, trains, and structures.
Throughout the layering process, one screen of sprites is divided into several horizontal strips, which are rendered, then sorted using threads. When merging the strips, the layering process means the game only needs to sort the end of each strip rather than the whole collection, which can be very economical when it comes to processing power.
After the layering process, all sorted 2D sprites are combined into several large 3D objects -- a process which, when rendering, allows the programmer to issue smaller commands to the GPU by only sending the ID of each sprite instead of the entire batch of individual images. Indices in the GPU are never touched but instead, assigned at the beginning of each rendering process to construct each new batch of sprites and allow the processing cost of each batch to be kept to a minimum.
Imitating Shadows and Light
In 2D, In-game shadows are also rendered on their own layer. Shadows on the ground are more straightforward and the game will render a sprite for each while taking into account the specific angle of individual objects. However, for Sweet Transit, I wanted shadows for all in-game objects, so I rendered each shadow as its own image, then layered this onto each shadow's parent sprite. This process produced more realistic shadows at only a tiny processing cost, so it was a no brainer. After being sorted, the shadow layer renders on top of the game layer to imitate day and night cycles. This works well, but the drawback is that shadows aren't cast on other objects.
The most common problem in making an isometric game is defining, to the system, how to sort two images positioned at an identical height on-screen. For a person, with our frames of reference and powers of spatial reasoning, it's clear what should be on top, but a computer has no idea. Several solutions exist for this problem but they're all quite processor heavy and for Sweet Transit, I wanted performance and economy of processing to be a priority.
For my own solution, I decided to slice each sprite into several parts. I manually defined how many slices should occur and then had an automatic system do the rest. However, this proved insufficient due to the sheer number of layers on top of each sprite. For example, a train wagon usually has several image layers sitting on top, such as a tint mask, lights, and cargo. When I ran the automatic system, it calculated the sorting based on how far the sliced sprite was from the centre of the image and produced layers of varying width. Not ideal and definitely not what I was after.
Next, I tried calculating offsets based on the parent sprite, so that the wagon layer would always be rendered below its cargo, but this meant I could no longer share cargo layers between wagons! In the end, I found the best option to be defining my exact slice positions and offsets. Now, when the game sliced each sprite, it calculated the position of each slice based on the exact position of each sprite (in this case the wagon), rather than the sprite itself. Success!
The Bridge Problem
Rendering bridges in Sweet Transit was another unforeseen challenge. In a typical train-under-bridge scenario, there are multiple layers being sorted and rendered. First, is the bridge behind the train; then the train itself; and finally, a bridge in front of the train. The number of layers isn't the problem but when you add outside influence, such as another train travelling across the top of a bridge, this adds in many more image layers to be sorted, all within a very tight space.
To solve this, I first added a top world layer, meaning every image above a certain height would be cut off and rendered separately by the game. This worked and meant that I could now sort the bottom and top trains separately. However, it was far from the cleanest solution. Executing this process would require 30% more sprites, and modders would then have an immediate overhead in making sure all taller sprites were sliced correctly. On top of this, sorting trains on an inclined surface also proved problematic, as it was the only instance of a bottom layer moving to the top. Even when an incline had ten layers, one would always clip through another.
After many attempts, I decided to add small, identifying indicators called ‘flags'. First, the game would sort each sprite based on its depth, then the program would do another pass and look for flags tagged to each sprite. If the rectangles intersected, the game would tell a sprite with a train flag to render above a sprite with a bridge flag. The idea was that I could correctly sort a bridge scenario by telling the game to render a train sprite with a train flag above a bridge sprite with a bridge flag. The downside was that performance took a hit due to the extra processes but this was balanced out by the game having to render fewer sprites overall.
Finally, the bridge problem was solved but as with everything in game development, not quickly, and with some pesky side cases. For example: wheels. A sprite with a train flag would search for sprites with a ‘wheels' flag to correctly position the wheels under the locomotive. Regardless of whether the train moved above or below the bridge, the wheels should also sort correctly. The problem was when two individual trains passed across and below the bridge at the same time. This meant multiple train sprites intersecting with multiple wheels sprites, and in some cases, the wheels rendering under the wrong train!
To solve this, I spent a day scratching my head, trying different approaches, and playing with sorting distances and the underlying logic of a second sorting pass. In the end, I had the game check the centre intersection of each wheels sprite, then prioritise the train sprite closest to each set of wheels.Voila! Wheels all sorted.
Decorating a 2D World
Sometimes, sorting itself is simply too processor heavy. In Sweet Transit, I discovered this myself when adding unique visual decorations, such as bushes and clumps of grass, to the game. To minimise the processing needed to render these objects, I made sure that some in-game decorations would be dynamic and not cached anywhere. This meant the visuals would have variety but once a structure was removed, the ground didn't look empty and barren to the player.
However, having all of these performance and RAM-cheap visual decorations introduced another problem: collision detection. For structures, this was fine, as these were rendered above any decorations. However, some parts of Sweet Transit's railway tracks render below the decorative elements, so this did sometimes cause some clipping.
To solve the decoration problem, I added collision checks to any railway tracks and decorations rendered within each frame. To my complete lack of surprise, this hit performance but, in pairing this with one of the oldest tricks in the book, the classic ‘gradual distance culling when zooming out', I took care of a good chunk of the problem. I also improved performance by making sure collision checks were as economical as possible and weren't creating any new variables when executed.
The world ofSweet Transit isn't static but living, so it also made sense that some railway tracks should be overgrown by plants. For this, I decided that those less frequently used railway tracks should decorate and become overgrown over time. These ‘decorations' would also be naturally trimmed any time a train ran over them.
As it turned out, no matter how many trains or tracks I added, there wasn't much of a problem and the solution was pretty economical! All tracks in Sweet Transit have a usage struct of three bytes: one for the overgrowth counter and two dedicated to the final second of each calculation. Each time a train uses a track, the struct will update the counter and mark the second. When the track is rendered, the game checks when the 3 byte calculation was last executed and updates, if necessary. From here, I just told the game to calculate how many procedural decorations it would need to render based on how often each track is used.
Making games in 2D often feels like solving all issues with duct tape. One addition I would love to make to Sweet Transit before its release later this year is to add proper locomotive headlights to trains running at night, but it gives me shivers just thinking about how I'd do this. At the end of the day, 2D image sorting is like making any art: if done properly, people won't notice you did anything at all, and that's the biggest achievement you can hope for. After working on the game for so long, there have been times when I've thought that maybe 3D would have been the easier choice in which to program, but I love the versatility of 2D sprites and how you can pack in so many tiny details while still keeping system requirements relatively low for the player. And it's the small things in Sweet Transit that I'm most proud of. I can't wait for everyone to discover them for themselves later this year.
Sweet Transit releases into Steam Early Access in 2022