Physics Sandbox

Visual demo of the SimVX physics engine.

▶ Run in browser

Tags: 3d physics rigid-body

Demonstrates: - Falling cubes under gravity - Static ground plane - Bouncing spheres with restitution - Impulse-driven interaction

Controls: SPACE : Spawn a new ball with random impulse R : Reset the scene ESC : Quit

Run with: uv run python examples/demos/physics_sandbox.py

Source

  1#!/usr/bin/env python3
  2"""Physics Sandbox: Visual demo of the SimVX physics engine.
  3
  4# /// simvx
  5# tags = ["3d", "physics", "rigid-body"]
  6# web = { width = 1280, height = 720, root = "PhysicsSandbox" }
  7# ///
  8
  9Demonstrates:
 10    - Falling cubes under gravity
 11    - Static ground plane
 12    - Bouncing spheres with restitution
 13    - Impulse-driven interaction
 14
 15Controls:
 16    SPACE : Spawn a new ball with random impulse
 17    R     : Reset the scene
 18    ESC   : Quit
 19
 20Run with:
 21    uv run python examples/demos/physics_sandbox.py
 22"""
 23
 24import random
 25
 26from simvx.core import (
 27    Camera3D,
 28    Input,
 29    InputMap,
 30    Key,
 31    Material,
 32    Mesh,
 33    MeshInstance3D,
 34    Node,
 35    Text2D,
 36    Vec3,
 37)
 38from simvx.core.collision import BoxShape, SphereShape
 39from simvx.core.physics import (
 40    PhysicsMaterial,
 41    PhysicsServer,
 42    RigidBody3D,
 43    StaticBody3D,
 44)
 45from simvx.graphics import App
 46
 47# ============================================================================
 48# Physics-aware mesh node helpers
 49# ============================================================================
 50
 51
 52class PhysicsCube(RigidBody3D):
 53    """A falling cube with physics."""
 54
 55    def __init__(self, size: float = 1.0, colour: tuple = (0.8, 0.3, 0.2, 1.0), **kwargs):
 56        super().__init__(**kwargs)
 57        self._size = size
 58        self._colour = colour
 59
 60    def on_ready(self):
 61        half = self._size / 2
 62        self.collision_shape = BoxShape(half_extents=(half, half, half))
 63        self.physics_material = PhysicsMaterial(friction=0.6, restitution=0.3)
 64
 65        mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
 66        mesh_node.mesh = Mesh.cube(size=self._size)
 67        mesh_node.material = Material(colour=self._colour)
 68
 69
 70class PhysicsBall(RigidBody3D):
 71    """A bouncing sphere with physics."""
 72
 73    def __init__(self, radius: float = 0.5, colour: tuple = (0.2, 0.6, 0.9, 1.0), **kwargs):
 74        super().__init__(**kwargs)
 75        self._radius = radius
 76        self._colour = colour
 77
 78    def on_ready(self):
 79        self.collision_shape = SphereShape(radius=self._radius)
 80        self.physics_material = PhysicsMaterial(friction=0.3, restitution=0.8)
 81
 82        mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
 83        mesh_node.mesh = Mesh.sphere(radius=self._radius)
 84        mesh_node.material = Material(colour=self._colour)
 85
 86
 87class Ground(StaticBody3D):
 88    """Static ground plane."""
 89
 90    def __init__(self, **kwargs):
 91        super().__init__(physics_material=PhysicsMaterial(friction=0.8, restitution=0.5), **kwargs)
 92
 93    def on_ready(self):
 94        self.collision_shape = BoxShape(half_extents=(25, 0.5, 25))
 95
 96        mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
 97        mesh_node.mesh = Mesh.cube(size=1)
 98        mesh_node.material = Material(colour=(0.4, 0.5, 0.4, 1.0))
 99        mesh_node.scale = Vec3(50, 1, 50)
