<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.maxgodefroy.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://www.maxgodefroy.com/" rel="alternate" type="text/html" /><updated>2026-10-27T12:05:54+00:00</updated><id>https://www.maxgodefroy.com/feed.xml</id><title type="html">Max Godefroy | Articles</title><subtitle>Personal website of Max Godefroy. You can find out more about me and my projects, and read my articles.</subtitle><entry><title type="html">Rendering directional shadow maps in 2026 - Part 2</title><link href="https://www.maxgodefroy.com/articles/directional-shadow-maps-part-2.html" rel="alternate" type="text/html" title="Rendering directional shadow maps in 2026 - Part 2" /><published>2026-10-27T00:00:00+00:00</published><updated>2026-10-27T00:00:00+00:00</updated><id>https://www.maxgodefroy.com/articles/directional-shadow-maps-part-2</id><content type="html" xml:base="https://www.maxgodefroy.com/articles/directional-shadow-maps-part-2.html"><![CDATA[<p>We ended the last part with the following state:</p>

<figure class="lab-viewport" data-demo="shadow-step" data-code="/assets/lab/src/shadow/wgsl/base.wgsl" data-options="{&quot;filter&quot;:&quot;nearest&quot;, &quot;viewControl&quot;:false}" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
  <figcaption class="lab-caption">Drag to orbit, right-drag to pan. Click the viewport to enable scroll-zoom.</figcaption>
</figure>

<p><br /></p>

<p>We technically have shadows, but at the expense of shadow acne.
Let’s fix that.</p>

<h1 id="shadow-biasing">Shadow biasing</h1>

<h2 id="the-fundamental-problem">The fundamental problem</h2>

<p>Okay, first let’s understand the problem. Why do we get this pattern? 
Remember our shadow map, and how it is basically a batched set of parallel rays, stored as intersection depth in a 
2D texture. 
These ray intersection results are what we use directly to determine if a fragment on screen is occluded.</p>

<p>The issue lies in the fact that this 2D texture has a resolution limit. 
What we actually store is the intersection result of the ray that passes at the center of the given texel.
But what we do is that we use this value as a comparison reference for any fragment, even those whose ray doesn’t go 
through the center of the texel.
Because of that we get incorrect occlusion results, which leads to shadow acne.</p>

<p>Let’s visualize it, it might be easier to understand:</p>

<figure class="lab-viewport" data-demo="shadow-step" data-code="/assets/lab/src/shadow/wgsl/base.wgsl" data-options="{&quot;filter&quot;:&quot;nearest&quot;, &quot;resolution&quot;:512, &quot;view&quot;:&quot;both&quot;}" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
  <figcaption class="lab-caption">Drag to orbit, right-drag to pan. Click the viewport to enable scroll-zoom.</figcaption>
</figure>

<p>I added debug quads that project the depth map texels onto the scene. Each quad corresponds to one shadow map texel.
If you look close, you can see that it is those quads that are actually casting shadows, not the scene geometry 
directly. (though technically the quads are just the scene geometry projected onto the 2D shadow map).</p>

<p>So, what was the point then?
Why do we even bother with this approach if it does not provide correct occlusion results?
Because it is still a great approximation which we can work around.</p>

<p>So how do we fix it? By accepting that we are dealing with an approximation, and that we can work around it by 
using a technique called shadow bias.</p>

<h2 id="constant-shadow-bias">Constant shadow bias</h2>

<p>So, as stated, the discrete nature of the shadow map is what leads to acne:</p>

<figure class="figure">
  <img src="/assets/img/articles/shadow-maps/shadow-acne.png" class="figure-img img-fluid" />
  
    <figcaption class="figure-caption"><a href="https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping">LearnOpenGL</a>, Joey de Vries, <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a></figcaption>
  
</figure>

<p>When the mesh surface is not orthogonal to the light ray, a part of the surface is likely to “slip” below the texel 
depth quad. 
But what if we add a small offset to the depth value we sample from the shadow map?</p>

<figure class="figure">
  <img src="/assets/img/articles/shadow-maps/shadow-bias.png" class="figure-img img-fluid" />
  
    <figcaption class="figure-caption"><a href="https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping">LearnOpenGL</a>, Joey de Vries, <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a></figcaption>
  
</figure>

<p>We call such an offset a shadow bias.
Let’s try it out:</p>

<figure class="lab-viewport" data-demo="shadow-step" data-code="/assets/lab/src/shadow/wgsl/constant-bias.wgsl" data-options="{&quot;filter&quot;:&quot;nearest&quot;}" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
  <figcaption class="lab-caption">Drag to orbit, right-drag to pan. Click the viewport to enable scroll-zoom.</figcaption>
</figure>

<div class="lab-code" hidden="" data-snippets="[
        {&quot;src&quot;: &quot;shadow/wgsl/constant-bias.wgsl&quot;, &quot;prev&quot;: &quot;shadow/wgsl/base.wgsl&quot;, &quot;title&quot;: &quot;Shadow visibility&quot;}
    ]"></div>

<p>If you play around with the interactive demo, you’ll notice that by using a big enough bias, the acne goes away. 
But start cranking the bias too high, and the shadow will start to detach itself from its caster, in a phenomenon
known as <strong>Peter panning</strong> (as a nod to Peter Pan’s shadow getting detached from its owner).</p>

<p>You have to find a middle ground between getting rid of acne, and not peter panning.</p>

<p>You will likely note that we don’t get a nice and easy formula for computing an optimal bias. It’s because there is 
none. You need to find a good value by trial and error. Historically, artists have been responsible for setting up 
bias values. These are often set per light in a scene for the best visuals.</p>

<p>If you play around some more, you may notice that changing the resolution of the shadow map impacts the effect of bias.
That’s because the texel world size gets greater, and while the bias is not exactly in world units but depth units,
as both are proportional to each other; the bias needs to be scaled proportionally to texel world width if you want 
to reuse your settings across different resolutions.</p>

<figure class="lab-viewport" data-demo="shadow-step" data-code="/assets/lab/src/shadow/wgsl/texel-bias.wgsl" data-options="{&quot;filter&quot;:&quot;nearest&quot;}" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
  <figcaption class="lab-caption">Drag to orbit, right-drag to pan. Click the viewport to enable scroll-zoom.</figcaption>
</figure>

<div class="lab-code" hidden="" data-snippets="[
        {&quot;src&quot;: &quot;shadow/wgsl/texel-bias.wgsl&quot;, &quot;prev&quot;: &quot;shadow/wgsl/constant-bias.wgsl&quot;, &quot;title&quot;: &quot;Shadow visibility&quot;}
    ]"></div>

<p>If you play around now and modify the shadow map resolution, you will notice that the bias behaviour now tends to
be a lot more predictable.</p>

<p>You might also notice that the higher the resolution, the lower the peter panning. 
This is because peter panning offest scales with shadow texel size. 
If the resolution is higher, the texel size is smaller, and thus the peter panning shifting is smaller, and less 
noticeable.</p>

<p>Finally, you may also notice that the steeper surfaces (compared to the light direction) require a higher bias to fully 
get rid of acne.
By the time you stop getting acne on the sphere, the shadows from other objects are already peter panning.
Frustrating, right? Let’s improve that.</p>

<h2 id="slope-based-bias">Slope-based bias</h2>

<p>As you may have noticed before, the greater the relative surface slope to the light direction, the greater bias 
you need to apply to get rid of acne. To the point where you have no choice but to peter pan.</p>

<p>So let’s be smart about it. How about we use that relative slope to dynamically adjust the bias?</p>

<figure class="lab-viewport" data-demo="shadow-step" data-code="/assets/lab/src/shadow/wgsl/slope-bias.wgsl" data-options="{&quot;filter&quot;:&quot;nearest&quot;}" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
  <figcaption class="lab-caption">Drag to orbit, right-drag to pan. Click the viewport to enable scroll-zoom.</figcaption>
</figure>

<div class="lab-code" hidden="" data-snippets="[
        {&quot;src&quot;: &quot;shadow/wgsl/slope-bias.wgsl&quot;, &quot;prev&quot;: &quot;shadow/wgsl/texel-bias.wgsl&quot;, &quot;title&quot;: &quot;Shadow visibility&quot;}
    ]"></div>

