threlte logo

ShadowMesh

ShadowMesh is a threejs addon that is used as very performant alternative to shadow mapping. It uses the renderer’s stencil buffer and a locally computed matrix.

<script lang="ts">
  import Scene from './Scene.svelte'
  import { Canvas } from '@threlte/core'
  import { Pane, Slider } from 'svelte-tweakpane-ui'
  import { WebGLRenderer } from 'three'

  let w = $state(0.01)
</script>

<Canvas
  createRenderer={(canvas) => {
    return new WebGLRenderer({ antialias: true, canvas, stencil: true })
  }}
>
  <Scene {w} />
</Canvas>

<Pane
  title="shadow mesh"
  position="fixed"
>
  <Slider
    bind:value={w}
    label="light position.w"
    min={0.1}
    max={0.9}
  />
</Pane>
<script lang="ts">
  import {
    DirectionalLight,
    Mesh,
    MeshNormalMaterial,
    PerspectiveCamera,
    Plane,
    TorusKnotGeometry,
    Vector3,
    Vector4
  } from 'three'
  import { ShadowMesh } from 'three/examples/jsm/objects/ShadowMesh.js'
  import { T, useTask } from '@threlte/core'
  import { OrbitControls } from '@threlte/extras'

  const planeY = 0
  const planeOffset = 0.01
  const planeConstant = planeY + planeOffset
  const yHat = new Vector3(0, 1, 0)
  const plane = new Plane(yHat, planeConstant)

  let { w = 0.01 } = $props()

  const mesh = new Mesh(new TorusKnotGeometry(), new MeshNormalMaterial())
  mesh.translateY(2)

  const translationAxis = new Vector3()

  // only used to create a DirectionalLightHelper
  // notice that it's not added to the scene at the bottom but the shadow is still visible
  const light = new DirectionalLight()
  light.translateOnAxis(translationAxis.set(1, 1, -1).normalize(), 5)
  const lightPosition4D = new Vector4(...light.position)

  $effect(() => {
    lightPosition4D.w = w
  })

  const shadowMesh = new ShadowMesh(mesh)

  const floor = new Mesh()
  const floorSize = 15
  floor.lookAt(plane.normal)

  const camera = new PerspectiveCamera()
  camera.translateOnAxis(translationAxis.set(1, 1, 1).normalize(), 20)
  camera.lookAt(floor.position)

  useTask((dt) => {
    mesh.rotateY(dt)
    shadowMesh.update(plane, lightPosition4D)
  })
</script>

<T
  is={camera}
  makeDefault
>
  <OrbitControls
    enableDamping
    maxPolarAngle={(2 / 5) * Math.PI}
  />
</T>

<T is={floor}>
  <T.PlaneGeometry args={[floorSize, floorSize]} />
  <T.MeshBasicMaterial color="#ccccaa" />
</T>

<T is={mesh} />

<T is={shadowMesh} />

<T
  is={light}
  attach={false}
  target={mesh}
/>

<T.DirectionalLightHelper args={[light]} />

Limitations

ShadowMesh has a few limitations, notably

  1. shadows must be cast onto a flat plane
  2. it does not support soft shadows
  3. you must use the renderer’s stencil buffer

Updating Mesh Geometry

If you update the shadow-casting mesh to a new instance, you must create an entirely new ShadowMesh instance or assign the shadow mesh’s geometry to the new geometry.

// mesh.geometry = someNewGeometry
shadowMesh.geometry = mesh.geometry

This is because the geometry of the shadow mesh is pulled from the mesh passed into its constructor at instantiation and does not update if the reference mesh’s geometry is updated.

Enabling the Stencil Buffer

When using the ShadowMesh, you must enable the stencil buffer of the renderer. This can be done with the createRenderer prop of the <Canvas/> component

<Canvas
  createRenderer={(canvas) => {
    return new WebGLRenderer({
      antialias: true,
      canvas,
      stencil: true
    })
  }}
>
  <Scene />
</Canvas>

Light Source

Because light sources are assumed to be pointed towards the plane, creating a light instance is unnecessary (unless you’re using materials that require lighting). Instead, all you need is the position of the light source.

const lightPosition4D = new Vector4(5, 5, 5, 0.1)

// later

shadowMesh.update(plane, lightPosition4D)

W Component

The w component of the light position vector controls the spread of the shadow. Values closer to 0 are suited for directional lights whereas values closer to 1 are for point lights.

Offsetting and Orienting the Plane

The plane should be offset from the ground or floor by a small amount. This is done to avoid z-fighting. An easy way to do this is to create the plane first and then orient the floor mesh to look in the direction of the plane’s normal.

const yHat = new Vector3(0, 1, 0)

// make a plane that faces straight up with a slight offset
const plane = new Plane(yHat, 0.01)
const planeGeometry = new PlaneGeometry()

const mesh = new Mesh(planeGeometry)

// orient the mesh to face in the plane's normal direction
mesh.lookAt(plane.normal)

Unfortunately, there’s not an easy way to get the normal of a plane geometry and involves accessing the geometry’s buffer attributes.