2D Lighting¶
Coloured point lights with shadow-casting occluders.
▶ Run in browserTags: 2d
Demonstrates:
PointLight2D with colour, energy, range and falloff, plus inner_radius (a flat full-brightness core, giving the blue light a solid disc) and falloff_gradient (a 1-D intensity LUT that replaces the analytic curve, turning the gold light into concentric rings)
LightOccluder2D polygons, which cast shadows for every light that has shadow_enabled set; the edges are PCF-softened by the light’s shadow_softness (default 1.0, and 0 gives a hard edge)
WorldEnvironment.ambient_light_2d, a dark ambient floor so the lights and their shadows read clearly
Run: uv run python examples/features/2d/light.py
Controls:
Mouse moves the white “cursor” light
1/2/3 toggles the red / green / blue lights on/off
S key toggles shadows on/off
ESC quits
Source¶
1"""2D Lighting: Coloured point lights with shadow-casting occluders.
2
3# /// simvx
4# web = { width = 1024, height = 768 }
5# ///
6
7Demonstrates:
8 - PointLight2D with colour, energy, range and falloff, plus inner_radius
9 (a flat full-brightness core, giving the blue light a solid disc) and
10 falloff_gradient (a 1-D intensity LUT that replaces the analytic curve,
11 turning the gold light into concentric rings)
12 - LightOccluder2D polygons, which cast shadows for every light that has
13 shadow_enabled set; the edges are PCF-softened by the light's
14 shadow_softness (default 1.0, and 0 gives a hard edge)
15 - WorldEnvironment.ambient_light_2d, a dark ambient floor so the lights and
16 their shadows read clearly
17
18Run: uv run python examples/features/2d/light.py
19
20Controls:
21 - Mouse moves the white "cursor" light
22 - 1/2/3 toggles the red / green / blue lights on/off
23 - S key toggles shadows on/off
24 - ESC quits
25"""
26
27import math
28import random
29
30import numpy as np
31
32from simvx.core import (
33 Input,
34 InputMap,
35 Key,
36 LightOccluder2D,
37 Node2D,
38 PointLight2D,
39 Property,
40 Vec2,
41 WorldEnvironment,
42)
43from simvx.graphics import App
44
45WIDTH, HEIGHT = 1024, 768
46
47# Box shapes for occluders (walls / obstacles)
48BOX_SMALL = [(-30, -30), (30, -30), (30, 30), (-30, 30)]
49BOX_WIDE = [(-80, -15), (80, -15), (80, 15), (-80, 15)]
50TRIANGLE = [(-40, 30), (0, -40), (40, 30)]
51
52
53class MouseLight(PointLight2D):
54 """A light that follows the mouse cursor."""
55
56 def __init__(self, **kwargs):
57 super().__init__(**kwargs)
58 self.colour = (1.0, 1.0, 1.0)
59 self.energy = 1.2
60 self.range = 250.0
61 self.falloff = 1.5
62
63 def on_update(self, dt: float):
64 mx, my = Input.mouse_position
65 self.position = Vec2(mx, my)
66
67
68class OrbitingLight(PointLight2D):
69 """A light that circles ``orbit_centre`` at ``orbit_radius``."""
70
71 orbit_centre = Property(Vec2(0.0, 0.0))
72 orbit_radius = Property(150.0)
73 orbit_speed = Property(1.0)
74
75 def __init__(self, **kwargs):
76 super().__init__(**kwargs)
77 self._angle = random.uniform(0, math.tau)
78
79 def on_update(self, dt: float):
80 self._angle += self.orbit_speed * dt
81 centre = self.orbit_centre
82 self.position = Vec2(
83 centre.x + math.cos(self._angle) * self.orbit_radius,
84 centre.y + math.sin(self._angle) * self.orbit_radius,
85 )
86
87
88class LightingDemo(Node2D):
89 """Main scene demonstrating 2D lighting with shadows."""
90
91 dynamic = True # overlay dots track orbiting/mouse lights every frame
92
93 shadows_on = Property(True)
94
95 def __init__(self, **kwargs):
96 super().__init__(name="LightingDemo", **kwargs)
97 self._lights: list[PointLight2D] = []
98 self._elapsed = 0.0
99
100 def on_ready(self):
101 InputMap.add_action("toggle_1", [Key.KEY_1])
102 InputMap.add_action("toggle_2", [Key.KEY_2])
103 InputMap.add_action("toggle_3", [Key.KEY_3])
104 InputMap.add_action("toggle_shadows", [Key.S])
105 InputMap.add_action("quit", [Key.ESCAPE])
106
107 # Fixed seed so the orbit start phases and occluder rotations are laid
108 # out identically every run; the scene still animates from there.
109 random.seed(20240607)
110
111 # Low ambient floor so unlit / shadowed areas read dark and the lights
112 # (and their shadows) stand out. Default is a flat 0.2 grey, which lifts
113 # the whole scene; a cool ~0.06 floor keeps the mood dark.
114 self.add_child(WorldEnvironment(ambient_light_2d=(0.06, 0.06, 0.09, 1.0)))
115
116 # --- Lights ---
117 # All layout is expressed as fractions of the live window size and
118 # applied by _apply_layout(), so the scene adapts to any resolution.
119
120 # Red orbiting light
121 red = self.add_child(OrbitingLight(name="RedLight"))
122 red.colour = (1.0, 0.2, 0.1)
123 red.energy = 1.8
124 red.range = 300.0
125 red.falloff = 1.2
126 red.orbit_radius = 120.0
127 red.orbit_speed = 0.8
128 red.shadow_enabled = True
129 self._lights.append(red)
130
131 # Green orbiting light
132 green = self.add_child(OrbitingLight(name="GreenLight"))
133 green.colour = (0.1, 1.0, 0.3)
134 green.energy = 1.5
135 green.range = 280.0
136 green.falloff = 1.5
137 green.orbit_radius = 100.0
138 green.orbit_speed = -1.2
139 green.shadow_enabled = True
140 self._lights.append(green)
141
142 # Blue pulsing light (stationary)
143 blue = self.add_child(PointLight2D(name="BlueLight"))
144 blue.colour = (0.2, 0.4, 1.0)
145 blue.energy = 2.0
146 blue.range = 350.0
147 blue.falloff = 0.8
148 # inner_radius carves a flat, full-brightness core before the falloff
149 # ramp begins: the blue light reads as a solid disk that only fades
150 # past 90 px.
151 blue.inner_radius = 90.0
152 blue.shadow_enabled = True
153 self._lights.append(blue)
154
155 # Gradient-cookie light: a banded ("ringed") falloff LUT replaces the
156 # analytic curve, so this light reads as concentric rings instead of a
157 # smooth disk. The LUT is intensity-only, sampled centre (index 0) to
158 # edge (index N-1).
159 gradient = self.add_child(PointLight2D(name="GradientLight"))
160 gradient.colour = (1.0, 0.85, 0.4)
161 gradient.energy = 1.6
162 gradient.range = 220.0
163 ramp = np.linspace(0.0, 1.0, 64, dtype=np.float32)
164 gradient.falloff_gradient = (0.5 + 0.5 * np.cos(ramp * math.tau * 3.0)) * (1.0 - ramp)
165 self._lights.append(gradient)
166
167 # Mouse-following white light
168 mouse = self.add_child(MouseLight(name="MouseLight"))
169 mouse.shadow_enabled = True
170 self._lights.append(mouse)
171
172 # --- Occluders (walls / obstacles) ---
173
174 # Center box
175 o1 = self.add_child(LightOccluder2D(name="CenterBox"))
176 o1.polygon = BOX_SMALL
177
178 # Left wall
179 o2 = self.add_child(LightOccluder2D(name="LeftWall", rotation=math.radians(30)))
180 o2.polygon = BOX_WIDE
181
182 # Right triangle
183 o3 = self.add_child(LightOccluder2D(name="RightTriangle"))
184 o3.polygon = TRIANGLE
185
186 # Top barrier
187 o4 = self.add_child(LightOccluder2D(name="TopBarrier"))
188 o4.polygon = BOX_WIDE
189
190 # (node, x fraction, y fraction) pairs resolved against the live window size
191 self._placed = [(o1, 0.5, 0.45), (o2, 0.25, 0.5), (o3, 0.75, 0.6), (o4, 0.5, 0.2)]
192
193 # Scattered small boxes
194 for i in range(4):
195 ob = self.add_child(
196 LightOccluder2D(
197 name=f"SmallBox{i}",
198 rotation=math.radians(random.uniform(-20, 20)),
199 )
200 )
201 ob.polygon = [(-20, -20), (20, -20), (20, 20), (-20, 20)]
202 self._placed.append((ob, 0.2 + 0.2 * i, 0.8))
203
204 # Re-flow once per resize instead of polling the window size every frame.
205 self.tree.screen_resized.connect(self._apply_layout)
206 self._apply_layout()
207
208 def _apply_layout(self, size: tuple[int, int] | None = None):
209 """Position lights and occluders as fractions of the current window size."""
210 w, h = size if size is not None else (self.app.width, self.app.height)
211 red, green, blue, gradient = self._lights[:4]
212 red.orbit_centre = Vec2(w * 0.3, h * 0.4)
213 green.orbit_centre = Vec2(w * 0.7, h * 0.4)
214 blue.position = Vec2(w * 0.5, h * 0.7)
215 gradient.position = Vec2(w * 0.85, h * 0.25)
216 for node, fx, fy in self._placed:
217 node.position = Vec2(w * fx, h * fy)
218
219 def on_update(self, dt: float):
220 self._elapsed += dt
221
222 # Toggle lights with number keys
223 if Input.is_action_just_pressed("toggle_1") and len(self._lights) > 0:
224 self._lights[0].enabled = not self._lights[0].enabled
225 if Input.is_action_just_pressed("toggle_2") and len(self._lights) > 1:
226 self._lights[1].enabled = not self._lights[1].enabled
227 if Input.is_action_just_pressed("toggle_3") and len(self._lights) > 2:
228 self._lights[2].enabled = not self._lights[2].enabled
229
230 # Toggle shadows
231 if Input.is_action_just_pressed("toggle_shadows"):
232 self.shadows_on = not self.shadows_on
233 for light in self._lights:
234 light.shadow_enabled = self.shadows_on
235
236 if Input.is_action_just_pressed("quit"):
237 self.app.quit()
238
239 # Pulse the blue light
240 if len(self._lights) > 2:
241 blue = self._lights[2]
242 blue.energy = 1.5 + 0.5 * math.sin(self._elapsed * 3.0)
243
244 def on_draw(self, renderer):
245 # Draw the scene background: dark floor covering the live window size
246 w, h = self.app.width, self.app.height
247 renderer.draw_rect((0, 0), (w, h), colour=(0.06, 0.06, 0.1), filled=True)
248
249 # Draw occluder outlines for visibility
250 for child in self.children:
251 if isinstance(child, LightOccluder2D) and child.polygon:
252 verts = child.global_polygon
253 if len(verts) >= 2:
254 pts_px = [(int(v[0]), int(v[1])) for v in verts]
255 renderer.draw_lines(pts_px, closed=True, colour=(0.24, 0.24, 0.31))
256
257 # Draw light position indicators
258 for light in self._lights:
259 if not light.enabled:
260 continue
261 lx, ly = int(light.world_position.x), int(light.world_position.y)
262 renderer.draw_circle((lx, ly), 5, colour=light.colour, segments=12, filled=True)
263
264 # HUD
265 renderer.draw_text("2D LIGHTING DEMO", (10, 10), scale=3, colour=(0.78, 0.78, 0.78))
266 grey = (0.59, 0.59, 0.59)
267 renderer.draw_text("Mouse = white light | 1/2/3 = toggle lights", (10, 60), scale=2, colour=grey)
268 shadow_text = "ON" if self.shadows_on else "OFF"
269 renderer.draw_text(f"S = shadows [{shadow_text}] | ESC = quit", (10, 90), scale=2, colour=grey)
270
271 # Light status
272 for i, light in enumerate(self._lights[:3]):
273 status = "ON" if light.enabled else "OFF"
274 r, g, b = light.colour
275 dim = (r * 0.78, g * 0.78, b * 0.78)
276 renderer.draw_text(f"Light {i+1}: {status}", (10, h - 100 + i * 30), scale=2, colour=dim)
277
278
279if __name__ == "__main__":
280 App("2D Lighting Demo", WIDTH, HEIGHT).run(LightingDemo())