<p>This method is what we call slope-based bias.
While there are variants of it, this formula is probably the closest to what is considered to be the standard one.</p>

<p>As you can see, it drastically reduces peter panning, as we can use much lower bias factors with it.</p>

<p>You may note that we are using a max clamping value that provides a hard limit to the bias.
This is because unclamped bias can still lead to excessive peter panning when using grazing angles:</p>

<link rel="stylesheet" href="/assets/css/image-compare.css" />

<script src="/assets/js/image-compare.js" defer=""></script>
<figure class="figure compare-figure">
  <div class="compare" style="--pos: 50%">
    <img class="compare-img compare-after" src="/assets/img/articles/shadow-maps/slope-bias-unclamped.png" alt="Floor shadows with and without the bias clamp" draggable="false" />
    <img class="compare-img compare-before" src="/assets/img/articles/shadow-maps/slope-bias-clamped.png" alt="Floor shadows with and without the bias clamp" draggable="false" /><span class="compare-tag compare-tag-before">Clamped</span><span class="compare-tag compare-tag-after">Unclamped</span>
    <span class="compare-handle" aria-hidden="true"></span>
    <input class="compare-slider" type="range" min="0" max="100" step="any" value="50" aria-label="Comparison position: Clamped on the left, Unclamped on the right" />
  </div>
  <figcaption class="figure-caption">Drag the slider.</figcaption>
</figure>

<p>Do note that this tends to be an edge case, so for most cases you can keep a high-bias clamp.</p>

<h2 id="hardware-bias">Hardware bias</h2>

<p>Slope-based bias has become such a mainstream approach to shadow biasing that it is natively supported by graphics APIs. 
It is often referred to as “hardware bias” because it is typically implemented directly in the hardware, which can 
provide better performance compared to software-based biasing.</p>

<p>Hardware bias is applied at the rasterizer stage, before the depth test. 
As such, you actually use hardware bias for the shadow pass, not the main render pass. 
This means that the bias is pre-applied to the shadow map, so you don’t need to apply it in the shader.</p>

<p>Setting up hardware bias is usually done when setting up a pipeline state object (PSO) depth stencil state,
though some APIs set it as a dynamic state (like Metal and OpenGL).
Thankfully, the hardware bias logic specification is universal between APIs, and I would recommend you read the
<a href="https://www.w3.org/TR/webgpu/#depth-stencil-state">WebGPU specification</a> for more details.</p>

<p>The APIs take three bias parameters:</p>
<ul>
  <li><strong>Constant bias</strong></li>
  <li><strong>Slope scale bias</strong></li>
  <li><strong>Depth bias clamp</strong> (optional, some APIs don’t support it)</li>
</ul>

<p>These are the same values we used in the previous demo, but with the caveat that the formula is a bit different:<br />
<code class="language-plaintext highlighter-rouge">bias = constantBias * r + slopeScaleBias * maxDepthSlope</code><br /></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">maxDepthSlope</code> is the maximum of the horizontal and vertical slopes of the fragment’s depth value. 
In shader code, it would be equivalent to <code class="language-plaintext highlighter-rouge">max(abs(dpdx(depth)), abs(dpdy(depth)))</code>, with the nuance that, as this 
value is the same across all fragments of the same primitive, the fixed function has to compute it only once per 
triangle.</li>
  <li><code class="language-plaintext highlighter-rouge">r</code> is a bit more complicated. It is what we call the smallest resolvable depth difference.
Basically, it is the smallest depth delta between two distinct depths for the current primitive. 
For UNorm depth formats, it is <code class="language-plaintext highlighter-rouge">1/(2^bits - 1)</code>, where bits is the number of bits in the depth format,
because the minimum depth delta is the same independently of the current depth. 
For float formats, it is a bit more complicated, as the minimum depth delta is not constant across the primitive.
In that case, it is set to <code class="language-plaintext highlighter-rouge">2^(exp(max z)-23)</code>.</li>
  <li>if available, the depth clamp is applied to <code class="language-plaintext highlighter-rouge">bias</code> afterward, and works the same as our <code class="language-plaintext highlighter-rouge">maxBias</code> value from the 
previous section.
It should be noted that three.js does not support bias clamping, so we can’t use it in the demos.</li>
</ul>

<p>In the demos, so far, I have used 16-bit UNorm depth formats, so I get <code class="language-plaintext highlighter-rouge">r = 1/65535</code>. 
Do note that <code class="language-plaintext highlighter-rouge">r</code> only affects the constant bias, not the slope factor.</p>

<p>A point to note is that, as <code class="language-plaintext highlighter-rouge">maxDepthSlope</code> is using derivatives, it already takes the resolution into account:
using derivatives means that this value is proportional to texel size.</p>

<p>Also, since we use a depth differential rather than the tangent of the angle between the normal and the light direction,
we are not using quite the same slope factor.
In theory, it is more accurate as it is based on the actual slope of the primitive, rather than using a normal that does 
not correspond to the actual geometry.</p>

<p>Since the setup is quite different, we can’t use the same values as the previous part, so this will require another
trial and error process to find the right values:</p>

<figure class="lab-viewport" data-demo="shadow-step" data-code="/assets/lab/src/shadow/wgsl/base.wgsl" data-options="{&quot;filter&quot;:&quot;nearest&quot;, &quot;hardwareBias&quot;:true}" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
  <figcaption class="lab-caption">Drag to orbit, right-drag to pan. Click the viewport to enable scroll-zoom.</figcaption>
</figure>

<div class="lab-code" hidden="" data-snippets="[
        {&quot;src&quot;: &quot;shadow/wgsl/base.wgsl&quot;, &quot;prev&quot;: &quot;shadow/wgsl/slope-bias.wgsl&quot;, &quot;title&quot;: &quot;Shadow visibility (we return to the original function, as the bias is pre-applied to the shadow map)&quot;},
        {&quot;src&quot;: &quot;demos/shadow-step.js&quot;, &quot;region&quot;: &quot;hardware-bias&quot;, &quot;title&quot;: &quot;Hardware bias setup&quot;}
    ]"></div>

<h2 id="receiver-plane-bias">Receiver plane bias</h2>

<p>Another approach for biasing is a method called <strong>receiver plane bias</strong>.
This method relies on two observations: the further a fragment is from the shadow texel center, the more it differs
from the stored depth value; and at a shadow texel granularity, the geometry surface can be approximated as a plane.</p>

<p>Based on these observations, the technique biases the fragment depth value by:</p>
<ul>
  <li>first emulating the surface plane at the fragment position. Geometrically speaking, we retrieve the <strong>tangent plane</strong>.</li>
  <li>computing the intersection between the plane and the light ray through the shadow texel center</li>
  <li>using this intersection as the fragment depth instead.</li>
</ul>

<p>Let’s illustrate this:</p>

<figure class="figure">
  <img src="/assets/img/articles/shadow-maps/receiver-plane-planar.svg" class="figure-img img-fluid" style="max-height: 500px;" />
  
    <figcaption class="figure-caption">Flat surface situation visualized</figcaption>
  
</figure>

<ul>
  <li><span class="kdmath">$F$</span> is the fragment position.</li>
  <li><span class="kdmath">$\vec N$</span> is the surface normal at <span class="kdmath">$F$</span>.</li>
  <li><span class="kdmath">$\vec T$</span> is the tangent vector of the surface at <span class="kdmath">$F$</span>.</li>
  <li><span class="kdmath">$S$</span> is the surface point stored in the shadow map.</li>
  <li><span class="kdmath">$P$</span> is the intersection between the tangent plane and the light ray through <span class="kdmath">$S$</span>.</li>
  <li><span class="kdmath">$\Delta x$</span> is the distance between <span class="kdmath">$F$</span> and <span class="kdmath">$P$</span>, projected onto the shadow plane.</li>
  <li><span class="kdmath">$\Delta z$</span> is the computed bias.</li>
  <li><span class="kdmath">$\epsilon$</span> is the final bias error.</li>
</ul>

<p>With a flat surface, we get a perfect bias. That’s pretty nice. But in practice we don’t often get flat surfaces,
so let’s see what we get with a curved surface. First, let’s look at a convex one:</p>

