2D Lighting¶
coloured point lights and shadow-casting occluders.
▶ Run in browserTags: 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_radiuscarves 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_gradientreplaces 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())