Published: November 13, 2022
Time for a devlog on performance optimizations!
Rhythm Quest has -- fortunately -- actually been running pretty smoothly on most platforms so far. Other than the occassional "I need to make sure this gets loaded early so it doesn't cause a hitch during gameplay" issue, the only other major performance work I've done has been trying to optimize the fragment shaders used to draw the level backdrops, since I noticed it was causing some slowdowns on some Android devices.
There is one place, however, where I've known there to be FPS drops for a while, mainly on Nintendo Switch...
Somewhat counter-intuitively, the main gameplay runs perfectly fine on Switch and it's only the menu scene that causes it to perform less optimally. Specifically, the transitions between different backdrop sets:
Each level backdrop group alone renders fine; it's just the transition between two of them that caused things to chug a bit. As you might guess, the problem is worse when it comes to backdrops that use lots of layered graphics, like the ones in world 4, and even worse when you transition multiple times in quick succession...
The problem here mostly boils down to fillrate or overdraw. The GPU has to render each one of these different (fullscreen) backdrop layers to the screen, and because most of the textures use transparency, it has to render them back-to-front, and ends up "redrawing" many pixels where layers overlap:
On most other platforms this ends up being fine (it helps that the draw calls are batched here, and use a single texture atlas), but for whatever reason the Switch seems to struggle in this department. Anecdotally, I've noticed in Pokemon Unite that UI transitions also frequently cause frame drops on Switch and not on mobile, so I'm not entirely surprised to see this being an issue.
So we're slow because we draw a lot of background layers to the entire screen. What can we do to fix that?
One option is to look at making the pixel/fragment shader faster. During the main game, I use a custom palette shader to transition smoothly between different color palettes at each checkpoint. I use a similar shader for the menu, and transition between different color palettes for each level/screen (along with fading in and out the background layers). This isn't strictly necessary though -- instead I could just export extra versions of all of the backdrops with the appropriate colors included, and then do a plain fade transition between them (i.e. drop the palette logic from the menu scene entirely). I haven't looked into this because it would be a pain to create all of those extra copies of the backdrops (not to mention it would increase the app size). So I haven't really done that much here.
Something else that could be done is to try to avoid drawing everything to the entire screen. If you take this background layer from level 4-2 as an example...
...you'll notice that most of the image is actually completely transparent. Despite that, the texture is authored at full-size (it's actually 500x1200...), so the shader is still running through (and doing texture lookups and everything) for all of those transparent pixels (without rendering any actual output). It would probably help a lot if I took all of these backdrop textures and exported them as smartly-cropped versions, so that each layer only has to render to a portion of the screen rather than all of it.
There are two reasons I didn't go with this approach. The first one is that I'm using automatic sprite tiling to tile the backdrops horizontally, so cropping these trees out for example would mean I would have to rework how that happens. Vertical crops are maybe not as bad, but the other issue is that doing this sort of cropping seems like it would be really tedious. My workflow for authoring these assets is set up such that I export them all at fullsize from the same project file and it just doesn't seem worth it to try and manually adjust a ton of these layers unless I really have to. It would be nice if Unity could trim the transparency out of the sprites as necessary and then embed some metadata such that sprite rendering would still act as if it was the original size, but that doesn't quite work out from what I can tell (again, tiling is an issue).
Since the slowdown only happens during transitions between two different backdrop sets, I wondered if I could cheat a bit in order to reduce the number of background layers that are drawn in that time.
My idea here was to capture a snapshot of the first level's backdrops using a render texture, and then just use that static snapshot during the transition instead of continuing to draw all of the individual backdrop layers.
Since I already knew each set of backdrops renders fine individually, this would pretty much guarantee that the transitions should be smooth since we're now only adding one additional fullscreen draw during transitions (the static snapshot). As a nice bonus, if you trigger a second transition before the first one is done, I can just re-snapshot the current intermediary state and have that fade out, so it all still works fine.
The one downside here is that the static snapshot doesn't scroll sideways with the rest of the camera movment like it's supposed to be. This is something that can't be avoided...each parallax layer is supposed to scroll at a different rate, so it would look weird to scroll the static snapshot at all. For me this is an acceptable tradeoff, and isn't super noticable since you still get the horizontal motion from the new level that's fading in. That said, I do prefer to do the full transition (without the snapshot cheat), so for now I'm only enabling this optimization for Switch specifically.
With that issue solved, most of the transitions were running pretty smoothly on Switch! However, I noticed that transitions to and from screens with lots of UI elements were still a bit choppy. Transitions like this one:
There's a couple of different things going on here. One is the same fillrate/overdraw problem, this time with all of the UI elements (each button features multiple sprites, plus all of the text needs to be rendered as well, and the icons...). Another problem is the scrolling of the UI...normally all of the draw calls for a UI canvas are batched up and cached between frames, but once you start moving or scaling UI elements around, that all goes out the window, so Unity has to redo a bunch of stuff every frame.
I spent some time trying a similar approach that I did for the backdrops, where I would snapshot the UI menus to a static render texture, and then just display that during the scroll, and then swap back to the actual UI elements once the transition was done. But this turned out to be too heavyweight -- it made the scrolling itself smooth, but there was a big hiccup at the beginning of the transition because of the extra draws to the render texture.
Luckily the fix here ended up not actually being too hard -- I just dug into the profiler and cached some things, optimized some functions here and there, and turned off some things that were running in the background needlessly (the shop coin display was updating all of the time, even outside of the shop...). I also disabled some logic (such as the animated pulsing effects) specifically during transitions, since they aren't too noticeable during that time anyways. All of that ended up helping out enough to the point where I'm not worried about Switch performance anymore. Hooray!
That's going to do it for now! The next devlog is probably going to be on level 4-4, which I've actually already completed the chart for (just needs backdrops and some polish now!).