2D Lighting

Play Demo

2D Lighting Demo — Coloured point lights with shadow-casting occluders.

Run: uv run python packages/graphics/examples/2d_light.py

Controls:

  • Mouse moves the white “cursor” light

  • WASD moves the camera/scene offset

  • 1/2/3 toggles individual lights on/off

  • S key toggles shadows on/off

  • ESC quits

Source Code

  1"""
  22D Lighting Demo — Coloured point lights with shadow-casting occluders.
  3
  4Run: uv run python packages/graphics/examples/2d_light.py
  5
  6Controls:
  7  - Mouse moves the white "cursor" light
  8  - WASD moves the camera/scene offset
  9  - 1/2/3 toggles individual lights on/off
 10  - S key toggles shadows on/off
 11  - ESC quits
 12"""
 13
 14
 15import math
 16import random
 17
 18from simvx.core import (
 19    Input,
 20    InputMap,
 21    Key,
 22    LightOccluder2D,
 23    Node2D,
 24    PointLight2D,
 25    Property,
 26    Vec2,
 27)
 28from simvx.graphics import App
 29
 30WIDTH, HEIGHT = 1024, 768
 31
 32# Box shapes for occluders (walls / obstacles)
 33BOX_SMALL = [(-30, -30), (30, -30), (30, 30), (-30, 30)]
 34BOX_WIDE = [(-80, -15), (80, -15), (80, 15), (-80, 15)]
 35TRIANGLE = [(-40, 30), (0, -40), (40, 30)]
 36
 37
 38class MouseLight(PointLight2D):
 39    """A light that follows the mouse cursor."""
 40
 41    def __init__(self, **kwargs):
 42        super().__init__(**kwargs)
 43        self.colour = (1.0, 1.0, 1.0)
 44        self.energy = 1.2
 45        self.range = 250.0
 46        self.falloff = 1.5
 47
 48    def process(self, dt: float):
 49        mx, my = Input.mouse_position
 50        self.position = Vec2(mx, my)
 51
 52
 53class OrbitingLight(PointLight2D):
 54    """A light that orbits around a center point."""
 55
 56    orbit_radius = Property(150.0)
 57    orbit_speed = Property(1.0)
 58
 59    def __init__(self, center: Vec2 = None, **kwargs):
 60        super().__init__(**kwargs)
 61        self._center = center or Vec2(WIDTH / 2, HEIGHT / 2)
 62        self._angle = random.uniform(0, math.tau)
 63
 64    def process(self, dt: float):
 65        self._angle += self.orbit_speed * dt
 66        self.position = Vec2(
 67            self._center.x + math.cos(self._angle) * self.orbit_radius,
 68            self._center.y + math.sin(self._angle) * self.orbit_radius,
 69        )
 70
 71
 72class LightingDemo(Node2D):
 73    """Main scene demonstrating 2D lighting with shadows."""
 74
 75    shadows_on = Property(True)
 76
 77    def __init__(self, **kwargs):
 78        super().__init__(name="LightingDemo", **kwargs)
 79        self._lights: list[PointLight2D] = []
 80
 81    def ready(self):
 82        InputMap.add_action("toggle_1", [Key.KEY_1])
 83        InputMap.add_action("toggle_2", [Key.KEY_2])
 84        InputMap.add_action("toggle_3", [Key.KEY_3])
 85        InputMap.add_action("toggle_shadows", [Key.S])
 86        InputMap.add_action("quit", [Key.ESCAPE])
 87
 88        # --- Lights ---
 89
 90        # Red orbiting light
 91        red = self.add_child(
 92            OrbitingLight(
 93                name="RedLight",
 94                center=Vec2(WIDTH * 0.3, HEIGHT * 0.4),
 95            )
 96        )
 97        red.colour = (1.0, 0.2, 0.1)
 98        red.energy = 1.8
 99        red.range = 300.0
