08 - The Shadow Map

The canonical two-pass shadow-map dance, ported from the dasVulkan shadow rung. A rotating cube hovers over a checkered floor while a directional light orbits the scene, and the cube casts a real, soft-edged shadow that sweeps across the floor as the sun moves.

The whole trick is to render the scene twice. The first pass – the shadow pass – renders only depth, from the light’s point of view, into an offscreen texture: for every light-space pixel, how far away is the closest caster. The second pass – the main pass – renders the scene from the camera, and for each fragment asks the shadow map “is anything closer to the light here than I am?” If yes, the fragment is in shadow.

// ===== shared shader globals =====
// Both passes' vertex shaders read u_light_vp + u_model; the main pass adds the
// camera matrices, the light direction, the camera position, the shadow sampler,
// and a per-draw u_is_floor flag (0 = cube, 1 = floor) that selects the material.
var @uniform u_light_vp : float4x4
var @uniform u_model : float4x4
var @uniform u_view : float4x4
var @uniform u_proj : float4x4
var @uniform u_light_dir : float3
var @uniform u_cam_pos : float3
var @uniform u_is_floor : float
var @uniform shadow_map : sampler2DShadow

var @in @location = 0 a_pos : float3
var @in @location = 1 a_normal : float3
// varyings: world-space position + normal for lighting, plus the position projected
// into light space (pre-divide) for the shadow lookup
var @inout v_world_pos : float3
var @inout v_world_normal : float3
var @inout v_light_space : float4
var @out f_FragColor : float4

// ===== shadow pass =====
// Render every caster's depth from the light's view. `gl_Position = light_vp * model
// * pos` is all the geometry work; the fragment shader writes nothing useful (the
// framebuffer has no colour attachment), it just keeps the program complete.

[vertex_program]
def shadow_vs {
    gl_Position = u_light_vp * u_model * float4(a_pos, 1.0)
}

[fragment_program]
def shadow_fs {
    // depth-only framebuffer: this colour is discarded (draw buffer is GL_NONE)
    f_FragColor = float4(1.0, 1.0, 1.0, 1.0)
}

// ===== main pass =====

[vertex_program]
def main_vs {
    let world = u_model * float4(a_pos, 1.0)
    v_world_pos = world.xyz
    // model is rotation + uniform scale only, so a (normal, 0) multiply is correct
    v_world_normal = (u_model * float4(a_normal, 0.0)).xyz
    v_light_space = u_light_vp * world
    gl_Position = u_proj * u_view * world
}

let SHADOW_DIM_F = 1024.0

// 3x3 PCF: average 9 taps over a one-texel radius. Each `textureCompare` is already
// a hardware 2x2 depth compare, so the effective footprint is ~36 samples -- soft
// shadow edges instead of a hard binary line, at low cost.
def pcf_shadow(uv : float2; ref : float) : float {
    let texel = 1.0 / SHADOW_DIM_F
    var sum = 0.0
    for (j in range(-1, 2)) {
        for (i in range(-1, 2)) {
            let off = float2(i, j) * texel
            sum += textureCompare(shadow_map, uv + off, ref)
        }
    }
    return sum * (1.0 / 9.0)
}