<figure class="figure">
  <img src="/assets/img/articles/shadow-maps/receiver-plane-curved.svg" class="figure-img img-fluid" style="max-height: 500px;" />
  
    <figcaption class="figure-caption">Convex curved surface situation visualized</figcaption>
  
</figure>

<p>Now we can see that we get some slight over-biasing, which is not ideal, but should be acceptable.
Let’s now see a concave surface:</p>

<figure class="figure">
  <img src="/assets/img/articles/shadow-maps/receiver-plane-concave.svg" class="figure-img img-fluid" style="max-height: 500px;" />
  
    <figcaption class="figure-caption">Concave curved surface situation visualized</figcaption>
  
</figure>

<p>Yeah… It was predictable, but sadly it is under-biasing. 
But it still helps in reducing the shadow acne, and with a bit more bias we can cover for situations like this.</p>

<p>Now let’s see how we implement it shader-wise. 
There are two approaches to achieve it: using screen-space derivatives or manually calculating the tangent plane.</p>

<h3 id="using-screen-space-derivatives">Using screen-space derivatives</h3>

<div class="alert alert-info" role="alert">
This approach is best suited for situations where you get proper primitive screen-space derivatives.
As such it works great in forward rendering pipelines, but is not recommended for deferred rendering pipelines
(unless you have access to primitive-level geometry data using something like a visibility buffer).
</div>

<p>This is the original approach, as presented by John Isidoro back in 2006 at GDC.</p>

<p>Basically, we want to retrieve</p>

<div class="kdmath">$$
b = \overrightarrow{CS} \cdot \frac{\partial z}{\partial UV}
$$</div>

<p>where <span class="kdmath">$b$</span> is the receiver plane bias, <span class="kdmath">$\overrightarrow{CS}$</span> is the UV-space vector from the texel center to the fragment shadow 
coordinates, and <span class="kdmath">$\frac{\partial z}{\partial UV}$</span> is the partial derivative of the depth value in the UV-space.</p>

<p><span class="kdmath">$\overrightarrow{CS}$</span> is the easy part, as we can easily retrieve it from the shadow coords and the shadow map 
resolution:</p>
<pre><code class="language-wgsl">let distanceToCenterUv = (fract(shadowCoord.xy * shadowResolution) - 0.5) / shadowResolution;
</code></pre>

<p>The partial derivative is a bit more tricky. With screen-space derivatives, we can get 
<span class="kdmath">$\frac{\partial z}{\partial XY}$</span>, <span class="kdmath">$\frac {\partial U} {\partial XY}$</span> and <span class="kdmath">$\frac {\partial V} {\partial XY}$</span>:</p>
<pre><code class="language-wgsl">let dz_dXY = vec2&lt;f32&gt;(dpdx(shadowCoord.z), dpdy(shadowCoord.z));
let dU_dXY = vec2&lt;f32&gt;(dpdx(shadowCoord.x), dpdy(shadowCoord.x));
let dV_dXY = vec2&lt;f32&gt;(dpdx(shadowCoord.y), dpdy(shadowCoord.y));
</code></pre>
<p>but that’s not exactly what we need, which is <span class="kdmath">$\frac{\partial z}{\partial UV}$</span>.</p>

<p>Thankfully, as explained in Isidoro’s presentation, we have a great mathematical tool to help us out: the Jacobian.</p>
<figure class="figure">
  <img src="/assets/img/articles/shadow-maps/isidoro-jacobian.png" class="figure-img img-fluid" style="max-height: 500px;" />
  
    <figcaption class="figure-caption">Courtesy of GDC Vault</figcaption>
  
</figure>
<p>(do note that the slide uses <span class="kdmath">$d$</span> instead of <span class="kdmath">$z$</span> like we do)</p>

<p>Let’s develop that inverse transpose:</p>

<div class="kdmath">$$
\def\arraystretch{3}
\def\p#1#2{\dfrac{\partial #1}{\partial #2}}
\begin{bmatrix}
\p{U}{X} & \quad \p{U}{Y} \\
\p{V}{X} & \quad \p{V}{Y}
\end{bmatrix}^{-T}
\quad = \quad
\dfrac{1}{\p{U}{X} \cdot \p{V}{Y} \; - \; \p{U}{Y} \cdot \p{V}{X}}
\quad
\begin{bmatrix}
\p{V}{Y} & \quad -\p{U}{Y} \\
-\p{V}{X} & \quad \p{U}{X}
\end{bmatrix}^T 
\\
\quad = \quad
\dfrac{1}{\p{U}{X} \cdot \p{V}{Y} \; - \; \p{U}{Y} \cdot \p{V}{X}}
\quad
\begin{bmatrix}
\p{V}{Y} & \quad -\p{V}{X} \\
-\p{U}{Y} & \quad \p{U}{X}
\end{bmatrix}
$$</div>

<p>That determinant can be zero, which happens only when the plane normal is perpendicular to the light direction.
Usually, that means that <span class="kdmath">$N \cdot L = 0$</span>, so for most cases we can ignore bias.</p>

<p>In shader code, this is translated to:</p>
<pre><code class="language-wgsl">let det = dU_dXY.x * dV_dXY.y - dU_dXY.y * dV_dXY.x;

var dz_dUV = vec2&lt;f32&gt;(0.0, 0.0);
if (det != 0.0) {
    dz_dUV.x = dot(dz_dXY, vec2&lt;f32&gt;(dV_dXY.y, -dV_dXY.x)) / det;
    dz_dUV.y = dot(dz_dXY, vec2&lt;f32&gt;(-dU_dXY.y, dU_dXY.x)) / det;
}
</code></pre>

<p>We can then complete the formula:</p>
<pre><code class="language-wgsl">let bias = dot(dz_dUV, distanceToCenterUv);
</code></pre>

<p>Let’s try it:</p>
<figure class="lab-viewport" data-demo="shadow-step" data-code="/assets/lab/src/shadow/wgsl/derivatives-receiver-plane.wgsl" data-options="{&quot;filter&quot;:&quot;nearest&quot;}" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
</figure>

<div class="lab-code" hidden="" data-snippets="[
        {&quot;src&quot;: &quot;shadow/wgsl/derivatives-receiver-plane.wgsl&quot;, &quot;prev&quot;: &quot;shadow/wgsl/base.wgsl&quot;, &quot;title&quot;: &quot;Shadow visibility&quot;}
    ]"></div>

<p><br /></p>

<p>You will notice that we’ve also added a bit of additional constant bias.
If you set it to zero, you will see that the receiver plane bias by itself is not enough.
This is due to both depth quantization and concave surfaces. 
As such, a bit of additional bias is required.</p>

<p>If you look close, you will also notice that the concave parts of the scene (like where boxes meet the surface plane)
still have some self-shadowing artifacts, as we predicted earlier.</p>

<p>Overall, though, it provides a pretty good result with less tweaking required, and can be combined with other 
techniques to provide ideal results.</p>

<h3 id="using-a-normal-vector">Using a normal vector</h3>

<p>When you don’t have reliable access to primitive-level derivatives, you can still use the same principles using a 
provided normal vector.
On average, the closer the normal vector is to the actual geometric normal, the better.
Interpolated normals are more approximative, but usually still okay.
Normal-mapped normals are where things tend to break a bit, so expect some artifacts.</p>

<p>The trick here is to project that normal into the light space, so that we can reuse the same logic as in the 
derivatives subsection.</p>
<pre><code class="language-wgsl">let dz_dUV = -shadowNormal.xy / shadowNormal.z;
</code></pre>

<p>The question then becomes: How do we retrieve that normal vector in the first place? 
Since the light projection is an orthographic matrix, we can directly use the transpose inverse of the light-space
matrix (combined with the NDC-to-UV transform matrix) as a normal matrix and apply it directly.</p>

<p>If, instead, you use the light view-projection matrix (and manually apply the ndc-to-uv transform), then do not forget
to apply a <code class="language-plaintext highlighter-rouge">(2, -2, 1)</code> scale to the “shadow normal” after applying the normal matrix to it.</p>

