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. The character is an ordinary kinematic body, so the falling bodies collide with it and can come to rest on its head; walk out from under a resting box and it is left behind to fall, because a character carries no riders.

Shows:

  • PhysicsBody3D (mode=DYNAMIC) spheres and boxes falling under gravity and resting or stacking on a PhysicsBody3D (mode=STATIC) slab; the world steps them and writes their poses back to the nodes each fixed step.

  • CharacterBody3D moved with move_and_slide() and is_on_floor(), with gravity read from the world it lives in and integrated by game code each fixed step. move_and_slide writes the deflected velocity back, so the next step reads its vertical speed straight off the character.

  • The standard body recipe: a CollisionShape3D child for the physics plus a MeshInstance3D child for the visual, which inherits the body’s transform.

  • A VirtualJoystick and VirtualButtons that inject the same keys the keyboard uses, so the demo plays with a mouse or a finger as well.

The world is Y-up, so gravity (-Y) and the visuals agree. Movement is camera-relative: pushing the stick (or W) away from you walks the character away from the camera, whichever way it is orbited.

Controls: WASD - move the character (camera-relative) Arrows - orbit the camera R - drop a fresh batch of bodies Escape - quit On-screen stick and buttons - move, orbit, drop (mouse or touch)

Run: uv run python examples/features/physics/playground3d.py Headless self-check: uv run python examples/features/physics/playground3d.py –test

Source¶

  1"""3D Physics Playground: falling bodies and a walking character.
  2
  3Drop a batch of spheres and boxes onto a static slab, then walk a glowing
  4character through the pile. The character is an ordinary kinematic body, so the
  5falling bodies collide with it and can come to rest on its head; walk out from
  6under a resting box and it is left behind to fall, because a character carries
  7no riders.
  8
  9Shows:
 10  - PhysicsBody3D (mode=DYNAMIC) spheres and boxes falling under gravity and
 11    resting or stacking on a PhysicsBody3D (mode=STATIC) slab; the world steps
 12    them and writes their poses back to the nodes each fixed step.
 13  - CharacterBody3D moved with move_and_slide() and is_on_floor(), with gravity
 14    read from the world it lives in and integrated by game code each fixed step.
 15    move_and_slide writes the deflected velocity back, so the next step reads its
 16    vertical speed straight off the character.
 17  - The standard body recipe: a CollisionShape3D child for the physics plus a
 18    MeshInstance3D child for the visual, which inherits the body's transform.
 19  - A VirtualJoystick and VirtualButtons that inject the same keys the keyboard
 20    uses, so the demo plays with a mouse or a finger as well.
 21
 22The world is Y-up, so gravity (-Y) and the visuals agree. Movement is
 23camera-relative: pushing the stick (or W) away from you walks the character away
 24from the camera, whichever way it is orbited.
 25
 26Controls:
 27    WASD    - move the character (camera-relative)
 28    Arrows  - orbit the camera
 29    R       - drop a fresh batch of bodies
 30    Escape  - quit
 31    On-screen stick and buttons - move, orbit, drop (mouse or touch)
 32
 33Run: uv run python examples/features/physics/playground3d.py
 34Headless self-check: uv run python examples/features/physics/playground3d.py --test
 35
 36# /// simvx
 37# tags = ["3d", "physics", "character"]
 38# ///
 39"""
 40
 41from __future__ import annotations
 42
 43import math
 44
 45from simvx.core import (
 46    AnchorPreset,
 47    BodyMode,
 48    BoxShape3D,
 49    Camera3D,
 50    CharacterBody3D,
 51    CollisionShape3D,
 52    Control,
 53    DirectionalLight3D,
 54    Input,
 55    InputMap,
 56    Key,
 57    Material,
 58    Mesh,
 59    MeshInstance3D,
 60    Node,
 61    PhysicsBody3D,
 62    SphereShape3D,
 63    Text2D,
 64    Vec2,
 65    Vec3,
 66)
 67from simvx.core.ui import VirtualButton, VirtualJoystick
 68from simvx.graphics import App
 69
 70_BODY_COLOURS = [
 71    (0.90, 0.30, 0.25, 1.0),
 72    (0.30, 0.70, 0.95, 1.0),
 73    (0.95, 0.80, 0.25, 1.0),
 74    (0.55, 0.85, 0.35, 1.0),
 75    (0.80, 0.45, 0.90, 1.0),
 76]
 77
 78#: The slab's top face sits at y = 0.5 and the bodies are 1 unit across, so a
 79#: body resting directly on the slab has its centre at y = 1.0: a centre below
 80#: this has settled rather than still falling or stacked on another body.
 81_REST_HEIGHT = 1.7
 82#: A resting body's centre never drops below this; lower means it sank into the
 83#: slab, which the self-check reports as a failure rather than a rest.
 84_MIN_REST_HEIGHT = 0.6
 85#: Stick tilt at which a digital direction key is considered held.
 86_STICK_THRESHOLD = 0.35
 87
 88
 89class PointerControls(Control):
 90    """On-screen stick and buttons so the demo plays with a mouse or a finger.
 91
 92    The stick sits bottom-left, the orbit and drop buttons bottom-right. Every
 93    widget injects the same key the keyboard binds, so there is a single input
 94    path to reason about, and the widgets scale with the viewport.
 95    """
 96
 97    def __init__(self, **kwargs):
 98        super().__init__(**kwargs)
 99        self.set_anchor_preset(AnchorPreset.FULL_RECT)
