Hey, I am OzoneX and I'm the technical artist and rendering programmer for the Sanctuary project. I have over 10 years of experience in the game industry working as someone liaising between artists and coders to connect these domains together. Artists usually don't understand the technical part of game art so it's my job to help them and provide them with anything they need to allow them to achieve amazing visuals. For coders I help them with connecting models, animations and effects with the game’s mechanics and systems and making sure they look and behave as the art and design teams wanted.
Before Sanctuary I was working on a map editor for Supreme Commander. This gave me a lot of insight into understanding how to do proper rendering for a real time strategy game and how all the visuals should behave to look good while achieving great performance.
In short, my role in the project is to make sure that everything looks as best as it can using the least processing power.
In real time strategy games it's very important to show what each unit is able to do, what it can see and what it can detect. The most simple way of displaying that information is to use lines of range. Most of these values are actually just a distance so we can display that as a ring with the same radius and that's why we call them range rings.
The problem with a naive approach starts when we select a large group of units in the game with different data for each of them and all of their ranges need to be shown together. If for example we take a ship that has a cannon, torpedo launcher and a radar then we already have 3 different pieces of information. Now imagine that we need to display that for 200 of the same units. To show all of that data we will need to draw 600 circles on the screen, each with different position and with different color for each type. Everything will start to sink in a sea of data, merging into one blob of color. In this image there are only 100 red circles and it's already hard to see anything.
100 red circles
Also projecting that many circles on the map can be expensive. In older games they were drawn by generating a mesh on the CPU. With that many units with different ranges it can take huge part of a processing power for creating and updating all that geometry when hardware already had a hard time calculating all the simulations of a game. It's not acceptable for the players that such a small and simple thing as a 2D overlay will slow down and lag the game.
A good way of improving the performance and readability of such data is to combine ranges of the same type into one shape. Instead of drawing hundreds of circles we can draw one area, so if we have the same 200 ships we only need to draw 3 of such areas for cannon, torpedoes and radar. In most cases that is enough data for the user, usually he just needs to know if any unit of that group can shoot or if an area is in range of any radar tower. This reduces the count of geometry displayed by the graphics card and makes everything clean.
Combined shape of all circles
We now have the visual part sorted out, but how do we convert that into code? That was the hardest part and it was a problem that many RTS games faced before. To find the best and modern solution first I needed to split the problem into smaller chunks.
First let's start with what is the best way of drawing a circle? Making a texture of it and projecting it into a terrain has a lot of issues. Resolution needs to increase with size and because we have strategic zoom everything needs to look sharp no matter how far or how near the camera is. One of the best solutions for achieving that is using distance fields. A distance field is a texture where each pixel stores information of how near the surface of a 2D object is. If that object is a circle then this will be a distance to the closest point on its perimeter.
We can describe a circle in shader like this:
First test of a distance field with UV and distance from closest circle
This will give us negative value when the pixel is inside of a circle and positive when it is outside. Now when you blend between pixels of that distance field texture you will get a smooth transition between their coordinates in a world so you are not limited with data to resolution of that texture, because you can interpolate all other data for any other resolution. This technique is often used in much more complex use cases like making a font that is sharp at any distance from the camera or doing a 3D distance field for getting a cheap shadow of an object.
We also included angle and radius in such data which allows us to later reconstruct the UV (2D coordinates) of a ring to project texture into it.
Result of such distance field with UV reconstruction
Now that we have a circle that looks good at any distance we need to somehow draw hundreds of them. The easiest way to draw them is to make a loop that draws the distance field of each circle and displays only the closest one. But that means we will need to calculate each circle for each pixel of a texture, even if it's too far away or is hidden by other circles. With 600 circles on a small 512x512 texture that means we need to do that calculation over 66 million times! For each frame of a game! We can't pre-bake that data like for fonts, because everything moves and changes all the time.
If only there was some way of culling objects and drawing the value of only the one on top… but wait, there is! This is exactly how the 3D rendering pipeline works. You first check what’s inside of a view and then using a depth buffer check what object is on top. Only then it draws only that surface of this one object on top. This is a well optimized process that is drawing on screen almost everything from a game 3D environment. It needs to be fast to be able to draw huge worlds with a lot of objects.
Using DrawInstancing is great for using this with many of the same objects. Instancing is a technique to draw the same object by the GPU but with small changes like position or color. Unity has great support for that, so by using ComputeBuffers and DrawMeshInstanced we are able to cheaply draw thousands of objects. We use that technique to also draw units, strategic icons and for anything that can be seen in multiples in our game.
We now have a way to describe a circle and a way to draw thousands of them. The last issue that we need to solve is how to blend these circles together. How to tell the GPU what circle is closest. We ideally don’t want to develop our own blending algorithm. It will be expensive, the same as blending a transparency, because each pixel of a circle needs to compare itself with other rings in the same position. We don't have that, because only one circle can be displayed in each pixel when all others are culled by view or depth buffer. The way of getting it done was to use what we already have. The information that is stored for each object which details how far its surface is from the camera is a kind of depth buffer. We need to make sure that the circle that is closest on the surface is also the closest one to the camera. Such an effect can be achieved by representing rings as cones, where the cone’s base is the radius of a ring and the height is also the same as the radius. The bigger the ring the closer its center will be to the camera.
Final distance field
3D Cones of range rings
One difficult challenge was finding how to draw multiple objects from one camera into separate distance field textures to have data for each type of a circle. I managed to make it work using Unity's CustomPass feature in the HDRP renderer. It allows for collecting objects from selected render queues and drawing them into a render texture using the same camera with its depth buffer in the same frame. Each ring just needs to be rendered with a different queue but I get that from using different materials for each circle type. It's not a big loss, we can't batch different types anyway because they are rendered separate.
This technique gives us a great and very performant way of drawing thousands of rings for almost no performance cost. We even tested it for 22 thousand circles spread over the biggest supported map size that is way more than the game needed to support and even then there was no noticeable performance drop. We also used that to draw fog of war and later even implemented terrain hole projection.
22000 range rings + fog of war