<figure class="lab-viewport" data-demo="shadow-step" data-code="/assets/lab/src/shadow/wgsl/normal-receiver-plane.wgsl" data-options="{&quot;filter&quot;:&quot;nearest&quot;}" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
</figure>

<div class="lab-code" hidden="" data-snippets="[
        {&quot;src&quot;: &quot;shadow/wgsl/normal-receiver-plane.wgsl&quot;, &quot;prev&quot;: &quot;shadow/wgsl/derivatives-receiver-plane.wgsl&quot;, &quot;title&quot;: &quot;Shadow visibility&quot;}
    ]"></div>

<p>I used this opportunity to also apply slope-based bias to show off how all techniques can be combined.</p>

<p>If you don’t have the possibility of providing the light normal matrix, there is an alternative approach using the 
tangent and bitangent vectors. 
You can even generate some on the fly if need be:</p>
<pre><code class="language-wgsl">let v = select(vec3&lt;f32&gt;(0.0, 1.0, 0.0), vec3&lt;f32&gt;(1.0, 0.0, 0.0), abs(normal.y) &gt; 0.99);
let tangent = normalize(cross(normal, v));
let bitangent = cross(normal, tangent);
</code></pre>

<p>You can use their projection into shadow space to compute <span class="kdmath">$\frac {\partial z} {\partial UV}$</span>:</p>
<pre><code class="language-wgsl">// Where one world unit along each tangent lands in shadowCoord space
// (we assume that the ndc-to-uv transform is included in `lightMatrix`)
let d1 = (lightMatrix * vec4&lt;f32&gt;(worldPos + tangent, 1.0)).xyz - shadowCoord;
let d2 = (lightMatrix * vec4&lt;f32&gt;(worldPos + bitangent, 1.0)).xyz - shadowCoord;

// dz = g.x * du + g.y * dv holds for both tangents, so solve for g = dz/d(u,v)
let det = d1.x * d2.y - d1.y * d2.x;
var dz_dUV = vec2&lt;f32&gt;(0.0);
if (abs(det) &gt; 1e-8) {
    dz_dUV = vec2&lt;f32&gt;(
        d1.z * d2.y - d1.y * d2.z,
        d1.x * d2.z - d1.z * d2.x
    ) / det;
}
</code></pre>

<p>While it allows you to avoid having to produce and fetch a normal matrix, it is achieved at the cost of an additional 
matrix multiplication.</p>

<h2 id="normal-offset-bias">Normal offset bias</h2>

<p>The final bias technique we will cover is called <strong>normal offset bias</strong>.
It consists in offsetting the original world position along its normal before projecting into light-space.
Only the lookup position moves, the shadow map itself is unchanged: it is as if we were testing against a slightly 
inflated version of the receiver.
The goal is to move the tested position out of the “hazard zone”, the thin region around a surface where the shadow map
texels can still hold depths from that very surface, which is what causes self-shadowing.</p>

<p>At grazing angles, this offset allows us to shift the bias horizontally rather than simply depth-wise, making this 
approach particularly useful for reducing self-shadowing artifacts.</p>

<p>For a deep dive, I highly recommend Daniel Holbert’s GDC poster “Saying goodbye to shadow acne” which explains the 
technique in detail.
<img src="https://user-images.githubusercontent.com/7088062/36948961-df37690e-1fea-11e8-8999-af8af60403fb.png" alt="Normal offset bias poster" /></p>

<p>Let’s implement it in our example. 
We will modify the light-local position retrieval function, but we will keep the basic shadow visibility function with 
no extra bias.</p>
<figure class="lab-viewport" data-demo="shadow-step" data-code="/assets/lab/src/shadow/wgsl/base.wgsl" data-options="{&quot;filter&quot;:&quot;nearest&quot;,&quot;normalOffset&quot;:true}" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
</figure>

<div class="lab-code" hidden="" data-snippets="[
        {
            &quot;src&quot;: &quot;shadow/wgsl/retrieveLightLocalPosition/normal-offset-position.wgsl&quot;, 
            &quot;prev&quot;: &quot;shadow/wgsl/retrieveLightLocalPosition/light-local-position.wgsl&quot;, 
            &quot;title&quot;: &quot;Retrieve light-local position&quot;
        }
    ]"></div>

<p>That’s impressive. With just a little bit of tweaking, we have almost no acne, with little to no peter panning.</p>

<p>But the result is not perfect: if you look closely, you can still see some self-shadowing in concave transitions, 
and raising the normal offset does not reliably remove it.
That’s because, near a concave corner, the offset is computed from the fragment’s own normal only.
It can push the fragment out of its own surface’s hazard zone while landing it inside the hazard zone of the 
neighbouring surface, which has a different orientation.</p>

<p>Not all concave surfaces get this issue, but as soon as we have somewhat tight concave spaces like we have in the scene,
you will get self-shadowing. 
In these cases, normal offsetting alone is not enough, and if you truly want to solve it, you will need some additional
biasing.</p>

<p>Another side effect of this technique is that it actually “shrinks” shadow silhouettes.
You can visually see this by cranking up the normal offset bias.</p>

<h2 id="back-face-shadow-maps-aka-side-stepping-the-issue-entirely">Back face shadow maps, a.k.a. side-stepping the issue entirely</h2>

<p>Lastly, another technique I wanted to cover is back-face shadow maps. 
While not exactly a bias technique, it has emerged as a widespread technique for dealing with self-shadowing artifacts.</p>

<p>The principle is quite simple: instead of rendering front faces for shadow maps, we render back faces (i.e., we cull 
front faces).
The goal with it is to keep receiving useful occlusion information while reducing self-shadowing occurrences.
Since fragments on these back faces tend to face away from the light anyway, they would be unlit due to
<span class="kdmath">$\vec L \cdot \vec N < 0$</span>, so even if there were self-shadowing artifacts, they would not be visible.</p>

<figure class="figure">
  <img src="/assets/img/articles/shadow-maps/backface.png" class="figure-img img-fluid" alt="Back face shadow maps" />
  
    <figcaption class="figure-caption"><a href="https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping">LearnOpenGL</a>, Joey de Vries, <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a></figcaption>
  
</figure>

<p>Let’s try it out:</p>

<figure class="lab-viewport" data-demo="shadow-step" data-code="/assets/lab/src/shadow/wgsl/base.wgsl" data-options="{&quot;filter&quot;:&quot;nearest&quot;,&quot;side&quot;:&quot;back&quot;}" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
</figure>

<p>Even without any bias, we get some pretty good results, but a close look will still reveal some self-shadowing 
artifacts.</p>

<p>If you look at the box borders that go from lighted to unlighted, you can easily see some self-shadowing staircase 
artifacts.
Looking some more, at the junction between boxes and the surface plane, you can also find some light leaking, the result 
of a slight peter panning.</p>

<p>If you lower the shadow map resolution, not only will these artifacts be more visible, but at some point, the thin
“umbrella” solid will start to self-shadow as well.</p>

<p>If a mesh is too thin (relative to the shadow map resolution), it will self-shadow.</p>

<p>A touch of normal offset bias should fix the self-shadowing issue.
For the peter panning, let’s try to add a bit of negative slope-scaled bias as well:</p>
<figure class="lab-viewport" data-demo="shadow-step" data-code="/assets/lab/src/shadow/wgsl/shadowVisibility/negative-bias.wgsl" data-options="{&quot;filter&quot;: &quot;nearest&quot;, &quot;normalOffset&quot;: true, &quot;side&quot;: &quot;back&quot;}" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
</figure>

<p>Okay, the normal offset bias has fixed the self-shadowing issue.
However, regarding the peter panning, the slope-scaled bias can’t fix the issue without creating some self-shadowing.</p>

<p>Also of note, we are working with closed-surface meshes here, but non-closed-surface meshes need additional care.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Biasing is a technique used to fix self-shadowing issues, resulting in acne in worst cases.
There is no final out-of-the-box solution for biasing, just a collection of techniques that can be used to mitigate the 
issue.</p>

<p>Usually, modern engines tend to use a combination of these techniques to achieve the best possible results.
For instance, I have worked on an internal engine that combines hardware bias and receiver plane bias.</p>