[fragment_program]
def main_fs {
    let n = normalize(v_world_normal)
    let l = normalize(u_light_dir)
    let ndotl = max(dot(n, l), 0.0)
    let v = normalize(u_cam_pos - v_world_pos)

    // Light-space NDC = clip.xyz / clip.w. OpenGL NDC is [-1, 1] on all three axes,
    // so remap xy to [0, 1] for the shadow-map UV and z to [0, 1] for the depth
    // reference (matching the [0, 1] depth the shadow pass stored).
    let w = max(v_light_space.w, 0.0001)
    let ndc = v_light_space.xyz / float3(w, w, w)
    let shadow_uv = ndc.xy * 0.5 + float2(0.5, 0.5)
    let ref_depth = ndc.z * 0.5 + 0.5

    // N.L-scaled bias on top of the pipeline's polygon offset: grazing faces push the
    // reference further from the light so the compare stops mis-firing on lit
    // surfaces, with a floor so even head-on faces get a touch of bias.
    let bias = max(0.0025 * (1.0 - ndotl), 0.0008)
    var lit = pcf_shadow(shadow_uv, ref_depth - bias)
    // Faces turned away from the light are in shadow regardless of the depth test;
    // skipping the lookup avoids bias artifacts at N.L ~ 0 where numerical noise
    // flips the comparison.
    if (ndotl < 0.05) {
        lit = 0.0
    }

    // Hemisphere ambient: warm-ish sky overhead blended to a dim ground bounce, by n.y.
    let amb_t = n.y * 0.5 + 0.5
    let ambient = lerp(float3(0.10, 0.09, 0.08), float3(0.30, 0.34, 0.42), float3(amb_t, amb_t, amb_t))
    let sun = float3(1.30, 1.15, 0.90)

    // Albedo: a checkerboard on the floor (parity of the integer cell), a warm solid
    // on the cube. u_is_floor is set per draw by the host.
    var albedo : float3
    var spec_strength = 0.5
    if (u_is_floor > 0.5) {
        let gx = floor(v_world_pos.x * 0.5 + 100.0)
        let gz = floor(v_world_pos.z * 0.5 + 100.0)
        let cell = gx + gz
        let parity = cell - floor(cell * 0.5) * 2.0
        let checker = parity < 0.5 ? 0.85 : 0.30
        albedo = float3(checker, checker, checker) * float3(0.80, 0.82, 0.88)
        spec_strength = 0.15
    } else {
        albedo = float3(0.85, 0.45, 0.30)
    }

    // Blinn-Phong specular, killed in shadow along with the diffuse.
    let hvec = normalize(l + v)
    let spec = pow(max(dot(n, hvec), 0.0), 48.0) * lit * spec_strength
    let diffuse = sun * ndotl * lit
    let col = ambient * albedo + albedo * diffuse + float3(spec, spec, spec)
    f_FragColor = float4(col, 1.0)
}

// ===== GL objects =====

var prog_shadow : uint
var prog_main : uint
var cube_vao, cube_vbo, cube_ebo : uint
var floor_vao, floor_vbo, floor_ebo : uint
var shadow_fbo, shadow_tex : uint
var window : GLFWwindow?
var time : float = 0.0

let SHADOW_DIM = 1024
let FLOOR_HALF = 5.0
let CUBE_HOVER = 1.5

[vertex_buffer]
struct SceneVertex {
    pos : float3
    normal : float3
}

// Unit cube, 24 split vertices (one face-normal per corner) -- the same winding as
// tutorial 04, proven correct under GL_BACK culling + perspective_rh_opengl. The
// model matrix scales + hovers + spins it; here it spans [-1, 1] in object space.
let cube_verts = [SceneVertex(
    pos=float3(1, 1, 1), normal=float3(0, 0, 1)), SceneVertex(
    pos=float3(-1, 1, 1), normal=float3(0, 0, 1)), SceneVertex(
    pos=float3(-1, -1, 1), normal=float3(0, 0, 1)), SceneVertex(
    pos=float3(1, -1, 1), normal=float3(0, 0, 1)), SceneVertex(
    pos=float3(1, 1, 1), normal=float3(1, 0, 0)), SceneVertex(
    pos=float3(1, -1, 1), normal=float3(1, 0, 0)), SceneVertex(
    pos=float3(1, -1, -1), normal=float3(1, 0, 0)), SceneVertex(
    pos=float3(1, 1, -1), normal=float3(1, 0, 0)), SceneVertex(
    pos=float3(1, 1, 1), normal=float3(0, 1, 0)), SceneVertex(
    pos=float3(1, 1, -1), normal=float3(0, 1, 0)), SceneVertex(
    pos=float3(-1, 1, -1), normal=float3(0, 1, 0)), SceneVertex(
    pos=float3(-1, 1, 1), normal=float3(0, 1, 0)), SceneVertex(
    pos=float3(-1, 1, 1), normal=float3(-1, 0, 0)), SceneVertex(
    pos=float3(-1, 1, -1), normal=float3(-1, 0, 0)), SceneVertex(
    pos=float3(-1, -1, -1), normal=float3(-1, 0, 0)), SceneVertex(
    pos=float3(-1, -1, 1), normal=float3(-1, 0, 0)), SceneVertex(
    pos=float3(-1, -1, -1), normal=float3(0, -1, 0)), SceneVertex(
    pos=float3(1, -1, -1), normal=float3(0, -1, 0)), SceneVertex(
    pos=float3(1, -1, 1), normal=float3(0, -1, 0)), SceneVertex(
    pos=float3(-1, -1, 1), normal=float3(0, -1, 0)), SceneVertex(
    pos=float3(1, -1, -1), normal=float3(0, 0, -1)), SceneVertex(
    pos=float3(-1, -1, -1), normal=float3(0, 0, -1)), SceneVertex(
    pos=float3(-1, 1, -1), normal=float3(0, 0, -1)), SceneVertex(
    pos=float3(1, 1, -1), normal=float3(0, 0, -1)
)];

