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 three balls tumble inside a walled arena. Spawn more balls with a random shove and watch the pile react, while a counter shows how many bodies the world is simulating.
A PhysicsRoot owns one isolated physics world: its gravity applies to
every body beneath it, and root.world.body_count is how many bodies that
world holds. Each object is a PhysicsBody3D whose shape Property is the
collider and whose MeshInstance3D child is what you see, sized to match.
BodyMode.DYNAMIC bodies (the cubes and balls) fall and collide;
BodyMode.STATIC ones (the ground and the four translucent walls) never move.
ball = PhysicsBody3D(mode=BodyMode.DYNAMIC, shape=SphereShape3D(radius=0.5), material=BOUNCY)
ball.add_child(MeshInstance3D(mesh=Mesh.sphere(radius=0.5), material=Material(colour=(0.2, 0.6, 0.9))))
A PhysicsMaterial sets a surface’s friction and restitution
(bounciness). It is an immutable value, so one instance is shared by every body
made of it: the balls bounce at 0.8, the cubes at 0.3.
body.push(impulse) applies an instant change of momentum, which is how a
spawned ball gets its random shove. Reset rebuilds the whole scene with
tree.change_scene(PhysicsSandbox()), and the new root brings its own world.
Controls: SPACE / click / tap : Spawn a ball R / Reset button : Reset the scene
Source¶
1# /// simvx
2# tags = ["3d", "physics", "rigid-body"]
3# web = { width = 1280, height = 720 }
4# ///
5"""Physics Sandbox: stack-toppling cubes and bouncy balls in a walled 3D arena.
6
7A pyramid of cubes and three balls tumble inside a walled arena. Spawn more
8balls with a random shove and watch the pile react, while a counter shows how
9many bodies the world is simulating.
10
11A ``PhysicsRoot`` owns one isolated physics world: its ``gravity`` applies to
12every body beneath it, and ``root.world.body_count`` is how many bodies that
13world holds. Each object is a ``PhysicsBody3D`` whose ``shape`` Property is the
14collider and whose ``MeshInstance3D`` child is what you see, sized to match.
15``BodyMode.DYNAMIC`` bodies (the cubes and balls) fall and collide;
16``BodyMode.STATIC`` ones (the ground and the four translucent walls) never move.
17
18 ball = PhysicsBody3D(mode=BodyMode.DYNAMIC, shape=SphereShape3D(radius=0.5), material=BOUNCY)
19 ball.add_child(MeshInstance3D(mesh=Mesh.sphere(radius=0.5), material=Material(colour=(0.2, 0.6, 0.9))))
20
21A ``PhysicsMaterial`` sets a surface's ``friction`` and ``restitution``
22(bounciness). It is an immutable value, so one instance is shared by every body
23made of it: the balls bounce at 0.8, the cubes at 0.3.
24
25``body.push(impulse)`` applies an instant change of momentum, which is how a
26spawned ball gets its random shove. Reset rebuilds the whole scene with
27``tree.change_scene(PhysicsSandbox())``, and the new root brings its own world.
28
29Controls:
30 SPACE / click / tap : Spawn a ball
31 R / Reset button : Reset the scene
32"""
33
34import random
35
36from simvx.core import (
37 Anchor,
38 BodyMode,
39 BoxShape3D,
40 Button,
41 Camera3D,
42 CanvasLayer,
43 Key,
44 Label,
45 Material,
46 Mesh,
47 MeshInstance3D,
48 Node,
49 PhysicsBody3D,
50 PhysicsMaterial,
51 PhysicsRoot,
52 Pointer,
53 SphereShape3D,
54 VBoxContainer,
55 Vec3,
56 on_input,
57)
58from simvx.graphics import App
59
60BOUNCY = PhysicsMaterial(friction=0.3, restitution=0.8)
61WOODEN = PhysicsMaterial(friction=0.6, restitution=0.3)
62GRASS = PhysicsMaterial(friction=0.8, restitution=0.5)
63GLASS = PhysicsMaterial(friction=0.5, restitution=0.7)
64
65CUBE_COLOURS = [(0.9, 0.2, 0.2), (0.2, 0.9, 0.2), (0.2, 0.2, 0.9), (0.9, 0.9, 0.2), (0.9, 0.2, 0.9), (0.2, 0.9, 0.9)]
66
67
68def cube(size, colour, position) -> PhysicsBody3D:
69 body = PhysicsBody3D(
70 mode=BodyMode.DYNAMIC, shape=BoxShape3D(half_extents=Vec3(size / 2)), material=WOODEN, position=position
71 )
72 body.add_child(MeshInstance3D(mesh=Mesh.box(size), material=Material(colour=colour)))
73 return body
74
75
76def ball(radius, colour, position) -> PhysicsBody3D:
77 body = PhysicsBody3D(mode=BodyMode.DYNAMIC, shape=SphereShape3D(radius=radius), material=BOUNCY, position=position)
78 body.add_child(MeshInstance3D(mesh=Mesh.sphere(radius=radius), material=Material(colour=colour)))
79 return body
80
81
82def slab(half_extents, look, surface, position) -> PhysicsBody3D:
83 body = PhysicsBody3D(
84 mode=BodyMode.STATIC, shape=BoxShape3D(half_extents=half_extents), material=surface, position=position
85 )
86 body.add_child(MeshInstance3D(mesh=Mesh.box(Vec3(half_extents) * 2), material=look))
87 return body
88
89
90class PhysicsSandbox(Node):
91 input_context = {
92 "spawn": [Key.SPACE, Pointer.PRIMARY],
93 "reset": [Key.R],
94 }
95
96 def on_ready(self):
97 self.add_child(Camera3D(position=(0, 15, 25), look_at=(0, 3, 0), fov=60.0))
98 self.physics_root = self.add_child(PhysicsRoot(gravity=(0, -9.8, 0)))
99 add = self.physics_root.add_child
100
101 add(slab((25, 0.5, 25), Material(colour=(0.4, 0.5, 0.4)), GRASS, position=(0, -0.5, 0)))
102 # The walls are translucent so the front one never hides the arena.
103 glass = Material(colour=(0.5, 0.5, 0.6, 0.5), blend="alpha")
104 for x, z in [(-12, 0), (12, 0), (0, -12), (0, 12)]:
105 half_extents = (0.5, 5, 12) if x else (12, 5, 0.5)
106 add(slab(half_extents, glass, GLASS, position=(x, 5, z)))
107
108 for row in range(3):
109 for col in range(3 - row):
110 colour = CUBE_COLOURS[(row * 3 + col) % len(CUBE_COLOURS)]
111 add(cube(1.2, colour, position=(-2 + col * 1.5, 1.5 + row * 1.5, 0)))
112 for i in range(3):
113 add(ball(0.6, (0.1 + i * 0.3, 0.5, 0.9 - i * 0.2), position=(-3 + i * 3, 8 + i * 2, 2)))
114
115 hud = self.add_child(CanvasLayer()).add_child(VBoxContainer(anchor=Anchor.TOP_LEFT, inset=20, separation=8))
116 hud.add_child(Label("Physics Sandbox", font_size=28))
117 self.count = hud.add_child(Label())
118
119 # Example help overlay: not part of the technique shown.
120 hud.add_child(Label("SPACE / click / tap: spawn a ball"))
121 hud.add_child(Button("Reset", action="reset"))
122
123 @on_input("spawn")
124 def spawn_ball(self, _event):
125 colour = (random.random(), random.random(), random.random())
126 position = (random.uniform(-5, 5), 12, random.uniform(-5, 5))
127 new_ball = self.physics_root.add_child(ball(random.uniform(0.3, 0.8), colour, position))
128 new_ball.push((random.uniform(-5, 5), random.uniform(0, 5), random.uniform(-5, 5)))
129
130 @on_input("reset")
131 def reset(self, _event):
132 self.tree.change_scene(PhysicsSandbox())
133
134 def on_update(self, dt):
135 self.count.text = f"Bodies: {self.physics_root.world.body_count}"
136 # A body thrown over a wall falls forever; drop it once it is well below the ground.
137 for body in self.physics_root.children:
138 if body.position.y < -20:
139 body.destroy()
140
141
142if __name__ == "__main__":
143 App(title="Physics Sandbox", width=1280, height=720).run(PhysicsSandbox())