2D Lighting

Coloured point lights with shadow-casting occluders.

▶ Run in browser

Tags: 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())