let cube_indices = fixed_array<int>(
    0, 1, 2, 2, 3, 0,
    4, 5, 6, 6, 7, 4,
    8, 9, 10, 10, 11, 8,
    12, 13, 14, 14, 15, 12,
    16, 17, 18, 18, 19, 16,
    20, 21, 22, 22, 23, 20)

// Floor quad in the XZ plane at y=0, normal up, sized to FLOOR_HALF -- big enough
// that the cube's shadow lands inside it. Drawn with an identity model matrix.
let floor_verts = [SceneVertex(
    pos=float3(-5.0, 0.0, -5.0), normal=float3(0, 1, 0)), SceneVertex(
    pos=float3(5.0, 0.0, -5.0), normal=float3(0, 1, 0)), SceneVertex(
    pos=float3(5.0, 0.0, 5.0), normal=float3(0, 1, 0)), SceneVertex(
    pos=float3(-5.0, 0.0, 5.0), normal=float3(0, 1, 0)
)];

let floor_indices = fixed_array<int>(0, 1, 2, 0, 2, 3)

def identity_m4 : float4x4 {
    var m : float4x4
    m[0] = float4(1, 0, 0, 0)
    m[1] = float4(0, 1, 0, 0)
    m[2] = float4(0, 0, 1, 0)
    m[3] = float4(0, 0, 0, 1)
    return m
}


// Create the offscreen depth texture + framebuffer that the shadow pass writes and
// the main pass samples. The depth texture's compare mode is what makes a
// `sampler2DShadow` lookup return the hardware compare result.
def create_shadow_target {
    glGenTextures(1, safe_addr(shadow_tex))
    glBindTexture(GL_TEXTURE_2D, shadow_tex)
    glTexImage2D(GL_TEXTURE_2D, 0, int(GL_DEPTH_COMPONENT24), SHADOW_DIM, SHADOW_DIM, 0,
        GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, null)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
    // hardware depth comparison: texture(sampler2DShadow, vec3(uv, ref)) returns
    // (ref <= stored_depth) ? 1 : 0, LINEAR-filtered into a [0,1] PCF result.
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL)

    glGenFramebuffers(1, safe_addr(shadow_fbo))
    glBindFramebuffer(GL_FRAMEBUFFER, shadow_fbo)
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, shadow_tex, 0)
    // depth-only: no colour buffer to draw to or read from
    var none_buf = uint(GL_NONE)
    glDrawBuffers(1, safe_addr(none_buf))
    glReadBuffer(uint(GL_NONE))
    if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
        panic("shadow framebuffer incomplete")
    }
    glBindFramebuffer(GL_FRAMEBUFFER, 0u)
}

def create_gl_objects {
    prog_shadow = create_shader_program(@@shadow_vs, @@shadow_fs)
    prog_main = create_shader_program(@@main_vs, @@main_fs)

    // cube mesh (pos + normal)
    glGenVertexArrays(1, safe_addr(cube_vao))
    glBindVertexArray(cube_vao)
    glGenBuffers(1, safe_addr(cube_vbo))
    glBindBuffer(GL_ARRAY_BUFFER, cube_vbo)
    glBufferData(GL_ARRAY_BUFFER, cube_verts, GL_STATIC_DRAW)
    bind_vertex_buffer(null, type<SceneVertex>)
    glGenBuffers(1, safe_addr(cube_ebo))
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, cube_ebo)
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, cube_indices, GL_STATIC_DRAW)

    // floor mesh (pos + normal)
    glGenVertexArrays(1, safe_addr(floor_vao))
    glBindVertexArray(floor_vao)
    glGenBuffers(1, safe_addr(floor_vbo))
    glBindBuffer(GL_ARRAY_BUFFER, floor_vbo)
    glBufferData(GL_ARRAY_BUFFER, floor_verts, GL_STATIC_DRAW)
    bind_vertex_buffer(null, type<SceneVertex>)
    glGenBuffers(1, safe_addr(floor_ebo))
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, floor_ebo)
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, floor_indices, GL_STATIC_DRAW)

    create_shadow_target()
}