100
101
102class Wall(StaticBody3D):
103    """Static wall for containing objects."""
104
105    def __init__(self, half_extents=(0.5, 5, 25), colour=(0.5, 0.5, 0.6, 0.5), **kwargs):
106        super().__init__(physics_material=PhysicsMaterial(friction=0.5, restitution=0.7), **kwargs)
107        self._half_extents = half_extents
108        self._colour = colour
109
110    def on_ready(self):
111        self.collision_shape = BoxShape(half_extents=self._half_extents)
112
113        mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
114        mesh_node.mesh = Mesh.cube(size=1)
115        mesh_node.material = Material(colour=self._colour)
116        he = self._half_extents
117        mesh_node.scale = Vec3(he[0] * 2, he[1] * 2, he[2] * 2)
118
119
120# ============================================================================
121# Main Scene
122# ============================================================================
123
124
125class PhysicsSandbox(Node):
126    """Main physics sandbox scene."""
127
128    def on_ready(self):
129        InputMap.add_action("space", [Key.SPACE])
130        InputMap.add_action("reset", [Key.R])
131
132        PhysicsServer.reset()
133        self._server = PhysicsServer.get()
134        self._server.gravity = Vec3(0, -9.8, 0)
135
136        # Camera
137        camera = self.add_child(Camera3D(name="Camera"))
138        camera.position = Vec3(0, 15, 25)
139        camera.look_at(Vec3(0, 3, 0))
140        camera.fov = 60.0
141
142        # Ground
143        self.add_child(Ground(name="Ground", position=Vec3(0, -0.5, 0)))
144
145        # Side walls
146        self.add_child(Wall(name="WallLeft", position=Vec3(-12, 5, 0), half_extents=(0.5, 5, 12)))
147        self.add_child(Wall(name="WallRight", position=Vec3(12, 5, 0), half_extents=(0.5, 5, 12)))
148        self.add_child(Wall(name="WallBack", position=Vec3(0, 5, -12), half_extents=(12, 5, 0.5)))
149        self.add_child(Wall(name="WallFront", position=Vec3(0, 5, 12), half_extents=(12, 5, 0.5)))
150
151        # Initial stack of cubes
152        colours = [
153            (0.9, 0.2, 0.2, 1),
154            (0.2, 0.9, 0.2, 1),
155            (0.2, 0.2, 0.9, 1),
156            (0.9, 0.9, 0.2, 1),
157            (0.9, 0.2, 0.9, 1),
158            (0.2, 0.9, 0.9, 1),
159        ]
160        for i in range(3):
161            for j in range(3 - i):
162                colour = colours[(i * 3 + j) % len(colours)]
163                self.add_child(
164                    PhysicsCube(
165                        name=f"Cube_{i}_{j}",
166                        position=Vec3(-2 + j * 1.5, 1.5 + i * 1.5, 0),
167                        size=1.2,
168                        colour=colour,
169                    )
170                )
171
172        # A couple of bouncy balls
173        for i in range(3):
174            x = -3 + i * 3
175            self.add_child(
176                PhysicsBall(
177                    name=f"Ball_{i}",
178                    position=Vec3(x, 8 + i * 2, 2),
179                    radius=0.6,
180                    colour=(0.1 + i * 0.3, 0.5, 0.9 - i * 0.2, 1),
181                )
182            )
183
184        # UI
185        self._spawn_count = 0
186        self.add_child(
187            Text2D(
188                name="Title",
189                text="Physics Sandbox",
190                x=20,
191                y=20,
192                font_scale=2.0,
193                colour=(1.0, 1.0, 1.0, 1.0),
194            )
195        )
196        self._info_text = self.add_child(
197            Text2D(
198                name="Info",
199                text="SPACE: spawn ball | R: reset | ESC: quit",
200                x=20,
201                y=60,
202                font_scale=1.0,
203                colour=(0.78, 0.78, 0.78, 1.0),
204            )
205        )
206        self._count_text = self.add_child(
207            Text2D(
208                name="Count",
209                text="Bodies: 0",
210                x=20,
211                y=90,
212                font_scale=1.0,
213                colour=(0.71, 0.71, 0.71, 1.0),
214            )
215        )
216
217    def on_process(self, dt: float):
218        # Spawn ball on space
219        if Input.is_action_just_pressed("space"):
220            self._spawn_ball()
221
222        # Reset on R
223        if Input.is_action_just_pressed("reset"):
224            self.tree.change_scene(PhysicsSandbox())
225            return
226
227        # Update body count
228        self._count_text.text = f"Bodies: {self._server.body_count}"
229
230        # Clean up fallen objects
231        for child in list(self.children):
232            if isinstance(child, RigidBody3D) and child.position.y < -20:
233                child.destroy()
234
235    def _spawn_ball(self):
236        self._spawn_count += 1
237        colour = (random.random(), random.random(), random.random(), 1.0)
238        ball = self.add_child(
239            PhysicsBall(
240                name=f"SpawnBall_{self._spawn_count}",
241                position=Vec3(random.uniform(-5, 5), 12, random.uniform(-5, 5)),
242                radius=random.uniform(0.3, 0.8),
243                colour=colour,
244            )
245        )
246        # Random impulse
247        ball.apply_impulse(
248            Vec3(
249                random.uniform(-5, 5),
250                random.uniform(0, 5),
251                random.uniform(-5, 5),
252            )
253        )
254
255
256# ============================================================================
257# Entry Point
258# ============================================================================
259
260
261if __name__ == "__main__":
262    App(title="Physics Sandbox: SimVX", width=1280, height=720, fps=60, physics_fps=60, mode="3d").run(
263        PhysicsSandbox()
264    )