3D Lighting¶
Directional and orbiting point lights.
▶ Run in browserTags: 3d
Demonstrates:
DirectionalLight3D for sun-like parallel illumination
Two coloured PointLight3D sources with intensity and range falloff
Point lights animated on a continuous orbit, with emissive marker bulbs
Grid of cubes and a ground plane lit by the combined light setup
Orbit camera with adjustable pitch
Controls: Left / Right - Orbit camera left / right Up / Down - Raise / lower camera pitch Escape - Quit
Run: uv run python examples/features/3d/lighting.py
Source¶
1"""3D Lighting: Directional and orbiting point lights.
2
3# /// simvx
4# web = { width = 1280, height = 720 }
5# ///
6
7Demonstrates:
8 - DirectionalLight3D for sun-like parallel illumination
9 - Two coloured PointLight3D sources with intensity and range falloff
10 - Point lights animated on a continuous orbit, with emissive marker bulbs
11 - Grid of cubes and a ground plane lit by the combined light setup
12 - Orbit camera with adjustable pitch
13
14Controls:
15 Left / Right - Orbit camera left / right
16 Up / Down - Raise / lower camera pitch
17 Escape - Quit
18
19Run: uv run python examples/features/3d/lighting.py
20"""
21
22import math
23
24from simvx.core import (
25 Camera3D,
26 DirectionalLight3D,
27 Input,
28 Key,
29 Material,
30 Mesh,
31 MeshInstance3D,
32 Node,
33 PointLight3D,
34 Text2D,
35 WorldEnvironment,
36)
37from simvx.graphics import App
38
39
40class LightingScene(Node):
41 input_actions = {
42 "orbit_left": [Key.LEFT],
43 "orbit_right": [Key.RIGHT],
44 "pitch_up": [Key.UP],
45 "pitch_down": [Key.DOWN],
46 "quit": [Key.ESCAPE],
47 }
48
49 def on_ready(self):
50 # Camera orbit parameters
51 self._cam_angle = 0.0 # horizontal orbit angle (radians)
52 self._cam_pitch = 0.3 # vertical pitch (radians)
53 self._cam_dist = 15.0
54 self._cam = Camera3D()
55 self.add_child(self._cam)
56 self._update_camera()
57
58 # Dark background so the coloured point-light pools read clearly, with
59 # just enough ambient fill to keep unlit faces from crushing to black.
60 env = self.add_child(WorldEnvironment())
61 env.sky_colour_top = (0.02, 0.02, 0.03, 1.0)
62 env.sky_colour_bottom = (0.04, 0.04, 0.05, 1.0)
63 env.ambient_light_colour = (0.06, 0.06, 0.08, 1.0)
64
65 # Ground plane so the cubes + point lights don't float in pure black.
66 self.add_child(
67 MeshInstance3D(
68 mesh=Mesh.cube(),
69 material=Material(colour=(0.32, 0.34, 0.38, 1.0), roughness=0.9, metallic=0.0),
70 position=(0, 0, -1.05),
71 scale=(20, 20, 0.1),
72 )
73 )
74
75 # Grid of cubes (shared mesh)
76 cube_mesh = Mesh.cube()
77 colours = [
78 (0.9, 0.2, 0.2, 1),
79 (0.2, 0.9, 0.2, 1),
80 (0.2, 0.2, 0.9, 1),
81 (0.9, 0.9, 0.2, 1),
82 (0.9, 0.2, 0.9, 1),
83 (0.2, 0.9, 0.9, 1),
84 ]
85 # The scene is Z-up: lay the grid out in the XY plane, cube bases
86 # resting on the ground (ground top is at z=-1, cube half-height 1).
87 for i, (x, y) in enumerate([(-4, -3), (0, -3), (4, -3), (-4, 3), (0, 3), (4, 3)]):
88 mat = Material(colour=colours[i], roughness=0.4, metallic=0.1)
89 cube = MeshInstance3D(mesh=cube_mesh, material=mat, position=(x, y, 0), scale=(2, 2, 2))
90 self.add_child(cube)
91
92 # Directional light: dimmed to a soft key so the dark scene stays moody
93 # and the coloured point-light pools dominate rather than a daylit sun.
94 sun = DirectionalLight3D(position=(5, 10, -5))
95 sun.colour = (1.0, 0.95, 0.8)
96 sun.intensity = 0.5
97 sun.look_at((0, 0, 0))
98 self.add_child(sun)
99
100 # Point lights (coloured)
101 self._red_light = PointLight3D(position=(5, 0, 3.0))
102 self._red_light.colour = (1.0, 0.2, 0.1)
103 self._red_light.intensity = 2.6
104 self._red_light.range = 12.0
105 self.add_child(self._red_light)
106
107 self._blue_light = PointLight3D(position=(-5, 0, 3.0))
108 self._blue_light.colour = (0.1, 0.3, 1.0)
109 self._blue_light.intensity = 2.6
110 self._blue_light.range = 12.0
111 self.add_child(self._blue_light)
112
113 # Small visible markers at the light positions: emissive so they
114 # read as bright "bulbs" even though they don't contribute light
115 # themselves (the PointLight3D beside them does).
116 marker_mesh = Mesh.sphere(radius=0.18, rings=12, segments=16)
117 self._red_marker = self.add_child(
118 MeshInstance3D(
119 mesh=marker_mesh,
120 material=Material(
121 colour=(1.0, 0.25, 0.15, 1.0),
122 emissive_colour=(1.0, 0.25, 0.15, 6.0),
123 roughness=0.4,
124 metallic=0.0,
125 ),
126 position=self._red_light.position,
127 )
128 )
129 self._blue_marker = self.add_child(
130 MeshInstance3D(
131 mesh=marker_mesh,
132 material=Material(
133 colour=(0.2, 0.4, 1.0, 1.0),
134 emissive_colour=(0.2, 0.4, 1.0, 6.0),
135 roughness=0.4,
136 metallic=0.0,
137 ),
138 position=self._blue_light.position,
139 )
140 )
141
142 # HUD
143 self._hud = Text2D(
144 text="Arrow keys: orbit camera | ESC to quit",
145 position=(10, 10),
146 font_scale=1.5,
147 )
148 self.add_child(self._hud)
149 self._fps_text = Text2D(text="FPS: --", position=(10, 35), font_scale=1.0)
150 self.add_child(self._fps_text)
151
152 self._time = 0.0
153 self._fps_accum = 0.0
154 self._fps_frames = 0
155
156 def _update_camera(self):
157 d = self._cam_dist
158 a = self._cam_angle
159 p = self._cam_pitch
160 x = d * math.cos(p) * math.sin(a)
161 y = -d * math.cos(p) * math.cos(a)
162 z = d * math.sin(p)
163 self._cam.position = (x, y, z)
164 self._cam.look_at((0, 0, 0), up=(0, 0, 1))
165
166 def on_update(self, dt):
167 if Input.is_action_just_pressed("quit"):
168 self.app.quit()
169 return
170
171 self._time += dt
172
173 # FPS counter (update every 0.5s)
174 self._fps_accum += dt
175 self._fps_frames += 1
176 if self._fps_accum >= 0.5:
177 fps = self._fps_frames / self._fps_accum
178 self._fps_text.text = f"FPS: {fps:.0f}"
179 self._fps_accum = 0.0
180 self._fps_frames = 0
181
182 # Camera orbit via arrow keys
183 rot_speed = 1.5
184 if Input.is_action_pressed("orbit_right"):
185 self._cam_angle += rot_speed * dt
186 if Input.is_action_pressed("orbit_left"):
187 self._cam_angle -= rot_speed * dt
188 if Input.is_action_pressed("pitch_up"):
189 self._cam_pitch = min(self._cam_pitch + rot_speed * dt, 1.4)
190 if Input.is_action_pressed("pitch_down"):
191 self._cam_pitch = max(self._cam_pitch - rot_speed * dt, -0.2)
192 self._update_camera()
193
194 # Orbit point lights in the (Z-up) XY ground plane at a fixed height,
195 # keeping the visible markers glued to them so you can see where each
196 # light actually is.
197 r = 5.0
198 h = 3.0
199 red_pos = (r * math.cos(self._time), r * math.sin(self._time), h)
200 blue_pos = (r * math.cos(self._time + math.pi), r * math.sin(self._time + math.pi), h)
201 self._red_light.position = red_pos
202 self._red_marker.position = red_pos
203 self._blue_light.position = blue_pos
204 self._blue_marker.position = blue_pos
205
206
207if __name__ == "__main__":
208 app = App(title="Lighting Demo", width=1280, height=720)
209 app.run(LightingScene())