Tutorial 59: Shadow Mapping
Shadow mapping requires a depth-format RenderTarget2D and custom GLSL shaders. It is available on the EASYGL and VULKAN backends only. The SDL_RENDERER backend does not support off-screen depth render targets or programmable shaders.
Shadow mapping is the dominant real-time shadow technique used in 3D games. The algorithm is conceptually elegant: before rendering the visible scene, render it from the light's point of view, recording only depth. This "shadow map" records how far the light can see in each direction. During the main render pass, for each fragment you compute where it would land in the light's view and compare its depth against the stored shadow map depth. If the fragment is farther from the light than what the map recorded, something else was in between — the fragment is in shadow.
Shadow Mapping Algorithm
The algorithm runs in two GPU passes per frame:
- Depth pass (light's POV): Set the render target to a depth-format texture (the shadow map). Render the entire scene using a minimal vertex shader that transforms positions into light space. The fragment shader is empty or writes only the depth. The hardware depth buffer is written automatically at the shadow map resolution.
- Lighting pass (camera's POV): Restore the normal render target (the back buffer). For each vertex, additionally compute its position in light space and pass it to the fragment shader as a varying. In the fragment shader, project that light-space position into shadow map texture coordinates and sample the shadow map. Compare the sampled depth against the current fragment's light-space depth. If the fragment is deeper (farther from the light), it is shadowed.
The shadow map is a Texture2D (not a cubemap) for directional and spot lights. Point lights require six shadow maps (one per cubemap face) or a special dual-paraboloid encoding, but that is beyond the scope of this tutorial.
Depth Render Pass to RenderTarget2D
Create the shadow map as a RenderTarget2D with a floating-point or packed depth surface format. A 1024×1024 shadow map is standard for local shadows; 2048×2048 or 4096×4096 for large outdoor scenes with sun shadows. Using a power-of-two size ensures hardware mip generation works correctly if needed.
// Create shadow map render target
shadowMap_ = std::make_unique<RenderTarget2D>(
gd,
1024, 1024,
false, // no mipmaps
SurfaceFormat::Single, // 32-bit float for depth storage
DepthFormat::Depth32 // hardware depth buffer for depth pass
);
// Alternatively, use DepthFormat::Depth24 for wider compatibility:
// DepthFormat::Depth24 uses a 24-bit depth buffer, sufficient for most scenes.
During the depth pass, bind the shadow map render target, clear it to maximum depth (white = 1.0), and render the scene geometry using the depth effect:
gd.SetRenderTarget(shadowMap_.get());
gd.Clear(Color::White); // Clears depth to 1.0 (maximum distance)
// ... render scene geometry with depth-only effect ...
gd.SetRenderTarget(nullptr); // restore back buffer
Light Space Matrix
The light space matrix transforms world-space positions into the light's clip space. For a directional light (like the sun), use an orthographic projection centred on the scene's bounding volume. For a spot light, use a perspective projection with a field of view matching the cone angle:
// Directional light (sun-like)
Vector3 lightPos = Vector3(10.0f, 20.0f, 10.0f); // position the light above the scene
Vector3 lightTarget = Vector3::Zero;
Vector3 lightUp = Vector3::Up;
Matrix lightView = Matrix::CreateLookAt(lightPos, lightTarget, lightUp);
// Orthographic projection covering a 40x40 unit area, depth range 1..60
Matrix lightProj = Matrix::CreateOrthographic(40.0f, 40.0f, 1.0f, 60.0f);
// Combine: this is what gets uploaded to the depth-pass vertex shader
Matrix lightSpaceMatrix_ = lightView * lightProj;
The orthographic extents (40×40 in the example) should tightly wrap the visible scene. An overly large frustum wastes shadow map resolution — each shadow map texel covers a larger world-space area, making shadows appear blockier. For best quality, compute tight bounds around the current camera frustum (a technique called "cascaded shadow maps" or "fitted shadow maps").
Shadow Map Sampling in the Lighting Shader
In the lighting pass vertex shader, compute the fragment's position in light space and pass it to the fragment shader as a varying:
// In the lighting vertex shader
uniform mat4 u_lightSpace;
uniform mat4 u_world;
out vec4 v_lightSpacePos;
void main() {
vec4 worldPos = u_world * vec4(a_position, 1.0);
v_lightSpacePos = u_lightSpace * worldPos;
// ... compute camera-space position for gl_Position ...
}
In the fragment shader, project the light-space position to [0,1] UV coordinates and sample the shadow map:
vec3 proj = v_lightSpacePos.xyz / v_lightSpacePos.w; // perspective divide
proj = proj * 0.5 + 0.5; // NDC [-1,1] -> [0,1]
// Fragments outside the light frustum are not in shadow
if (proj.z > 1.0) { /* fully lit */ }
float shadowMapDepth = texture(u_shadowMap, proj.xy).r;
float fragDepth = proj.z;
// If the fragment is deeper than what the shadow map recorded, it's in shadow
bool inShadow = (fragDepth > shadowMapDepth + bias);
Percentage-Closer Filtering (PCF)
A single shadow map sample produces hard-edged "blocky" shadows with visible aliasing at the shadow boundary, especially when the light frustum is large relative to the shadow map resolution. Percentage-Closer Filtering (PCF) mitigates this by sampling the shadow map at multiple nearby texel locations and averaging the binary shadow test results. This effectively blurs the shadow edge, producing a soft penumbra without a true area light calculation:
float shadowFactor(vec4 lsPos) {
vec3 proj = lsPos.xyz / lsPos.w;
proj = proj * 0.5 + 0.5;
if (proj.z > 1.0) return 1.0; // outside light frustum = lit
float bias = 0.005;
float shadow = 0.0;
vec2 texelSize = 1.0 / vec2(textureSize(u_shadowMap, 0));
// 3x3 PCF kernel
for (int x = -1; x <= 1; ++x) {
for (int y = -1; y <= 1; ++y) {
float pcfDepth = texture(u_shadowMap,
proj.xy + vec2(x, y) * texelSize).r;
shadow += (proj.z - bias > pcfDepth) ? 0.0 : 1.0;
}
}
return shadow / 9.0; // average of 9 samples
}
A 3×3 kernel (9 samples) is a good balance between quality and performance. A 5×5 kernel (25 samples) produces noticeably softer shadows at roughly 2.8x the cost. For very high quality, use a Poisson disk kernel with 16–64 samples, optionally rotated randomly per pixel to break the regular pattern.
Bias to Avoid Shadow Acne
Shadow acne is a self-shadowing artifact: the surface appears to cast shadows on itself, producing a characteristic stripey or moire pattern. It arises because the shadow map depth is a discretised approximation — each texel covers a non-zero area, and the surface depth sampled during the depth pass may be slightly different from the depth computed during the lighting pass due to floating-point precision differences and the fact that neighbouring texels represent different points on the surface.
The fix is to add a small constant "bias" to the shadow map depth comparison. If the fragment's depth minus the bias is still greater than the shadow map depth, it is in shadow; otherwise it is lit. This pushes the comparison threshold slightly outward, eliminating the self-shadowing artifacts:
float bias = 0.005;
float inShadow = (proj.z - bias > shadowMapDepth) ? 0.0 : 1.0;
The bias value requires careful tuning. Too small: acne remains. Too large: "peter-panning" occurs — the shadow appears detached from the shadow-casting object, as if the object is floating. A common technique to automate bias selection is slope-based bias, which scales the bias with the angle between the light direction and the surface normal:
// Slope-scaled bias: larger bias on steep surfaces
float cosTheta = clamp(dot(N, L), 0.0, 1.0);
float bias = max(0.01 * (1.0 - cosTheta), 0.001);
Platform Limitations
Shadow mapping has the following platform and backend constraints in CNA:
- EASYGL backend: Fully supported. Uses
GL_DEPTH_COMPONENT32Ftexture format for the shadow map, sampled as asampler2D. Hardware depth comparison samplers (sampler2DShadowwithtextureCompare) are also available for hardware PCF at no extra sample cost on supported drivers. - VULKAN backend: Shadow mapping with custom
RenderTarget2Dand depth formats is planned and partially implemented. Refer to the CNA roadmap for current status. - SDL_RENDERER backend: Shadow mapping is not supported. Off-screen depth render targets and programmable shaders are not available on this backend.
- Android / WebGL: Floating-point depth textures require the
OES_depth_textureextension on OpenGL ES 2.0, but are native on OpenGL ES 3.0 (which CNA targets). The EASYGL backend on Android and WebAssembly uses ES 3.0 and fully supports shadow mapping.
Complete GLSL Shaders
Depth Pass — Vertex Shader (shadow_depth.vert)
#version 300 es
precision highp float;
layout(location = 0) in vec3 a_position;
uniform mat4 u_lightSpace;
uniform mat4 u_world;
void main() {
gl_Position = u_lightSpace * u_world * vec4(a_position, 1.0);
}
Depth Pass — Fragment Shader (shadow_depth.frag)
#version 300 es
precision highp float;
out vec4 fragColor;
void main() {
// The hardware depth buffer is written automatically.
// Output depth value explicitly for SurfaceFormat::Single targets.
fragColor = vec4(gl_FragCoord.z, 0.0, 0.0, 1.0);
}
Lighting Pass — Fragment Shader with PCF (shadow_lighting.frag)
#version 300 es
precision highp float;
in vec3 v_worldPos;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_lightSpacePos;
uniform sampler2D u_shadowMap;
uniform sampler2D u_diffuse;
uniform vec3 u_lightDir; // world-space, towards light source
uniform vec3 u_lightColor;
uniform vec3 u_ambientColor;
out vec4 fragColor;
float shadowFactor(vec4 lsPos) {
vec3 proj = lsPos.xyz / lsPos.w;
proj = proj * 0.5 + 0.5;
if (proj.z > 1.0) return 1.0; // outside light frustum: fully lit
float cosTheta = clamp(dot(normalize(v_normal), normalize(u_lightDir)), 0.0, 1.0);
float bias = max(0.01 * (1.0 - cosTheta), 0.001);
float shadow = 0.0;
vec2 texelSize = 1.0 / vec2(textureSize(u_shadowMap, 0));
for (int x = -1; x <= 1; ++x) {
for (int y = -1; y <= 1; ++y) {
float pcfDepth = texture(u_shadowMap,
proj.xy + vec2(x, y) * texelSize).r;
shadow += (proj.z - bias > pcfDepth) ? 0.0 : 1.0;
}
}
return shadow / 9.0;
}
void main() {
vec3 N = normalize(v_normal);
vec3 L = normalize(u_lightDir);
float diff = max(dot(N, L), 0.0);
float sf = shadowFactor(v_lightSpacePos);
vec4 albedo = texture(u_diffuse, v_texcoord);
vec3 ambient = u_ambientColor * albedo.rgb;
vec3 diffuse = u_lightColor * diff * sf * albedo.rgb;
fragColor = vec4(ambient + diffuse, albedo.a);
}
Complete C++ Two-Pass Draw Loop
class ShadowGame final : public Game {
std::unique_ptr<RenderTarget2D> shadowMap_;
std::unique_ptr<Effect> depthEffect_;
std::unique_ptr<Effect> lightEffect_;
Matrix lightSpaceMatrix_;
struct SceneObj { VertexBuffer* VB; int TriCount; Matrix World; };
std::vector<SceneObj> scene_;
Camera camera_;
Vector3 lightPos_{ 10.0f, 20.0f, 10.0f };
void LoadContent() override {
auto& gd = getGraphicsDeviceProperty();
shadowMap_ = std::make_unique<RenderTarget2D>(
gd, 1024, 1024, false,
SurfaceFormat::Single, DepthFormat::Depth32);
depthEffect_ = Content.Load<Effect>("shaders/shadow_depth");
lightEffect_ = Content.Load<Effect>("shaders/shadow_lighting");
// Configure static lighting parameters
lightEffect_->Parameters["u_lightColor" ].SetValue(Vector3(1.0f, 0.95f, 0.85f));
lightEffect_->Parameters["u_ambientColor"].SetValue(Vector3(0.15f, 0.15f, 0.2f));
BuildScene();
}
void Update(const GameTime& gt) override {
// Recompute light space matrix each frame (for moving light)
Matrix lightView = Matrix::CreateLookAt(
lightPos_, Vector3::Zero, Vector3::Up);
Matrix lightProj = Matrix::CreateOrthographic(40.0f, 40.0f, 1.0f, 60.0f);
lightSpaceMatrix_ = lightView * lightProj;
lightEffect_->Parameters["u_lightDir" ].SetValue(
Vector3::Normalize(Vector3::Zero - lightPos_));
lightEffect_->Parameters["u_lightSpace" ].SetValue(lightSpaceMatrix_);
depthEffect_->Parameters["u_lightSpace" ].SetValue(lightSpaceMatrix_);
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
// ---------------------------------------------------------------
// Pass 1: Render depth to shadow map from light's point of view
// ---------------------------------------------------------------
gd.SetRenderTarget(shadowMap_.get());
gd.Clear(Color::White);
gd.setRasterizerState(RasterizerState::CullCounterClockwise);
for (auto& obj : scene_) {
depthEffect_->Parameters["u_world"].SetValue(obj.World);
gd.setVertexBuffer(*obj.VB);
for (auto& pass : depthEffect_->getCurrentTechnique().Passes) {
pass.Apply();
gd.DrawPrimitives(PrimitiveType::TriangleList, 0, obj.TriCount);
}
}
// ---------------------------------------------------------------
// Pass 2: Render scene with shadow test from camera's point of view
// ---------------------------------------------------------------
gd.SetRenderTarget(nullptr);
gd.Clear(Color::CornflowerBlue);
gd.setRasterizerState(RasterizerState::CullCounterClockwise);
// Bind the shadow map as a texture for the lighting shader
lightEffect_->Parameters["u_shadowMap" ].SetValue(shadowMap_.get());
lightEffect_->Parameters["u_view" ].SetValue(camera_.View());
lightEffect_->Parameters["u_projection"].SetValue(camera_.Projection());
for (auto& obj : scene_) {
lightEffect_->Parameters["u_world"].SetValue(obj.World);
gd.setVertexBuffer(*obj.VB);
for (auto& pass : lightEffect_->getCurrentTechnique().Passes) {
pass.Apply();
gd.DrawPrimitives(PrimitiveType::TriangleList, 0, obj.TriCount);
}
}
gd.Present();
}
};
Depth buffer format caution: When using SurfaceFormat::Single for the shadow map, both the red channel of the texture and the hardware depth buffer store depth values. Make sure you sample from the red channel (.r) in the PCF loop. On some drivers, sampling from .r vs .rrrr differs when the texture is not set up as a depth comparison sampler. If you see incorrect shadows, verify the swizzle used when sampling u_shadowMap.