Shadows are an important aspect of immersive visualizations. They bind things together, showing that the whole simulation adheres to the same physics of light.
Early in the project, once the first ray-tracing experiments in terrain rendering succeeded, I realized that we can re-use it to compute light bounces for shadows and reflections:
Drawing quality aside, this picture is meant to say that we were casting rays left and right:
- from camera to the ground
- from ground to the sun
- from water surface to another ground
This wasn’t cheap to compute, but was surely easy to prototype and experiment. It produced nice shadows and reflections of the ground:
There was only one problem: mechos looked out of place. They didn’t cast shadows onto the terrain, and weren’t receiving shadows from it.
Shadow map
If we can’t raycast the terrain against the polygons of mechos, lets rasterize the terrain. Here comes the old good shadow map. We are going to rasterize the terrain into both the shadow map and the main screen, by using ray casting. That would allow the shadow map to also combine shadows from the mechos, and it’s easy to apply it uniformly to everything rendered.
The first step in building a shadow map is writing down the math that builds the light projection matrix at any given frame. I did it in several steps:
- Computing the frustum of the main camera
- Compute the world bounding box of it
- Place the virtual sun at the center of this box. The light is going to be directional, equally affecting everything things both in front and behind the sun. Putting it at the center just allows to reduce the numerical precision issues.
- Compute the bounding box in the light coordinate system. That’s our orthogonal projection!
This is how our scene looks from the light point of view now:
Sampling
Applying the shadow map is the easiest part. It’s just a single depth-comparison sample from the shadow map based on the world position:
float fetch_shadow(vec3 pos) {
vec4 homogeneous_coords = u_LightViewProj * vec4(pos, 1.0);
if (homogeneous_coords.w <= 0.0) {
return 0.0;
}
vec3 light_local = vec3(
0.5 * (homogeneous_coords.xy * flip_correction/homogeneous_coords.w + 1.0),
homogeneous_coords.z / homogeneous_coords.w
);
return texture(
sampler2DShadow(u_ShadowTexture, u_ShadowSampler),
light_local.xyz
);
}
This code is included and used in both object and terrain rendering. Careful reader may notice that it’s overly generic: it does perspective division even though our light is directional. The end result looks like this:
It’s nice to see the shadow when jumping, being shaded while driving below the road, by abandoned structures - the game looks much more coherent now.