3D Lighting

Play Demo

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())