3D Physics Playground¶
falling bodies and a walking character.
â–¶ Run in browserTags: 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())