2D Lighting

coloured point lights and shadow-casting occluders.

▶ Run in browser

Tags: 2d

A 2D scene is lit by adding light nodes to it. Each PointLight2D adds its colour times energy over a disc of radius range around its position, fading toward the edge along a curve whose steepness is falloff (1.0 is linear, higher is more concentrated). WorldEnvironment.ambient_light_2d is the light everything gets anyway; here it is a dark blue floor, so the lights and their shadows read clearly.

Two more settings reshape a light’s disc:

  • inner_radius carves a flat full-brightness core, so the blue light is a solid disc that only starts to fade 90 pixels out. Its energy also pulses over time.

  • falloff_gradient replaces the curve with a 1-D intensity table, sampled from the centre (first entry) to the edge (last). The gold light’s table is a fading cosine, which turns it into concentric rings.

A LightOccluder2D is a polygon that blocks light. It casts a shadow from every light with shadow_enabled set, softened at the edge by the light’s shadow_softness (0 is a hard edge). An occluder has no look of its own, so each wall here is a Polygon2D carrying an occluder of the same shape as its child. The red and green lights ride a spinning Node2D pivot, which carries them round in circles, and the white light follows the pointer.

Move the mouse or drag a finger to move the white light. The buttons, or the keys 1, 2 and 3, switch the red, green and blue lights on and off, and S switches every light’s shadows.

