In this blog post, I'll dive into tech behind these air drifts I made for my game, Go Jay. Performance was a challenge here, but I arrived at some interesting solutions along the way. The air drifts are undulating 2D planes made of smaller directional arrows. From a gameplay perspective, the challenge for the player is to fly along the contours of the air drifts to maximize your speed. The arrows should clearly display direction while generally staying out of the way (especially when they’re between the player and the third-person camera). Direction is important because it symbolizes the direction of air movement. For gameplay, the more closely you align yourself with the arrow’s direction, the greater the boost you receive.
To make the directionality more intuitive, I created a shader that would change color depending on how aligned you are with the arrow’s direction. Using the dot product of the arrow and player's forward vectors, I can lerp between two colors depending on my approach angle. I also added a Fresnel effect to make them pop out more from the background.
Since I want to fly through these arrows and put huge sheets of them across the sky, I need a way to keep them from blocking out the whole screen. I first tried transparent arrows, which were beautiful, but huge sheets of them created a big performance strain. The second try was with dithering which solved both problems and was very performant. Dithered materials are not transparent, they only appear transparent by varying the density of many non-transparent dots. Dithering looks a bit rough and somewhat cut off from the rest of the lighting, but it also has a unique stylization aspect that fits with the cel-shaded visuals of the game. For me, the trade-off is ok because this is part of what made the last build work on Steam Deck.
Instead of creating an additional occlusion effect to keep the screen clear, I can also just decrease the density of the dithering within a certain distance of my player camera. This way, the arrows dissipate to nothing as you fly through them. With that, the arrows looked pretty good and could now be populated into some cool, organically undulating sheets.
Unreal Engine has a way to populate static meshes along the surface of a closed spline. The only real point of inflection is whether to create individual actors or an instanced static mesh. For performance, an instanced static mesh is ideal to reduce the number of draw calls. Unfortunately, this comes with a big drawback for the air drifts. Since the material and static mesh are calculated and drawn once-per-frame then copied to each instance, all the arrows in each spline have to point in the same direction or the feedback will be inconsistent.
For a more variable visual presentation, generating individual actors with multiple draw calls allows for the orientation shader to be individually calculated for each arrow. It looks great, but it’s a bit expensive for the CPU when the number of arrows gets huge. For this reason, the majority of the air drifts I put in the game really should be unidirectional using instanced static meshes or it will get rough quickly.
The method of generating individual actors can make a more dynamic drift that boosts in many directions. The varying colors also creates a cool shimmering effect making the unidirectional drifts appear monochrome and flat in comparison. Ultimately I think both styles have a use in the game, but the performance difference is notable. Two gigantic unidirectional drifts placed next to each other are just two instanced static meshes instead of the thousands of individual actors created by spawning without merging. For a small drift with a few hundred arrows, the performance impact is negligible. The same allowance cannot be made for particle effects.
There needs to be some visual feeback for when an arrow gets triggered. I made the arrows flash a white outline material for a brief interval, but a lightweight Niagara particle effect layers nicely with the dithered look of the arrows. Putting a Niagara component on each arrow is easy, but obviously a bad idea. This is the perfect application for Niagara Data Channels (NDCs). This system lets you create one Niagara component and a data channel where all the relevant actors can dump their particle effect requests. Sometimes, the particle effect doesn’t fire, but it also doesn’t lag. For this application, that trade-off was ok to me. Setting up the NDCs was a breeze with Epic’s videos. These docs are actually some of the best I’ve seen from Epic. [https://dev.epicgames.com/community/learning/tutorials/OpJ8/unreal-engine-niagara-data-channels-5-4-update] I used the legacy setup shown in Epic's tutorial since I couldn't figure out how to get the new version working.
The end result is a single Niagara particle system spawning particles at the correct locations and orientations instead of thousands in this case. The performance impact is huge here so NDCs are the only viable option. Additionally, because the particle effect has the same material as the arrows, it has the dithering effect that keeps the player's view clear and unobstructed.
The arrows use a blueprint interface to send calls to the player character. There might be a cleaner way to do this. I’ve heard that overuse of blueprint interfaces has "code smell” but it seemed clean enough for this and I do have other boosts like the flying-game-standard ring '(0)' boosts. A big part of tuning the boost for me was manipulating camera lag on the player spring arm to created a natural gradient so that powerful ring '(0)' boosts created much heavier camera lag than the drifts
The sound effects have been difficult because I still can't decide how smooth or discrete I wanted it to be. I want the air to sound present and gaseous. It will take some more work, but right now, I open up a continuous airstream sound effect based on total active boost and trigger a light one-shot puff effect for each arrow.
I like the way these air drifts feel because, for me, they balance a believable airiness with intuitive readability. It would be nice to have more movement throughout the overall drift, and I'm excited to keep experimenting with this foundation. Placing the drifts throughout the world in a way that feels rewarding and intentional is still the most challenging part, but at least for now, the tech is sorted out. I can stretch the unidirectional drifts to humongous scales needed for this mongo map we are working with and the non-merged drifts still work well at a small-to-medium scale.