3D Physics Playground

falling bodies and a walking character.

▶ Run in browser

Tags: physics 3d character

Drop a batch of spheres and boxes onto a static slab, then walk a glowing character through the pile.

A PhysicsBody3D is simulated by the world: mode=BodyMode.DYNAMIC falls under gravity, tumbles and stacks, and BodyMode.STATIC (the slab) never moves. Its shape Property is the collider, and a MeshInstance3D child is what you see; the child inherits the pose the world writes back each fixed step.

body = PhysicsBody3D(mode=BodyMode.DYNAMIC, shape=SphereShape3D(radius=0.5), position=(0, 6, 0))
body.add_child(MeshInstance3D(mesh=Mesh.sphere(radius=0.5), material=Material(colour=(1, 0, 0))))

A CharacterBody3D is moved by game code instead. Each fixed step the scene sets its velocity and calls move_and_slide(dt), which sweeps it through the world, slides it along what it hits and writes the deflected velocity back, so the next step reads its fall speed straight off the character. Gravity is not applied for you: the scene adds the world’s own gravity to that fall speed, so the character drops at the rate the bodies do, and is_on_floor() reports whether the last move ended standing on something. Dynamic bodies collide with the character and can come to rest on its head, and it shoves the ones it walks into; walk out from under a resting box and it is left behind to fall, because a character carries no riders.

Movement is camera-relative: the move action is one Vec2 from WASD or the left stick, rotated by the OrbitCamera3D’s yaw so pushing away from you walks away from the camera however it is orbited. The camera takes its own input (drag, right stick, wheel); the arrow keys orbit it through camera.orbit(). A TouchController publishes an on-screen twin-stick pad as a real controller, so the same bindings make it playable with a finger.

Controls: WASD / left stick - Walk (camera-relative) Drag / arrows / right stick - Orbit the camera Scroll - Zoom R / A / Drop button - Drop a fresh batch of bodies