100        red.falloff = 1.2
101        red.orbit_radius = 120.0
102        red.orbit_speed = 0.8
103        red.shadow_enabled = True
104        self._lights.append(red)
105
106        # Green orbiting light
107        green = self.add_child(
108            OrbitingLight(
109                name="GreenLight",
110                center=Vec2(WIDTH * 0.7, HEIGHT * 0.4),
111            )
112        )
113        green.colour = (0.1, 1.0, 0.3)
114        green.energy = 1.5
115        green.range = 280.0
116        green.falloff = 1.5
117        green.orbit_radius = 100.0
118        green.orbit_speed = -1.2
119        green.shadow_enabled = True
120        self._lights.append(green)
121
122        # Blue pulsing light (stationary)
123        blue = self.add_child(
124            PointLight2D(
125                name="BlueLight",
126                position=Vec2(WIDTH * 0.5, HEIGHT * 0.7),
127            )
128        )
129        blue.colour = (0.2, 0.4, 1.0)
130        blue.energy = 2.0
131        blue.range = 350.0
132        blue.falloff = 0.8
133        blue.shadow_enabled = True
134        self._lights.append(blue)
135
136        # Mouse-following white light
137        mouse = self.add_child(MouseLight(name="MouseLight"))
138        mouse.shadow_enabled = True
139        self._lights.append(mouse)
140
141        # --- Occluders (walls / obstacles) ---
142
143        # Center box
144        o1 = self.add_child(
145            LightOccluder2D(
146                name="CenterBox",
147                position=Vec2(WIDTH * 0.5, HEIGHT * 0.45),
148            )
149        )
150        o1.polygon = BOX_SMALL
151
152        # Left wall
153        o2 = self.add_child(
154            LightOccluder2D(
155                name="LeftWall",
156                position=Vec2(WIDTH * 0.25, HEIGHT * 0.5),
157                rotation=math.radians(30),
158            )
159        )
160        o2.polygon = BOX_WIDE
161
162        # Right triangle
163        o3 = self.add_child(
164            LightOccluder2D(
165                name="RightTriangle",
166                position=Vec2(WIDTH * 0.75, HEIGHT * 0.6),
167            )
168        )
169        o3.polygon = TRIANGLE
170
171        # Top barrier
172        o4 = self.add_child(
173            LightOccluder2D(
174                name="TopBarrier",
175                position=Vec2(WIDTH * 0.5, HEIGHT * 0.2),
176            )
177        )
178        o4.polygon = BOX_WIDE
179
180        # Scattered small boxes
181        for i in range(4):
182            x = WIDTH * (0.2 + 0.2 * i)
183            y = HEIGHT * 0.8
184            ob = self.add_child(
185                LightOccluder2D(
186                    name=f"SmallBox{i}",
187                    position=Vec2(x, y),
188                    rotation=math.radians(random.uniform(-20, 20)),
189                )
190            )
191            ob.polygon = [(-20, -20), (20, -20), (20, 20), (-20, 20)]
192
193    def process(self, dt: float):
194        self._elapsed = getattr(self, "_elapsed", 0.0) + dt
195
196        # Toggle lights with number keys
197        if Input.is_action_just_pressed("toggle_1") and len(self._lights) > 0:
198            self._lights[0].enabled = not self._lights[0].enabled
199        if Input.is_action_just_pressed("toggle_2") and len(self._lights) > 1:
200            self._lights[1].enabled = not self._lights[1].enabled
201        if Input.is_action_just_pressed("toggle_3") and len(self._lights) > 2:
202            self._lights[2].enabled = not self._lights[2].enabled
203
204        # Toggle shadows
205        if Input.is_action_just_pressed("toggle_shadows"):
206            self.shadows_on = not self.shadows_on
207            for light in self._lights:
208                light.shadow_enabled = self.shadows_on
209
210        if Input.is_action_just_pressed("quit"):
211            self.app.quit()
212
213        # Pulse the blue light
214        if len(self._lights) > 2:
215            blue = self._lights[2]
216            blue.energy = 1.5 + 0.5 * math.sin(self._elapsed * 3.0)
217
218    def draw(self, renderer):
219        # Draw the scene background — dark floor
220        renderer.draw_rect((0, 0), (WIDTH, HEIGHT), colour=(0.06, 0.06, 0.1), filled=True)
221
222        # Draw occluder outlines for visibility
223        for child in self.children:
224            if isinstance(child, LightOccluder2D) and child.polygon:
225                verts = child.global_polygon
226                if len(verts) >= 2:
227                    pts_px = [(int(v[0]), int(v[1])) for v in verts]
228                    renderer.draw_lines(pts_px, closed=True, colour=(0.24, 0.24, 0.31))
229
230        # Draw light position indicators
231        for light in self._lights:
232            if not light.enabled:
233                continue
234            lx, ly = int(light.world_position.x), int(light.world_position.y)
235            renderer.draw_circle((lx, ly), 5, colour=light.colour, segments=12, filled=True)
236
237        # HUD
238        renderer.draw_text("2D LIGHTING DEMO", (10, 10), scale=3, colour=(0.78, 0.78, 0.78))
239        renderer.draw_text("Mouse = white light  |  1/2/3 = toggle lights", (10, 60), scale=2, colour=(0.59, 0.59, 0.59))
240        shadow_text = "ON" if self.shadows_on else "OFF"
241        renderer.draw_text(f"S = shadows [{shadow_text}]  |  ESC = quit", (10, 90), scale=2, colour=(0.59, 0.59, 0.59))
242
243        # Light status
244        for i, light in enumerate(self._lights[:3]):
245            status = "ON" if light.enabled else "OFF"
246            r, g, b = light.colour
247            renderer.draw_text(f"Light {i+1}: {status}", (10, HEIGHT - 100 + i * 30), scale=2, colour=(r * 0.78, g * 0.78, b * 0.78))
248
249
250if __name__ == "__main__":
251    App("2D Lighting Demo", WIDTH, HEIGHT).run(LightingDemo())