WebGL Shadow Maps Part 1: As Simple as Possible

Shadow maps are surprisingly not too hard to implement in WebGL, at least not much harder than regular texturing, but you wouldn’t know that by looking for tutorials on them. The aim of this post is to fix that and provide a walkthrough from the simplest possible shadow map implementation to a reasonably nice final implementation. The associated code is plain JavaScript and GLSL and uses no external libraries. The companion project is available here on github.

Baseline Scene

We will start with a baseline scene. In order to focus on the shadow map implementation, the baseline scene is as simple as possible, two cubes are drawn, one to cast the  shadow, one for the shadow to be cast on. For simplicity we won’t even be doing shading on these cubes until the very end, so each side is drawn with a different color to make them easy to see. Please see the code here and make sure it makes sense, it simply draws two multi-colored cubes from a camera position.

Adding a Shadow Map

The linked wikipedia article on shadow maps provides a great description of how they work, and I highly recommend reading it. This quote from the article summarizes it nicely:

Shadows are created by testing whether a pixel is visible from the light source, by comparing the pixel to a z-buffer or depth image of the light source’s view, stored in the form of a texture.

So step one we need to render from the point of view of the light rather than the camera, and instead of outputting colors to our canvas, we output depth values to a texture. Let’s create a new WebGL program to do that. Here are our shaders. Note that line numbers in this section match where the code lives in simplest-a.js.

Create Depth Texture

Depth Vertex

#version 300 es

layout(location=0) in vec4 aPosition;

uniform mat4 lightPovMvp;

void main() {
  gl_Position = lightPovMvp * aPosition;
}

Depth Fragment

#version 300 es
precision mediump float;

out float fragDepth;

void main() {
 fragDepth = gl_FragCoord.z;
}

These actually aren’t that different from our main shaders that rendered our basic scene, just instead of taking in a matrix for the camera view, we take one in for the light view. Just like regular rendering our vertices are multiplied by that matrix. In the fragment shader, rather than output a color, we output the z value of the current pixel. This is how we will create our depth texture. We already have our cubes coming in at location 0, so all we need to do to make this work is compile our program and create our light pov matrix.

const depthProgram = createProgram(gl, depthVertexShader, depthFragmentShader);

// Create light pov
const inverseLightDirection = new DOMPoint(-0.5, 2, -2);
const lightPovProjection = createOrtho(-1,1,-1,1,0,4);
const lightPovView = createLookAt(inverseLightDirection, origin);
const lightPovMvp = lightPovProjection.multiply(lightPovView);

const lightPovMvpDepthLocation = gl.getUniformLocation(depthProgram, 'lightPovMvp');
gl.useProgram(depthProgram);
gl.uniformMatrix4fv(lightPovMvpDepthLocation, false, lightPovMvp.toFloat32Array());

If you’ve ever rendered a scene with an orthographic projection before this should look pretty familiar, as that is exactly what we are doing. The only difference is we are outputting depth value instead of color. We can however actually still render this out:

Of course we need to render this to a texture rather than to the canvas, but it’s helpful to see exactly what our light “sees”. You can see the scene here but from a higher angle and orthographic projection, and that the color gets darker the closer something is to the camera.

Now that we know we are rendering the correct thing, lets create a depth texture to render it to.

// Depth Texture
const depthTextureSize = new DOMPoint(1024, 1024);
const depthTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, depthTexture);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.DEPTH_COMPONENT32F, depthTextureSize.x, depthTextureSize.y);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

const depthFramebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, depthFramebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTexture, 0);

Initially creating the texture isn’t really any different from creating any other texture in WebGL. The big difference is at line 119, where we set the compare mode. This tells WebGL that when we sample this texture, we actually want it to compare the pixel we are currently rendering with the pixel we sample. WebGL will do this for us automatically and return either 1 or 0. You can see more details here. We don’t have to set the TEXTURE_COMPARE_FUNC because we want the default, LEQUAL, which means return one if the current pixel is <= the sample. This is exactly what we want to do to determine if the current pixel is in light or shadow.

In order to get this automatic comparison, we have to specify our texture as a depth texture, which we do on line 118 saying the format is DEPTH_COMPONENT32F. We don’t want want our texture to repeat, so we specify CLAMP_TO_EDGE.

Additionally, we can’t actually render directly to a texture, we need the intermediate framebuffer. So we create a framebuffer and connect our texture to it.

We’ve now created the texture, but we need a way to get it into our main fragment shader for our comparison. We’ll add a sampler2DShadow to our main fragment shader:

uniform mediump sampler2DShadow shadowMap;

And get it’s location:

const shadowMapLocation = gl.getUniformLocation(program, 'shadowMap');

We are now ready to render to our depth texture. Lets look at the updated draw call:

