3D Lighting

Directional and orbiting point lights.

▶ Run in browser

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