Cascaded Shadows¶
how a directional light’s shadow map is split over distance.
▶ Run in browserTags: 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_cascadestints 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())