Friday, 26 April 2013

Dynamic 2D Shadows

This article is based on the following theories and samples. My implementation was merely ported across to C++/DirectX for learning purposes. All credit goes to the authors.

Recommended Reading:

What are dynamic 2D shadows

Monaco: What's Yours is Mine
They are an aspect of lighting for 2D games (sidescrolling, top-down, etc.) where as the light source moves through the environment, shadows are updated and cast in real-time. As such, you can get some quite impressive effects as you walk past a series of columns for example.

Of course, this aspect does not need to be used just for lighting specifically. Monaco is a game which uses this concept for the player's field of view, such that you can only see the parts of the level that your character actually has line of sight with.

How you could implement it

There are two main aspects to consider when using this approach. The first is the concept of Render to Texture, where instead of rendering to the usual backbuffer you render to a separate texture; and the second is Texture Blending, where you control how multiple textures are blended together for different visual effects. It's important that you have a basic grasp of these techniques, so I'd recommend reading up on them if you're not too sure what they mean.

Our game rendering code is broken up into three phases. First we render what's known as a lightmap to a separate render texture, then we render our level geometry to the backbuffer and finally we then blend the backbuffer with the lightmap to create our final scene. Since creating the lightmap requires the most work, it will be the focus for this article.

Creating a lightmap

A lightmap is simply a map of the light (and consequently dark) areas in a scene. In our case, it's simply a coloured texture which we combine with our scene, where the dark areas of the lightmap are rendered in ambient lighting and the bright areas are rendered according to their colour.

Creating shadow primitives
In order to create a light map, we need to create geometry for our shadows. For each light and object pair, we loop through each of the object's faces and compare whether it is facing the light source. This is done by taking each face's normal and using the dot product to compare it to the direction of the light (greater than 0 means it's facing the light and less than 0 means it's facing away).We then take the points which make up these faces and project them away from the light source to create a shadow primitive.

If we were to simply loop through each light, render a simple feathered circle texture for each and then render a black primitive behind each of our objects for shadows, we wouldn't be able to blend multiple light sources together (as the black would override any other colour already there). As such, in order to create our lightmap, we're going to use a little trick to control what parts of the light texture are rendered.

First, we render the shadow primitives to the texture's alpha channel as 0. The alpha channel controls how transparent a texture is, and since the lightmap will always be completely opaque we can use it as a form of temporary storage instead. To do this in DirectX 9, we set the following render state parameter before drawing our primitives.


Then we render our light texture using a blending operation where the amount of light texture added depends on the target's alpha. For DirectX 9, the way we achieve this is by setting the device's render state blending parameters before drawing our light texture. We also reset the ColorWriteEnable parameter so we can render the light texture's RGB values.

device->SetRenderState(D3DRS_COLORWRITEENABLE, 0x0000000F);
device->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_ONE);

A lightmap created from 6 lights
Source Blend is the factor for the texture we're rendering (light texture), where as Destination Blend is the factor for the rendering target (lightmap). Setting the Source Blend Factor as Destination Alpha means that the amount of the source texture used is dependant on the render target's alpha. Setting the Destination Blend as ONE means that 100% of the render target is always used. By default, the Blending Operation is set to ADD, which means that the source factor is added to the desination. As such, this means that our new light value is added to any existing value at that position, rather than overwriting it.

If we repeat this process for each light we can create a lightmap containing all of our lights, correctly shadowed and blended where two lights meet. Don't forget to clear the alpha channel between drawing each light so that our shadow data doesn't carry across into the following lights. Unfortunately both the Clear() and ColorFill() methods in DirectX ignore the ColorWriteEnable parameter, so you have to manually render a full-screen quad if you want to clear the lightmap's alpha channel.

Combining the lightmap with the scene

After rendering your scene geometry, we can then combine it with the lightmap to form the final lit scene. To do this, we use another blending operation to control how much of the scene colour we render. Specifically, we render our target texture where the amount of target texture used depends on the lightmap's colour or RGB values. To this in DirectX 9, we set the device's render state blending parameters to the following values.

device->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_ZERO);

Scene combined with lightmap
In this case the Source Blend Factor is ZERO, meaning that none of the source texture is used. The Destination Blend Factor is set as SRCCOLOR though, which means that the RGB components used for the destination are the same as those in the source texture. This means that any areas in a scene where the lightmap is red for example will only render their red components.

An interesting thing to note is the effects of multiple light sources. In the picture shown, you can see how some of the shadows from the blue light are rendered as more pink since they're still receiving light from the pink source. Light is also correctly blended where the two lights meet, forming a purple area.

Source code and comments

While this method does produce very impressive visual results, it can also be quite taxing for the framerate. Every frame we have to compare every light to every object in the scene and calculate shadows for each of them. In my sample, adding just one extra hull caused the FPS to drop by 60 frames. If you consider how sparse the scene is, you can already begin to imagine the problems you'd have with using this solution in a complex game.

I've attempted to optimise the code by batching all of the shadows into one draw call for each light, but even then the performance save was only some tens of frames per second. There may be other ways to increase performance though, so if you have any ideas then do feel free to share them. You should find an executable in the Release folder, which will need the latest DirectX 9 redistributable or SDK to run.

George - Wizard Sprite
Studio Evil - Ground Texture

Sample Archive
DirectX 9 Redistributable

No comments:

Post a Comment