2D Lighting

Coloured point lights with shadow-casting occluders.

▶ Run in browser

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