Shadow caster budget¶
several point lights casting at once.
▶ Run in browserTags: 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()