Cascaded Shadows

how a directional light’s shadow map is split over distance.

▶ Run in browser

Tags: 3d

A DirectionalLight3D with shadows=True casts shadows across the whole view. One shadow map stretched from the camera to the horizon would spend as many texels on a pillar far away as on the cube at your feet, so the engine splits the view into cascades: bands of depth, each with a shadow map of its own, the nearest covering the least ground and so keeping the most detail.

Both knobs live on the scene’s WorldEnvironment and take effect on the next frame:

env = WorldEnvironment(shadow_cascade_count=3, shadow_debug_cascades=True)
  • shadow_cascade_count (1 to 3) is how many bands the view is split into. With one, a single map covers everything and near shadows go coarse; with three, the near band keeps its detail.

  • shadow_debug_cascades tints every lit surface by the cascade it sampled: red for the near band, green for the middle, blue for the far one. It is a desktop diagnostic; the browser renderer ignores it.

Cubes, spheres and pillars are spread from the camera’s feet to the far edge of the ground so every band has something in it. The OrbitCamera3D turns slowly on its own, sweeping the bands across the scene, and dragging orbits it by hand. The buttons are Button(action=name): each presses its action’s key, so one @on_input handler serves the key and the button alike.

Controls: D / Debug button - Toggle the cascade tint 1, 2, 3 / buttons - Use one, two or three cascades Drag / scroll - Orbit and zoom the camera

Source

  1"""Cascaded Shadows: how a directional light's shadow map is split over distance.
  2
  3A ``DirectionalLight3D`` with ``shadows=True`` casts shadows across the whole
  4view. One shadow map stretched from the camera to the horizon would spend as
  5many texels on a pillar far away as on the cube at your feet, so the engine
  6splits the view into *cascades*: bands of depth, each with a shadow map of its
  7own, the nearest covering the least ground and so keeping the most detail.
  8
  9Both knobs live on the scene's ``WorldEnvironment`` and take effect on the
 10next frame:
 11
 12    env = WorldEnvironment(shadow_cascade_count=3, shadow_debug_cascades=True)
 13
 14- ``shadow_cascade_count`` (1 to 3) is how many bands the view is split into.
 15  With one, a single map covers everything and near shadows go coarse; with
 16  three, the near band keeps its detail.
 17- ``shadow_debug_cascades`` tints every lit surface by the cascade it sampled:
 18  red for the near band, green for the middle, blue for the far one. It is a
 19  desktop diagnostic; the browser renderer ignores it.
 20
 21Cubes, spheres and pillars are spread from the camera's feet to the far edge
 22of the ground so every band has something in it. The ``OrbitCamera3D`` turns
 23slowly on its own, sweeping the bands across the scene, and dragging orbits it
 24by hand. The buttons are ``Button(action=name)``: each presses its action's
 25key, so one ``@on_input`` handler serves the key and the button alike.
 26
 27Controls:
 28    D / Debug button   - Toggle the cascade tint
 29    1, 2, 3 / buttons  - Use one, two or three cascades
 30    Drag / scroll      - Orbit and zoom the camera
 31"""
 32
 33import math
 34
 35from simvx.core import (
 36    Anchor,
 37    Button,
 38    CanvasLayer,
 39    DirectionalLight3D,
 40    HBoxContainer,
 41    Key,
 42    Material,
 43    Mesh,
 44    MeshInstance3D,
 45    Node,
 46    OrbitCamera3D,
 47    WorldEnvironment,
 48    on_input,
 49)
 50from simvx.graphics import App
 51
 52CASCADE_ACTIONS = {"cascades_1": 1, "cascades_2": 2, "cascades_3": 3}
 53PALETTE = [(0.9, 0.3, 0.2), (0.2, 0.7, 0.9), (0.9, 0.8, 0.2), (0.6, 0.3, 0.8), (0.3, 0.9, 0.4), (0.9, 0.5, 0.1)]
 54AUTO_ORBIT_SPEED = 0.15  # radians per second
 55
 56
 57class CascadedShadows(Node):
 58    input_context = {
 59        "debug": [Key.D],
 60        "cascades_1": [Key.KEY_1],
 61        "cascades_2": [Key.KEY_2],
 62        "cascades_3": [Key.KEY_3],
 63    }
 64
 65    def on_ready(self):
 66        self.env = self.add_child(WorldEnvironment())
 67        self.camera = self.add_child(
 68            OrbitCamera3D(
 69                distance=27.7,
 70                yaw=0,
 71                pitch=math.radians(-25.6),
 72                min_pitch=math.radians(-80),
 73                max_pitch=math.radians(-5),  # stays above the ground, so the shadows are always seen from over them
 74                min_distance=10,
 75                max_distance=45,
 76                fov=60,
 77            )
 78        )
 79        self.add_child(
 80            DirectionalLight3D(colour=(1.0, 0.95, 0.85), intensity=1.2, direction=(8, -10, -12), shadows=True)
 81        )
 82
 83        ground = Material(colour=(0.35, 0.45, 0.35), roughness=0.9)
 84        self.add_child(MeshInstance3D(mesh=Mesh.box((30, 0.2, 30)), material=ground, position=(0, -0.5, 0)))
 85
 86        # Near cubes, a mid row of spheres, far blocks, and tall pillars that cast long shadows.
 87        cube, ball, block = Mesh.box(), Mesh.sphere(), Mesh.box((1.5, 3.0, 1.5))
 88        shapes = [(cube, (i * 2 - 3, 1, 2)) for i in range(4)]
 89        shapes += [(ball, (i * 3 - 6, 1.2, -5)) for i in range(5)]
 90        shapes += [(block, (i * 4 - 4, 1.5, -15)) for i in range(3)]
 91        for i, (mesh, position) in enumerate(shapes):
 92            material = Material(colour=PALETTE[i % len(PALETTE)], roughness=0.4, metallic=0.2)
 93            self.add_child(MeshInstance3D(mesh=mesh, material=material, position=position))
 94        pillar = Material(colour=(0.7, 0.7, 0.75), roughness=0.6, metallic=0.3)
 95        for x in (-8, 0, 8):
 96            self.add_child(MeshInstance3D(mesh=Mesh.box((0.5, 6.0, 0.5)), material=pillar, position=(x, 3, 0)))
 97
 98        hud = self.add_child(CanvasLayer())
 99        bar = hud.add_child(HBoxContainer(anchor=Anchor.TOP_LEFT, inset=12))
100        self.debug_button = bar.add_child(Button("Debug", action="debug"))
101        self.cascade_buttons = {
102            count: bar.add_child(Button(f"{count} cascade{'s' if count > 1 else ''}", action=name))
103            for name, count in CASCADE_ACTIONS.items()
104        }
105        self.show_state()
106
107    @on_input("debug")
108    def toggle_debug(self, _event):
109        self.env.shadow_debug_cascades = not self.env.shadow_debug_cascades
110        self.show_state()
111
112    @on_input(tuple(CASCADE_ACTIONS))
113    def set_cascades(self, event):
114        self.env.shadow_cascade_count = CASCADE_ACTIONS[event.action]
115        self.show_state()
116
117    def show_state(self):
118        self.debug_button.checked = self.env.shadow_debug_cascades
119        for count, button in self.cascade_buttons.items():
120            button.checked = count == self.env.shadow_cascade_count
121
122    def on_update(self, dt):
123        self.camera.orbit(AUTO_ORBIT_SPEED * dt, 0)
124
125
126if __name__ == "__main__":
127    App(title="Cascaded Shadows", width=1280, height=720).run(CascadedShadows())