Shadow Quality

cascaded shadow maps and the cascade debug view.

▶ Run in browser

Tags: 3d

Demonstrates:

  • Directional shadows across a scene scattered from near to far

  • Debug cascade colouring: red=near, green=mid, blue=far

  • Runtime cascade count (1, 2, or 3) via WorldEnvironment

The camera orbits slowly so shadows sweep and the cascade splits move through the scene. With one cascade the whole view shares a single shadow map (coarse far shadows); with three, the near band keeps its detail.

Controls: D or click/tap left half - Toggle cascade debug colour overlay 1 / 2 / 3 or click/tap right half - Set/cycle active cascade count Escape - Quit

Run: uv run python examples/features/3d/shadows.py

Source

  1"""Shadow Quality: cascaded shadow maps and the cascade debug view.
  2
  3Demonstrates:
  4  - Directional shadows across a scene scattered from near to far
  5  - Debug cascade colouring: red=near, green=mid, blue=far
  6  - Runtime cascade count (1, 2, or 3) via WorldEnvironment
  7
  8The camera orbits slowly so shadows sweep and the cascade splits move through
  9the scene. With one cascade the whole view shares a single shadow map (coarse
 10far shadows); with three, the near band keeps its detail.
 11
 12Controls:
 13    D or click/tap left half          - Toggle cascade debug colour overlay
 14    1 / 2 / 3 or click/tap right half - Set/cycle active cascade count
 15    Escape                            - Quit
 16
 17Run: uv run python examples/features/3d/shadows.py
 18"""
 19
 20import math
 21
 22from simvx.core import (
 23    Camera3D,
 24    DirectionalLight3D,
 25    Input,
 26    InputMap,
 27    Key,
 28    Material,
 29    Mesh,
 30    MeshInstance3D,
 31    MouseButton,
 32    Node3D,
 33    Text2D,
 34    WorldEnvironment,
 35)
 36from simvx.graphics import App
 37
 38
 39class ShadowQualityScene(Node3D):
 40    def on_ready(self):
 41        InputMap.add_action("toggle_debug", [Key.D])
 42        InputMap.add_action("cascades_1", [Key.KEY_1])
 43        InputMap.add_action("cascades_2", [Key.KEY_2])
 44        InputMap.add_action("cascades_3", [Key.KEY_3])
 45        InputMap.add_action("quit", [Key.ESCAPE])
 46
 47        # Two WorldEnvironment properties drive this page: shadow_debug_cascades
 48        # tints every shaded fragment by the cascade that lit it, and
 49        # shadow_cascade_count sets how many splits the directional light
 50        # renders. Both take effect on the next frame.
 51        self._env = self.add_child(WorldEnvironment(name="Env"))
 52        self._env.shadow_debug_cascades = False
 53
 54        # Camera
 55        self._cam = self.add_child(Camera3D(position=(0, 12, 25), fov=60, look_at=(0, 0, 0)))
 56
 57        # Sun (directional light producing shadows)
 58        sun = DirectionalLight3D(position=(-8, 10, 12))
 59        sun.colour = (1.0, 0.95, 0.85)
 60        sun.intensity = 1.2
 61        sun.shadows = True  # directional shadows are opt-in
 62        sun.look_at((0, 0, 0))
 63        self.add_child(sun)
 64
 65        # Ground plane
 66        ground_mat = Material(colour=(0.35, 0.45, 0.35, 1), roughness=0.9, metallic=0.0)
 67        ground = MeshInstance3D(mesh=Mesh.cube(), material=ground_mat, position=(0, -0.5, 0))
 68        ground.scale = (30, 0.2, 30)
 69        self.add_child(ground)
 70
 71        # Scatter objects at various distances to show cascade splits
 72        cube_mesh = Mesh.cube()
 73        sphere_mesh = Mesh.sphere()
 74        colours = [
 75            (0.9, 0.3, 0.2, 1),
 76            (0.2, 0.7, 0.9, 1),
 77            (0.9, 0.8, 0.2, 1),
 78            (0.6, 0.3, 0.8, 1),
 79            (0.3, 0.9, 0.4, 1),
 80            (0.9, 0.5, 0.1, 1),
 81        ]
 82        # Near objects
 83        for i in range(4):
 84            mat = Material(colour=colours[i % len(colours)], roughness=0.4, metallic=0.2)
 85            self.add_child(MeshInstance3D(mesh=cube_mesh, material=mat, position=(i * 2 - 3, 1, 2)))
 86
 87        # Mid-range objects
 88        for i in range(5):
 89            mat = Material(colour=colours[(i + 2) % len(colours)], roughness=0.3, metallic=0.5)
 90            self.add_child(MeshInstance3D(mesh=sphere_mesh, material=mat, position=(i * 3 - 6, 1.2, -5)))
 91
 92        # Far objects
 93        for i in range(3):
 94            mat = Material(colour=colours[(i + 4) % len(colours)], roughness=0.5, metallic=0.1)
 95            obj = MeshInstance3D(mesh=cube_mesh, material=mat, position=(i * 4 - 4, 1.5, -15))
 96            obj.scale = (1.5, 3.0, 1.5)
 97            self.add_child(obj)
 98
 99        # Tall pillars casting long shadows
100        pillar_mat = Material(colour=(0.7, 0.7, 0.75, 1), roughness=0.6, metallic=0.3)
101        for x in (-8, 0, 8):
102            pillar = MeshInstance3D(mesh=cube_mesh, material=pillar_mat, position=(x, 3, 0))
103            pillar.scale = (0.5, 6.0, 0.5)
104            self.add_child(pillar)
105
106        # HUD
107        self._hud = self.add_child(Text2D(text="", position=(10, 10), font_scale=1.5))
108
109        self._time = 0.0
110
111    def on_update(self, dt):
112        self._time += dt
113
114        if Input.is_action_just_pressed("quit"):
115            self.app.quit()
116            return
117
118        # Mouse/touch: left half toggles the debug overlay, right half cycles
119        # the active cascade count (touch arrives as MouseButton.LEFT on web).
120        if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
121            if float(Input.mouse_position.x) < self.app.width * 0.5:
122                self._env.shadow_debug_cascades = not self._env.shadow_debug_cascades
123            else:
124                self._env.shadow_cascade_count = self._env.shadow_cascade_count % 3 + 1
125
126        if Input.is_action_just_pressed("toggle_debug"):
127            self._env.shadow_debug_cascades = not self._env.shadow_debug_cascades
128        if Input.is_action_just_pressed("cascades_1"):
129            self._env.shadow_cascade_count = 1
130        if Input.is_action_just_pressed("cascades_2"):
131            self._env.shadow_cascade_count = 2
132        if Input.is_action_just_pressed("cascades_3"):
133            self._env.shadow_cascade_count = 3
134
135        debug = "ON" if self._env.shadow_debug_cascades else "OFF"
136        self._hud.text = (
137            f"[D / tap left] Debug cascades: {debug}    "
138            f"[1/2/3 / tap right] Active cascades: {self._env.shadow_cascade_count}"
139        )
140
141        # Slowly orbit camera so shadows move and cascade transitions are visible
142        r = 25.0
143        angle = self._time * 0.15
144        self._cam.position = (r * math.sin(angle), 12, r * math.cos(angle))
145        self._cam.look_at((0, 0, 0))
146
147
148if __name__ == "__main__":
149    app = App(title="Shadow Quality Demo", width=1280, height=720)
150    app.run(ShadowQualityScene())