## 2D Ambient Shadows

For the last few days I’ve taken some time off from the AI work to mess around with some graphical effects, and in particular I’ve been experimenting with a 2d ambient shadows effect. This is inspired by the Screen Space Ambient Occulsion (SSAO) effect which has gotten popular lately, and is largely a translation and adaptation of it into a 2d world.

To start with, here’s my test scene (unrelated to the current platformer/ai work):

(all screenshots a quater full size, click to view the full sized version)

That’s a whole bunch of tightly packed parallax layers with some trees and letters interleaved between them. The parallax is quite subtle, so it’s mostly lost in a static shot but it creates a nice 3d effect when scrolling.

The first step is to also generate a depth map from this. Since we’re in 2d and we don’t have a z-buffer, we can fake one with a simple shader to tint the sprites based on their depth.

(I’ve artificially tweeked the colour levels in the above to exagerate the layers, as otherwise you only really see white objects on a black background). It looks a bit jaggier than the base colour because we clamp the alpha values of the sprite textures to either be zero or one in the depth shader as otherwise the semi-transparent pixels introduce errors in the next step.

Next is the real magic, we apply the ambient shader. This accepts the previously generated depth texture as an input. For each fragment it looks up it’s base depth, then samples a number of surrounding texels and finds their depth as well. Surrounding depths which are higher than our base depth (i.e. it’s from a surface in front of us) darken our ambient shadow factor. We also apply a cutoff for this test so that depths which are really far in front get ignored as we decide that their shadow won’t be cast onto our current pixel. Surrounding depths lower (i.e. behind) our base depth are ignored.

Surrounding texels are found using precalculated poisson disc offsets in a similar way to traditional growable blur. We also apply a constant offset to these samples so that the shadows appear dropped slightly down-left of the shadow casters.

This produces the raw ambient map:

You can see how the grass layers are much more clearly defined and that letters both cast shadows onto trees behind them and receive shadows from trees in front as well.

Since this is a little noisy, we apply a simple blur to the raw ambient map:

Then as a final stage we combine the blurred ambient map with the colour map (and any other layers, like a bloom map) to the framebuffer:

A pretty neat effect I think – it’s certainly got a lot more depth than the basic colour version, and the shadows on moving objects really help them feel like they’re part of the world.

And if anyone wants to play around with this, here’s the GLSL shader to generate the raw ambient map:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
uniform sampler2D depthMap; const int numSamples = 16; const float divisor = 1.0 / float(numSamples); vec2 samples[numSamples]; void main() { // Our generated poisson disc sample offsets samples[0] = vec2(0.007937789, 0.73124397); samples[1] = vec2(-0.10177308, -0.6509396); samples[2] = vec2(-0.9906806, -0.63400936); samples[3] = vec2(-0.5583586, -0.3614012); samples[4] = vec2(0.7163085, 0.22836149); samples[5] = vec2(-0.65210974, 0.37117887); samples[6] = vec2(-0.12714535, 0.112056136); samples[7] = vec2(0.48898065, -0.66669613); samples[8] = vec2(-0.9744036, 0.9155904); samples[9] = vec2(0.9274436, -0.9896486); samples[10] = vec2(0.9782181, 0.90990245); samples[11] = vec2(0.96427417, -0.25506377); samples[12] = vec2(-0.5021933, -0.9712455); samples[13] = vec2(0.3091557, -0.17652994); samples[14] = vec2(0.4665941, 0.96454906); samples[15] = vec2(-0.461774, 0.9360856); // Sample spread distance float spread = 0.007; // Offset to make shadows set slightly down and left from their caster vec2 depthOffset = vec2(0.001, 0.003); // Grab the base texture coord vec2 baseCoord = gl_TexCoord[0].xy; float baseDepth = texture2D(depthMap, baseCoord).r; float ambient = 1.0; for (int i=0; i<numSamples; i++) { float offsetDepth = texture2D(depthMap, baseCoord + depthOffset + (samples[i] * spread) ).r; float diff = offsetDepth - baseDepth; // diff is +itive if offset depth // is in front of us float cutoff = 0.08; // limits how far objects can cast a shadow float threshold = 0.01; // must cross this threshold to cast a shdow if (diff < cutoff && diff > 0.01) { diff = clamp(diff, 0.0, cutoff); diff = cutoff - diff; ambient -= diff; } } gl_FragColor = vec4(ambient, ambient, ambient, 1.0); } |

All of the other shaders are trivial, so I won’t include those. And the above is probably pretty sub-optimal as it was written for clarity rather than speed but it seems to fly along at a nice smooth framerate regardless.

If anyone experiments further with this I’d be interested in hearing about your results and comments. Ta.

Tags: dev