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