100
101        self._stick = VirtualJoystick(name="MoveStick")
102        self._stick.moved.connect(self._on_stick)
103        self.add_child(self._stick)
104
105        self._orbit_left = self._add_button("<", Key.LEFT, "OrbitLeft")
106        self._orbit_right = self._add_button(">", Key.RIGHT, "OrbitRight")
107        self._drop = self._add_button("DROP", Key.R, "Drop")
108
109        #: Direction keys the stick currently holds down, so keys are injected on
110        #: edges only and released cleanly when the stick re-centres.
111        self._held: set[Key] = set()
112
113    def _add_button(self, label: str, key: Key, name: str) -> VirtualButton:
114        button = VirtualButton(label=label, name=name)
115        button.pressed.connect(lambda: Input.inject_key(key, True))
116        button.released.connect(lambda: Input.inject_key(key, False))
117        return self.add_child(button)
118
119    def on_enter_tree(self):
120        self._layout(self.tree.screen_size)
121        self.tree.screen_resized.connect(self._layout)
122
123    def on_exit_tree(self):
124        self.tree.screen_resized.disconnect(self._layout)
125        for key in self._held:
126            Input.inject_key(key, False)
127        self._held = set()
128
129    def _layout(self, size):
130        w, h = float(size[0]), float(size[1])
131        unit = min(w, h)
132        inset = max(20.0, unit * 0.04)  # safe-area margin from the edges
133
134        stick_r = max(56.0, unit * 0.12)
135        self._stick.radius = stick_r
136        self._stick.size = Vec2(stick_r * 2, stick_r * 2)
137        self._stick.position = Vec2(inset, h - inset - stick_r * 2)
138
139        btn_r = max(40.0, unit * 0.075)
140        for button in (self._orbit_left, self._orbit_right, self._drop):
141            button.button_radius = btn_r
142            button.size = Vec2(btn_r * 2, btn_r * 2)
143
144        gap = btn_r * 0.5
145        row_y = h - inset - btn_r * 2
146        self._orbit_right.position = Vec2(w - inset - btn_r * 2, row_y)
147        self._orbit_left.position = Vec2(w - inset - btn_r * 4 - gap, row_y)
148        self._drop.position = Vec2(w - inset - btn_r * 3 - gap / 2, row_y - btn_r * 2 - gap)
149
150    def _on_stick(self, nx: float, ny: float):
151        """Map the analog tilt onto the movement keys (stick +Y is screen-down)."""
152        want: set[Key] = set()
153        if nx <= -_STICK_THRESHOLD:
154            want.add(Key.A)
155        elif nx >= _STICK_THRESHOLD:
156            want.add(Key.D)
157        if ny <= -_STICK_THRESHOLD:
158            want.add(Key.W)
159        elif ny >= _STICK_THRESHOLD:
160            want.add(Key.S)
161
162        for key in self._held - want:
163            Input.inject_key(key, False)
164        for key in want - self._held:
165            Input.inject_key(key, True)
166        self._held = want
167
168
169class PhysicsScene(Node):
170    def on_ready(self):
171        InputMap.add_action("move_fwd", [Key.W])
172        InputMap.add_action("move_back", [Key.S])
173        InputMap.add_action("move_left", [Key.A])
174        InputMap.add_action("move_right", [Key.D])
175        InputMap.add_action("orbit_left", [Key.LEFT])
176        InputMap.add_action("orbit_right", [Key.RIGHT])
177        InputMap.add_action("pitch_up", [Key.UP])
178        InputMap.add_action("pitch_down", [Key.DOWN])
179        InputMap.add_action("respawn", [Key.R])
180        InputMap.add_action("quit", [Key.ESCAPE])
181
182        self._cam_angle = 0.6
183        self._cam_pitch = 0.5
184        self._cam = self.add_child(Camera3D())
185        self._update_camera()
186
187        sun = DirectionalLight3D(position=(6, 12, 8))
188        sun.colour = (1.0, 0.96, 0.85)
189        sun.intensity = 1.0
190        sun.look_at((0, 0, 0))
191        self.add_child(sun)
192
193        # Ground: a static slab. BoxShape3D half-extents match the visual scale.
194        ground = PhysicsBody3D(mode=BodyMode.STATIC, position=(0, 0, 0))
195        ground.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(12, 0.5, 12))))
196        ground.add_child(
197            MeshInstance3D(
198                mesh=Mesh.cube(),
199                material=Material(colour=(0.24, 0.26, 0.30, 1.0), roughness=0.9, metallic=0.0),
200                scale=(24, 1, 24),
201            )
202        )
203        self.add_child(ground)
204
205        # A walking character (a sphere collider for the basic tier).
206        self._char = CharacterBody3D(position=(0, 1.2, 4))
207        self._char.add_child(CollisionShape3D(shape=SphereShape3D(radius=0.6)))
208        self._char.add_child(
209            MeshInstance3D(
210                mesh=Mesh.sphere(radius=0.6, rings=16, segments=24),
211                material=Material(colour=(1.0, 0.55, 0.15, 1.0), emissive_colour=(1.0, 0.45, 0.10, 1.5), roughness=0.5),
212            )
213        )
214        self.add_child(self._char)
215        # The character integrates gravity itself; take it from the world its body
216        # was created in so it falls at exactly the rate the dynamic bodies do.
217        self._gravity_y = float(self._char.world.gravity.y)
218
219        self._sphere_mesh = Mesh.sphere(radius=0.5, rings=16, segments=24)
220        self._cube_mesh = Mesh.cube()
221        self._bodies: list[PhysicsBody3D] = []
222        self._spawn_batch()
223
224        # Two lines rather than one: the whole HUD has to stay inside the frame at
225        # the size the site publishes screenshots at, not just at the window size.
226        self._hud = Text2D(text="WASD move | Arrows orbit | R drop | ESC quit", position=(10, 10), font_scale=1.4)
227        self.add_child(self._hud)
228        self._hud_touch = Text2D(text="or use the on-screen stick and buttons", position=(10, 38), font_scale=1.4)
229        self.add_child(self._hud_touch)
230        self._status = Text2D(text="", position=(10, 66), font_scale=1.0)
231        self.add_child(self._status)
232        self.add_child(PointerControls(name="PointerControls"))
233
234    def _spawn_batch(self):
235        for b in self._bodies:
236            b.destroy()
237        self._bodies = []
238        for i in range(8):
239            x = (i % 4 - 1.5) * 1.6
240            z = (i // 4 - 0.5) * 1.6
241            y = 6.0 + (i % 4) * 0.9
242            if i % 2 == 0:
243                shape, mesh = SphereShape3D(radius=0.5), self._sphere_mesh
244            else:
245                shape, mesh = BoxShape3D(half_extents=Vec3(0.5, 0.5, 0.5)), self._cube_mesh
246            body = PhysicsBody3D(mode=BodyMode.DYNAMIC, position=(x, y, z), mass=1.0)
247            body.add_child(CollisionShape3D(shape=shape))
248            body.add_child(
249                MeshInstance3D(
250                    mesh=mesh,
251                    material=Material(colour=_BODY_COLOURS[i % len(_BODY_COLOURS)], roughness=0.4, metallic=0.1),
252                )
253            )
254            self.add_child(body)
255            self._bodies.append(body)
256
257    def _update_camera(self):
258        d, a, p = 22.0, self._cam_angle, self._cam_pitch
259        self._cam.position = (d * math.cos(p) * math.sin(a), d * math.sin(p) + 3.0, d * math.cos(p) * math.cos(a))
260        self._cam.look_at((0, 1.5, 0), up=(0, 1, 0))
261
262    def on_fixed_update(self, dt):
263        # Character: horizontal from input, gravity on Y, slide along the world.
264        speed = 6.0
265        # get_vector normalises the diagonal, so walking NE is not faster than N.
266        move = Input.get_vector("move_left", "move_right", "move_back", "move_fwd")
267        # Ground-plane camera basis: forward is where the camera looks, right is
268        # a quarter turn from it, so the stick and WASD read as screen directions.
269        fwd_x, fwd_z = -math.sin(self._cam_angle), -math.cos(self._cam_angle)
270        right_x, right_z = math.cos(self._cam_angle), -math.sin(self._cam_angle)
271        vx = (right_x * move.x + fwd_x * move.y) * speed
272        vz = (right_z * move.x + fwd_z * move.y) * speed
273        # move_and_slide wrote the deflected velocity back last step, so the fall
274        # speed is read from the character rather than shadowed in game state.
275        vy = float(self._char.velocity.y)
276        if self._char.is_on_floor() and vy < 0.0:
277            vy = 0.0
278        vy += self._gravity_y * dt
279        self._char.velocity = Vec3(vx, vy, vz)
280        self._char.move_and_slide(dt)
281
282    def on_update(self, dt):
283        if Input.is_action_just_pressed("quit"):
284            self.app.quit()
285            return
286        if Input.is_action_just_pressed("respawn"):
287            self._spawn_batch()
288        rot = 1.5
289        self._cam_angle += Input.get_axis("orbit_left", "orbit_right") * rot * dt
290        self._cam_pitch = max(0.05, min(1.3, self._cam_pitch + Input.get_axis("pitch_down", "pitch_up") * rot * dt))
291        self._update_camera()
292        resting = sum(1 for b in self._bodies if b.world_position.y < _REST_HEIGHT)
293        self._status.text = f"bodies: {len(self._bodies)} | resting: {resting} | on_floor: {self._char.is_on_floor()}"
294
295
296def _selftest() -> bool:
297    """Headless: run a couple of seconds, screenshot, and assert bodies fell + rested."""
298    from simvx.graphics.testing import assert_not_blank, save_png
299
300    app = App(title="Physics3D", width=1280, height=720, visible=False)
301    scene = PhysicsScene(name="PhysicsScene")
302    frames = app.run_headless(scene, frames=180, capture_frames=[179])
303    frame = frames[0]
304    assert_not_blank(frame)
305    save_png(frame, "/tmp/physics3d_new_test.png")
306    ys = [float(b.world_position.y) for b in scene._bodies]
307    rested = [y for y in ys if _MIN_REST_HEIGHT < y < _REST_HEIGHT]
308    print(f"body y after 3s: min={min(ys):.2f} max={max(ys):.2f} ; rested-on-ground: {len(rested)}/{len(ys)}")
309    print(f"character y={float(scene._char.world_position.y):.2f} on_floor={scene._char.is_on_floor()}")
310    print("screenshot: /tmp/physics3d_new_test.png")
311    ok = len(rested) >= len(ys) - 1 and 0.0 < float(scene._char.world_position.y) < 3.0
312    print("SELFTEST:", "PASS" if ok else "FAIL")
313    return ok
314
315
316if __name__ == "__main__":
317    import sys
318
319    if "--test" in sys.argv:
320        sys.exit(0 if _selftest() else 1)
321    app = App(title="3D Physics Playground", width=1280, height=720)
322    app.run(PhysicsScene())