The following piece was written by Julian Adams, originally for the development blog of Ruin of the Reckless. He has been kind enough to share the knowledge on our tech blog! This article comes with a free GMX example provided by Julian himself here:
GMX Example download (provided by Julian Adams under the MIT License)
There are a number of solutions to the silhouette problem that have been tried over the years. The most obvious is simply to prevent objects from ever going behind walls to a position where they are not visible. This is very effective – it’s cheap and requires no additional effort – but it compromises the authenticity of the environment. A Link To The Past uses this method an unusual way; employing a warped perspective to show the the front, the sides, and the back of obstacles in the world; this, unfortunately, locks the graphical style into a cartoony, surreal design.
Around the late 90s, some games started to pursue a more direct visual solution. Thanks to greater processing power available, they could now show units moving behind obstructing features without compromising the artistic expression of the environment. Well, I couldn’t find any screenshots from older games, but here is basically what it looks like:
Here’s a small example of the silhouette system in action. – Ruin of the Reckless
When the developers of Ruin of the Reckless contacted me, I was already interested in solving ‘the silhouette problem.’ We discussed different methods for displaying silhouettes in an orthographic/isometric perspective.
The silhouette system needs to work in very large rooms, on the order of 5,000×5,000 pixels, lined with walls and obstacles.
The silhouette system needs to have minimal impact on the frame rate. They’ve spent a lot of time making their levels full of content.
The silhouette system should be pixel-perfect and support static tiles and animated objects. A wrought iron gate should only silhouette the specific parts of objects covered by the fence and not the holes.
The silhouette system should work with their object-based visual effects. The silhouette system should require as few parallel variables as possible.
The silhouette system should not mess with the depth and draw order system (using the ever-popular “depth = -y;” method). Silhouettes should obey this draw ordering.
The silhouette system should support different coloured silhouettes for different types of object (orange for enemies, blue for the player, green for items etc).
The silhouette system must be implemented in procedural, randomly generated environments.
There are a number of pre-existing systems that have been described in GameMaker. Here is a relatively well known and popular solution:
This silhouetting approach work as follows: Every frame, “occluders” are drawn to a surface, but only if they are below the player. The player’s sprite (an “actor”) has its pixel data forced to a particular silhouette colour and is then drawn to this surface. This drawing operation is special – it doesn’t change the transparency of the surface data, only the colour data. This means wherever a player pixel and an occluder pixel cross over, a silhouette colour is drawn to the surface.
This has a number of deficiencies – firstly, it requires constantly drawing and redrawing tiles to the screen any time the view or player moves. This is hugely wasteful of resources. Secondly, it only supports one actor at a time – the player. Whilst the example provided with this method only includes support for tiles, adding objects to the mix isn’t hard.
Here is another solution, designed by HeartBeast:
This solution works somewhat differently and is, in some ways, much cruder. Every frame, a surface is cleared black. The player is then drawn to this surface in a pure white colour, maintaining transparency, but not colour information. Occluders are then drawn at alpha=0.5 and pure black to this surface. This means any pixels that share a player and occluder pixel have a colour of #7F7F7F or, to us mere mortals, “grey”. The surface is drawn to the screen using a shader that only draws pixels that are grey.
This application is also flawed, though in a different manner. This method requires redrawing to the screen every frame, due to drawing the player to the masking surface. Whilst this creates a somewhat accurate end result, this method fudges a lot of the fine detail through the use of an approximate inequality in the shader. Edge cases can also break – if an occluder has a low alpha (highly transparent) or many occluders overlap in once place. Most of all, built explicitly for a 2D side-on perspective, it cannot express three-quarters/isometric perspective at all. Unlike the previous example, however, it can support multiple actors although only with one colour of silhouette.
Another shot of the silhouette system being used- Ruin of the Reckless There are some commonalities between these two popular systems:
Redrawing a masking surface every frame is expensive. Requiring the player to be drawn to the masking surface at all makes optimizing this impossible.
Redrawing, when done, must be very fast. Indexing through tiles is far too slow.
Supporting many actors, with many silhouette colours, is unsolved by these two methods.
If you’ve not heard of vertex buffers (and you’re a little bit unsure about how to use shaders and why they’re useful), it’s time to go read up a little bit. You’ll also want to learn some basic shader usage (the two topics are somewhat interlinked anyway).
Conceptually speaking, we want to create a system where the occluder images are stored in video memory and are ready for very fast rendering to a masking surface. We then want to sample this masking surface only when and where we specifically need it; i.e. where we know that actor and occluders are going to overlap. We also want to be controlling each individual silhouette so we can customise what colour is displayed and what order the silhouettes are drawn in (following GM’s native depth ordering system).
The fastest way to get geometry from VRAM onto the screen is by using a magical new feature introduced in GM:S called “vertex buffers”. They’re the equivalent of storing a list of triangles on the GPU; which are textured and coloured however you wish. We build a vertex buffer that contains all the static geometry for the level in one vertex buffer and, when it’s needed, this vertex buffer is submitted to the masking surface. Vertex buffers can send tens of thousands of triangles to a modern GPU at extreme speed, enabling the number of static occluders to reach the thousands before the framerate even begins to dip.
What we draw to the masking surface is not actually the true occluder image. Instead, we draw the occluder with its diffuse colour information replaced with information encoded in the red channel of each pixel. Each occluder sprite (the same principle applies for tiles/backgrounds) is added to the vertex buffer as two triangle stitched together to make a rectangle. The bottom of this rectangle is coloured black, the top of this rectangle is coloured red. Each pixel-thick line going from the bottom of this rectangle to the top increases in redness by 1, this means a 200-pixel high wall starts off at #000000 at the bottom and has a red colour of #C80000 at the top.
A peak at the occluder surface, and how it uses the red channel to resolve silhouette information. Note that each occluder is rgb 0,0,0 at the bottom and the red channel creeps up to 255 pixel by pixel – Ruin of the Reckless
This red channel information represents the distance from any given pixel to the very bottom of that particular occluder. When we want to draw a silhouette, we sample from this masking surface and compare the y-position of the actor being drawn to the colour of the pixel on the masking surface. If the masking pixel is in front of the actor, we know that particular pixel (for the actor) is being occluded. Since we’re sampling the mask surface per actor rather than in one big lump, we can change what the silhouette colour is per actor. This is all done in a shader. Any object for any reason can be drawn as a silhouette with no more than a few extra lines of code.
The actual mechanics of how this is done uses GM’s native depth order and requires each actor to be drawn twice – once for the normal sprite, once for the silhouette. The shader is constantly being set and reset. If the specification is partially broken, a custom depth ordering can be used (using a priority queue or equivalent) to batch all the silhouetting significantly increasing the rendering speed. As it stands, however, the system used as per Ruin of the Reckless’ specification produces one silhouetted actor at the same cost as three non-silhouetted actors.
Introducing animated objects as occluders requires the masking surface to be redrawn every step when they’re on the screen. This is unavoidable but, thankfully, the vertex buffer submission is so fast that this has a minor impact on the frame rate. An occlusion object’s positional data can be drawn to the masking surface on the green or blue channel with an additive blend and treated similarly to static occluders.
There are some structural limitations, however. With the current system, occluders can only be a maximum of 256 pixels tall before we run out of room in the red channel to describe the necessary data. This limitation manifests itself mostly as a limit on the actor size (256px). This limit can be lifted through extra work in a shader to express height using additional channels to have heights of up to 2^24 (which is an absurdly high number). Using a single vertex buffer to store all the occluder geometry means that all sprites/tiles stored in that vertex buffer must be on the same texture page. For large games with a large number of tiles and sprites, this is unlikely to be the case; in these cases, you’ll need to use more than one vertex buffer.
Thanks again to Julian Adams for the above article! You can follow him on twitter and learn more about Ruin of the Reckless at it's website: http://ruinofthereckless.com/
Have you written or would you like to write an interesting, quality piece like this one and have it join our Tech Blog? Contact us on Twitter or Facebook!