Graphics Programming

Fix 5 Common WebGPU Shadow Bugs: 2025 Quick Guide

Struggling with WebGPU shadows? Fix Peter Panning, shadow acne, jagged edges & more with our 2025 quick guide. Dive into practical code fixes and concepts.

A

Alex Grayson

Lead Graphics Engineer specializing in real-time rendering and modern web graphics APIs.

7 min read11 views

You’ve done it. After wrestling with buffers, pipelines, and bind groups, your 3D scene is finally rendering in WebGPU. The models are there, the lighting feels right, but then you enable shadows and… ugh. Your beautiful scene is suddenly plagued by weird artifacts. Shadows are floating in mid-air, objects are covered in strange speckles, and everything has jagged edges.

If this sounds painfully familiar, you're in the right place. Dynamic shadows are a cornerstone of realistic 3D graphics, but they're notoriously tricky to get right. With a low-level API like WebGPU, you're in the driver's seat, which means you have immense power but also the full responsibility for taming these shadowy beasts.

This guide is your 2025 field manual for diagnosing and fixing the five most common shadow mapping bugs you'll encounter in WebGPU. We'll skip the high-level fluff and dive straight into the practical causes and code-level solutions to get your renders looking sharp.

Bug #1: Peter Panning (The Floating Shadow)

The Problem: The shadow appears disconnected from the object casting it, making the object look like it's floating slightly above the surface it's on. It’s a classic bug named after the character who could fly and had a detached shadow.

An example of Peter Panning, where a cube's shadow is detached and floating away from the ground plane.

Why It Happens

Ironically, Peter Panning is often a side effect of fixing our next bug, shadow acne. To prevent a surface from incorrectly shadowing itself, we apply a small depth "bias." If this bias value is too large, it pushes the shadow depth so far that the shadow no longer touches the object that casts it, creating a noticeable gap.

The Fix in WebGPU

The solution lies in carefully tuning the depth bias settings within your shadow pass's GPURenderPipelineDescriptor. WebGPU offers three key properties in the depthStencil state:

  • depthBias: A constant value added to the depth of each fragment.
  • depthBiasSlopeScale: A multiplier that scales the bias based on the polygon's slope.
  • depthBiasClamp: A maximum value for the total applied bias (a useful safety net).

Start with a small depthBias and a moderate depthBiasSlopeScale. Your goal is to find the smallest possible values that eliminate shadow acne (our next topic) without introducing Peter Panning.

// Inside your render pipeline creation
const shadowPipeline = device.createRenderPipeline({
  // ... other pipeline settings
  depthStencil: {
    format: 'depth24plus',
    depthWriteEnabled: true,
    depthCompare: 'less',
    // --- Start tuning here! ---
    depthBias: 2, // Constant bias
    depthBiasSlopeScale: 2.5, // Slope-scaled bias
    depthBiasClamp: 0.0, // Optional: 0 means no clamp
  },
});

Bug #2: Shadow Acne (The Speckled Mess)

The Problem: You see strange, dark speckles or striped patterns on surfaces that should be fully lit. This self-shadowing artifact is commonly called "shadow acne."

An example of shadow acne, where a lit surface is covered in dark, incorrect shadow speckles.

Why It Happens

Shadow acne is a depth precision problem. A shadow map stores depth values from the light's point of view. When shading a pixel, we compare its depth to the value in the shadow map. Due to floating-point inaccuracies and the discrete nature of the shadow map, a fragment can sometimes test as being slightly behind the depth value it just wrote, causing it to incorrectly shadow itself.

The Fix in WebGPU

As mentioned, the fix is applying a depth bias. By adding a small positive offset to a fragment's depth during the shadow pass, you effectively push the geometry slightly away from the light, ensuring it passes the depth test. The depthBiasSlopeScale is particularly effective because surfaces nearly parallel to the light direction (steep slopes) require a much larger bias than surfaces perpendicular to it. Slope-scaled bias automatically handles this for you.

Advertisement

Pro Tip: Always use a slope-scaled bias (depthBiasSlopeScale) in addition to a small constant bias (depthBias). It handles tricky edge cases on angled surfaces much more robustly.

Here’s a quick comparison of the bias settings:

Setting Effect on Shadow Acne Effect on Peter Panning Recommendation
depthBias Reduces acne on flat surfaces High values cause Peter Panning Start with a small integer value (e.g., 1 or 2)
depthBiasSlopeScale Excellent for reducing acne on slopes Less likely to cause Peter Panning Use a small float (e.g., 2.0-3.0) in tandem with depthBias
depthBiasClamp Caps the total bias applied Prevents extreme shadow detachment Set as a safety net if you see wild artifacts

Bug #3: Jagged Edges (The Blocky Nightmare)

The Problem: The edges of your shadows are sharp, blocky, and aliased. They don't look soft and natural; they look like a staircase.

Why It Happens

A shadow map is a texture. Like any texture, it has a finite resolution. If you stretch a low-resolution shadow map over a large area, each texel of the map covers a significant portion of your scene, resulting in that characteristic blocky look. This is called aliasing.

The Fix in WebGPU

You have two primary tools to fight jagged edges:

1. Increase Shadow Map Resolution: The most straightforward approach. If your shadow map is 1024x1024, try bumping it to 2048x2048. This gives you more detail but comes at the cost of increased VRAM usage and slower shadow map rendering. It's a trade-off.

// In your shadow map texture creation
const shadowDepthTexture = device.createTexture({
  size: [2048, 2048], // Formerly [1024, 1024]
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
  format: 'depth24plus',
});

2. Percentage-Closer Filtering (PCF): A much more effective technique. Instead of a single binary check (is it in shadow or not?), PCF samples the shadow map multiple times in a small grid around the target coordinate and averages the results. This turns the hard, blocky edge into a soft, smooth gradient.

Here's a simple 2x2 PCF implementation in WGSL:

// Assumes t_shadow is your texture_depth_2d and s_shadow is your sampler_comparison
// shadowPos is the fragment's position in light-space (vec3<f32>)
// shadowMapSize is a uniform for the texture size (e.g., 2048.0)

fn calculate_shadow_pcf(shadowPos: vec3<f32>, shadowMapSize: f32) -> f32 {
  let texelSize = 1.0 / shadowMapSize;
  var shadowFactor: f32 = 0.0;

  // 2x2 PCF Kernel
  for (var y: i32 = -1; y <= 0; y = y + 1) {
    for (var x: i32 = -1; x <= 0; x = x + 1) {
      let offset = vec2<f32>(f32(x) * texelSize, f32(y) * texelSize);
      shadowFactor += textureSampleCompare(
        t_shadow, 
        s_shadow, 
        shadowPos.xy + offset, 
        shadowPos.z
      );
    }
  }
  return shadowFactor / 4.0;
}

Bug #4: Mismatched Projections (The Warped Shadow)

The Problem: Shadows appear stretched, squashed, or severely distorted. They might move incorrectly as the light or camera moves, or be projected in the wrong direction entirely.

Why It Happens

This is a classic data integrity bug. The view-projection matrix you used to render the scene from the light's perspective (during the shadow pass) does not match the one you're using in the main fragment shader to test for shadows. Common culprits include:

  • Passing the camera's matrix to the shadow shader by mistake.
  • Calculating the light's orthographic or perspective projection incorrectly.
  • Forgetting to update the light matrix uniform buffer when the light's position or direction changes.
  • Matrix multiplication order errors. Remember, matrix math is not commutative!

The Fix in WebGPU

This requires careful debugging. There's no magic setting; you just have to ensure your data is correct.

  1. Isolate Your Matrices: Have one clear source of truth for your light's view-projection matrix. Calculate it once per frame (if the light moves).
  2. Use a Reliable Math Library: Don't write matrix math from scratch unless you're an expert. Use a well-tested library like gl-matrix to handle look-at, perspective, and orthographic projections.
  3. Check Your Uniforms: Ensure the uniform buffer that holds the light matrix is correctly bound to both the shadow pass pipeline and the main rendering pipeline. Verify that it's being updated on the GPU every frame if needed.
  4. Log and Verify: When in doubt, console.log() your matrix values before you write them to the GPU buffer. Do they look reasonable? Are they all NaNs? This can save you hours of headache.

Bug #5: The Disappearing Act (The Vanishing Shadow)

The Problem: Shadows on objects far from the camera are missing entirely. As you move closer, they suddenly pop into existence.

Why It Happens

The volume covered by the light's projection (its frustum) is not large enough to contain all the objects visible to the camera. Any object (or part of an object) outside the light's frustum will not be rendered into the shadow map and, therefore, cannot cast a shadow.

The Fix in WebGPU

You need to ensure the light's frustum covers what the camera can see.

The Simple Fix: Enlarge the Light's Frustum. For a directional light (using an orthographic projection), you can increase the width, height, and depth of the projection volume. The major downside is that you are now stretching the same resolution shadow map over a larger area, which can bring back the jagged edges from Bug #3. It's a constant balancing act.

The Advanced Fix: Cascaded Shadow Maps (CSM). This is the industry-standard technique for large outdoor scenes. The core idea is to split the camera's view frustum into several sections or "cascades" (e.g., near, middle, and far). You then render a separate, high-quality shadow map for each cascade.

  • The near cascade covers a small area close to the camera with a very high-resolution shadow map.
  • The far cascade covers a huge area in the distance with a lower-resolution shadow map.

In your main shader, you determine which cascade a fragment falls into and sample the appropriate shadow map. This gives you the best of both worlds: crisp, detailed shadows up close and stable, present shadows in the distance. Implementing CSM is a more involved topic, but it's the ultimate solution to this problem.

Bringing It All Together

Shadows are one of the most challenging, yet rewarding, aspects of real-time 3D graphics. While WebGPU gives you unprecedented control, it also asks you to manage the complexities that older APIs might have hidden. Don't be discouraged! These five bugs—Peter Panning, acne, aliasing, mismatched matrices, and disappearing shadows—represent the vast majority of issues you'll face.

By learning to identify them and systematically applying the right fixes, from tuning bias settings to implementing PCF and managing your frustums, you'll be well on your way to rendering beautiful, dynamic, and artifact-free scenes. Happy coding!

Tags

You May Also Like