Physics Sandbox¶
stack-toppling cubes and bouncy balls in a walled 3D arena.
▶ Run in browserTags: 3d physics rigid-body
A pyramid of cubes and a handful of spheres tumble inside a walled arena. Spawn extra balls with a random impulse and watch the pile react, while a live counter tracks how many bodies the world is simulating.
Engine features showcased:
- A PhysicsRoot owning one isolated PhysicsWorld (per-scene gravity
and body_count)
- PhysicsBody3D in DYNAMIC mode for the cubes and spheres, STATIC for
the ground and the walls
- PhysicsMaterial tuning friction and restitution per object
- Impulse-driven interaction via PhysicsBody3D.push
- Instant scene reset via SceneTree.change_scene
Controls: SPACE / click / tap : 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: stack-toppling cubes and bouncy balls in a walled 3D arena.
3
4# /// simvx
5# tags = ["3d", "physics", "rigid-body"]
6# web = { width = 1280, height = 720, root = "PhysicsSandbox" }
7# ///
8
9A pyramid of cubes and a handful of spheres tumble inside a walled arena.
10Spawn extra balls with a random impulse and watch the pile react, while a live
11counter tracks how many bodies the world is simulating.
12
13Engine features showcased:
14 - A ``PhysicsRoot`` owning one isolated ``PhysicsWorld`` (per-scene gravity
15 and ``body_count``)
16 - ``PhysicsBody3D`` in DYNAMIC mode for the cubes and spheres, STATIC for
17 the ground and the walls
18 - ``PhysicsMaterial`` tuning friction and restitution per object
19 - Impulse-driven interaction via ``PhysicsBody3D.push``
20 - Instant scene reset via ``SceneTree.change_scene``
21
22Controls:
23 SPACE / click / tap : Spawn a new ball with random impulse
24 R : Reset the scene
25 ESC : Quit
26
27Run with:
28 uv run python examples/demos/physics_sandbox.py
29"""
30
31import random
32
33from simvx.core import (
34 BodyMode,
35 BoxShape3D,
36 Camera3D,
37 Input,
38 Key,
39 Material,
40 Mesh,
41 MeshInstance3D,
42 MouseButton,
43 Node,
44 PhysicsBody3D,
45 PhysicsMaterial,
46 PhysicsRoot,
47 SphereShape3D,
48 Text2D,
49 Vec3,
50)
51from simvx.graphics import App
52
53# ============================================================================
54# Physics-aware mesh node helpers
55# ============================================================================
56
57
58class PhysicsCube(PhysicsBody3D):
59 """A falling cube with physics (DYNAMIC PhysicsBody3D)."""
60
61 def __init__(self, size: float = 1.0, colour: tuple = (0.8, 0.3, 0.2, 1.0), **kwargs):
62 half = size / 2
63 super().__init__(
64 mode=BodyMode.DYNAMIC,
65 shape=BoxShape3D(half_extents=Vec3(half, half, half)),
66 material=PhysicsMaterial(friction=0.6, restitution=0.3),
67 **kwargs,
68 )
69 self._size = size
70 self._colour = colour
71
72 def on_ready(self):
73 mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
74 mesh_node.mesh = Mesh.cube(size=self._size)
75 mesh_node.material = Material(colour=self._colour)
76
77
78class PhysicsBall(PhysicsBody3D):
79 """A bouncing sphere with physics (DYNAMIC PhysicsBody3D)."""
80
81 def __init__(self, radius: float = 0.5, colour: tuple = (0.2, 0.6, 0.9, 1.0), **kwargs):
82 super().__init__(
83 mode=BodyMode.DYNAMIC,
84 shape=SphereShape3D(radius=radius),
85 material=PhysicsMaterial(friction=0.3, restitution=0.8),
86 **kwargs,
87 )
88 self._radius = radius
89 self._colour = colour
90
91 def on_ready(self):
92 mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
93 mesh_node.mesh = Mesh.sphere(radius=self._radius)
94 mesh_node.material = Material(colour=self._colour)
95
96
97class Ground(PhysicsBody3D):
98 """Static ground plane (STATIC PhysicsBody3D)."""
99
100 def __init__(self, **kwargs):
101 super().__init__(
102 mode=BodyMode.STATIC,
103 shape=BoxShape3D(half_extents=Vec3(25, 0.5, 25)),
104 material=PhysicsMaterial(friction=0.8, restitution=0.5),
105 **kwargs,
106 )
107
108 def on_ready(self):
109 mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
110 mesh_node.mesh = Mesh.cube(size=1)
111 mesh_node.material = Material(colour=(0.4, 0.5, 0.4, 1.0))
112 mesh_node.scale = Vec3(50, 1, 50)
113
114
115class Wall(PhysicsBody3D):
116 """Static wall for containing objects (STATIC PhysicsBody3D)."""
117
118 def __init__(self, half_extents=(0.5, 5, 25), colour=(0.5, 0.5, 0.6, 0.5), **kwargs):
119 super().__init__(
120 mode=BodyMode.STATIC,
121 shape=BoxShape3D(half_extents=Vec3(*half_extents)),
122 material=PhysicsMaterial(friction=0.5, restitution=0.7),
123 **kwargs,
124 )
125 self._half_extents = half_extents
126 self._colour = colour
127
128 def on_ready(self):
129 mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
130 mesh_node.mesh = Mesh.cube(size=1)
131 # The walls are translucent so the front one never hides the arena.
132 mesh_node.material = Material(colour=self._colour, blend="alpha")
133 he = self._half_extents
134 mesh_node.scale = Vec3(he[0] * 2, he[1] * 2, he[2] * 2)
135
136
137# ============================================================================
138# Main Scene
139# ============================================================================
140
141
142class PhysicsSandbox(Node):
143 """Main physics sandbox scene."""
144
145 input_actions = {
146 "spawn": [Key.SPACE, MouseButton.LEFT],
147 "reset": [Key.R],
148 "quit": [Key.ESCAPE],
149 }
150
151 def on_ready(self):
152 # One isolated physics world for the whole sandbox.
153 self._world_root = self.add_child(PhysicsRoot(name="World", gravity=Vec3(0, -9.8, 0)))
154
155 # Camera
156 camera = self.add_child(Camera3D(name="Camera"))
157 camera.position = Vec3(0, 15, 25)
158 camera.look_at(Vec3(0, 3, 0))
159 camera.fov = 60.0
160
161 # Ground
162 self._world_root.add_child(Ground(name="Ground", position=Vec3(0, -0.5, 0)))
163
164 # Side walls
165 self._world_root.add_child(Wall(name="WallLeft", position=Vec3(-12, 5, 0), half_extents=(0.5, 5, 12)))
166 self._world_root.add_child(Wall(name="WallRight", position=Vec3(12, 5, 0), half_extents=(0.5, 5, 12)))
167 self._world_root.add_child(Wall(name="WallBack", position=Vec3(0, 5, -12), half_extents=(12, 5, 0.5)))
168 self._world_root.add_child(Wall(name="WallFront", position=Vec3(0, 5, 12), half_extents=(12, 5, 0.5)))
169
170 # Initial stack of cubes
171 colours = [
172 (0.9, 0.2, 0.2, 1),
173 (0.2, 0.9, 0.2, 1),
174 (0.2, 0.2, 0.9, 1),
175 (0.9, 0.9, 0.2, 1),
176 (0.9, 0.2, 0.9, 1),
177 (0.2, 0.9, 0.9, 1),
178 ]
179 for i in range(3):
180 for j in range(3 - i):
181 colour = colours[(i * 3 + j) % len(colours)]
182 self._world_root.add_child(
183 PhysicsCube(
184 name=f"Cube_{i}_{j}",
185 position=Vec3(-2 + j * 1.5, 1.5 + i * 1.5, 0),
186 size=1.2,
187 colour=colour,
188 )
189 )
190
191 # A couple of bouncy balls
192 for i in range(3):
193 x = -3 + i * 3
194 self._world_root.add_child(
195 PhysicsBall(
196 name=f"Ball_{i}",
197 position=Vec3(x, 8 + i * 2, 2),
198 radius=0.6,
199 colour=(0.1 + i * 0.3, 0.5, 0.9 - i * 0.2, 1),
200 )
201 )
202
203 # UI
204 self._spawn_count = 0
205 self.add_child(
206 Text2D(
207 name="Title",
208 text="Physics Sandbox",
209 position=(20, 20),
210 font_scale=2.0,
211 colour=(1.0, 1.0, 1.0, 1.0),
212 )
213 )
214 self._info_text = self.add_child(
215 Text2D(
216 name="Info",
217 text="SPACE / click: spawn ball | R: reset | ESC: quit",
218 position=(20, 60),
219 font_scale=1.0,
220 colour=(0.78, 0.78, 0.78, 1.0),
221 )
222 )
223 self._count_text = self.add_child(
224 Text2D(
225 name="Count",
226 text="Bodies: 0",
227 position=(20, 90),
228 font_scale=1.0,
229 colour=(0.71, 0.71, 0.71, 1.0),
230 )
231 )
232
233 def on_update(self, dt: float):
234 # Quit on ESC
235 if Input.is_action_just_pressed("quit"):
236 self.app.quit()
237 return
238
239 # Spawn ball on space or click/tap
240 if Input.is_action_just_pressed("spawn"):
241 self._spawn_ball()
242
243 # Reset on R
244 if Input.is_action_just_pressed("reset"):
245 self.tree.change_scene(PhysicsSandbox())
246 return
247
248 # Live body count from this scene's world.
249 self._count_text.text = f"Bodies: {self._world_root.world.body_count}"
250
251 # Clean up fallen objects
252 for child in list(self._world_root.children):
253 if isinstance(child, PhysicsBody3D) and child.position.y < -20:
254 child.destroy()
255
256 def _spawn_ball(self):
257 self._spawn_count += 1
258 colour = (random.random(), random.random(), random.random(), 1.0)
259 ball = self._world_root.add_child(
260 PhysicsBall(
261 name=f"SpawnBall_{self._spawn_count}",
262 position=Vec3(random.uniform(-5, 5), 12, random.uniform(-5, 5)),
263 radius=random.uniform(0.3, 0.8),
264 colour=colour,
265 )
266 )
267 # Random impulse
268 ball.push(
269 Vec3(
270 random.uniform(-5, 5),
271 random.uniform(0, 5),
272 random.uniform(-5, 5),
273 )
274 )
275
276
277# ============================================================================
278# Entry Point
279# ============================================================================
280
281
282if __name__ == "__main__":
283 App(title="Physics Sandbox: SimVX", width=1280, height=720, target_fps=60, physics_fps=60).run(PhysicsSandbox())