Motion Blur

Camera-based motion blur orbiting a scene of cubes.

📄 Docs only

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