[export]
def init {
    if (glfwInit() == 0) {
        panic("can't init glfw")
    }
    glfwInitOpenGL(3, 3)
    window = glfwCreateWindow(640, 480, "OpenGL - 08 shadow map", null, null)
    if (window == null) {
        panic("can't create window")
    }
    glfwMakeContextCurrent(window)
    create_gl_objects()
}

// The cube's model matrix: hover + spin about a tilted axis, scaled to a tidy box.
def cube_model(t : float) : float4x4 {
    let rot = quat_from_unit_vec_ang(normalize(float3(0.3, 1.0, 0.2)), t * 0.7)
    return compose(float3(0.0, CUBE_HOVER, 0.0), rot, float3(0.55))
}

def draw_mesh(vao, ebo : uint; count : int) {
    glBindVertexArray(vao)
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo)
    glDrawElements(GL_TRIANGLES, count, GL_UNSIGNED_INT, null)
}

[export]
def update : bool {
    time += 1.0 / 60.0
    let t = time
    var display_w, display_h : int
    glfwGetFramebufferSize(window, display_w, display_h)
    let h = max(display_h, 1)
    let aspect = float(display_w) / float(h)

    // Directional light orbits in XZ, tilted up, so the cube's shadow sweeps the floor.
    let sun_yaw = t * 0.6
    let light_dir = normalize(float3(cos(sun_yaw), 1.4, sin(sun_yaw)))
    let light_eye = light_dir * 8.0
    let light_view = look_at_rh(light_eye, float3(0.0, 0.0, 0.0), float3(0, 1, 0))
    // Orthographic light projection sized to the scene (cube + floor). ortho_rh is the
    // OpenGL convention (z in [-1, 1]); the shader remaps z to [0, 1] for the lookup.
    let light_proj = ortho_rh(-4.0, 4.0, -4.0, 4.0, 0.1, 20.0)
    u_light_vp = light_proj * light_view
    u_light_dir = light_dir

    // Camera orbits the scene, slightly elevated.
    let cam_angle = t * 0.35
    let cam_pos = float3(cos(cam_angle) * 5.0, 3.2, sin(cam_angle) * 5.0)
    u_view = look_at_rh(cam_pos, float3(0.0, CUBE_HOVER * 0.5, 0.0), float3(0, 1, 0))
    u_proj = perspective_rh_opengl(50.0 * PI / 180.0, aspect, 0.1, 50.0)
    u_cam_pos = cam_pos

    // ===== Pass 1: shadow map from the light's point of view =====
    glViewport(0, 0, SHADOW_DIM, SHADOW_DIM)
    glBindFramebuffer(GL_FRAMEBUFFER, shadow_fbo)
    glClear(GL_DEPTH_BUFFER_BIT)
    glEnable(GL_DEPTH_TEST)
    glDepthFunc(GL_LESS)
    glDepthMask(true)
    glDisable(GL_CULL_FACE)
    // slope-scaled depth bias pushes caster depth away from the light -> no acne
    glEnable(GL_POLYGON_OFFSET_FILL)
    glPolygonOffset(2.0, 4.0)
    glUseProgram(prog_shadow)
    u_model = cube_model(t)
    shadow_vs_bind_uniform(prog_shadow)
    draw_mesh(cube_vao, cube_ebo, 36)
    u_model = identity_m4()
    shadow_vs_bind_uniform(prog_shadow)
    draw_mesh(floor_vao, floor_ebo, 6)
    glDisable(GL_POLYGON_OFFSET_FILL)

    // ===== Pass 2: main scene, sampling the shadow map =====
    glBindFramebuffer(GL_FRAMEBUFFER, 0u)
    glViewport(0, 0, display_w, display_h)
    glClearColor(0.04, 0.05, 0.09, 1.0)
    glDepthMask(true)
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
    glEnable(GL_DEPTH_TEST)
    glDepthFunc(GL_LESS)
    glDisable(GL_CULL_FACE)
    glUseProgram(prog_main)
    shadow_map := shadow_tex
    u_model = cube_model(t)
    u_is_floor = 0.0
    main_vs_bind_uniform(prog_main)
    main_fs_bind_uniform(prog_main)
    draw_mesh(cube_vao, cube_ebo, 36)
    u_model = identity_m4()
    u_is_floor = 1.0
    main_vs_bind_uniform(prog_main)
    main_fs_bind_uniform(prog_main)
    draw_mesh(floor_vao, floor_ebo, 6)

    glfwPollEvents()
    glfwSwapBuffers(window)
    return glfwWindowShouldClose(window) == 0
}

