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