Tutorial 59: Shadow Mapping
What you’ll learn
- The two-pass shadow-map algorithm end to end.
- Rendering depth from the light into a
RenderTarget2Dand building the light-space matrix. - Sampling the shadow map, softening it with PCF, and tuning bias to kill shadow acne.
Before you start — Tutorial 23: Render Targets for Off-Screen Rendering (the depth pass renders to a target), Tutorial 52: Writing Custom Shaders (ShaderEffect) (the comparison happens in your own shader) and Tutorial 33: Matrices and Transformations (light-space transforms). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the thirteen 2D-only renderers (SDL_RENDERER, DIRECT2D, CANVAS, HTML_DOM, SKIA, BLEND2D, FREEDIRECT, DIRECTX1, GDI, SVG_DOM, OPENVG, NANOVG, PIXIJS) throw on 3D calls. Requires a renderer with render-target support.
Shadow mapping requires an off-screen RenderTarget2D and custom GLSL shaders. It needs a renderer with off-screen render targets and a custom ShaderEffect that actually executes — OPENGLES3 and VULKAN both qualify. The thirteen 2D-only renderers do not. Those two tutorial configurations use the framework's SurfaceFormat::Color path, so this example stores depth packed into RGBA8; Skia and IGL's promoted format exceptions do not make float targets portable.
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
The tutorial's portable target is SurfaceFormat::Color. OPENGLES3 and Vulkan defer to the framework's Color-only gate, so this cross-renderer example packs depth into RGBA8. Skia and IGL's promoted exceptions do not supply the same custom-shader/MRT portability contract.
Create the shadow map as a RenderTarget2D in SurfaceFormat::Color, with a real hardware depth buffer for the depth pass itself. 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::Color, // RGBA8 - portable for this renderer pair
DepthFormat::Depth24 // hardware depth buffer for the depth pass
);
The colour target stores depth as a packed RGBA8 value written by the fragment shader; DepthFormat::Depth24 is the hardware depth buffer that performs the actual depth test during the pass. They serve different purposes and both are needed.
Packing 24 bits of depth into RGBA8
The standard trick spreads a normalised float across the four channels, giving roughly 32 bits of storage of which about 24 bits are usable in practice. Add these two helpers to your shaders:
// Pack a float in [0,1] into RGBA8.
vec4 packDepth(float depth) {
const vec4 bitShift = vec4(1.0, 255.0, 65025.0, 16581375.0);
const vec4 bitMask = vec4(1.0 / 255.0, 1.0 / 255.0, 1.0 / 255.0, 0.0);
vec4 res = fract(depth * bitShift);
res -= res.gbaa * bitMask;
return res;
}
// Unpack RGBA8 back into a float in [0,1].
float unpackDepth(vec4 rgba) {
const vec4 bitShift = vec4(1.0,
1.0 / 255.0,
1.0 / 65025.0,
1.0 / 16581375.0);
return dot(rgba, bitShift);
}
Precision is lower than a true 32-bit float target, which shows up as slightly coarser self-shadowing. Compensate with a marginally larger depth bias rather than a higher shadow map resolution.
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 = unpackDepth(texture(u_shadowMap, proj.xy));
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 = unpackDepth(texture(u_shadowMap,
proj.xy + vec2(x, y) * texelSize));
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 renderer constraints in CNA:
- Portable surface format limit: the OPENGLES3/Vulkan configurations above defer to
Texture::ValidateFormat, which accepts onlySurfaceFormat::Color. Skia has a broad promoted set and IGL promotesRg32/Single, but those renderer-qualified exceptions do not change this portable example. OPENGLES3: Supported. The shadow map is aSurfaceFormat::Colorrender target sampled as an ordinarysampler2D, withunpackDepth()reconstructing the stored value.VULKAN: Render targets are real here, with a real stencil PSO, MSAA resolve and device-queried depth formats. The sameSurfaceFormat::Colorrestriction applies.- The thirteen 2D-only renderers: Shadow mapping is not supported. They have no off-screen depth render targets and no programmable pipeline, and 3D calls throw deterministically rather than drawing nothing.
- Android / WebGL:
OPENGLES3on Android andWEBGL2in the browser share one internal implementation (EasyGL) over OpenGL ES 3.0. The lower profilesOPENGLES2andWEBGL1are a different matter — they lose MRT, occlusion queries,Texture3Dand instancing outright. Because the technique above needs nothing beyond an RGBA8 render target and standard GLSL ES 3.00, it carries across without extension checks.
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;
vec4 packDepth(float depth) {
const vec4 bitShift = vec4(1.0, 255.0, 65025.0, 16581375.0);
const vec4 bitMask = vec4(1.0 / 255.0, 1.0 / 255.0, 1.0 / 255.0, 0.0);
vec4 res = fract(depth * bitShift);
res -= res.gbaa * bitMask;
return res;
}
void main() {
// The hardware depth buffer is written automatically. The colour target
// is SurfaceFormat::Color (RGBA8), so write depth in packed form.
fragColor = packDepth(gl_FragCoord.z);
}
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 unpackDepth(vec4 rgba) {
const vec4 bitShift = vec4(1.0,
1.0 / 255.0,
1.0 / 65025.0,
1.0 / 16581375.0);
return dot(rgba, bitShift);
}
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 = unpackDepth(texture(u_shadowMap,
proj.xy + vec2(x, y) * texelSize));
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
Both passes in this tutorial use ShaderEffect, not the XNB compiled-effect path. Alpha.1 can load XNA/FNA D3D9 Effect Framework bytecode on FNA3D and explicitly enabled SDL_GPU, EasyGL-family or Vulkan builds, but that format is renderer-qualified and is not HLSL .fx source. This portable example keeps its renderer-native shader text explicit. ShaderEffect exposes no Parameters collection; uniforms go through SetUniformXxx() after Apply().
#include "Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp"
#include "System/IO/File.hpp"
class ShadowGame final : public Game {
std::unique_ptr<RenderTarget2D> shadowMap_;
std::unique_ptr<ShaderEffect> depthEffect_;
std::unique_ptr<ShaderEffect> lightEffect_;
Matrix lightSpaceMatrix_;
Vector3 lightDir_;
struct SceneObj { VertexBuffer* VB; int TriCount; Matrix World; };
std::vector<SceneObj> scene_;
Camera camera_;
Vector3 lightPos_{ 10.0f, 20.0f, 10.0f };
// Small helper: ShaderEffect's matrix setter takes raw column-major floats.
static void SetMat4(ShaderEffect& fx, const char* name, const Matrix& m) {
float cm[16];
m.ToColumnMajor(cm);
fx.SetUniformMat4(name, cm);
}
void LoadContent() override {
auto& gd = getGraphicsDeviceProperty();
shadowMap_ = std::make_unique<RenderTarget2D>(
gd, 1024, 1024, false,
SurfaceFormat::Color, DepthFormat::Depth24);
// Three arguments: device, vertex source, fragment source. Not file paths.
depthEffect_ = std::make_unique<ShaderEffect>(
gd,
System::IO::File::ReadAllText("Content/shaders/shadow_depth.vert.glsl"),
System::IO::File::ReadAllText("Content/shaders/shadow_depth.frag.glsl"));
lightEffect_ = std::make_unique<ShaderEffect>(
gd,
System::IO::File::ReadAllText("Content/shaders/shadow_lighting.vert.glsl"),
System::IO::File::ReadAllText("Content/shaders/shadow_lighting.frag.glsl"));
// Neither constructor throws on a compile failure -- check both.
if (!depthEffect_->IsEffectValid() || !lightEffect_->IsEffectValid()) {
// The GLSL did not compile. Do not draw with it.
}
// Static lighting uniforms. Apply() binds the program these writes target.
lightEffect_->Apply();
lightEffect_->SetUniformVec3("u_lightColor", 1.0f, 0.95f, 0.85f);
lightEffect_->SetUniformVec3("u_ambientColor", 0.15f, 0.15f, 0.2f);
lightEffect_->SetUniformInt ("u_shadowMap", 1); // sampler unit assignments
lightEffect_->SetUniformInt ("u_diffuse", 0);
BuildScene();
}
void Update(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;
lightDir_ = Vector3::Normalize(Vector3::Zero - lightPos_);
// Uniforms are pushed in Draw(), after each effect's own Apply().
}
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.setRasterizerStateProperty(RasterizerState::CullCounterClockwise);
for (auto& obj : scene_) {
// Apply() first: the uniform setters write to the bound program.
depthEffect_->Apply();
SetMat4(*depthEffect_, "u_lightSpace", lightSpaceMatrix_);
SetMat4(*depthEffect_, "u_world", obj.World);
gd.SetVertexBuffer(obj.VB);
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.setRasterizerStateProperty(RasterizerState::CullCounterClockwise);
for (auto& obj : scene_) {
lightEffect_->Apply();
SetMat4(*lightEffect_, "u_lightSpace", lightSpaceMatrix_);
SetMat4(*lightEffect_, "u_view", camera_.View());
SetMat4(*lightEffect_, "u_projection", camera_.Projection());
SetMat4(*lightEffect_, "u_world", obj.World);
lightEffect_->SetUniformVec3("u_lightDir",
lightDir_.X, lightDir_.Y, lightDir_.Z);
// Bind the shadow map on unit 1, matching the SetUniformInt above.
lightEffect_->SetTexture(1, *shadowMap_);
gd.SetVertexBuffer(obj.VB);
gd.DrawPrimitives(PrimitiveType::TriangleList, 0, obj.TriCount);
}
gd.Present();
}
};
SetTexture takes a reference, so the shadow map is passed as *shadowMap_. RenderTarget2D derives from Texture2D, so it binds like any other texture — just make sure it is no longer the active render target when you sample it.
Depth format caution: Two different depths are in play during the depth pass — the packed RGBA8 value your fragment shader writes to the colour target, and the hardware depth buffer created by DepthFormat::Depth24. Only the packed colour value is what you later sample, so every read of u_shadowMap must go through unpackDepth(). Sampling .r directly returns just the high byte and produces shadows that snap between 255 coarse depth steps. Because packed RGBA8 carries less usable precision than a true float target, expect to raise the depth bias slightly compared to the values you would use on a platform with float render targets.