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