I've really been enjoying some of the other devlogs that give insight into the implementation details
of various systems in their games. Some great examples are
Mages 'n' Peasantsand of course
Moonman. In the spirit of this I thought I'd document my lighting system.
Setting out, I had a few primary goals:
- Soft shadows
- Support for lots of lights
- Support for moving lights
- No pre-baking of lightmaps, since levels will be procedural
So with these goals in mind I looked for existing implementations.
I found
this articlewhich described the exact effect I was going for. The article does a great job of expalaining the steps,
but I'll outline it again here. The main addition I made was rendering multiple lights in a single pass.
It's similar to a deferred lighting approach. The main steps in the renderer are:
1. Render diffuse map
2. Render anything that occludes light to a texture
3. Render a 1D shadowmap for each light
4. Render a lightmap using the shadowmap data for each light
5. Blend the lightmap with the diffuse map of the main scene to get the final image
In pictures it looks like this:
1. Diffuse map:

These are just the colors, straight from the sprites.
2. Occluder map:

Everything that is intended to occlude light. In my case, I just pick what I've named the "foreground" layer
in my tilemap and render it to a texture.
3. Shadow map:

Each row in the shadowmap represents a light. Each pixel in the the row represents the distance that a
ray cast at the angle x / SHADOW_MAP_WIDTH * 2PI got before it hit an occluder.
Here is the fragment shader for the shadowmap. I won't include the vertex shader, because it's just my
standard sprite vertex shader that positions 256x1 pixel quad on the right row of the shadowmap texture.
#version 150
#define PI 3.14159265359
#define ALPHA_THRESHOLD 0.75
#define STEPS 256.0
uniform sampler2D occluder;
uniform mat4 projection;
// x,y is light size, z,w is occluder texture size. Both in pixels.
uniform vec4 scale;
in vec2 texcoord;
// Light position in screen space
in vec2 lightPosition;
out vec4 color0;
void main(void) {
float distance = 1.0;
// Convert tex coord into an angle from (0, 2PI)
float theta = PI * (2 * texcoord.s);
for (float y = 0.0; y < scale.y; y += 1.0) {
// Each step is one pixel of light range
float r = y / scale.y;
// This vector ranges from 0 -> 1 in length
vec2 coord = r * vec2(cos(theta), sin(theta));
// Scale factor so that we sample 1 pixel of the occluder texture
// for each pixel of the light range (each step in the for loop)
vec2 scaleFactor = vec2(scale.x / scale.z, scale.y / scale.w);
coord = coord * scaleFactor;
// Scale into texture coordinate space (length 0 -> 0.5)
vec2 sampleCoord = coord.xy / 2.0 + 0.5;
// Offset the lookup by the light's position in the occluder texture
sampleCoord = sampleCoord + lightPosition / 2.0;
// Sample occluder texture
vec4 sample = texture2D(occluder, sampleCoord);
// If the sample is above our threshold, check the distance
// and see if it is shorter than the current distance
if (sample.a > ALPHA_THRESHOLD) {
distance = min(distance, r);
}
}
color0 = vec4(distance, 0.0, 0.0, 1.0);
}
So essentially, for each pixel in the shadowmap, cast a ray and figure out where on that ray the nearest occluder is.
Write that value to the red channel of the shadowmap texture. This texture has been set up as a floating point 256x256 texture
with only a red channel (GL_R32F).
4. Light map:

Rendered here as just the alpha channel over black.
Every light rendered as a 256x256 square, sampling the shadowmap texture to find out if
a given pixel should be lit, and how much. Apply a falloff based on the distance from the
center of the light. Mix samples from neighbouring rays to get some blurriness, increasing the
blurriness the farther we are from the light. A small about of base ambient light is added to each light so the
neighbouring tiles get some illumination.
Here's the fragment shader for the lightmap. Again, not including the vertex shader here. It just positions
a 256x256 quad centered on the light location.
#version 150
#define PI 3.14159265359
#define AMBIENT 0.03
#define STEPS 256.0
#define MAX_LIGHTS 256.0
uniform sampler2D shadowmap;
in vec2 texcoord;
in float index;
in float brightness;
in vec3 color;
out vec4 color0;
void main(void) {
// Convert rectangular coordinates to polar coordinates
vec2 normal = texcoord.st * 2.0 - 1.0;
float theta = atan(-normal.y, normal.x);
float r = length(normal);
float sampleCoord = theta / (2 * PI);
// Increase blur amount with radius
float blur = (1.0 / STEPS) * smoothstep(0.0, 1.0, r);
// Compute the Y coordinate (row in the shadowmap for this light)
float sampleY = 1.0 - index / MAX_LIGHTS - 0.5 / MAX_LIGHTS;
// Sample the shadowmap
float sum = 0.0;
vec4 samples[9];
samples[0] = texture2D(shadowmap, vec2(sampleCoord - 4.0 * blur, sampleY));
samples[1] = texture2D(shadowmap, vec2(sampleCoord - 3.0 * blur, sampleY));
samples[2] = texture2D(shadowmap, vec2(sampleCoord - 2.0 * blur, sampleY));
samples[3] = texture2D(shadowmap, vec2(sampleCoord - 1.0 * blur, sampleY));
samples[4] = texture2D(shadowmap, vec2(sampleCoord, sampleY));
samples[5] = texture2D(shadowmap, vec2(sampleCoord + 1.0 * blur, sampleY));
samples[6] = texture2D(shadowmap, vec2(sampleCoord + 2.0 * blur, sampleY));
samples[7] = texture2D(shadowmap, vec2(sampleCoord + 3.0 * blur, sampleY));
samples[8] = texture2D(shadowmap, vec2(sampleCoord + 4.0 * blur, sampleY));
float steps[9];
steps[0] = step(r, samples[0].r);
steps[1] = step(r, samples[1].r);
steps[2] = step(r, samples[2].r);
steps[3] = step(r, samples[3].r);
steps[4] = step(r, samples[4].r);
steps[5] = step(r, samples[5].r);
steps[6] = step(r, samples[6].r);
steps[7] = step(r, samples[7].r);
steps[8] = step(r, samples[8].r);
sum += (steps[0]) * 0.05;
sum += (steps[1]) * 0.09;
sum += (steps[2]) * 0.12;
sum += (steps[3]) * 0.15;
sum += steps[4] * 0.16;
sum += (steps[5]) * 0.15;
sum += (steps[6]) * 0.12;
sum += (steps[7]) * 0.09;
sum += (steps[8]) * 0.05;
// It's at least AMBIENT bright
sum = max(sum, AMBIENT);
// Radius falloff
float alpha = sum * smoothstep(brightness, 0.0, r);
color0 = vec4(color, 1.0) * vec4(vec3(1.0), alpha);
}
5. Final composite:

Blend the diffuse map with the light map. Final scene!
I really like how the lighting turned out, and I'm especially happy that I can have a boat load of lights if I want to, with no additional draw calls. The light count is only limited by the height of the shadowmap texture. I could also increase the number of light rays by increasing the width of that texture. This would improve the fidelity of the effect, but since I'm doing a pixel art style, I don't think I need more than 256.
Let me know if you'd like more detail on the draw call submission / setup side of this effect. I didn't go into too much detail there because the original article documents most of that.
As for actual devlog updates, I'm currently chucking out my custom collision system in favor of Box2D. I really liked working with it when I did Super Wizard Fever. After using it there I realized how much my own system is lacking and decided to switch over.