@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
<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.
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:
<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:
<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:
<CSM enabled={settings.shadows}>
<!-- Your scene goes here -->
{#snippet fallback()}
<!-- Will be mounted if settings.shadows is false -->
<T.DirectionalLight castShadow={false} />
{/snippet}
</CSM>