glTF Directional Light Shadow Map Alignment
This article provides an overview of how shadow mapping projections align with directional light vectors in the glTF 2.0 format. Aligning shadow maps with glTF directional lights requires transforming the format's local light vector convention into a world-space orientation, constructing an orthographic view-projection matrix along that vector, and mapping the resulting light-space coordinates to the target graphics API's normalized device coordinate system for depth sampling.
glTF Directional Light Vector Conventions
In the glTF specification (via the KHR_lights_punctual
extension), a directional light emits light from its local origin down
the negative Z-axis [0, 0, -1]. To determine the global
direction of the light in a 3D scene, this local forward vector must be
transformed by the world transformation matrix of the node containing
the light:
- Extract the rotation and translation components from the node’s global transform matrix.
- Multiply the vector
[0, 0, -1, 0]by the node's global transform matrix (or rotate it using the node's global quaternion). - Normalize the resulting vector to obtain the world-space light direction vector, \(\vec{L}_{\text{dir}}\).
Because directional lights represent distant sources like the sun, their rays are parallel, and their position in space does not alter the light direction, only its orientation.
Constructing the Light View Matrix
Shadow mapping requires rendering the scene from the perspective of the light source. For directional lights, this involves constructing a "look-at" view matrix:
- Forward Vector: The camera representing the light must point in the direction the light is shining (\(\vec{L}_{\text{dir}}\)).
- Position: The position is placed at a virtual origin offset in the opposite direction (\(-\vec{L}_{\text{dir}}\)) by a distance sufficient to encompass the scene.
- Up Vector: An arbitrary world vector (typically
[0, 1, 0]) is chosen to stabilize orientation. If \(\vec{L}_{\text{dir}}\) is parallel or nearly parallel to the selected up vector, an alternate axis such as[0, 0, 1]must be used to prevent cross-product singularities.
The resulting view matrix transforms scene vertices from world space into light view space, orienting the negative Z-axis directly along the light path.
Generating the Orthographic Projection
Because directional light rays do not diverge, the projection matrix
must be orthographic rather than perspective. The dimensions of the
orthographic bounding box (defined by left,
right, bottom, top,
near, and far planes) define the shadow
volume:
- Bounding the Scene: The orthographic box must encapsulate either the entire scene geometry or the visible frustum of the main camera (such as in Cascaded Shadow Maps).
- Depth Extents: The
nearandfarplanes must be tightly fitted along the light's direction vector. Setting the near plane too close or the far plane too far reduces depth buffer precision, leading to shadow acne or bias artifacts.
The orthographic projection transforms coordinates from light view space into Normalized Device Coordinates (NDC).
Mapping Light Space to Texture Coordinates
Once the combined light view-projection matrix transforms a vertex to clip space, perspective division produces coordinates in NDC. Because texture mapping relies on coordinates in the range \([0, 1]\) rather than NDC ranges (such as \([-1, 1]\) in OpenGL or \([0, 1]\) for depth in Vulkan/DirectX), a bias transformation is applied:
\[\mathbf{M}_{\text{shadow}} = \mathbf{T}_{\text{bias}} \times \mathbf{P}_{\text{ortho}} \times \mathbf{V}_{\text{light}}\]
Where \(\mathbf{T}_{\text{bias}}\) maps:
- X and Y coordinates from \([-1, 1]\) to \([0, 1]\).
- Z coordinates to match the target API's depth range (e.g., \([-1, 1]\) to \([0, 1]\) in OpenGL, or keeping \([0, 1]\) in Vulkan/DirectX/Metal).
When a fragment is shaded in the primary camera pass, its world position is multiplied by \(\mathbf{M}_{\text{shadow}}\). The resulting XY coordinates index directly into the shadow depth texture, and the Z coordinate is compared against the stored depth value to determine occlusion.