threlte logo
@threlte/extras

<CSM>

webgl <CSM> works with WebGL and is available from the @threlte/extras export. webgpu <CSM> works with WebGPU and is available from the @threlte/extras/webgpu export.

The <CSM/> component offers a streamlined convenience integration of the Cascaded Shadow Maps technique, sourced from the Three.js addon.

<script lang="ts">
  import { Canvas, T } from '@threlte/core'
  import { CSM } from '@threlte/extras'
  import { Checkbox, Folder, List, Pane, Point, Slider } from 'svelte-tweakpane-ui'
  import type { CSM as CSMRef } from 'three/examples/jsm/csm/CSM.js'
  import Helper from './Helper.svelte'
  import Scene from './Scene.svelte'

  let enabled = $state(true)
  let cascades = $state(4)
  let mode = $state<'practical' | 'uniform' | 'logarithmic'>('practical')
  let maxFar = $state(2000)
  let lightMargin = $state(200)
  let fade = $state(false)
  let lightDirection = $state<[number, number, number]>([1, -0.75, 1])
  let shadowMapSize = $state(2048)
  let shadowNormalBias = $state(0)
  let shadowRadius = $state(1)
  let shadowIntensity = $state(1)
  let shadowNear = $state(1)
  let shadowFar = $state(4000)

  let showHelper = $state(false)
  let displayFrustum = $state(true)
  let displayPlanes = $state(true)
  let displayShadowBounds = $state(true)

  let csm = $state.raw<CSMRef>()
</script>

<Pane
  title="CSM"
  position="fixed"
>
  <Checkbox
    bind:value={enabled}
    label="enabled"
  />
  <Slider
    bind:value={cascades}
    label="cascades"
    step={1}
    min={1}
    max={4}
  />
  <List
    bind:value={mode}
    label="mode"
    options={{ practical: 'practical', uniform: 'uniform', logarithmic: 'logarithmic' }}
  />
  <Slider
    bind:value={maxFar}
    label="maxFar"
    step={1}
    min={1}
    max={5000}
  />
  <Slider
    bind:value={lightMargin}
    label="lightMargin"
    step={1}
    min={0}
    max={200}
  />
  <Checkbox
    bind:value={fade}
    label="fade"
  />
  <Point
    bind:value={lightDirection}
    label="lightDirection"
    step={0.05}
    min={-1}
    max={1}
  />
  <List
    bind:value={shadowMapSize}
    label="shadowMapSize"
    options={{ 512: 512, 1024: 1024, 2048: 2048 }}
  />
  <Slider
    bind:value={shadowNormalBias}
    label="shadowNormalBias"
    step={0.01}
    min={0}
    max={2}
  />
  <Slider
    bind:value={shadowRadius}
    label="shadowRadius"
    step={0.1}
    min={0}
    max={10}
  />
  <Slider
    bind:value={shadowIntensity}
    label="shadowIntensity"
    step={0.05}
    min={0}
    max={1}
  />
  <Slider
    bind:value={shadowNear}
    label="shadowNear"
    step={1}
    min={1}
    max={1000}
  />
  <Slider
    bind:value={shadowFar}
    label="shadowFar"
    step={100}
    min={100}
    max={20000}
  />
  <Folder title="CSMHelper">
    <Checkbox
      bind:value={showHelper}
      label="show helper"
    />
    <Checkbox
      bind:value={displayFrustum}
      label="frustum"
    />
    <Checkbox
      bind:value={displayPlanes}
      label="planes"
    />
    <Checkbox
      bind:value={displayShadowBounds}
      label="shadow bounds"
    />
  </Folder>
</Pane>