// Render shadow map to depth texture
gl.useProgram(depthProgram);
gl.bindFramebuffer(gl.FRAMEBUFFER, depthFramebuffer);
gl.viewport(0, 0, depthTextureSize.x, depthTextureSize.y);
gl.drawArrays(gl.TRIANGLES, 0, verticesPerCube * 2);

// Set depth texture and render scene to canvas
gl.useProgram(program);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.bindTexture(gl.TEXTURE_2D, depthTexture);
gl.uniform1i(shadowMapLocation, 0);
gl.drawArrays(gl.TRIANGLES, 0, verticesPerCube * 2);

We tell WebGL to use our depth program, we bind to our depthFramebuffer so we render to our depth texture, we set the viewport to the depth texture size, and then we draw our cubes.

Now we have to tell WebGL to switch back to our main rendering program, bind the frame buffer back to the canvas (a null value binds to canvas), and set the viewport back to our canvas size. Now we send our depth texture into our main fragment shader and then draw our cubes. All that’s left now is the the depth comparison.

Pixel Depth Comparison

Using the texture compare mode COMPARE_REF_TO_TEXTURE means that WebGL will make the pixel comparison for us when we sample the texture. However, it has to be sampled from the point of view of the light. So for render we have to transform the pixel by the regular mvp matrix, but for depth testing we have to transform it by the light pov matrix. You can see an example of the same pixel transformed by each below:

This means our rendering shader needs access to the light pov matrix just like the depth shader. Lets look at the vertex shader first:

Main Vertex

#version 300 es

layout(location=0) in vec4 aPosition;
layout(location=1) in vec3 aColor;

uniform mat4 modelViewProjection;
uniform mat4 lightPovMvp;

out vec3 vColor;
out vec4 positionFromLightPov;

void main()
{
    vColor = aColor;
    gl_Position = modelViewProjection * aPosition;
    positionFromLightPov = lightPovMvp * aPosition;
}

From the baseline project, we’ve added lines 30, 33, and 39. We add the lightPovMvp uniform, an output for our transformed vertex, and then we multiply our vertex by the light pov matrix and output it. Lets populate the matrix:

const lightPovMvpRenderLocation = gl.getUniformLocation(program, 'lightPovMvp');
gl.useProgram(program);
gl.uniformMatrix4fv(lightPovMvpRenderLocation, false, lightPovMvp.toFloat32Array());

Now our fragment shader has access to the pixel from both viewpoints and we can sample our texture. Here’s our final fragment shader:

Main Fragment

#version 300 es
precision mediump float;

in vec3 vColor;
in vec4 positionFromLightPov;

uniform mediump sampler2DShadow shadowMap;

out vec3 fragColor;

float ambientLight = 0.5;

void main()
{
  vec3 lightPovPositionInTexture = positionFromLightPov.xyz * 0.5 + 0.5;
  float hitByLight = texture(shadowMap, lightPovPositionInTexture);
  float litPercent = max(hitByLight, ambientLight);
  fragColor = vColor * litPercent;
}

We’ve added our input with the position from the light’s pov, but you can see from line 56 there’s still one more thing we have to do before we can sample the texture. Remember that WebGL clip space is between -1 and 1, but texture space is between 0 and 1. By multiplying by 0.5 then adding 0.5, we convert from clip space to texture space and can sample our depth texture at the correct place.

Sampling our texture returns 1 if the pixel is hit by light, or 0 if it isn’t. Since we don’t want 100% black shadows, we take the max of hitByLight and ambientLight, meaning our final lit percent is either 1.0 or 0.5. We multiply that by our color and we’re done:

Well, sort of. There is a shadow map there, but also lots of strange artifacts. This is known as “shadow acne”, and is similar to z fighting. For areas in the light, the difference between the pixel position and depth texture depth is so small that due to floating point rounding it swaps between shadow and light at random.

Luckily that means the issue is quite easy to fix, we can just offset our position a little bit before we test it against the depth texture. Here’s the updated main method in our final fragment shader:

void main()
{
  vec4 lightPovPositionInTexture = positionFromLightPov * 0.5 + 0.5;
  float bias = 0.004;
  vec3 biased = vec3(lightPovPositionInTexture.xy, lightPovPositionInTexture.z - bias);
  float hitByLight = texture(shadowMap, biased);
  float litPercent = max(hitByLight, ambientLight);
  fragColor = vColor * litPercent;
}

You can find this code in no-acne.js here. And the acne is gone:

And we’re done…for now. You can see that the front side is casting a shadow on the back side here, with a bright border at the top due to the bias. This won’t be a problem when we add proper light based shading. So in part 2 we’ll add lighting to shade the surfaces properly, we’ll smooth out the edges of our shadow map, make some minor code and performance improvements, and look at another option for biasing.