<p>To implement shadow mapping in your own engine, you will need to look at those techniques and choose one for yourself. 
From my personal experience, I have found that normal offset biasing is the best to start with, but it might not fit
your own requirements.</p>

<p>It should be noted that some of the self-shadowing issues can be further alleviated using suitable shadow filtering, 
but this is a topic we will be covering in the next part.</p>

<p>Previous part: <a href="/articles/directional-shadow-maps-part-1.html">Part 1</a>.
Next part: Part 3.</p>

<h1 id="references">References</h1>

<ul>
  <li>LearnOpenGL - <a href="https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping">Shadow mapping</a></li>
  <li>Render Diagrams - <a href="https://renderdiagrams.org/2024/12/18/shadowmap-bias/">Shadowmap Bias</a></li>
  <li>WebGPU specification - <a href="https://www.w3.org/TR/webgpu/#depth-stencil-state">Depth stencil state</a></li>
  <li>Microsoft Docs - <a href="https://learn.microsoft.com/en-us/windows/win32/direct3d11/d3d10-graphics-programming-guide-output-merger-stage-depth-bias">Depth Bias</a></li>
  <li>Real-Time Rendering, 4th Edition - <a href="https://www.realtimerendering.com/">7.4. Shadow maps</a></li>
  <li><a href="https://gdcvault.com/play/1013442/Shadow-Mapping-Tricks-and">Shadow mapping tricks and techniques</a>, John Isidoro, GDC 2006.</li>
  <li><a href="https://github.com/godotengine/godot/issues/17260">Shadow normal offset bias</a>, Godot proposals</li>
</ul>]]></content><author><name></name></author><category term="articles" /><summary type="html"><![CDATA[Fix shadow acne and peter-panning in shadow maps: constant, slope-based, hardware, receiver plane and normal offset bias, and back face rendering, with interactive WebGPU demos.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.maxgodefroy.com/assets/img/articles/shadow-maps/illustration.png" /><media:content medium="image" url="https://www.maxgodefroy.com/assets/img/articles/shadow-maps/illustration.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Rendering directional shadow maps in 2026 - Part 1</title><link href="https://www.maxgodefroy.com/articles/directional-shadow-maps-part-1.html" rel="alternate" type="text/html" title="Rendering directional shadow maps in 2026 - Part 1" /><published>2026-10-22T00:00:00+00:00</published><updated>2026-10-22T00:00:00+00:00</updated><id>https://www.maxgodefroy.com/articles/directional-shadow-maps-part-1</id><content type="html" xml:base="https://www.maxgodefroy.com/articles/directional-shadow-maps-part-1.html"><![CDATA[<p><img src="/assets/img/articles/shadow-maps/illustration.png" alt="Directional shadow map illustration" /></p>

<p>A few weeks ago, I was working on implementing a shadow map solution for one of my engine samples, and was quite 
surprised at how complicated it was to find up-to-date information on the subject. Not to shame any of the devs who 
have been publishing articles on the subject, but most articles tend to either focus too much on only a part of
the subject, or are outdated.</p>

<p>It resulted in me having to spend a considerable amount of time researching and experimenting with different approaches 
before finally finding a solution that worked for me.</p>

<p>So, to aid future developers who may be facing similar challenges, I decided to write a series of articles on the 
subject, where I will try and present a comprehensive and up-to-date approach to rendering directional shadow maps.
I will try to cover the many known techniques at each step, both the ones that are still relevant and the ones that 
have been superseded by newer techniques.</p>

<p>As this is a long and complicated topic, I have decided to break it down into four parts:</p>
<ul>
  <li>Part 1: Rendering a simple directional shadow map</li>
  <li>Part 2: Shadow biasing</li>
  <li>Part 3: Shadow filtering</li>
  <li>Part 4: Cascaded shadow maps</li>
</ul>

<p>To make this series accessible to the most, I will try to explain everything in detail (and avoid using too much
jargon). Feel free to ask any questions you may have, and I will do my best to answer them, and fix the articles 
accordingly.</p>

<p>All along these articles, I will be providing interactive demos, both to illustrate and to allow you to navigate the
scene, change the parameters, and see the possible issues for yourself. 
The scenes will be rendered using three.js WebGPU, so make sure to use a compatible browser. As such, code samples 
will be provided in JavaScript (for CPU code) and WGSL (for shaders).</p>

<p>We will be using the following demo scene:</p>

<figure class="lab-viewport" data-demo="unshadowed" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
  <figcaption class="lab-caption">Drag to orbit, right-drag to pan. Click the viewport to enable scroll-zoom.</figcaption>
</figure>

<h3 id="disclaimer-ai-use">Disclaimer: AI use</h3>

<p>AI coding was used in the making of this series, to generate the rendering workflow that allows for the interactive 
demos. 
However, this entire series is based on my own experience, knowledge and research.
This use was made to allow me to focus on the article itself rather than spending days setting up the rendering 
infrastructure.</p>

<h1 id="rendering-a-simple-directional-shadow-map">Rendering a simple directional shadow map</h1>

<h2 id="the-principle-behind-shadow-mapping">The principle behind shadow mapping</h2>

<p>Shadow mapping is a technique first presented by Lance Williams back in 1978. It tries to solve the following issue:
How do we test if a given point is occluded from a given light source? Once we have a way to answer this question,
we can use it to determine if a given point is in shadow or not.</p>

<figure class="figure">
  <img src="/assets/img/articles/shadow-maps/light-occlusion.png" class="figure-img img-fluid" />
  
    <figcaption class="figure-caption"><a href="https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping">LearnOpenGL</a>, Joey de Vries, <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a></figcaption>
  
</figure>

<p>A naive approach would be to trace a ray for each fragment displayed on screen to the emitting light source, and if the 
ray is occluded, then the fragment is in shadow. 
It is a method actually used in some real-time rendering engines, using techniques like hardware raytracing
or software ray marching. But it requires a non-negligible amount of computation and a specific separate spatial 
representation, which can be a bottleneck in real-time applications, especially on older hardware.</p>

<p>Shadow maps are an alternative to that method. The principle is to render a depth map of the scene from the light’s
perspective, the so-called shadow map. 
Why do that? Because it basically allows us to trace a batch of rays!
Each rendered pixel can be interpreted as the result of a ray trace. 
All this by using a standard rasterizer pipeline. 
This is what makes this approach so attractive.</p>

<p>If you use a directional light source, you can use orthographic projection, as it emulates parallel rays.
For spotlights and point lights, since rays come from a single point, you need to use perspective projection instead.</p>

<p>To determine if a fragment is in shadow, you can project its position in the light space and retrieve the depth
value at that point. If the stored depth value is closer to the light source than the projected point depth, then
you can assume that this fragment is in shadow.</p>

<figure class="figure">
  <img src="/assets/img/articles/shadow-maps/shadow-mapping.png" class="figure-img img-fluid" />
  
    <figcaption class="figure-caption"><a href="https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping">LearnOpenGL</a>, Joey de Vries, <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a></figcaption>
  
</figure>

<h2 id="rendering-the-shadow-map-itself">Rendering the shadow map itself</h2>

<p>So, first, we need to render the shadow map. This is done by rendering the scene from the light’s perspective. 
The resulting depth map is then used to determine if a fragment is in shadow.</p>

<p>Setting up the light camera requires you to first retrieve the light position and direction to determine a view
matrix, and then add an orthographic matrix to the mix.</p>

<p>That’s where things get a bit more complicated. Since you want your shadow map to detect occlusions in the scene, you
need to make sure the entire scene is visible in the shadow map frustum.
There are many ways to do this, but for now, let’s set it up manually.</p>

<script type="text/plain" id="inline-file-light-camera">
  function makeLightCamera( light, top, bottom, left, right, near, far ) {
    const camera = new THREE.OrthographicCamera( left, right, top, bottom, near, far );
    camera.position.copy( light.position );
    camera.lookAt( light.target.position );
    return camera;
  }
</script>

<figure class="lab-viewport" data-demo="light-camera" style="--lab-height: 520px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
  <figcaption class="lab-caption">Drag to orbit, right-drag to pan. Click the viewport to enable scroll-zoom.</figcaption>
</figure>

<div class="lab-code" hidden="" data-snippets="[
        {&quot;el&quot;: &quot;#inline-file-light-camera&quot;, &quot;lang&quot;: &quot;js&quot;, &quot;title&quot;: &quot;Light camera&quot;},
        {&quot;src&quot;: &quot;shadow/directional-shadow-pass.js&quot;, &quot;region&quot;: &quot;depth-pass&quot;, &quot;title&quot;: &quot;Shadow pass&quot;}
    ]"></div>

<p><br /></p>

<p>Feel free to play around with the light frustum parameters to see how they affect the shadow map.</p>

<p>You will likely notice that the shadow map has a limited resolution. This means that you will need
to make sure that the light frustum is as tight as possible to make better use of that limited resolution.
But you also have to make sure that the frustum is large enough to cover the entire scene, because if you don’t,
you will be unable to render shadows for objects that are outside the frustum.</p>

<p>But let’s say you want to render shadows for a large scene. You will likely have to choose between having a large 
frustum that covers the entire scene, or at least a large part of it, at the cost of diminished precision; or having
a smaller frustum that covers only a small part of the scene, but which provides better precision.</p>

<p>This is a topic we will discuss a bit more in part 4 of the series, when we tackle cascade shadow maps. 
For now, let’s keep a small scene and implement our shadows there.</p>

<p>A good approach currently is to retrieve the bounding volume of the current scene and use it as a guideline for the
light frustum.</p>

<div class="lab-code" hidden="" data-snippets="[
        {&quot;src&quot;: &quot;shadow/directional-shadow-pass.js&quot;, &quot;region&quot;: &quot;fit-frustum&quot;, &quot;title&quot;: &quot;Auto-setting frustum based on scene bounding sphere&quot;}
    ]"></div>

<h2 id="applying-the-shadow-map">Applying the shadow map</h2>

<p>Now that we have our shadow map rendered, we need to project it onto the scene from the camera’s perspective.</p>

<p>The process is quite simple. We simply need to project the fragment world position into the light’s space, so basically
we apply the light view-projection matrix to the fragment world position:</p>

<div class="lab-code" hidden="" data-snippets="[
        {&quot;src&quot;: &quot;shadow/wgsl/retrieveLightLocalPosition/light-local-position.wgsl&quot;, &quot;title&quot;: &quot;Retrieve light-local position&quot;}
    ]"></div>

