Motion Blur¶
Camera-based motion blur orbiting a scene of cubes.
📄 Docs onlyTags: 3d
Controls: M : Toggle motion blur on/off Up/Down : Adjust motion blur intensity Left/Right: Adjust sample count A/D : Speed up / slow down orbit ESC : Quit
Usage: uv run python examples/features/3d/motion_blur.py uv run python examples/features/3d/motion_blur.py –test
Source¶
1#!/usr/bin/env python3
2"""Motion Blur: Camera-based motion blur orbiting a scene of cubes.
3
4# /// simvx
5# web = { disabled = true, reason = "Motion blur not yet supported on web." }
6# ///
7
8Controls:
9 M : Toggle motion blur on/off
10 Up/Down : Adjust motion blur intensity
11 Left/Right: Adjust sample count
12 A/D : Speed up / slow down orbit
13 ESC : Quit
14
15Usage:
16 uv run python examples/features/3d/motion_blur.py
17 uv run python examples/features/3d/motion_blur.py --test
18"""
19
20
21import math
22import sys
23
24from simvx.core import (
25 Camera3D,
26 DirectionalLight3D,
27 Input,
28 InputMap,
29 Key,
30 Material,
31 Mesh,
32 MeshInstance3D,
33 Node,
34 Property,
35 Text2D,
36 Vec3,
37 WorldEnvironment,
38)
39from simvx.graphics import App
40
41WIDTH, HEIGHT = 1280, 720
42
43
44class MotionBlurDemo(Node):
45 """Scene with orbiting camera to demonstrate motion blur.
46
47 Uses Node (not Node3D) as root to avoid Quat*ndarray issue when computing
48 world_position for child Node3D nodes.
49 """
50
51 orbit_speed = Property(2.0, range=(0.1, 5.0))
52 orbit_radius = Property(8.0, range=(3.0, 20.0))
53
54 def __init__(self, **kwargs):
55 super().__init__(**kwargs)
56 self._time = 0.0
57 self._blur_on = True
58 self._intensity = 1.0
59 self._samples = 12
60
61 def on_ready(self):
62 super().on_ready()
63
64 InputMap.add_action("toggle_blur", [Key.M])
65 InputMap.add_action("intensity_up", [Key.UP])
66 InputMap.add_action("intensity_down", [Key.DOWN])
67 InputMap.add_action("samples_up", [Key.RIGHT])
68 InputMap.add_action("samples_down", [Key.LEFT])
69 InputMap.add_action("orbit_faster", [Key.D])
70 InputMap.add_action("orbit_slower", [Key.A])
71 InputMap.add_action("quit", [Key.ESCAPE])
72
73 # Camera
74 self.camera = self.add_child(Camera3D(position=Vec3(0.0, 4.0, 8.0), look_at=Vec3(0.0, 0.0, 0.0)))
75
76 # Directional light
77 light = DirectionalLight3D()
78 light.direction = Vec3(-0.5, -1.0, -0.3)
79 light.colour = (1.0, 0.95, 0.9)
80 light.intensity = 1.5
81 self.add_child(light)
82
83 # Ground plane
84 ground = MeshInstance3D(mesh=Mesh.cube(), material=Material(colour=(0.3, 0.35, 0.3), roughness=0.9))
85 ground.position = Vec3(0.0, -0.6, 0.0)
86 ground.scale = Vec3(20.0, 0.2, 20.0)
87 self.add_child(ground)
88
89 # Coloured cubes in a grid
90 colours = [
91 (0.8, 0.2, 0.2), (0.2, 0.7, 0.2), (0.2, 0.3, 0.8), (0.8, 0.7, 0.1),
92 (0.7, 0.2, 0.7), (0.2, 0.7, 0.7), (0.9, 0.5, 0.1), (0.6, 0.6, 0.6),
93 ]
94 cube_mesh = Mesh.cube()
95 idx = 0
96 for x in range(-2, 2):
97 for z in range(-2, 2):
98 scale_y = 0.5 + (idx % 3) * 0.5
99 cube = MeshInstance3D(
100 mesh=cube_mesh,
101 material=Material(colour=colours[idx % len(colours)], roughness=0.4, metallic=0.1),
102 )
103 cube.position = Vec3(x * 2.0 + 1.0, scale_y * 0.5, z * 2.0 + 1.0)
104 cube.scale = Vec3(1.0, scale_y, 1.0)
105 self.add_child(cube)
106 idx += 1
107
108 # Tall pillar
109 pillar = MeshInstance3D(mesh=cube_mesh, material=Material(colour=(0.9, 0.85, 0.7), roughness=0.3))
110 pillar.position = Vec3(0.0, 2.0, 0.0)
111 pillar.scale = Vec3(0.6, 4.0, 0.6)
112 self.add_child(pillar)
113
114 # Metallic sphere
115 sphere = MeshInstance3D(
116 mesh=Mesh.sphere(),
117 material=Material(colour=(0.1, 0.5, 0.9), roughness=0.2, metallic=0.8),
118 )
119 sphere.position = Vec3(4.0, 1.0, 0.0)
120 self.add_child(sphere)
121
122 # WorldEnvironment for motion blur control
123 self._env = self.add_child(WorldEnvironment(name="Env"))
124 self._env.motion_blur_enabled = True
125 self._env.motion_blur_intensity = self._intensity
126 self._env.motion_blur_samples = self._samples
127
128 # HUD text
129 self._hud_blur = self.add_child(Text2D(text="Motion Blur: ON", x=10, y=8, font_scale=1.4))
130 self._hud_intensity = self.add_child(Text2D(text="Intensity: 1.0 (Up/Down)", x=10, y=35, font_scale=1.1))
131 self._hud_samples = self.add_child(Text2D(text="Samples: 12 (Left/Right)", x=10, y=58, font_scale=1.1))
132 self._hud_speed = self.add_child(Text2D(text="Orbit: 2.0 (A/D)", x=10, y=81, font_scale=1.1))
133 self.add_child(Text2D(text="M: Toggle blur", x=10, y=690, font_scale=1.0))
134
135 def on_process(self, dt: float):
136 if Input.is_action_just_pressed("quit"):
137 self.app.quit()
138 return
139
140 self._time += dt
141
142 # Orbit camera
143 angle = self._time * self.orbit_speed
144 x = math.cos(angle) * self.orbit_radius
145 z = math.sin(angle) * self.orbit_radius
146 y = 3.0 + math.sin(self._time * 0.5) * 1.5
147 self.camera.position = Vec3(x, y, z)
148 self.camera.look_at(Vec3(0.0, 0.5, 0.0))
149
150 # Controls
151 if Input.is_action_just_pressed("toggle_blur"):
152 self._blur_on = not self._blur_on
153 if Input.is_action_just_pressed("intensity_up"):
154 self._intensity = min(2.0, self._intensity + 0.1)
155 if Input.is_action_just_pressed("intensity_down"):
156 self._intensity = max(0.0, self._intensity - 0.1)
157 if Input.is_action_just_pressed("samples_up"):
158 self._samples = min(32, self._samples + 2)
159 if Input.is_action_just_pressed("samples_down"):
160 self._samples = max(4, self._samples - 2)
161 if Input.is_action_pressed("orbit_faster"):
162 self.orbit_speed = min(5.0, self.orbit_speed + 1.5 * dt)
163 if Input.is_action_pressed("orbit_slower"):
164 self.orbit_speed = max(0.1, self.orbit_speed - 1.5 * dt)
165
166 # Apply to WorldEnvironment (renderer syncs each frame)
167 self._env.motion_blur_enabled = self._blur_on
168 self._env.motion_blur_intensity = self._intensity
169 self._env.motion_blur_samples = self._samples
170
171 # Update HUD
172 self._hud_blur.text = f"Motion Blur: {'ON' if self._blur_on else 'OFF'}"
173 self._hud_intensity.text = f"Intensity: {self._intensity:.1f} (Up/Down)"
174 self._hud_samples.text = f"Samples: {self._samples} (Left/Right)"
175 self._hud_speed.text = f"Orbit: {self.orbit_speed:.1f} (A/D)"
176
177
178if __name__ == "__main__":
179 test_mode = "--test" in sys.argv
180 scene = MotionBlurDemo(name="MotionBlurDemo")
181 app = App(title="Motion Blur Demo", width=WIDTH, height=HEIGHT, visible=not test_mode)
182 if test_mode:
183 from simvx.graphics.testing import assert_not_blank, save_png
184 captured = app.run_headless(scene, frames=120, capture_frames=[10, 30, 60, 90, 119])
185 print(f"Captured {len(captured)} frames")
186 for i, frame in enumerate(captured):
187 save_png(f"/tmp/motion_blur_test_{i}.png", frame)
188 assert_not_blank(frame)
189 print("All frames non-blank: PASSED")
190 else:
191 app.run(scene)