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 InputMap,
29 Key,
30 Material,
31 Mesh,
32 MeshInstance3D,
33 Node,
34 PointLight3D,
35 Text2D,
36)
37from simvx.graphics import App
38
39
40class LightingScene(Node):
41 def on_ready(self):
42 InputMap.add_action("orbit_left", [Key.LEFT])
43 InputMap.add_action("orbit_right", [Key.RIGHT])
44 InputMap.add_action("pitch_up", [Key.UP])
45 InputMap.add_action("pitch_down", [Key.DOWN])
46 InputMap.add_action("quit", [Key.ESCAPE])
47
48 # Camera orbit parameters
49 self._cam_angle = 0.0 # horizontal orbit angle (radians)
50 self._cam_pitch = 0.3 # vertical pitch (radians)
51 self._cam_dist = 15.0
52 self._cam = Camera3D()
53 self.add_child(self._cam)
54 self._update_camera()
55
56 # Ground plane so the cubes + point lights don't float in pure black.
57 ground = self.add_child(MeshInstance3D(
58 mesh=Mesh.cube(),
59 material=Material(colour=(0.2, 0.22, 0.25, 1.0), roughness=0.9, metallic=0.0),
60 position=(0, 0, -1.05),
61 scale=(20, 20, 0.1),
62 ))
63 _ = ground # silence "unused" diagnostics; child is retained via add_child
64
65 # Grid of cubes (shared mesh)
66 cube_mesh = Mesh.cube()
67 colours = [
68 (0.9, 0.2, 0.2, 1),
69 (0.2, 0.9, 0.2, 1),
70 (0.2, 0.2, 0.9, 1),
71 (0.9, 0.9, 0.2, 1),
72 (0.9, 0.2, 0.9, 1),
73 (0.2, 0.9, 0.9, 1),
74 ]
75 for i, (x, z) in enumerate([(-4, -3), (0, -3), (4, -3), (-4, 3), (0, 3), (4, 3)]):
76 mat = Material(colour=colours[i], roughness=0.4, metallic=0.1)
77 cube = MeshInstance3D(mesh=cube_mesh, material=mat, position=(x, 0, z), scale=(2, 2, 2))
78 self.add_child(cube)
79
80 # Directional light (sun)
81 sun = DirectionalLight3D(position=(5, 10, -5))
82 sun.colour = (1.0, 0.95, 0.8)
83 sun.intensity = 0.6
84 sun.look_at((0, 0, 0))
85 self.add_child(sun)
86
87 # Point lights (coloured)
88 self._red_light = PointLight3D(position=(-4, -3, 0))
89 self._red_light.colour = (1.0, 0.2, 0.1)
90 self._red_light.intensity = 2.0
91 self._red_light.range = 12.0
92 self.add_child(self._red_light)
93
94 self._blue_light = PointLight3D(position=(4, -3, 0))
95 self._blue_light.colour = (0.1, 0.3, 1.0)
96 self._blue_light.intensity = 2.0
97 self._blue_light.range = 12.0
98 self.add_child(self._blue_light)
99
100 # Small visible markers at the light positions: emissive so they
101 # read as bright "bulbs" even though they don't contribute light
102 # themselves (the PointLight3D beside them does).
103 marker_mesh = Mesh.sphere(radius=0.18, rings=12, segments=16)
104 self._red_marker = self.add_child(MeshInstance3D(
105 mesh=marker_mesh,
106 material=Material(
107 colour=(1.0, 0.25, 0.15, 1.0),
108 emissive_colour=(1.0, 0.25, 0.15, 6.0),
109 roughness=0.4, metallic=0.0,
110 ),
111 position=self._red_light.position,
112 ))
113 self._blue_marker = self.add_child(MeshInstance3D(
114 mesh=marker_mesh,
115 material=Material(
116 colour=(0.2, 0.4, 1.0, 1.0),
117 emissive_colour=(0.2, 0.4, 1.0, 6.0),
118 roughness=0.4, metallic=0.0,
119 ),
120 position=self._blue_light.position,
121 ))
122
123 # HUD
124 self._hud = Text2D(
125 text="Arrow keys: orbit camera | 1 Dir + 2 orbiting Point lights | ESC to quit",
126 x=10, y=10, font_scale=1.5,
127 )
128 self.add_child(self._hud)
129 self._fps_text = Text2D(text="FPS: --", x=10, y=35, font_scale=1.0)
130 self.add_child(self._fps_text)
131
132 self._time = 0.0
133 self._fps_accum = 0.0
134 self._fps_frames = 0
135
136 def _update_camera(self):
137 d = self._cam_dist
138 a = self._cam_angle
139 p = self._cam_pitch
140 x = d * math.cos(p) * math.sin(a)
141 y = -d * math.cos(p) * math.cos(a)
142 z = d * math.sin(p)
143 self._cam.position = (x, y, z)
144 self._cam.look_at((0, 0, 0), up=(0, 0, 1))
145
146 def on_process(self, dt):
147 if Input.is_action_just_pressed("quit"):
148 self.app.quit()
149 return
150
151 self._time += dt
152
153 # FPS counter (update every 0.5s)
154 self._fps_accum += dt
155 self._fps_frames += 1
156 if self._fps_accum >= 0.5:
157 fps = self._fps_frames / self._fps_accum
158 self._fps_text.text = f"FPS: {fps:.0f}"
159 self._fps_accum = 0.0
160 self._fps_frames = 0
161
162 # Camera orbit via arrow keys
163 rot_speed = 1.5
164 if Input.is_action_pressed("orbit_right"):
165 self._cam_angle += rot_speed * dt
166 if Input.is_action_pressed("orbit_left"):
167 self._cam_angle -= rot_speed * dt
168 if Input.is_action_pressed("pitch_up"):
169 self._cam_pitch = min(self._cam_pitch + rot_speed * dt, 1.4)
170 if Input.is_action_pressed("pitch_down"):
171 self._cam_pitch = max(self._cam_pitch - rot_speed * dt, -0.2)
172 self._update_camera()
173
174 # Orbit point lights in XZ plane, and keep the visible markers glued
175 # to them so you can see where each light actually is.
176 r = 5.0
177 red_pos = (r * math.cos(self._time), -3.0, r * math.sin(self._time))
178 blue_pos = (r * math.cos(self._time + math.pi), -3.0, r * math.sin(self._time + math.pi))
179 self._red_light.position = red_pos
180 self._red_marker.position = red_pos
181 self._blue_light.position = blue_pos
182 self._blue_marker.position = blue_pos
183
184
185if __name__ == "__main__":
186 app = App(title="Lighting Demo", width=1280, height=720)
187 app.run(LightingScene())