Shadow caster budget

several point lights casting at once.

▶ Run in browser

Tags: 3d

WorldEnvironment.shadow_caster_count is how many point lights, and how many spot lights, may cast a shadow in the same frame. It defaults to 1 of each: one caster draws every shadow-casting mesh six times, and pays that again whenever its light, the casting geometry or a material they are drawn with changes, so the budget is a quality dial you raise deliberately rather than a limit to max out. A still scene draws each of its shadow maps once and then keeps them.

Four coloured lamps ring the scene, each with its own block to throw a shadow from. Press 1-4 to set the budget and watch shadows appear and disappear: the lamps that fit the budget cast, the rest still light the scene and cast nothing (the renderer logs that once). Which lamps fit is decided by PointLight3D.shadow_priority first and then by how much each lamp is worth to the camera (its brightness and reach against its distance), so orbiting round hands the shadow to the lamp you approach. A lamp already casting keeps its map until another is worth a clear margin more, so the handover lands just past the halfway point between two lamps rather than exactly on it. The dial reallocates the shadow atlases, so expect a hitch on the frame it changes – set it once for a scene, not per frame.

Controls: 1 / 2 / 3 / 4 - Shadow caster budget A / D - Orbit camera W / S - Zoom in / out

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

Source

  1#!/usr/bin/env python3
  2"""Shadow caster budget: several point lights casting at once.
  3
  4``WorldEnvironment.shadow_caster_count`` is how many point lights, and how many
  5spot lights, may cast a shadow in the same frame. It defaults to 1 of each: one
  6caster draws every shadow-casting mesh six times, and pays that again whenever
  7its light, the casting geometry or a material they are drawn with changes, so
  8the budget is a quality dial you raise deliberately rather than a limit to max
  9out. A still scene draws each of its shadow maps once and then keeps them.
 10
 11Four coloured lamps ring the scene, each with its own block to throw a shadow
 12from. Press 1-4 to set the budget and watch shadows appear and disappear: the
 13lamps that fit the budget cast, the rest still light the scene and cast nothing
 14(the renderer logs that once). Which lamps fit is decided by
 15``PointLight3D.shadow_priority`` first and then by how much each lamp is worth
 16to the camera (its brightness and reach against its distance), so orbiting round
 17hands the shadow to the lamp you approach. A lamp already casting keeps its map
 18until another is worth a clear margin more, so the handover lands just past the
 19halfway point between two lamps rather than exactly on it. The dial reallocates
 20the shadow atlases, so expect a hitch on the frame it changes -- set it once for
 21a scene, not per frame.
 22
 23Controls:
 24    1 / 2 / 3 / 4 - Shadow caster budget
 25    A / D         - Orbit camera
 26    W / S         - Zoom in / out
 27
 28Run: uv run python examples/features/3d/shadow_casters.py
 29"""
 30
 31import math
 32
 33from simvx.core import (
 34    Camera3D,
 35    Input,
 36    InputMap,
 37    Key,
 38    Material,
 39    Mesh,
 40    MeshInstance3D,
 41    Node3D,
 42    PointLight3D,
 43    Text2D,
 44    Vec3,
 45    WorldEnvironment,
 46)
 47from simvx.graphics import App
 48
 49WIDTH, HEIGHT = 1280, 720
 50
 51# One lamp per ring position, each its own colour so its shadows are readable.
 52LAMP_COLOURS = [(1.0, 0.75, 0.4), (0.4, 0.8, 1.0), (0.5, 1.0, 0.5), (1.0, 0.5, 0.8)]
 53
 54
 55class ShadowCastersScene(Node3D):
 56    def __init__(self):
 57        super().__init__(name="ShadowCasters")
 58        self._budget = 4
 59        self._cam_angle = 35.0
 60        self._cam_dist = 52.0
 61
 62        self._env = self.add_child(WorldEnvironment(name="Environment"))
 63        self._env.sky_colour_top = (0.03, 0.03, 0.05, 1.0)
 64        self._env.sky_colour_bottom = (0.05, 0.05, 0.07, 1.0)
 65        self._env.ambient_light_colour = (0.04, 0.04, 0.06, 1.0)
 66        self._env.shadow_caster_count = self._budget
 67
 68        self.camera = self.add_child(Camera3D(name="Camera", fov=55, far=200.0))
 69        self._update_camera()
 70
 71        self.add_child(
 72            MeshInstance3D(
 73                name="Floor",
 74                mesh=Mesh.cube(1.0),
 75                material=Material(colour=(0.75, 0.75, 0.78), roughness=0.9),
 76                position=Vec3(0, -0.1, 0),
 77                scale=Vec3(80, 0.2, 80),
 78            )
 79        )
 80
 81        # Four stations around a ring: a block and, just outside it, the lamp
 82        # that lights it. Each lamp's range is short enough that its pool barely
 83        # reaches its neighbours, so every shadow belongs to exactly one lamp
 84        # and no other lamp fills it back in.
 85        self._lamps = []
 86        for i, colour in enumerate(LAMP_COLOURS):
 87            angle = math.tau * i / len(LAMP_COLOURS)
 88            direction = Vec3(math.cos(angle), 0.0, math.sin(angle))
 89            self.add_child(
 90                MeshInstance3D(
 91                    name=f"Block{i}",
 92                    mesh=Mesh.cube(2.0),
 93                    material=Material(colour=(0.55, 0.5, 0.5), roughness=0.7),
 94                    position=Vec3(direction.x * 22.0, 1.0, direction.z * 22.0),
 95                )
 96            )
 97            lamp = self.add_child(
 98                PointLight3D(name=f"Lamp{i}", position=Vec3(direction.x * 25.0, 5.0, direction.z * 25.0))
 99            )