Source

  1# /// simvx
  2# web = { width = 1024, height = 768 }
  3# ///
  4"""2D Lighting: coloured point lights and shadow-casting occluders.
  5
  6A 2D scene is lit by adding light nodes to it. Each ``PointLight2D`` adds its
  7``colour`` times ``energy`` over a disc of radius ``range`` around its position,
  8fading toward the edge along a curve whose steepness is ``falloff`` (1.0 is linear,
  9higher is more concentrated). ``WorldEnvironment.ambient_light_2d`` is the light
 10everything gets anyway; here it is a dark blue floor, so the lights and their
 11shadows read clearly.
 12
 13Two more settings reshape a light's disc:
 14
 15- ``inner_radius`` carves a flat full-brightness core, so the blue light is a solid
 16  disc that only starts to fade 90 pixels out. Its energy also pulses over time.
 17- ``falloff_gradient`` replaces the curve with a 1-D intensity table, sampled from
 18  the centre (first entry) to the edge (last). The gold light's table is a fading
 19  cosine, which turns it into concentric rings.
 20
 21A ``LightOccluder2D`` is a polygon that blocks light. It casts a shadow from every
 22light with ``shadow_enabled`` set, softened at the edge by the light's
 23``shadow_softness`` (0 is a hard edge). An occluder has no look of its own, so each
 24wall here is a ``Polygon2D`` carrying an occluder of the same shape as its child.
 25The red and green lights ride a spinning ``Node2D`` pivot, which carries them round
 26in circles, and the white light follows the pointer.
 27
 28Move the mouse or drag a finger to move the white light. The buttons, or the keys
 291, 2 and 3, switch the red, green and blue lights on and off, and S switches every
 30light's shadows.
 31"""
 32
 33import math
 34
 35import numpy as np
 36
 37from simvx.core import (
 38    Anchor,
 39    Button,
 40    CanvasLayer,
 41    Input,
 42    Key,
 43    LightOccluder2D,
 44    Node2D,
 45    PointLight2D,
 46    Polygon2D,
 47    Property,
 48    VBoxContainer,
 49    WorldEnvironment,
 50)
 51from simvx.graphics import App
 52
 53FLOOR = (0.06, 0.06, 0.1, 1.0)
 54WALL = (0.24, 0.24, 0.31, 1.0)
 55SQUARE = [(-30, -30), (30, -30), (30, 30), (-30, 30)]
 56SMALL_SQUARE = [(-20, -20), (20, -20), (20, 20), (-20, 20)]
 57BAR = [(-80, -15), (80, -15), (80, 15), (-80, 15)]
 58TRIANGLE = [(-40, 30), (0, -40), (40, 30)]
 59
 60
 61class Lamp(PointLight2D):
 62    """A point light that marks its position with a dot while it is on."""
 63
 64    def on_draw(self, renderer):
 65        if self.enabled:
 66            renderer.circle((0, 0), 5, fill=self.colour)
 67
 68
 69class Pivot(Node2D):
 70    """Turns at ``speed`` radians per second, carrying its children round its origin."""
 71
 72    speed = Property(1.0)
 73
 74    def on_update(self, dt):
 75        self.rotation += self.speed * dt
 76
 77
 78def wall(shape, rotation=0.0) -> Polygon2D:
 79    """A visible wall whose occluder child casts the shadow."""
 80    body = Polygon2D(polygon=shape, colour=WALL, rotation=rotation)
 81    body.add_child(LightOccluder2D(polygon=shape))
 82    return body
 83
 84
 85class LightingDemo(Node2D):
 86    input_context = {"red": [Key.KEY_1], "green": [Key.KEY_2], "blue": [Key.KEY_3], "shadows": [Key.S]}
 87
 88    def on_ready(self):
 89        self.add_child(WorldEnvironment(ambient_light_2d=(0.06, 0.06, 0.09, 1.0)))
 90
 91        red_pivot = self.add_child(Pivot(speed=0.8))
 92        green_pivot = self.add_child(Pivot(speed=-1.2, rotation=2.0))
 93        self.lights = {
 94            "red": red_pivot.add_child(
 95                Lamp(position=(120, 0), colour=(1.0, 0.2, 0.1), energy=1.8, range=300.0, falloff=1.2)
 96            ),
 97            "green": green_pivot.add_child(
 98                Lamp(position=(100, 0), colour=(0.1, 1.0, 0.3), energy=1.5, range=280.0, falloff=1.5)
 99            ),
100            "blue": self.add_child(
101                Lamp(colour=(0.2, 0.4, 1.0), energy=2.0, range=350.0, falloff=0.8, inner_radius=90.0)
102            ),
103        }
104        ramp = np.linspace(0.0, 1.0, 64, dtype=np.float32)
105        rings = (0.5 + 0.5 * np.cos(ramp * math.tau * 3.0)) * (1.0 - ramp)
106        gold = self.add_child(Lamp(colour=(1.0, 0.85, 0.4), energy=1.6, range=220.0, falloff_gradient=rings))
107        self.pointer_light = self.add_child(Lamp(energy=1.2, range=250.0, falloff=1.5))
108        for light in self.find_all(Lamp):
109            light.shadow_enabled = True
110
111        # Each node's position as a fraction of the window, applied every frame so the scene follows a resize.
112        self.layout = [
113            (red_pivot, 0.3, 0.4),
114            (green_pivot, 0.7, 0.4),
115            (self.lights["blue"], 0.5, 0.7),
116            (gold, 0.85, 0.25),
117            (self.add_child(wall(SQUARE)), 0.5, 0.45),
118            (self.add_child(wall(BAR, rotation=math.radians(30))), 0.25, 0.5),
119            (self.add_child(wall(TRIANGLE)), 0.75, 0.6),
120            (self.add_child(wall(BAR)), 0.5, 0.2),
121        ]
122        for i, tilt in enumerate((-12, 17, -5, 9)):
123            self.layout.append((self.add_child(wall(SMALL_SQUARE, rotation=math.radians(tilt))), 0.2 + 0.2 * i, 0.8))
124
125        # Example help overlay: not part of the technique shown.
126        buttons = self.add_child(CanvasLayer()).add_child(VBoxContainer(anchor=Anchor.TOP_LEFT, inset=16))
127        self.toggles = {
128            name: buttons.add_child(Button(action=name, text=f"{name.capitalize()} light", checked=True))
129            for name in self.lights
130        }
131        self.toggles["shadows"] = buttons.add_child(Button(action="shadows", text="Shadows", checked=True))
132
133    def on_update(self, dt):
134        w, h = self.viewport_size
135        for node, fx, fy in self.layout:
136            node.position = (w * fx, h * fy)
137        self.pointer_light.position = Input.mouse_position
138        self.lights["blue"].energy = 1.5 + 0.5 * math.sin(self.tree.now * 3.0)
139
140        for name, button in self.toggles.items():
141            if Input.just_pressed(name):
142                button.checked = not button.checked
143                if name == "shadows":
144                    for light in self.find_all(Lamp):
145                        light.shadow_enabled = button.checked
146                else:
147                    self.lights[name].enabled = button.checked
148
149    def on_draw(self, renderer):
150        # The lights brighten what is drawn, so the floor gives them a surface to land on.
151        renderer.rect((0, 0), self.viewport_size, fill=FLOOR)
152
153
154if __name__ == "__main__":
155    App(title="2D Lighting", width=1024, height=768).run(LightingDemo())