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:

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.

7 Responses to “2D Ambient Shadows”

  1. Drilian Says:

    Wow, that’s…that’s really quite cool :)

    Well done! I really wanted to get shadowing into Mop of Destiny, but I couldn’t come up with a good way to do so. I wish I’d had this at the time! Maybe for the sequel…

  2. Riven Says:

    From the sourcecode:
    for (int i=0; i 0.01)

    Which might be:
    for (int i=0; i LT ??; i+=0.01)
    so what’s the upper bound?

    Maybe add html-encoding to see LT, GT, EQ?

  3. JC Says:

    Oops, my mistake. Should be fixed now, ta.

  4. Calsmurf2904 Says:


    I just implemented this in my 2D DirectX game, but I have one question?
    How did you handle images with alpha values?

    I had to add this check to the depthmap creation:
    if(color.a > 0.1f)
    return float4(1.0f-CurrentDepth, 1.0f-CurrentDepth, 1.0f-CurrentDepth, 1);
    return float4(0, 0, 0, 0);

    Yet, I’m not quite satisfied with this.
    Anyway, you can see my results here:
    Before: http://img25.imageshack.us/f/beforejw.png/
    After: http://img37.imageshack.us/f/afterh.png/

    This is after applying a box-blur over the texture.

  5. Manuel Says:

    Nice one! This adds a really nice feeling to the scene.
    Also!? Have you ever released the code for your other article at http://www.gamedev.net/reference/articles/article2032.asp?

    Keep up the good work!

  6. nike shox r3 Says:

    nike shox r3…

    1 the m茅tier signifiant monteur installateur : un m茅tier 脿 risques…

  7. Yiiq Says:

    一開始睇你果啲 video 就知道係自己擺個 Cam 自拍咁影 我都睇到無貓紙 其實以一人對 Cam 即時咁拍 時間同 scprit 已經掌握得好好 因為就算電視台有隊 term 有晒 scprit 有埋 NG 拍出來都係咁上下 最多打燈會好啲咁解 睇佢 youtube video 的話 可選擇 high quality 來觀看 畫質會靚很多

Leave a Reply