<p><br /></p>

<p>Now that we have the light-local position, we can simply sample the shadow map to check for shadow visibility:
if the light-local position depth is closer to the light than the sampled value, we are in the light (so return 1.0),
otherwise we’re occluded, so in shadow (return 0.0).</p>

<div class="alert alert-info" role="alert">
Don't forget that the raw light-local position is in NDC space, in [-1, 1] range for x and y coordinates. 
Make sure to convert to UV space according to the texture-coordinate convention of your renderer.
<br /><br />
For instance, in WebGPU, the transform from NDC to UV space is: <br />
<code>let uv: vec2&lt;f32&gt; = (ndc.xy * vec2&lt;f32&gt;(0.5, -0.5)) + 0.5;</code>
</div>

<p>Do note that we will be using a comparison sampler here. 
Basically, it is an extension of the base sampler.
You provide a comparison operator (less than, greater or equal,…) upon declaration, and a comparison value upon use 
in the shader. 
With nearest filtering, if the comparison returns true, it returns 1.0, otherwise it returns 0.0.
(The comparison is done with the comparison value as the left operand, and the sampled value as the right operand.)
Other filtering modes are possible and result in a weighted average of the comparison results from the sampled texels.</p>

<p>The similar result can be achieved with a regular sampler.
You will need to manually do the interpolation yourself if you do so, but sometimes it allows for much more control
this way.
The comparison sampler has the added benefit of allowing for hardware acceleration of the sampling operation.</p>

<script type="text/plain" id="inline-file-shadow-visibility">
    fn shadowVisibility(
        shadowMap: texture_depth_2d,
        shadowSampler: sampler_comparison,
        shadowCoord: vec3<f32>
    ) -> f32 {
        // Outside the light's frustum there is no information: treat it as lit.
        if (any(shadowCoord.xy < vec2<f32>(0.0)) || any(shadowCoord.xy > vec2<f32>(1.0)) || shadowCoord.z > 1.0) {
            return 1.0;
        }
    
        // 1.0 where the receiver is closer to the light than the stored occluder, else 0.0.
        return textureSampleCompareLevel(shadowMap, shadowSampler, shadowCoord.xy, shadowCoord.z);
    }
</script>

<script type="text/plain" id="inline-file-apply-visibility">
    var directLight: vec3<f32> = computeLight( position, normal, light );

    let ndcShadowCoords: vec3<f32> = retrieveLightLocalPosition( light.matrix, position );
    let shadowCoords: vec3<f32> = vec3<f32>( ndcToUv( ndcShadowCoords.xy ), ndcShadowCoords.z );
    // Modulate direct light by shadow visibility.
    directLight *= shadowVisibility( shadowMap, shadowSampler, shadowCoords );
</script>

