Three.js Threlte Shadow Cut-Off Issue Explanation and Solution| Sabbirz | Blog

Three.js Threlte Shadow Cut-Off Issue Explanation and Solution

Three js Threlte Shadow Cut Off issue

Shadow CutOff problem in Threlte and Three.js

Have you ever crafted the perfect 3D scene in Threlte, added a stunning directional light, and then—bam—your shadows get sliced off or vanish completely when objects move? 😱

Shadow Cutoff Problem

You are not alone, friend! This is a classic "gotcha" in the world of Three.js and creative coding. But don't worry, it’s not a bug—it’s a feature (sort of), and we’re going to fix it together.

In this deep dive, we’ll explore why this happens, how to visualize the invisible shadow box, and how to configure your scene for cinematic, glitch-free shadows. Let's make your scene shine! ✨


The "Invisible Box" Dilemma 📦

Here is the techy truth: Directional lights mimic the sun, casting rays from infinitely far away. ☀️ But asking your GPU to calculate shadows for an infinite universe? That's a recipe for a melted computer. 🔥

To keep things performant, 3D engines like Three.js (the powerhouse behind Threlte) use a trick. They only calculate shadows inside a specifically defined frustum (or "box").

  • Inside the box? glorious shadows. ✅
  • Outside the box? shadow void. ❌

If your scene is larger than this default box, your shadows will clip, cut off, or disappear. It looks something like this:

Shadow Cutoff Problem with CameraHelper

See that sharp line? That’s where the math stops and the heartbreak begins. 💔

Step 1: The Basic Scene

To demonstrate this issue, let’s create a simple scene with a directional light, a plane, and a cube.

threejs threlte basic scene

I have creted a route called shadow and added 2 files there

  1. shadow/+page.svelte
    <script>
      import { Canvas } from "@threlte/core";
      import Scene from "./Scene.svelte";
    </script>
    
    <div class="h-screen w-full bg-black">
      <canvas>
        <Scene />
      </canvas>
    </div>
  2. shadow/Scene.svelte
    <script>
      import { T } from "@threlte/core";
      import { OrbitControls, Sky } from "@threlte/extras";
    </script>
    
    <T.PerspectiveCamera makeDefault position={[0, 5, 10]} fov={80}>
         <OrbitControls enableDamping />
    
         <!-- Blue Box -->
         <T.Mesh castShadow receiveShadow position={[0, -2, -4]}>
             <T.BoxGeometry args={[1, 1, 1]} />
             <T.MeshStandardMaterial color="#3b82f6" />
         </T.Mesh>
    
    </T.PerspectiveCamera>
    
     <Sky />
     <T.AmbientLight intensity={0.5} />
    
    <T.DirectionalLight position={[10, 20, 10]} intensity={5} castShadow></T.DirectionalLight>
    
     <!-- Ground Plane -->
    <T.Mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
         <T.PlaneGeometry args={[50, 50]} />
         <T.MeshStandardMaterial color="#9ca3af" />
    </T.Mesh>
    
     <!-- Red Box -->
    <T.Mesh castShadow receiveShadow position={[0, 0.5, 0]}>
         <T.BoxGeometry args={[1, 1, 1]} />
         <T.MeshStandardMaterial color="#fe0032" />
    </T.Mesh>
    
     <!-- Green Box -->
    <T.Mesh castShadow receiveShadow position={[7, 0.5, 0]}>
         <T.BoxGeometry args={[1, 1, 1]} />
         <T.MeshStandardMaterial color="#0cbe42" />
    </T.Mesh>

Note: We have 3 box in the scene. The blue box is parented to the camera. The red box stays in the center. The green box is half out of direction light. So currently the green and blue box having half shadow.


Step 2: Seeing the Invisible (Debugging) 🕵️‍♂️

You can't fix what you can't see. So, let’s expose this invisible box using a CameraHelper. This is effectively "Developer Mode" for your lights.

We need to grab a reference to our light and pass its internal shadow.camera to a helper component.

<script>
    // rest of the code
    let dirLight;
