Shadow quality demo

cascade debug visualization and texel snapping.

▶ Run in browser

Tags: 3d

Demonstrates:

  • Cascaded shadow maps with texel snapping (prevents edge swimming)

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

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

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 demo -- cascade debug visualization and texel snapping.
  2
  3Demonstrates:
  4  - Cascaded shadow maps with texel snapping (prevents edge swimming)
  5  - Debug cascade colouring: red=near, green=mid, blue=far
  6  - Runtime cascade count (1, 2, or 3) via WorldEnvironment
  7
  8Controls:
  9    D or click/tap left half    - Toggle cascade debug colour overlay
 10    1 / 2 / 3 or click/tap right half - Set/cycle active cascade count
 11    Escape  - Quit
 12
 13Run: uv run python examples/features/3d/shadows.py
 14"""
 15
 16
 17import math
 18
 19from simvx.core import (
 20    Camera3D,
 21    DirectionalLight3D,
 22    Input,
 23    InputMap,
 24    Key,
 25    Material,
 26    Mesh,
 27    MeshInstance3D,
 28    MouseButton,
 29    Node3D,
 30    Text2D,
 31    WorldEnvironment,
 32)
 33from simvx.graphics import App
 34
 35
 36class ShadowQualityScene(Node3D):
 37    def on_ready(self):
 38        InputMap.add_action("toggle_debug", [Key.D])
 39        InputMap.add_action("cascades_1", [Key.KEY_1])
 40        InputMap.add_action("cascades_2", [Key.KEY_2])
 41        InputMap.add_action("cascades_3", [Key.KEY_3])
 42        InputMap.add_action("quit", [Key.ESCAPE])
 43
 44        # WorldEnvironment: cascade debug toggle + runtime count both flow
 45        # through EnvironmentSync into the shared shadow SSBO
 46        # (``debug_cascades_flag`` and ``active_cascade_count``).
 47        self._env = self.add_child(WorldEnvironment(name="Env"))
 48        self._env.shadow_debug_cascades = False
 49
 50        # Camera
 51        self._cam = self.add_child(Camera3D(
 52            position=(0, -25, 12), fov=60, look_at=(0, 0, 0), up=(0, 0, 1),
 53        ))
 54
 55        # Sun (directional light producing shadows)
 56        sun = DirectionalLight3D(position=(-8, -12, 10))
 57        sun.colour = (1.0, 0.95, 0.85)
 58        sun.intensity = 1.2
 59        sun.shadows = True  # directional shadows are opt-in
 60        sun.look_at((0, 0, 0))
 61        self.add_child(sun)
 62
 63        # Ground plane
 64        ground_mat = Material(colour=(0.35, 0.45, 0.35, 1), roughness=0.9, metallic=0.0)
 65        ground = MeshInstance3D(mesh=Mesh.cube(), material=ground_mat, position=(0, 0, -0.5))
 66        ground.scale = (30, 30, 0.2)
 67        self.add_child(ground)
 68
 69        # Scatter objects at various distances to show cascade splits
 70        cube_mesh = Mesh.cube()
 71        sphere_mesh = Mesh.sphere()
 72        colours = [
 73            (0.9, 0.3, 0.2, 1),
 74            (0.2, 0.7, 0.9, 1),
 75            (0.9, 0.8, 0.2, 1),
 76            (0.6, 0.3, 0.8, 1),
 77            (0.3, 0.9, 0.4, 1),
 78            (0.9, 0.5, 0.1, 1),
 79        ]
 80        # Near objects
 81        for i in range(4):
 82            mat = Material(colour=colours[i % len(colours)], roughness=0.4, metallic=0.2)
 83            self.add_child(MeshInstance3D(mesh=cube_mesh, material=mat, position=(i * 2 - 3, -2, 1)))
 84
 85        # Mid-range objects
 86        for i in range(5):
 87            mat = Material(colour=colours[(i + 2) % len(colours)], roughness=0.3, metallic=0.5)
 88            self.add_child(MeshInstance3D(mesh=sphere_mesh, material=mat, position=(i * 3 - 6, 5, 1.2)))
 89
 90        # Far objects
 91        for i in range(3):
 92            mat = Material(colour=colours[(i + 4) % len(colours)], roughness=0.5, metallic=0.1)
 93            obj = MeshInstance3D(mesh=cube_mesh, material=mat, position=(i * 4 - 4, 15, 1.5))
 94            obj.scale = (1.5, 1.5, 3.0)
 95            self.add_child(obj)
 96
 97        # Tall pillars casting long shadows
 98        pillar_mat = Material(colour=(0.7, 0.7, 0.75, 1), roughness=0.6, metallic=0.3)
 99        for x in (-8, 0, 8):
100            pillar = MeshInstance3D(mesh=cube_mesh, material=pillar_mat, position=(x, 0, 3))
101            pillar.scale = (0.5, 0.5, 6.0)
102            self.add_child(pillar)
103
104        # HUD
105        self._hud = self.add_child(
106            Text2D(text="", position=(10, 10), font_scale=1.5)
107        )
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), -r * math.cos(angle), 12)
145        self._cam.look_at((0, 0, 0), up=(0, 0, 1))
146
147
148if __name__ == "__main__":
149    app = App(title="Shadow Quality Demo", width=1280, height=720)
150    app.run(ShadowQualityScene())