<div class="lab-code" hidden="" data-snippets="[
        {&quot;el&quot;: &quot;#inline-file-shadow-visibility&quot;, &quot;lang&quot;: &quot;wgsl&quot;, &quot;title&quot;: &quot;Shadow visibility&quot;},
        {&quot;el&quot;: &quot;#inline-file-apply-visibility&quot;, &quot;lang&quot;: &quot;wgsl&quot;, &quot;title&quot;: &quot;Apply shadow visibility&quot;}
    ]"></div>

<p><br /></p>

<p>Alternatively, we can pre-apply the NDC-to-UV transform to the light-space matrix to avoid having to
do the transform in the shader.
<span class="kdmath">$L = 
\begin{bmatrix}
    \frac{1}{2} & 0 & 0 & \frac{1}{2} \\
    0 & -\frac{1}{2} & 0 & \frac{1}{2} \\
    0 & 0 & 1 & 0 \\
    0 & 0 & 0 & 1
\end{bmatrix}
\cdot {P}_{light}
\cdot {V}_{light}$</span></p>

<p>Let’s see what we get:</p>

<figure class="lab-viewport" data-demo="shadow-step" data-code="/assets/lab/src/shadow/wgsl/base.wgsl" data-options="{&quot;filter&quot;:&quot;nearest&quot;, &quot;viewControl&quot;:false}" style="--lab-height: 420px">
  <div class="lab-stage">
    <canvas></canvas>
    <div class="lab-status" role="status">Scroll here to load the demo…</div>
  </div>
  <div class="lab-params"></div>
  <figcaption class="lab-caption">Drag to orbit, right-drag to pan. Click the viewport to enable scroll-zoom.</figcaption>
</figure>

<p><br /></p>

<p>It does not work quite as expected. If you look, you can see that we have shadows cast, but we also have these 
strange black artifacts, which will show up as a Moiré pattern. 
This phenomenon is called shadow acne, and it is a recurring issue with shadow mapping.</p>

<p>We will address this in the next part, <a href="/articles/directional-shadow-maps-part-2.html">“Shadow biasing”</a>.</p>

<h1 id="references">References</h1>

<ul>
  <li><a href="https://history.siggraph.org/learning/casting-curved-shadows-on-curved-surfaces-by-williams/">Casting Curved Shadows on Curved Surfaces</a>, Lance Williams (1978)</li>
  <li>Wikipedia - <a href="https://en.wikipedia.org/wiki/Shadow_mapping">Shadow mapping</a></li>
  <li>Learn OpenGL - <a href="https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping">Shadow mapping chapter</a></li>
</ul>]]></content><author><name></name></author><category term="articles" /><summary type="html"><![CDATA[An up-to-date guide to directional shadow mapping with WebGPU: how shadow maps work, rendering the depth map from the light, and sampling it in a shader, with interactive demos.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.maxgodefroy.com/assets/img/articles/shadow-maps/illustration.png" /><media:content medium="image" url="https://www.maxgodefroy.com/assets/img/articles/shadow-maps/illustration.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Rewind time in real-time</title><link href="https://www.maxgodefroy.com/articles/project-merge-mechanic.html" rel="alternate" type="text/html" title="Rewind time in real-time" /><published>2023-05-24T00:00:00+00:00</published><updated>2023-05-24T00:00:00+00:00</updated><id>https://www.maxgodefroy.com/articles/project-merge-mechanic</id><content type="html" xml:base="https://www.maxgodefroy.com/articles/project-merge-mechanic.html"><![CDATA[<p>The main feature of my <a href="/portfolio/project-merge">Project: Merge</a> old game prototype is its time rewind
mechanic. 
It allowed the player to rewind the state of the game in real-time, up to any moment. The end-goal was to
allow the player to use this ability to solve different situations, such as combats and puzzles.</p>

<p>Here’s a quick demo:</p>
<div class="ratio ratio-16x9">
    <iframe src="https://www.youtube.com/embed/vPMXBt8YsHA" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe>
</div>
<p><br /></p>

<p>So when I saw a very similar mechanic in the latest Legend of Zelda game, I was quite intrigued:
<img src="/assets/img/articles/time-rewind/totk-rewind.gif" alt="Rewinding an object in Zelda: Tears of the Kingdom" /></p>

<p>As such, I plan on detailing my own implementation and then compare it to what we see in that game. 
While it isn’t unlikely that the devs at Nintendo went in a completely different direction, I believe it 
might still be interesting to see what problematics they might have encountered and how they solved them.</p>

<p><br /></p>

<h2 id="defining-the-problem-and-the-specs">Defining the problem and the specs</h2>

<p>The first step here is to define the specifications of what we want this mechanic to achieve: 
what to rewind? rewind to when?…</p>

<p><br /></p>

<p>There are two types of things we want to be able to rewind for now : the rigid body objects (like 
the cubes lying around the scene) and the player. <br />
The rigid body objects should be able to be rewinded along their physical state: position, velocity, 
rotation and angular velocity. <br /></p>

<p>The player should have is position and camera rotation rewinded.</p>

<p>We might also want to rewind other things at some point, like the gun and bullet animations, 
player interactions (like pulling a lever), particle effects,… As such, we need to design a 
system that can handle those type of entities, which will most likely have some custom behaviour.</p>

<p>Also, we will not try to compartmentize which objects should be rewinded for now, we will assume that we’ll
rewind everything.
However, it is not necessary for the rewinding itself so this is a problem that can be tackled separately.</p>

<p><br /></p>

<h2 id="implementing-the-rewinding">Implementing the rewinding</h2>

<p>Now that we have an idea of what we want, let’s jump into the heart of the subject.
For simplicity’s sake, we will exclusively focus on the rewinding of rigid bodies. The other objects to 
rewind will follow a very similar pattern after all.</p>

<p>The first thing to note is that we’re dealing we’re not dealing with a purely procedural physical state,
in the sense that we don’t have an easy way to retrieve its value at any particular time. 
In fact, we are using physics simulation withe discrete steps. 
Some punctual forces can be applied to the object and even if we keep tracking them all, this is not going 
to be easy to back-track the simulation along them.</p>

<p>As such, the first solution that comes to mind is to save the physical state itself as time advances as 
discrete events.</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="nc">FTrackedPhysicsState</span>
<span class="p">{</span>
    <span class="n">FVector</span> <span class="n">PositionW</span><span class="p">;</span>
    <span class="n">FVector</span> <span class="n">VelocityW</span><span class="p">;</span>
    
    <span class="c1">// Here we'll use Euler angles, but quaternions are likely to be a better solution down the line</span>
    <span class="n">FVector</span> <span class="n">RotationW</span><span class="p">;</span>
    <span class="n">FVector</span> <span class="n">AngularVelocityW</span><span class="p">;</span>
    
    <span class="kt">double</span> <span class="n">Timestamp</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div></div>

<p>This is not the most precise way to register and replay the state, but it should allow us to approximate it
using interpolation.</p>

<p>A first approach would be the following:</p>
<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">FVector</span> <span class="n">FTrackedPhysicsState</span><span class="o">::</span><span class="n">InterpolatePosition</span><span class="p">(</span>
    <span class="k">const</span> <span class="n">FTrackedPhysicsState</span><span class="o">&amp;</span> <span class="n">PreviousState</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">Time</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">LerpFactor</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="n">FMath</span><span class="o">::</span><span class="n">Lerp</span><span class="p">(</span><span class="n">PreviousState</span><span class="p">.</span><span class="n">PositionW</span><span class="p">,</span> <span class="n">PositionW</span><span class="p">,</span> <span class="n">LerpFactor</span><span class="p">);</span>
<span class="p">}</span>

<span class="n">FVector</span> <span class="n">FTrackedPhysicsState</span><span class="o">::</span><span class="n">InterpolateVelocity</span><span class="p">(</span>
    <span class="k">const</span> <span class="n">FTrackedPhysicsState</span><span class="o">&amp;</span> <span class="n">PreviousState</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">Time</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">LerpFactor</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="n">FMath</span><span class="o">::</span><span class="n">Lerp</span><span class="p">(</span><span class="n">PreviousState</span><span class="p">.</span><span class="n">VelocityW</span><span class="p">,</span> <span class="n">VelocityW</span><span class="p">,</span> <span class="n">LerpFactor</span><span class="p">);</span>
<span class="p">}</span>

<span class="n">FVector</span> <span class="n">FTrackedPhysicsState</span><span class="o">::</span><span class="n">InterpolateRotation</span><span class="p">(</span>
    <span class="k">const</span> <span class="n">FTrackedPhysicsState</span><span class="o">&amp;</span> <span class="n">PreviousState</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">Time</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">LerpFactor</span><span class="p">)</span>
<span class="p">{</span>
    <span class="c1">// Convert back to Quat to use Slerp, then back again to Euler.</span>
    <span class="k">return</span> <span class="n">FQuat</span><span class="o">::</span><span class="n">Slerp</span><span class="p">(</span>
        <span class="n">FQuat</span><span class="o">::</span><span class="n">MakeFromEuler</span><span class="p">(</span><span class="n">PreviousState</span><span class="p">.</span><span class="n">RotationW</span><span class="p">),</span>
        <span class="n">FQuat</span><span class="o">::</span><span class="n">MakeFromEuler</span><span class="p">(</span><span class="n">RotationW</span><span class="p">),</span>
        <span class="n">LerpFactor</span>
    <span class="p">).</span><span class="n">Euler</span><span class="p">();</span>
<span class="p">}</span>

<span class="n">FVector</span> <span class="n">FTrackedPhysicsState</span><span class="o">::</span><span class="n">InterpolateAngularVelocity</span><span class="p">(</span>
    <span class="k">const</span> <span class="n">FTrackedPhysicsState</span><span class="o">&amp;</span> <span class="n">PreviousState</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">Time</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">LerpFactor</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="n">FMath</span><span class="o">::</span><span class="n">Lerp</span><span class="p">(</span><span class="n">PreviousState</span><span class="p">.</span><span class="n">AngularVelocityW</span><span class="p">,</span> <span class="n">AngularVelocityW</span><span class="p">,</span> <span class="n">LerpFactor</span><span class="p">);</span>
<span class="p">}</span>

<span class="n">FTrackedPhysicsState</span> <span class="n">FTrackedPhysicsState</span><span class="o">::</span><span class="n">Interpolate</span><span class="p">(</span>
    <span class="k">const</span> <span class="n">FTrackedPhysicsState</span><span class="o">&amp;</span> <span class="n">PreviousState</span><span class="p">,</span> 
    <span class="kt">double</span> <span class="n">Time</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">check</span><span class="p">(</span><span class="n">Time</span> <span class="o">&lt;=</span> <span class="n">Timestamp</span> <span class="o">&amp;&amp;</span> <span class="n">Time</span> <span class="o">&gt;=</span> <span class="n">PreviousState</span><span class="p">.</span><span class="n">Timestamp</span><span class="p">);</span>
    
    <span class="k">const</span> <span class="k">auto</span> <span class="n">LerpFactor</span> <span class="o">=</span> <span class="p">(</span><span class="n">PreviousState</span><span class="p">.</span><span class="n">Timestamp</span> <span class="o">==</span> <span class="n">Timestamp</span><span class="p">)</span>
        <span class="o">?</span> <span class="mf">1.0</span>
        <span class="o">:</span> <span class="p">(</span><span class="n">Timestamp</span> <span class="o">-</span> <span class="n">Time</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">Timestamp</span> <span class="o">-</span> <span class="n">PreviousState</span><span class="p">.</span><span class="n">Timestamp</span><span class="p">);</span>

    <span class="n">FTrackedPhysicsState</span> <span class="n">Result</span> <span class="p">{</span>
        <span class="n">InterpolatePosition</span><span class="p">(</span><span class="n">PreviousState</span><span class="p">,</span> <span class="n">Time</span><span class="p">,</span> <span class="n">LerpFactor</span><span class="p">),</span>
        <span class="n">InterpolateVelocity</span><span class="p">(</span><span class="n">PreviousState</span><span class="p">,</span> <span class="n">Time</span><span class="p">,</span> <span class="n">LerpFactor</span><span class="p">),</span>
        <span class="n">InterpolateRotation</span><span class="p">(</span><span class="n">PreviousState</span><span class="p">,</span> <span class="n">Time</span><span class="p">,</span> <span class="n">LerpFactor</span><span class="p">),</span>
        <span class="n">InterpolateAngleVelocity</span><span class="p">(</span><span class="n">PreviousState</span><span class="p">,</span> <span class="n">Time</span><span class="p">,</span> <span class="n">LerpFactor</span><span class="p">),</span>
        <span class="n">Time</span>
    <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>
<p>The interpolation result clearly won’t be the best, especially for the position. 
There is likely going to be visible artifacts.
However, this is something that can be easily iterated over with better interpolation for each member.</p>

<p><br /></p>

<p>Now that we now how to track the physics state and to interpolate between them, we have what we need to 
do time rewind.
We just need to store these states regularly in a container.</p>

<p>However, we start to reach a problem: when do we save these states. If we save them every frame and never free them,
with enough rigid bodies in our scene we’ll overload our system memory.</p>

<p>A first approach would be to set a hard limit to how many states can be saved per body, this way we can keep the memory 
footprint predictable.
But it limits how many states we can save.</p>

<p>A second cheap iteration is to only save the states where the object is moving around. 
There is no need to save consecutive states of the object not moving.
This should help in most situations, but it also has its limit. 
Some situations cannot profit from this. 
An example would be a cube endlessly spinning in zero gravity.</p>

<p>A third iteration then would be to reduce the overall amount of state saving, by only saving the state when it is 
relevant, and let the interpolation do the heavy lifting.</p>

<p><br /></p>

<p>For this third iteration, what I did was that I went ahead and compared the current velocity and angular velocity with 
the ones from the latest saved state. If they differ too much, the current state is saved.</p>

<p>To be more specific I check if the change in direction and relative norm goes beyond a certain threshold for each one. 
As for the threshold itself, I actually use a varying threshold based on an exponential falloff based on the time 
passed since the last state save:
<img src="/assets/img/articles/time-rewind/exp-threshold.png" alt="Plot of the exponential threshold function" /></p>

<p>This allows the system to save slight changes over longer periods of time while avoiding over-saving when you have a 
very quick succession of changes.</p>

<p>This however brings its own set of issues as now more than ever you need to experiment yourself to find the most 
appropriate parameters, which are not obvious and will require a lot of iteration depending on your situation.</p>

<p><br /></p>

<h2 id="how-does-the-zelda-version-compare-">How does the Zelda version compare ?</h2>

<p>Let’s just roll it again here:
<img src="/assets/img/articles/time-rewind/totk-rewind.gif" alt="Rewinding an object in Zelda: Tears of the Kingdom" /></p>

<p>If we go back to the Rewind power from Tears of the Kingdom, we can see that they seem to have a very similar situation.
There are a few differences though:</p>
<ul>
  <li>they only rewind one object, and lets it interact with the rest of the world (which is still going forward in time)
as an unstoppable force of chaos. The prototype was trying to rewind the entire scene, which is a lot harder to do.
There approach works way better for what they want to achieve, since it is simpler.</li>
  <li>The object does not keep its momentum when the rewind stops. This is simply a matter of resetting the momentum to zero
once we stop rewinding.</li>
  <li>They allow the player to rewind the object up to a certain duration back in time. The prototype does not.</li>
  <li>They cut the periods of time when the object has no momentum or is not interacted with (notably by using the 
Ultrahand power)</li>
</ul>

<p>Overall it is still very compatible with the approach I took, as long as you do some alterations.</p>

<p>With their setup, they could simply save the physics state at certain time intervals and dump the states which go beyond 
their imposed time limit, limiting the memory impact. Using streaming and taking a look at how the actual game works, I 
expect them to only have to track a few hundreds of bodies at once at most, which is very manageable.</p>

<p>I would go as far as to suggest that they actually did not even save the velocity and angular velocity in the physics 
state, since they don’t need to reapply the momentum when stopping the rewind.</p>

<p>This would have allowed them to bypass many issues that I encountered myself in a very elegant way.</p>

<p><br /></p>

<h2 id="some-suggestions-for-improvement">Some suggestions for improvement</h2>

<h3 id="better-interpolation">Better interpolation</h3>

<p>We should be able to improve the position interpolation easily by integrating the position with its 
velocity.
You might be tempted to simply use one of the two states, but you’d end up with two issues: there will 
likely be a big mismatch between this computed position at the end of the segment and the actual 
position of the other state, resulting in a non-continuous trajectory when swapping segment. Also since
we’d only be using velocity, we’d keep straight interpolated trajectories.
You must also decide which state to use as a start point.</p>

<p>The trick to this is actually to compute this value from both state and interpolate between the results.
This way the interpolated trajectory will remain continuous, and the trajectory will be parabolic:
https://www.desmos.com/calculator/uc2f9a1ybt</p>
<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">FVector</span> <span class="n">FTrackedPhysicsState</span><span class="o">::</span><span class="n">InterpolatePosition</span><span class="p">(</span>
    <span class="k">const</span> <span class="n">FTrackedPhysicsState</span><span class="o">&amp;</span> <span class="n">PreviousState</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">Time</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">LerpFactor</span><span class="p">)</span>
<span class="p">{</span>
    <span class="c1">// Do a linear interpolation between a forward and a backward trajectory.</span>
    <span class="c1">// Results in a parabolic trajectory: https://www.desmos.com/calculator/uc2f9a1ybt</span>

    <span class="k">const</span> <span class="k">auto</span> <span class="n">PositionWFromPrevious</span> <span class="o">=</span> <span class="n">PreviousState</span><span class="p">.</span><span class="n">PositionW</span> <span class="o">+</span> <span class="p">(</span><span class="n">Time</span> <span class="o">-</span> <span class="n">PreviousState</span><span class="p">.</span><span class="n">Timestamp</span><span class="p">)</span> <span class="o">*</span> <span class="n">PreviousState</span><span class="p">.</span><span class="n">VelocityW</span><span class="p">;</span>
    <span class="k">const</span> <span class="k">auto</span> <span class="n">PositionWFromCurrent</span> <span class="o">=</span> <span class="n">PositionW</span> <span class="o">+</span> <span class="p">(</span><span class="n">Time</span> <span class="o">-</span> <span class="n">Timestamp</span><span class="p">)</span> <span class="o">*</span> <span class="n">VelocityW</span><span class="p">;</span>
    <span class="k">return</span> <span class="n">FMath</span><span class="o">::</span><span class="n">Lerp</span><span class="p">(</span><span class="n">PositionWFromPrevious</span><span class="p">,</span> <span class="n">PositionWFromCurrent</span><span class="p">,</span> <span class="n">LerpFactor</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>You can use a similar approach with rotation.</p>

<p><br /></p>

<p>Interpolation could be improved further still using splines, but I’ll leave that to the reader for exploring.</p>]]></content><author><name></name></author><category term="articles" /><summary type="html"><![CDATA[How I implemented a real-time time rewind mechanic for rigid bodies in a game prototype, and how it compares to the rewind ability in Zelda: Tears of the Kingdom.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.maxgodefroy.com/assets/img/articles/time-rewind/exp-threshold.png" /><media:content medium="image" url="https://www.maxgodefroy.com/assets/img/articles/time-rewind/exp-threshold.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>