[export]
def shutdown {
    glfwDestroyWindow(window)
    glfwTerminate()
}

// Desktop driver. On the web this is never called -- the run path drives the
// three lifecycle functions directly and persists the Context across frames.
[export]
def main {
    init()
    while (update()) {
    }
    shutdown()
}

The depth texture, two roles

One depth texture serves both passes. create_shadow_target allocates a GL_DEPTH_COMPONENT24 texture and – the key line – sets GL_TEXTURE_COMPARE_MODE to GL_COMPARE_REF_TO_TEXTURE. That flips the texture into comparison mode: sampling it through a sampler2DShadow does not return the stored depth, it returns the result of comparing your reference value against the stored depth (GL_LEQUAL), already 2x2-filtered by the hardware into a [0, 1] result. That is what textureCompare(shadow_map, uv, ref) lowers to. In pass 1 the same texture is the framebuffer’s depth attachment; in pass 2 it is a sampler.

A depth-only framebuffer holds it: a framebuffer object with a depth attachment and the colour draw buffer set to GL_NONE (glDrawBuffers + glReadBuffer). The shadow pass writes no colour at all – shadow_vs is the whole geometry stage, and shadow_fs writes a colour that is simply discarded.

The shadow lookup

main_vs passes each vertex’s position projected into light space (u_light_vp * world) forward as a varying. In main_fs the perspective divide gives light-space NDC. OpenGL NDC spans [-1, 1] on all three axes, so the xy is remapped to [0, 1] for the shadow-map UV and the z is remapped to [0, 1] for the depth reference – matching the [0, 1] depth the shadow pass stored. (The Vulkan rung leaves z as-is because Vulkan’s NDC z is already [0, 1]; this remap is the one real GL-vs-Vulkan difference in the shader.)

pcf_shadow then averages a 3x3 grid of textureCompare taps. Each tap is already a hardware 2x2 depth compare, so the effective footprint is ~36 samples – soft penumbra edges instead of a hard binary line.

Killing shadow acne

Without a depth bias, every surface slightly self-shadows: the depth it stored in pass 1 and the depth it computes in pass 2 differ by tiny amounts, and the comparison flickers. Two biases fix it: glPolygonOffset during the shadow pass pushes caster depth away from the light, and the fragment shader adds a small N·L-scaled bias (grazing faces need more) with a floor so even head-on faces get a touch. Faces turned away from the light (N·L < 0.05) skip the lookup entirely and are simply dark, avoiding bias artifacts where the comparison is meaningless.

An emitter note: GLSL ES 3.00 predeclares a default precision for sampler2D and samplerCube in fragment shaders, but not for sampler2DShadow (nor sampler3D / sampler2DArray). dasGlsl emits a precision highp sampler2DShadow; line in the ES 3.00 preamble so the shadow sampler compiles on the web – a hole this tutorial found and the emitter now plugs.

Run it

Locally, in a window:

daslang tutorials/opengl/08_shadow/08_shadow.das

In the browser, it runs live in the daslang playground – the same .das, lowered to WebGL2: a cube casting a soft shadow that sweeps the floor as the light orbits, rendered with a real depth-texture shadow map on your GPU.