</script>

    <!--Rest of the code -->

    <!--Add reference to the Existing directional light -->
    <T.DirectionalLight bind:ref="{dirLight}" position={[10,20,10]} castShadow />

    <!-- 🟡 Visualize the shadow camera's bounds -->
    {#if dirLight}
    <T.CameraHelper args={[dirLight.shadow.camera]} />
    {/if}

    <!--Rest of the code -->

Now, take a look at your scene:

Camera Helper Debugging

Aha! 💡 The yellow wireframe box shows exactly where shadows are born. Our blue object is drifting out of bounds, and the shadow dies at the edge.


Step 2: Breaking Out of the Box (The Fix) 🛠️

Explanation: Directional lights mimic the sun, casting rays from infinitely far away. ☀️ But asking your GPU to calculate shadows for an infinite universe? That's a recipe for a melted computer. 🔥

To keep things performant, 3D engines like Three.js (the powerhouse behind Threlte) use a trick.

Threlte (read three.js) only calculate shadows inside a specifically defined frustum (or "box").

The solution is deceptively simple: Expand the box!

We can manually control the dimensions of this orthographic shadow camera using specific props on the <T.DirectionalLight>. We need to adjust left, right, top, and bottom to cover our entire active scene.

threejs threlte expand directional light area

<T.DirectionalLight
  bind:ref={dirLight}
  position={[10, 20, 10]}
  intensity={5}
  castShadow
  shadow.camera.left={-10}
  shadow.camera.right={10}
  shadow.camera.top={10}
  shadow.camera.bottom={-10}
  shadow.camera.near={0.1}
  shadow.camera.far={100}
  shadow.mapSize={[2048, 2048]}
/>

[!CAUTION] > ⚠️ The Resolution Trap

You might be tempted to set these values to 10000 just to be safe. Don't!

Your shadow map is a texture (image) with a fixed resolution (default is often 512x512). If you stretch that small image over a massive area, your pixels get huge, and your shadows will look like Minecraft blocks. 🧱


Step 3: The "Chef's Kiss" (Soft Shadows) 🧑‍🍳👌

Fixed shadows are good, but beautiful shadows are better. By default, 3D shadows can look razor-sharp and unnatural. Real light diffuses.

Let's bring in the SoftShadows component from @threlte/extras. It uses a technique called PCSS (Percentage Closer Soft Shadows) to simulate realistic variable blur.

threejs threlte soft shadow

<script>
  import { SoftShadows } from '@threlte/extras'
</script>

<!-- 🎨 Add this to your scene for instant realism -->
<SoftShadows size={25} focus={0.5} samples={10} />
  • size: How "physically big" the light source is. Bigger light = softer shadows. ☁️
  • focus: enhancing the sharpness at the contact point. 🎯
  • samples: quality of the noise lookup. Higher is better but more expensive. 📉

💻 The Final Code

Here is your copy-paste-ready snippet:

<script>
    import { T } from '@threlte/core';
    import { OrbitControls, Sky } from '@threlte/extras';
    import { SoftShadows } from '@threlte/extras';
    let dirLight;
</script>

<T.PerspectiveCamera makeDefault position={[0, 5, 10]} fov={80}>
    <OrbitControls enableDamping />

    <T.Mesh castShadow receiveShadow position={[0, -2, -4]}>
        <T.BoxGeometry args={[1, 1, 1]} />
        <T.MeshStandardMaterial color="#3b82f6" />
    </T.Mesh>
</T.PerspectiveCamera>

<Sky />
<T.AmbientLight intensity={0.5} />

<SoftShadows size={30} focus={0.5} samples={10} />

<T.DirectionalLight
    bind:ref={dirLight}
    position={[10, 20, 10]}
    intensity={5}
    castShadow
    shadow.camera.left={-10}
    shadow.camera.right={10}
    shadow.camera.bottom={-10}
    shadow.camera.top={10}
    shadow.camera.near={0.01}
    shadow.camera.far={100}
    shadow.mapSize={[2048, 2048]}
></T.DirectionalLight>

{#if dirLight}
    <T.CameraHelper args={[dirLight.shadow.camera]} />
{/if}

<T.Mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
    <T.PlaneGeometry args={[50, 50]} />
    <T.MeshStandardMaterial color="#9ca3af" />
</T.Mesh>

<T.Mesh castShadow receiveShadow position={[0, 0.5, 0]}>
    <T.BoxGeometry args={[1, 1, 1]} />
    <T.MeshStandardMaterial color="#fe0032" />
</T.Mesh>

<T.Mesh castShadow receiveShadow position={[7, 0.5, 0]}>
    <T.BoxGeometry args={[1, 1, 1]} />
    <T.MeshStandardMaterial color="#0cbe42" />
</T.Mesh>

Now go build something incredible! 🚀 Note: If you face issue related to shadow map glitch or stripes, try adjusting the shadow.bias (try -0.0001)!

Related posts