2D light and shadow using offscreen rendering

Shadows are drawn first in white and then lights are drawn in black. Once we switch to the custom shader, the grayscale value of each pixel becomes the alpha value of a black pixel that will be drawn on top of the background.

2D light and shadow using offscreen rendering

I've been working on a new mobile game called Axor the Mighty. I'll go into further detail about the game itself another time, but for now I will discuss the light and shadow system that I just finished implementing.

Axor is built using my language Rogue and my game programming framework Plasmacore, but the technique described here can be applied using most game tech.

Side note: Axor's excellent graphics were created by Owl Studio and I highly recommend them for your game art needs!

Without shadows

Axor without shadows looks fine, but it feels like something is missing:

Testbed level without shadows.

The classic approach

For Axor I wanted shadows below each combatant and below and to the right of each wall tile. The classic solution would be to draw translucent shadow sprites & tiles and perhaps use alternate ground tiles with baked-in shadows. There are two problems with this traditional overlay approach:

  1. If the shadows are translucent instead of an opaque dark color (as they often were in classic games), the intersections of wall shadows and combatant shadows will create a darker shadow, which doesn't look right.
  2. Lava is a common hazard in Axor. It is self-illuminating and shouldn't have any shadows cast on it.

The following screenshot shows Issue #2 in play. It's using a limited version of my solution and unfortunately there is not an easy way to show Issue #1.

Classic overlay shadows darken the lava. That doesn't really make sense! Of course fighting undead gladiators, anthropomorphic roosters, and giant crab things in a lava-filled arena doesn't make that much sense either, but you have to draw the line somewhere!

Using an offscreen buffer as a shadow mask

To solve these issues I used the following approach:

  1. I created an offscreen buffer to draw both light and shadows. I'll call this the shadow layer.
  2. For each frame drawn, the shadow layer is cleared to be transparent black, shadows are drawn as opaque white images and lights are drawn as opaque black images.
  3. All shadows are drawn first and then all lights are drawn on top of the shadows.
  4. A simple custom shader is used to draw the offscreen buffer to the primary display. Each output pixel is black but its alpha (transparency) is taken from the grayscale value of the offscreen buffer (I selected the red component; same difference as green or blue). That would mean solid white is drawn as opaque black and black or transparent areas appear as fully transparent. I wanted my shadows to darken areas by 20% instead of 100% so I divided the grayscale alpha value by 5.

Here is the Rogue code that manages the offscreen buffer (called a Canvas in Plasmacore):

I'll post the code for ShadowShader in a moment, but first here's a look at what is actually drawn to the shadow layer, here rendered on top of everything using a regular shader:

Shadows are drawn first in white and then lights are drawn in black. Once we switch to the custom shader, the grayscale value of each pixel becomes the alpha value of a black pixel that will be drawn on top of the background. Transparent areas have been cleared to a grayscale value of 0 (black).

Here's the ShadowShader definition:

Here's the final result using the custom shader and drawing the shadow layer in-between the ground and the above-ground objects (instead of on top of everything as with the previous example):

Final result (shadows are 20% black).

The shadows look great! The effect of the lava light shining through the shadows is fairly subtle - it's one of those things where it was easy to spot that something was wrong with the previous classic shadowing example but you may not really notice the correct behavior of this example.

Just to satisfy everyone that everything is working correctly, here's the same example with the /5.0 omitted from the shader so that the shadows are 100% black instead of 20% black:

A spot check using deep shadows (100% black). The lava cracks are shining through nicely!

As a final note, this is the first blog post I've written on ghost.io in three years. Their article editor is leaps and bounds beyond where it was and it's been a great experience.

Whelp, see you all again in three more years! I kid, I kid - the seal has been broken and it wouldn't surprise me if I blog again soon. 'Til next time!