Physics Sandbox

stack-toppling cubes and bouncy balls in a walled 3D arena.

▶ Run in browser

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