100            lamp.colour = colour
101            lamp.intensity = 6.0
102            lamp.range = 14.0
103            lamp.shadows = True  # every lamp asks; the budget decides
104            lamp.add_child(
105                MeshInstance3D(
106                    name=f"Bulb{i}",
107                    mesh=Mesh.sphere(0.2, rings=8, segments=12),
108                    material=Material(colour=colour, emissive_colour=(*colour, 4.0)),
109                )
110            )
111            self._lamps.append(lamp)
112
113        self.add_child(Text2D(text="SHADOW CASTER BUDGET", position=(10, 8), font_scale=1.6))
114        self._readout = self.add_child(Text2D(text="", position=(10, 38), font_scale=1.2))
115        self._hint = self.add_child(Text2D(text="1-4: budget   A/D: orbit   W/S: zoom", font_scale=1.2))
116        self._update_readout()
117
118    def on_ready(self):
119        InputMap.add_action("cam_left", [Key.A, Key.LEFT])
120        InputMap.add_action("cam_right", [Key.D, Key.RIGHT])
121        InputMap.add_action("cam_in", [Key.W, Key.UP])
122        InputMap.add_action("cam_out", [Key.S, Key.DOWN])
123        for i, key in enumerate((Key.KEY_1, Key.KEY_2, Key.KEY_3, Key.KEY_4), start=1):
124            InputMap.add_action(f"budget_{i}", [key])
125
126    def _update_camera(self):
127        rad = math.radians(self._cam_angle)
128        self.camera.position = Vec3(math.cos(rad) * self._cam_dist, 34.0, math.sin(rad) * self._cam_dist)
129        self.camera.look_at(Vec3(0, 1.5, 0))
130
131    def _update_readout(self):
132        casting = min(self._budget, len(self._lamps))
133        self._readout.text = f"shadow_caster_count = {self._budget}  ({casting} of {len(self._lamps)} lamps casting)"
134
135    def set_budget(self, budget: int) -> None:
136        if budget == self._budget:
137            return
138        self._budget = budget
139        self._env.shadow_caster_count = budget
140        self._update_readout()
141
142    def on_update(self, dt: float):
143        self._hint.position = (10, self.tree.screen_size[1] - 30)
144        for i in range(1, 5):
145            if Input.is_action_just_pressed(f"budget_{i}"):
146                self.set_budget(i)
147        speed = 45.0
148        if Input.is_action_pressed("cam_left"):
149            self._cam_angle += speed * dt
150        if Input.is_action_pressed("cam_right"):
151            self._cam_angle -= speed * dt
152        if Input.is_action_pressed("cam_in"):
153            self._cam_dist = max(24.0, self._cam_dist - 20 * dt)
154        if Input.is_action_pressed("cam_out"):
155            self._cam_dist = min(80.0, self._cam_dist + 20 * dt)
156        self._update_camera()
157
158
159def main():
160    app = App(title="SimVX Shadow Caster Budget", width=WIDTH, height=HEIGHT)
161    app.run(ShadowCastersScene())
162
163
164if __name__ == "__main__":
165    main()