<div>
  <Canvas>
    <CSM
      bind:csm
      {enabled}
      {cascades}
      {mode}
      {maxFar}
      {lightMargin}
      {fade}
      {lightDirection}
      {shadowMapSize}
      {shadowNormalBias}
      {shadowRadius}
      {shadowIntensity}
      {shadowNear}
      {shadowFar}
    >
      <Scene />

      {#if showHelper && csm !== undefined}
        <Helper
          {csm}
          {displayFrustum}
          {displayPlanes}
          {displayShadowBounds}
        />
      {/if}

      {#snippet fallback()}
        <T.DirectionalLight castShadow={false} />
      {/snippet}
    </CSM>
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { T, useTask } from '@threlte/core'
  import type { CSM } from 'three/examples/jsm/csm/CSM.js'
  import { CSMHelper } from 'three/examples/jsm/csm/CSMHelper.js'

  interface Props {
    csm: CSM
    displayFrustum: boolean
    displayPlanes: boolean
    displayShadowBounds: boolean
  }

  let { csm, displayFrustum, displayPlanes, displayShadowBounds }: Props = $props()

  const helper = $derived(new CSMHelper(csm))

  $effect(() => {
    helper.displayFrustum = displayFrustum
    helper.displayPlanes = displayPlanes
    helper.displayShadowBounds = displayShadowBounds
    helper.updateVisibility()
  })

  useTask(() => helper.update(), { autoInvalidate: false })
</script>

<T is={helper} />
<script lang="ts">
  import { T } from '@threlte/core'
  import { Float, OrbitControls } from '@threlte/extras'
  import { MathUtils } from 'three'

  const threlteOrange = '#ff3e00'
  const cream = '#fff3e0'

  const monoliths = Array.from({ length: 56 }, (_, index) => {
    const random = MathUtils.seededRandom(index)
    const depth = -index * 26
    const height = 14 + Math.sin(index * 0.45) * 10 + random * 18

    return {
      height,
      width: 5 + random * 4,
      depth,
      lean: (random - 0.5) * 0.12,
      color: index % 3 === 0 ? threlteOrange : cream
    }
  })

  const orbs = Array.from({ length: 6 }, (_, index) => ({
    position: [-index * 210 - 70, 30 + (index % 3) * 9, (index % 2 === 0 ? 1 : -1) * 12] as [
      number,
      number,
      number
    ],
    radius: 7 + (index % 3) * 2
  }))
</script>

<T.PerspectiveCamera
  makeDefault
  position={[120, 45, 0]}
  fov={70}
  near={0.5}
  far={5000}
>
  <OrbitControls
    maxPolarAngle={Math.PI / 2.05}
    target.y={38}
  />
</T.PerspectiveCamera>

<T.Color
  args={['#0e0e12']}
  attach="background"
/>

<T.AmbientLight intensity={1.2} />

<T.Mesh
  rotation.x={MathUtils.DEG2RAD * -90}
  receiveShadow
>
  <T.PlaneGeometry args={[10_000, 10_000]} />
  <T.MeshStandardMaterial color="#16161a" />
</T.Mesh>

{#each monoliths as monolith}
  {#each [1, -1] as side}
    <T.Mesh
      castShadow
      receiveShadow
      position={[monolith.depth, monolith.height / 2, side * 30]}
      rotation.z={monolith.lean * side}
    >
      <T.BoxGeometry args={[monolith.width, monolith.height, monolith.width]} />
      <T.MeshStandardMaterial
        color={monolith.color}
        roughness={0.6}
      />
    </T.Mesh>
  {/each}
{/each}

{#each orbs as orb}
  <Float
    floatingRange={[-10, 10]}
    rotationIntensity={0.6}
  >
    <T.Mesh
      castShadow
      position={orb.position}
    >
      <T.IcosahedronGeometry args={[orb.radius, 0]} />
      <T.MeshStandardMaterial
        color={threlteOrange}
        flatShading
        roughness={0.35}
      />
    </T.Mesh>
  </Float>
{/each}
<script lang="ts">
  import { Canvas, T } from '@threlte/core/webgpu'
  import { CSM } from '@threlte/extras/webgpu'
  import { Checkbox, Folder, List, Pane, Point, Slider } from 'svelte-tweakpane-ui'
  import type { CSMShadowNode } from 'three/examples/jsm/csm/CSMShadowNode.js'
  import Helper from './Helper.svelte'
  import Scene from './Scene.svelte'

  let enabled = $state(true)
  let cascades = $state(4)
  let mode = $state<'practical' | 'uniform' | 'logarithmic'>('practical')
  let maxFar = $state(2000)
  let lightMargin = $state(200)
  let fade = $state(false)
  let lightDirection = $state<[number, number, number]>([1, -0.75, 1])
  let shadowMapSize = $state(2048)
  let shadowNormalBias = $state(0)
  let shadowRadius = $state(1)
  let shadowIntensity = $state(1)
  let shadowNear = $state(1)
  let shadowFar = $state(4000)

  let showHelper = $state(false)
  let displayFrustum = $state(true)
  let displayPlanes = $state(true)
  let displayShadowBounds = $state(true)

  let csm = $state.raw<CSMShadowNode>()
</script>

<Pane
  title="CSM"
  position="fixed"
>
  <Checkbox
    bind:value={enabled}
    label="enabled"
  />
  <Slider
    bind:value={cascades}
    label="cascades"
    step={1}
    min={1}
    max={4}
  />
  <List
    bind:value={mode}
    label="mode"
    options={{ practical: 'practical', uniform: 'uniform', logarithmic: 'logarithmic' }}
  />
  <Slider
    bind:value={maxFar}
    label="maxFar"
    step={1}
    min={1}
    max={5000}
  />
  <Slider
    bind:value={lightMargin}
    label="lightMargin"
    step={1}
    min={0}
    max={200}
  />
  <Checkbox
    bind:value={fade}
    label="fade"
  />
  <Point
    bind:value={lightDirection}
    label="lightDirection"
    step={0.05}
    min={-1}
    max={1}
  />
  <List
    bind:value={shadowMapSize}
    label="shadowMapSize"
    options={{ 512: 512, 1024: 1024, 2048: 2048 }}
  />
  <Slider
    bind:value={shadowNormalBias}
    label="shadowNormalBias"
    step={0.01}
    min={0}
    max={2}
  />
  <Slider
    bind:value={shadowRadius}
    label="shadowRadius"
    step={0.1}
    min={0}
    max={10}
  />
  <Slider
    bind:value={shadowIntensity}
    label="shadowIntensity"
    step={0.05}
    min={0}
    max={1}
  />
  <Slider
    bind:value={shadowNear}
    label="shadowNear"
    step={1}
    min={1}
    max={1000}
  />
  <Slider
    bind:value={shadowFar}
    label="shadowFar"
    step={100}
    min={100}
    max={20000}
  />
  <Folder title="CSMHelper">
    <Checkbox
      bind:value={showHelper}
      label="show helper"
    />
    <Checkbox
      bind:value={displayFrustum}
      label="frustum"
    />
    <Checkbox
      bind:value={displayPlanes}
      label="planes"
    />
    <Checkbox
      bind:value={displayShadowBounds}
      label="shadow bounds"
    />
  </Folder>
</Pane>

<div>
  <Canvas>
    <CSM
      bind:csm
      {enabled}
      {cascades}
      {mode}
      {maxFar}
      {lightMargin}
      {fade}
      {lightDirection}
      {shadowMapSize}
      {shadowNormalBias}
      {shadowRadius}
      {shadowIntensity}
      {shadowNear}
      {shadowFar}
    >
      <Scene />

      {#if showHelper && csm !== undefined}
        <Helper
          {csm}
          {displayFrustum}
          {displayPlanes}
          {displayShadowBounds}
        />
      {/if}

      {#snippet fallback()}
        <T.DirectionalLight castShadow={false} />
      {/snippet}
    </CSM>
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { T, useTask } from '@threlte/core/webgpu'
  import { CSMHelper } from 'three/examples/jsm/csm/CSMHelper.js'
  import type { CSMShadowNode } from 'three/examples/jsm/csm/CSMShadowNode.js'

  interface Props {
    csm: CSMShadowNode
    displayFrustum: boolean
    displayPlanes: boolean
    displayShadowBounds: boolean
  }

  let { csm, displayFrustum, displayPlanes, displayShadowBounds }: Props = $props()

  const helper = $derived(new CSMHelper(csm))

  $effect(() => {
    helper.displayFrustum = displayFrustum
    helper.displayPlanes = displayPlanes
    helper.displayShadowBounds = displayShadowBounds
    helper.updateVisibility()
  })

  useTask(() => helper.update(), { autoInvalidate: false })
</script>

<T is={helper} />
<script lang="ts">
  import { T } from '@threlte/core/webgpu'
  import { Float, OrbitControls } from '@threlte/extras/webgpu'
  import { MathUtils } from 'three/webgpu'

  const threlteOrange = '#ff3e00'
  const cream = '#fff3e0'

  const monoliths = Array.from({ length: 56 }, (_, index) => {
    const random = MathUtils.seededRandom(index)
    const depth = -index * 26
    const height = 14 + Math.sin(index * 0.45) * 10 + random * 18

    return {
      height,
      width: 5 + random * 4,
      depth,
      lean: (random - 0.5) * 0.12,
      color: index % 3 === 0 ? threlteOrange : cream
    }
  })

  const orbs = Array.from({ length: 6 }, (_, index) => ({
    position: [-index * 210 - 70, 30 + (index % 3) * 9, (index % 2 === 0 ? 1 : -1) * 12] as [
      number,
      number,
      number
    ],
    radius: 7 + (index % 3) * 2
  }))
</script>

<T.PerspectiveCamera
  makeDefault
  position={[120, 45, 0]}
  fov={70}
  near={0.5}
  far={5000}
>
  <OrbitControls
    maxPolarAngle={Math.PI / 2.05}
    target.y={38}
  />
</T.PerspectiveCamera>

<T.Color
  args={['#0e0e12']}
  attach="background"
/>

<T.AmbientLight intensity={1.2} />

<T.Mesh
  rotation.x={MathUtils.DEG2RAD * -90}
  receiveShadow
>
  <T.PlaneGeometry args={[10_000, 10_000]} />
  <T.MeshStandardMaterial color="#16161a" />
</T.Mesh>

{#each monoliths as monolith}
  {#each [1, -1] as side}
    <T.Mesh
      castShadow
      receiveShadow
      position={[monolith.depth, monolith.height / 2, side * 30]}
      rotation.z={monolith.lean * side}
    >
      <T.BoxGeometry args={[monolith.width, monolith.height, monolith.width]} />
      <T.MeshStandardMaterial
        color={monolith.color}
        roughness={0.6}
      />
    </T.Mesh>
  {/each}
{/each}

{#each orbs as orb}
  <Float
    floatingRange={[-10, 10]}
    rotationIntensity={0.6}
  >
    <T.Mesh
      castShadow
      position={orb.position}
    >
      <T.IcosahedronGeometry args={[orb.radius, 0]} />
      <T.MeshStandardMaterial
        color={threlteOrange}
        flatShading
        roughness={0.35}
      />
    </T.Mesh>
  </Float>
{/each}

Cascaded Shadow Maps (CSM) are a technique employed to render shadows with varying levels of detail based on their distance from the camera. CSMs utilize multiple shadow maps to produce higher resolution shadows closer to the camera and gradually decrease this resolution for shadows farther away. This method is especially beneficial when rendering sun-cast shadows over vast terrains, ensuring detailed and performance-optimized shadow renderings.

Usage

Scene.svelte
<script lang="ts">
  import { CSM } from '@threlte/extras'
</script>

<CSM
  cascades={4}
  maxFar={1000}
  lightDirection={[1, -1, 1]}
>
  <!-- Your scene goes here -->
</CSM>

For CSM to work, shadow maps must be enabled. Threlte does this by default. Do not disable shadowmaps by <Canvas shadows={false}>.

Every prop is reactive. Changing cascades recreates the underlying instance, as is changing fade on WebGPU, since both are baked into the generated shader; the rest are applied in place.

With fade enabled, shadows subtly diminish close to maxFar distance, presenting a more aesthetic transition compared to an abrupt cut-off. It may be more visually pleasing, but it’s important to consider potential performance implications. While typically minimal, the computational complexity can escalate based on the number of cascades and lights present in the scene.

Renderer differences

Three.js ships a separate CSM implementation per renderer — CSM for WebGL and CSMShadowNode for WebGPU — and @threlte/extras presents both through the signature above, so switching renderers does not mean rewriting your markup. One difference is worth knowing:

WebGL patches the shader code of every material inside the component, and support is limited to THREE.MeshStandardMaterial and THREE.MeshPhongMaterial. WebGPU drives a single THREE.DirectionalLight through a shadow node instead, so every material that receives shadows works.

Wrapping your scene in <CSM> is only required on WebGL, where the component collects the materials to patch. The children snippet exists on both so the same markup works either way.

Setting castShadow property on any lights in a scene with <CSM> enabled leads to crashes.

Custom splits

Pass a function as mode to compute the cascade splits yourself. It receives the cascade count and the near and far plane of the camera, and returns one split per cascade as a fraction of the frustum depth, the last of which should be 1:

Scene.svelte
<CSM
  cascades={3}
  mode={() => [0.05, 0.3, 1]}
>
  <!-- Your scene goes here -->
</CSM>

Visualizing the cascades

Bind csm to get at the underlying Three.js instance, for instance to drive a CSMHelper:

Scene.svelte
<script lang="ts">
  import { T, useTask } from '@threlte/core'
  import { CSM } from '@threlte/extras'
  import type { CSM as CSMRef } from 'three/examples/jsm/csm/CSM.js'
  import { CSMHelper } from 'three/examples/jsm/csm/CSMHelper.js'

  let csm = $state.raw<CSMRef>()
  const helper = $derived(csm ? new CSMHelper(csm) : undefined)

  useTask(() => helper?.update(), { autoInvalidate: false })
</script>

<CSM bind:csm>
  <!-- Your scene goes here -->
</CSM>

{#if helper}
  <T is={helper} />
{/if}

Providing a fallback

You may want to use a regular <T.DirectionalLight> when CSM is not enabled, for instance if you are developing a game and want to support low-end devices by disabling shadows altogehter. You can do this by using the enabled prop and the fallback snippet:

Scene.svelte
<CSM enabled={settings.shadows}>
  <!-- Your scene goes here -->

  {#snippet fallback()}
    <!-- Will be mounted if settings.shadows is false -->
    <T.DirectionalLight castShadow={false} />
  {/snippet}
</CSM>

Component Signature

Props

name
type
required
default
description

camera
THREE.Camera
no
The camera the cascades are fit to. Defaults to the camera set by makeDefault.

cascades
number
no
3
The number of shadow cascades.

color
THREE.ColorRepresentation
no
The color of the light.

csm
CSM | CSMShadowNode
no
Bindable. The underlying Three.js instance.

enabled
boolean
no
true

fade
boolean
no
false
Fades shadows out towards the far edge of every cascade.

intensity
number
no
3
The intensity of the light.

lightDirection
THREE.Vector3Tuple
no
[1, -1, 1]
The direction the light points in. Does not need to be normalized.

lightMargin
number
no
200
How far the shadow camera of each cascade is moved back along the light direction. Larger scenes require larger values.

maxFar
number
no
100000
Shadows are not rendered farther away from the camera than this. May be smaller than camera.far.

mode
'practical' | 'uniform' | 'logarithmic' | ((cascades: number, near: number, far: number) => number[])
no
'practical'
How the camera frustum is split up into cascades. Pass a function to compute the splits yourself.

shadowBias
number
no
0.000001
The shadow bias of the first cascade. Every following cascade scales it by its index, as a cascade covering more ground needs a larger offset.

shadowBlurSamples
number
no
8
The number of samples used to blur the shadow edges. Only applies when the renderer uses VSMShadowMap.

shadowFar
number
no
2000
The far plane of the shadow camera of every cascade.

shadowIntensity
number
no
1
How dark the shadows are, from 0 to 1.

shadowMapSize
number
no
2048
The resolution of the shadow map of every cascade.

shadowNear
number
no
1
The near plane of the shadow camera of every cascade.

shadowNormalBias
number
no
0
Offsets the shadow lookup along the surface normal. Usually a better way to remove shadow acne than shadowBias, which detaches shadows from their casters as it grows.

shadowRadius
number
no
1
The radius the shadow edges are blurred over.