Source

  1# /// simvx
  2# tags = ["3d", "physics", "character"]
  3# ///
  4"""3D Physics Playground: falling bodies and a walking character.
  5
  6Drop a batch of spheres and boxes onto a static slab, then walk a glowing
  7character through the pile.
  8
  9A ``PhysicsBody3D`` is simulated by the world: ``mode=BodyMode.DYNAMIC`` falls
 10under gravity, tumbles and stacks, and ``BodyMode.STATIC`` (the slab) never
 11moves. Its ``shape`` Property is the collider, and a ``MeshInstance3D`` child is
 12what you see; the child inherits the pose the world writes back each fixed step.
 13
 14    body = PhysicsBody3D(mode=BodyMode.DYNAMIC, shape=SphereShape3D(radius=0.5), position=(0, 6, 0))
 15    body.add_child(MeshInstance3D(mesh=Mesh.sphere(radius=0.5), material=Material(colour=(1, 0, 0))))
 16
 17A ``CharacterBody3D`` is moved by game code instead. Each fixed step the scene
 18sets its ``velocity`` and calls ``move_and_slide(dt)``, which sweeps it through
 19the world, slides it along what it hits and writes the deflected velocity back,
 20so the next step reads its fall speed straight off the character. Gravity is not
 21applied for you: the scene adds the world's own ``gravity`` to that fall speed,
 22so the character drops at the rate the bodies do, and ``is_on_floor()`` reports
 23whether the last move ended standing on something. Dynamic bodies collide with
 24the character and can come to rest on its head, and it shoves the ones it walks
 25into; walk out from under a resting box and it is left behind to fall, because a
 26character carries no riders.
 27
 28Movement is camera-relative: the ``move`` action is one ``Vec2`` from WASD or the
 29left stick, rotated by the ``OrbitCamera3D``'s ``yaw`` so pushing away from you
 30walks away from the camera however it is orbited. The camera takes its own
 31input (drag, right stick, wheel); the arrow keys orbit it through
 32``camera.orbit()``. A ``TouchController`` publishes an on-screen twin-stick pad
 33as a real controller, so the same bindings make it playable with a finger.
 34
 35Controls:
 36    WASD / left stick           - Walk (camera-relative)
 37    Drag / arrows / right stick - Orbit the camera
 38    Scroll                      - Zoom
 39    R / A / Drop button         - Drop a fresh batch of bodies
 40"""
 41
 42from simvx.core import (
 43    Anchor,
 44    Axis2D,
 45    BodyMode,
 46    BoxShape3D,
 47    Button,
 48    CanvasLayer,
 49    CharacterBody3D,
 50    Controller,
 51    DirectionalLight3D,
 52    Input,
 53    Key,
 54    Label,
 55    Material,
 56    Mesh,
 57    MeshInstance3D,
 58    Node3D,
 59    OrbitCamera3D,
 60    PhysicsBody3D,
 61    SphereShape3D,
 62    TouchController,
 63    TouchControllerLayout,
 64    VBoxContainer,
 65    Vec3,
 66    on_input,
 67)
 68from simvx.graphics import App
 69
 70WALK_SPEED = 6.0
 71# The bodies are 1 unit across and the slab's top is y = 0.5, so a body lying on
 72# the slab has its centre near y = 1.0; above this it is still falling or stacked.
 73REST_HEIGHT = 1.7
 74
 75BODY_COLOURS = [
 76    (0.90, 0.30, 0.25),
 77    (0.30, 0.70, 0.95),
 78    (0.95, 0.80, 0.25),
 79    (0.55, 0.85, 0.35),
 80    (0.80, 0.45, 0.90),
 81]
 82SPHERE = Mesh.sphere(radius=0.5, rings=16, segments=24)
 83CUBE = Mesh.box(1.0)
 84
 85
 86class PhysicsScene(Node3D):
 87    input_context = {
 88        "move": [Axis2D.WASD, Controller.LEFT_STICK],
 89        "orbit": [Axis2D.ARROWS],
 90        "respawn": [Key.R, Controller.A],
 91    }
 92
 93    def on_ready(self):
 94        self.camera = self.add_child(
 95            OrbitCamera3D(
 96                pivot=Vec3(0, 1.5, 0),
 97                distance=22.0,
 98                yaw=0.6,
 99                pitch=-0.5,
100                min_pitch=-1.3,
101                max_pitch=-0.05,
102                min_distance=8.0,
103                max_distance=40.0,
104            )
105        )
106        self.add_child(DirectionalLight3D(colour=(1.0, 0.96, 0.85), intensity=1.0, direction=(-6, -12, -8)))
107
108        ground = self.add_child(
109            PhysicsBody3D(name="Ground", mode=BodyMode.STATIC, shape=BoxShape3D(half_extents=(12, 0.5, 12)))
110        )
111        ground.add_child(
112            MeshInstance3D(mesh=Mesh.box((24, 1, 24)), material=Material(colour=(0.24, 0.26, 0.30), roughness=0.9))
113        )
114
115        self.character = self.add_child(
116            CharacterBody3D(name="Character", shape=SphereShape3D(radius=0.6), position=(0, 1.2, 4))
117        )
118        self.character.add_child(
119            MeshInstance3D(
120                mesh=Mesh.sphere(radius=0.6, rings=16, segments=24),
121                material=Material(colour=(1.0, 0.55, 0.15), emissive_colour=(1.0, 0.45, 0.10, 1.5), roughness=0.5),
122            )
123        )
124
125        self.bodies: list[PhysicsBody3D] = []
126        self.spawn_batch()
127
128        self.add_child(TouchController(layout=TouchControllerLayout.TWIN_STICK, buttons=(Controller.A,)))
129        hud = self.add_child(CanvasLayer()).add_child(VBoxContainer(anchor=Anchor.TOP_LEFT, inset=12, separation=8))
130        self.status = hud.add_child(Label(font_size=16))
131
132        # Example help overlay: not part of the technique shown.
133        hud.add_child(Label("WASD: walk   Drag / arrows: orbit   Scroll: zoom"))
134        hud.add_child(Button("Drop", action="respawn"))
135
136    def spawn_batch(self):
137        for body in self.bodies:
138            body.destroy()
139        self.bodies = []
140        for i in range(8):
141            is_sphere = i % 2 == 0
142            body = self.add_child(
143                PhysicsBody3D(
144                    mode=BodyMode.DYNAMIC,
145                    shape=SphereShape3D(radius=0.5) if is_sphere else BoxShape3D(half_extents=(0.5, 0.5, 0.5)),
146                    position=((i % 4 - 1.5) * 1.6, 6.0 + (i % 4) * 0.9, (i // 4 - 0.5) * 1.6),
147                )
148            )
149            colour = BODY_COLOURS[i % len(BODY_COLOURS)]
150            body.add_child(
151                MeshInstance3D(mesh=SPHERE if is_sphere else CUBE, material=Material(colour=colour, roughness=0.4))
152            )
153            self.bodies.append(body)
154
155    @on_input("respawn")
156    def _on_respawn(self, _event):
157        self.spawn_batch()
158
159    def on_fixed_update(self, dt):
160        # Rotating the input by the camera's yaw turns "up the screen" into "away from the camera".
161        walk = Input.value("move").rotated(-self.camera.yaw) * WALK_SPEED
162        fall = float(self.character.velocity.y) + float(self.character.world.gravity.y) * dt
163        self.character.velocity = Vec3(walk.x, fall, walk.y)
164        self.character.move_and_slide(dt)
165
166    def on_update(self, dt):
167        turn = Input.value("orbit")
168        speed = self.camera.stick_speed * dt
169        self.camera.orbit(-turn.x * speed, -turn.y * speed)
170
171        resting = sum(1 for body in self.bodies if body.world_position.y < REST_HEIGHT)
172        self.status.text = f"Resting: {resting}/{len(self.bodies)}   On floor: {self.character.is_on_floor()}"
173
174
175if __name__ == "__main__":
176    App(title="3D Physics Playground", width=1280, height=720).run(PhysicsScene())