Sleeping Bodies¶
what wakes a settled pile, and how to edit one without waking it.
â–¶ Run in browserTags: physics 3d sleeping
A pile of crates settles on the ground and falls asleep; a sleeping body is skipped by integration and the contact solve until something disturbs it, which is what keeps a large resting scene cheap. Bodies are tinted by their live sleep state, so the pile visibly dims as it settles and lights up the moment anything reaches it.
The interesting half is what counts as a disturbance. Changing a body wakes what
it was holding up: shrink the pillar under the crate on top and the crate falls,
because the seam wakes its riders rather than waiting for a contact that has
already gone. The same edit with wake=False moves the pillar and leaves the
crate hanging in mid-air, which is the picture of what suppression buys (level
streaming and pooled respawns do not re-simulate a parked scene) and what it
costs (you now own the consequences).
The other direction is handing a body TO the simulation. A crate can be streamed in parked – dynamic and asleep where it stands, costing nothing until something reaches it – which is what a level chunk arriving mid-game wants instead of paying to fall into place.
A pile sleeps as a whole and wakes as a whole. Pull the bottom crate out of a stack and every crate it was holding up wakes in the same step, not just the one directly above it: the crate above is falling by the time the next collision pass runs, so a wake that reached one level would leave the rest of the stack asleep in mid-air. The HUD’s drift readout is the other side of that bargain – while a crate is asleep its pose does not change at all, which is what makes a settled scene free rather than merely quiet.
Shows:
PhysicsBody3D.is_sleeping,wake()andsleep(): the read-back and the two explicit controls.can_sleep: a body that must stay simulated however long it rests.Re-shaping a support wakes the bodies resting on it, through the node API (assigning
CollisionShape3D.shape).The
wake=Falseopt-out, which lives on the world seam rather than on the node: reached through the publicbody.world/body.handlepair.The parked hand-over: a body freed into
DYNAMICand put straight back to sleep, which is what the seam does for a mode flip made withwake=False.Island-atomic wake: one crate taken out from under a stack wakes all of it.
Asleep is frozen: the HUD reports how far any sleeping crate has moved since it fell asleep, which stays at zero.
Capability.SLEEP: a backend that does not advertise it never sleeps anything, so the HUD says so instead of showing a count that cannot move.
Controls: 1 - shrink the pillar (default): the crate on top wakes and falls 2 - slide the pillar away with wake=False: the crate stays asleep 3 - slide the pillar away normally: the crate wakes and falls 4 - stream a crate in parked: it hangs where it lands until it is woken 5 - yank the bottom crate out of a stack: the whole stack wakes W / S - wake / sleep the whole pile C - toggle can_sleep on the pile Arrows - orbit the camera R - rebuild the scene Escape - quit
Run: uv run python examples/features/physics/sleeping.py Headless self-check: uv run python examples/features/physics/sleeping.py –test
Source¶
1"""Sleeping Bodies: what wakes a settled pile, and how to edit one without waking it.
2
3A pile of crates settles on the ground and falls asleep; a sleeping body is
4skipped by integration and the contact solve until something disturbs it, which
5is what keeps a large resting scene cheap. Bodies are tinted by their live sleep
6state, so the pile visibly dims as it settles and lights up the moment anything
7reaches it.
8
9The interesting half is what counts as a disturbance. Changing a body wakes what
10it was holding up: shrink the pillar under the crate on top and the crate falls,
11because the seam wakes its riders rather than waiting for a contact that has
12already gone. The same edit with ``wake=False`` moves the pillar and leaves the
13crate hanging in mid-air, which is the picture of what suppression buys (level
14streaming and pooled respawns do not re-simulate a parked scene) and what it
15costs (you now own the consequences).
16
17The other direction is handing a body TO the simulation. A crate can be streamed
18in parked -- dynamic and asleep where it stands, costing nothing until something
19reaches it -- which is what a level chunk arriving mid-game wants instead of
20paying to fall into place.
21
22A pile sleeps as a whole and wakes as a whole. Pull the bottom crate out of a
23stack and every crate it was holding up wakes in the same step, not just the one
24directly above it: the crate above is falling by the time the next collision pass
25runs, so a wake that reached one level would leave the rest of the stack asleep in
26mid-air. The HUD's drift readout is the other side of that bargain -- while a
27crate is asleep its pose does not change at all, which is what makes a settled
28scene free rather than merely quiet.
29
30Shows:
31 - ``PhysicsBody3D.is_sleeping``, ``wake()`` and ``sleep()``: the read-back and
32 the two explicit controls.
33 - ``can_sleep``: a body that must stay simulated however long it rests.
34 - Re-shaping a support wakes the bodies resting on it, through the node API
35 (assigning ``CollisionShape3D.shape``).
36 - The ``wake=False`` opt-out, which lives on the world seam rather than on the
37 node: reached through the public ``body.world`` / ``body.handle`` pair.
38 - The parked hand-over: a body freed into ``DYNAMIC`` and put straight back to
39 sleep, which is what the seam does for a mode flip made with ``wake=False``.
40 - Island-atomic wake: one crate taken out from under a stack wakes all of it.
41 - Asleep is frozen: the HUD reports how far any sleeping crate has moved since
42 it fell asleep, which stays at zero.
43 - ``Capability.SLEEP``: a backend that does not advertise it never sleeps
44 anything, so the HUD says so instead of showing a count that cannot move.
45
46Controls:
47 1 - shrink the pillar (default): the crate on top wakes and falls
48 2 - slide the pillar away with wake=False: the crate stays asleep
49 3 - slide the pillar away normally: the crate wakes and falls
50 4 - stream a crate in parked: it hangs where it lands until it is woken
51 5 - yank the bottom crate out of a stack: the whole stack wakes
52 W / S - wake / sleep the whole pile
53 C - toggle can_sleep on the pile
54 Arrows - orbit the camera
55 R - rebuild the scene
56 Escape - quit
57
58Run: uv run python examples/features/physics/sleeping.py
59Headless self-check: uv run python examples/features/physics/sleeping.py --test
60
61# /// simvx
62# tags = ["3d", "physics", "sleeping"]
63# ///
64"""
65
66from __future__ import annotations
67
68import math
69
70from simvx.core import (
71 BodyMode,
72 BoxShape3D,
73 Camera3D,
74 CollisionShape3D,
75 DirectionalLight3D,
76 Input,
77 InputMap,
78 Key,
79 Material,
80 Mesh,
81 MeshInstance3D,
82 Node,
83 PhysicsBody3D,
84 Text2D,
85 Vec3,
86)
87from simvx.core.physics import Capability
88from simvx.graphics import App
89
90#: Where the pillar stands, and how tall it is to begin with.
91_PILLAR_X = 4.0
92_PILLAR_HALF_HEIGHT = 2.0
93_SHRUNK_HALF_HEIGHT = 0.5
94#: How far along X the pillar is slid to take it out from under the crate: clear of
95#: it by a wide margin, and still in shot so you can see where it went.
96_SLIDE_DISTANCE = 8.0
97#: The crate that rides the pillar starts just above its top face, and settles on it.
98_RIDER_Y = _PILLAR_HALF_HEIGHT * 2 + 0.55
99_RIDER_REST_Y = _PILLAR_HALF_HEIGHT * 2 + 0.5
100#: Where a streamed-in crate is handed over, well clear of the pile and the pillar.
101_STREAM_X = -0.5
102_STREAM_Y = 7.0
103#: How far forward the yanked crate is pulled: clear of the stack it was under,
104#: and still in shot next to it.
105_YANK_Z = -6.0
106
107_ASLEEP = Material(colour=(0.30, 0.34, 0.42, 1.0), roughness=0.9, metallic=0.0)
108_AWAKE = Material(colour=(0.95, 0.72, 0.25, 1.0), emissive_colour=(0.7, 0.45, 0.05, 0.8), roughness=0.4)
109#: A body that may never sleep is always drawn awake; a distinct tint says why.
110_NEVER_SLEEPS = Material(colour=(0.35, 0.80, 0.95, 1.0), emissive_colour=(0.1, 0.4, 0.5, 0.6), roughness=0.4)
111
112
113class SleepingScene(Node):
114 def on_ready(self):
115 InputMap.add_action("shrink_pillar", [Key.KEY_1])
116 InputMap.add_action("slide_quietly", [Key.KEY_2])
117 InputMap.add_action("slide_loudly", [Key.KEY_3])
118 InputMap.add_action("stream_in", [Key.KEY_4])
119 InputMap.add_action("yank_bottom", [Key.KEY_5])
120 InputMap.add_action("wake_all", [Key.W])
121 InputMap.add_action("sleep_all", [Key.S])
122 InputMap.add_action("toggle_can_sleep", [Key.C])
123 InputMap.add_action("orbit_left", [Key.LEFT])
124 InputMap.add_action("orbit_right", [Key.RIGHT])
125 InputMap.add_action("rebuild", [Key.R])
126 InputMap.add_action("quit", [Key.ESCAPE])
127
128 self._cam_angle = 0.7
129 self._cam = self.add_child(Camera3D())
130 self._update_camera()
131
132 sun = DirectionalLight3D(position=(6, 12, 8))
133 sun.colour = (1.0, 0.96, 0.85)
134 sun.intensity = 3.0
135 sun.look_at((0, 0, 0))
136 self.add_child(sun)
137
138 self._cube = Mesh.cube()
139 self._bodies: list[PhysicsBody3D] = []
140 self._stacks: list[list[PhysicsBody3D]] = []
141 self._visuals: dict[PhysicsBody3D, MeshInstance3D] = {}
142 # Where each crate was when it last fell asleep, so the HUD can report
143 # that a sleeper's pose really does stop changing.
144 self._asleep_at: dict[PhysicsBody3D, tuple[float, float, float]] = {}
145 self._ground: PhysicsBody3D | None = None
146 self._pillar: PhysicsBody3D | None = None
147 self._pillar_shape: CollisionShape3D | None = None
148 self._pillar_mesh: MeshInstance3D | None = None
149 self._rider: PhysicsBody3D | None = None
150 self._can_sleep = True
151 self._streamed = 0
152 self._build()
153
154 self._hud = Text2D(
155 text=(
156 "1 shrink pillar | 2 slide it (wake=False) | 3 slide it | 4 stream one in parked | "
157 "5 yank a stack's bottom crate | W/S wake/sleep | C can_sleep | R reset"
158 ),
159 position=(10, 10),
160 font_scale=1.3,
161 )
162 self.add_child(self._hud)
163 self._status = self.add_child(Text2D(text="", position=(10, 34), font_scale=1.1))
164 self._note = self.add_child(Text2D(text="", position=(10, 58), font_scale=1.0))
165
166 # -- scene construction -------------------------------------------------
167
168 def _crate(self, position, *, can_sleep: bool = True, mode: BodyMode = BodyMode.DYNAMIC) -> PhysicsBody3D:
169 body = PhysicsBody3D(mode=mode, position=position, mass=1.0, can_sleep=can_sleep)
170 body.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(0.5, 0.5, 0.5))))
171 # Kept beside the body rather than looked up each frame: the tint is
172 # rewritten whenever the sleep state changes.
173 self._visuals[body] = body.add_child(MeshInstance3D(mesh=self._cube, material=_AWAKE))
174 self.add_child(body)
175 self._bodies.append(body)
176 return body
177
178 def _build(self):
179 for body in self._bodies:
180 body.destroy()
181 self._bodies = []
182 self._stacks = []
183 self._visuals = {}
184 self._asleep_at = {}
185 self._streamed = 0
186 if self._pillar is not None:
187 self._pillar.destroy()
188 if self._ground is not None:
189 self._ground.destroy()
190
191 self._ground = PhysicsBody3D(mode=BodyMode.STATIC, position=(0, -0.5, 0))
192 self._ground.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(14, 0.5, 14))))
193 self._ground.add_child(
194 MeshInstance3D(
195 mesh=self._cube,
196 material=Material(colour=(0.22, 0.24, 0.28, 1.0), roughness=0.95),
197 scale=(28, 1, 28),
198 )
199 )
200 self.add_child(self._ground)
201
202 # A STATIC pillar with one crate settled on top: the support whose edits
203 # the number keys drive.
204 self._pillar = PhysicsBody3D(mode=BodyMode.STATIC, position=(_PILLAR_X, _PILLAR_HALF_HEIGHT, 0))
205 self._pillar_shape = CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(1.0, _PILLAR_HALF_HEIGHT, 1.0)))
206 self._pillar.add_child(self._pillar_shape)
207 # Kept, not anonymous: shrinking the collider has to shrink the box the
208 # player can see, or the crate falls through a pillar that still looks solid.
209 self._pillar_mesh = self._pillar.add_child(
210 MeshInstance3D(
211 mesh=self._cube,
212 material=Material(colour=(0.45, 0.42, 0.55, 1.0), roughness=0.8),
213 scale=(2, _PILLAR_HALF_HEIGHT * 2, 2),
214 )
215 )
216 self.add_child(self._pillar)
217 self._rider = self._crate((_PILLAR_X, _RIDER_Y, 0), can_sleep=self._can_sleep)
218
219 # And a loose pile on the ground, so the sleep count has something to say.
220 # Three short stacks: enough for the sleep count to move visibly as they
221 # settle, few enough that each crate stays readable. Kept as stacks, not
222 # just as a list, because pulling one crate out from under its own stack
223 # is what shows the wake reaching all of it.
224 for column in range(3):
225 x = (column - 1) * 1.4 - 3.0
226 self._stacks.append(
227 [self._crate((x, 0.55 + level * 1.02, 0.0), can_sleep=self._can_sleep) for level in range(3)]
228 )
229
230 def _update_camera(self):
231 d, height = 20.0, 8.0
232 self._cam.position = (d * math.sin(self._cam_angle), height, d * math.cos(self._cam_angle))
233 self._cam.look_at((0, 2.0, 0), up=(0, 1, 0))
234
235 # -- the four disturbances the keys drive -------------------------------
236
237 def _shrink_pillar(self):
238 """Assigning a new shape wakes the bodies the old geometry was holding up."""
239 self._pillar_shape.shape = BoxShape3D(half_extents=Vec3(1.0, _SHRUNK_HALF_HEIGHT, 1.0))
240 self._pillar_mesh.scale = (2, _SHRUNK_HALF_HEIGHT * 2, 2)
241 self._note.text = "pillar shrank: the crate on top woke and is falling"
242
243 def _slide_pillar(self, *, wake: bool):
244 """Move the pillar clear, with the wake either honoured or suppressed.
245
246 ``wake=`` is a seam argument rather than a node property, so this reaches
247 the world through the body's own public ``world`` / ``handle`` pair. Writing
248 ``pillar.position`` instead would always wake, which is the right default
249 for game code and the wrong one for streaming.
250 """
251 pose = (Vec3(_PILLAR_X + _SLIDE_DISTANCE, _PILLAR_HALF_HEIGHT, 0.0), self._pillar.world_rotation)
252 self._pillar.world.set_body_transform(self._pillar.handle, pose, wake=wake)
253 # The pose went to the body, so the node has not moved and the mesh hanging
254 # off it has not either. Offsetting the mesh in its parent's space puts the
255 # drawn pillar back on its collider without touching the node's position.
256 self._pillar_mesh.position = (_SLIDE_DISTANCE, 0.0, 0.0)
257 if wake:
258 self._note.text = "pillar slid away: the crate woke and is falling"
259 else:
260 self._note.text = "pillar slid away with wake=False: the crate is still asleep, in mid-air"
261
262 def _stream_in_crate(self):
263 """Hand a crate to the simulation parked, the way a level chunk arrives.
264
265 The node form of the seam's ``set_body_mode(mode, wake=False)``: freeing the
266 body wakes it, as every write does, so it is put straight back to sleep
267 before another step can run. It then hangs exactly where it was placed,
268 costing nothing, until W (or anything reaching it) wakes it.
269 """
270 crate = self._crate((_STREAM_X + self._streamed * 1.3, _STREAM_Y, 0.0), mode=BodyMode.STATIC)
271 self._streamed += 1
272 crate.mode = BodyMode.DYNAMIC
273 crate.sleep()
274 self._note.text = "streamed a crate in parked: dynamic, asleep, and staying put until something wakes it"
275
276 def _yank_bottom_crate(self) -> list[PhysicsBody3D]:
277 """Pull the bottom crate out of a settled stack and wake all of it.
278
279 Sleep latches per contact island and the wake follows the same island, so
280 the crates above the one that left wake in the step it happens. Waking
281 only what the moved crate was recorded as touching would reach one level:
282 that crate is falling before the next collision pass, and the rest of the
283 stack would be left asleep in mid-air with nothing under it.
284
285 Returns the stack, bottom crate first, so a caller can check who woke.
286 """
287 stack = self._stacks[0]
288 bottom = stack[0]
289 bottom.position = (bottom.position[0], 0.55, _YANK_Z)
290 self._note.text = "yanked the bottom crate out: the whole stack woke, not only the crate above it"
291 return stack
292
293 def _sleeping_pose_drift(self) -> float:
294 """How far any crate has moved since it fell asleep, in metres.
295
296 Asleep means out of the step entirely: not integrated, not solved, and not
297 shifted by the contact position pass either. A crate that is awake has no
298 frozen pose to compare against and drops out of the reckoning.
299 """
300 drift = 0.0
301 for body in self._bodies:
302 if not body.is_sleeping:
303 self._asleep_at.pop(body, None)
304 continue
305 here = tuple(float(v) for v in body.position)
306 drift = max(drift, math.dist(here, self._asleep_at.setdefault(body, here)))
307 return drift
308
309 def _set_can_sleep(self, enabled: bool):
310 self._can_sleep = enabled
311 for body in self._bodies:
312 body.can_sleep = enabled
313 self._note.text = f"can_sleep = {enabled} on every crate"
314
315 # -- per-frame ----------------------------------------------------------
316
317 def on_update(self, dt):
318 if Input.is_action_just_pressed("quit"):
319 self.app.quit()
320 return
321 if Input.is_action_just_pressed("rebuild"):
322 self._build()
323 self._note.text = "rebuilt"
324 if Input.is_action_just_pressed("shrink_pillar"):
325 self._shrink_pillar()
326 if Input.is_action_just_pressed("slide_quietly"):
327 self._slide_pillar(wake=False)
328 if Input.is_action_just_pressed("slide_loudly"):
329 self._slide_pillar(wake=True)
330 if Input.is_action_just_pressed("stream_in"):
331 self._stream_in_crate()
332 if Input.is_action_just_pressed("yank_bottom"):
333 self._yank_bottom_crate()
334 if Input.is_action_just_pressed("wake_all"):
335 for body in self._bodies:
336 body.wake()
337 self._note.text = "woke every crate by hand"
338 if Input.is_action_just_pressed("sleep_all"):
339 for body in self._bodies:
340 body.sleep()
341 self._note.text = "parked every crate by hand (a no-op where can_sleep is False)"
342 if Input.is_action_just_pressed("toggle_can_sleep"):
343 self._set_can_sleep(not self._can_sleep)
344
345 self._cam_angle += Input.get_axis("orbit_left", "orbit_right") * 1.5 * dt
346 self._update_camera()
347
348 for body in self._bodies:
349 visual = self._visuals[body]
350 if not body.can_sleep:
351 visual.material = _NEVER_SLEEPS
352 else:
353 visual.material = _ASLEEP if body.is_sleeping else _AWAKE
354
355 asleep = sum(1 for b in self._bodies if b.is_sleeping)
356 world = self._bodies[0].world if self._bodies else None
357 if world is not None and Capability.SLEEP not in world.capabilities():
358 self._status.text = (
359 "this backend does not advertise Capability.SLEEP: nothing here ever sleeps "
360 f"({asleep}/{len(self._bodies)})"
361 )
362 else:
363 rider = "asleep" if self._rider.is_sleeping else "awake"
364 self._status.text = (
365 f"asleep: {asleep}/{len(self._bodies)} | rider on the pillar: {rider} | "
366 f"can_sleep: {self._can_sleep} | sleeper drift: {self._sleeping_pose_drift() * 1000:.2f} mm"
367 )
368
369
370def _selftest() -> bool:
371 """Headless: settle, then drive both directions of the wake gate and check them."""
372 from simvx.graphics.testing import assert_not_blank, save_png
373
374 app = App(title="Sleeping", width=1280, height=720, visible=False)
375 scene = SleepingScene(name="SleepingScene")
376 frames = app.run_headless(scene, frames=400, capture_frames=[399])
377 assert_not_blank(frames[0])
378 save_png(frames[0], "/tmp/physics_sleeping_test.png")
379
380 settled = sum(1 for b in scene._bodies if b.is_sleeping)
381 rider, world = scene._rider, scene._rider.world
382 rider_y = float(world.body_transform(rider.handle)[0][1])
383 print(f"settled asleep: {settled}/{len(scene._bodies)} ; rider y={rider_y:.3f}")
384
385 # The rest is driven straight on the world: a second run_headless would rebuild
386 # the scene, and nothing below needs a rendered frame.
387 def advance(steps: int) -> float:
388 for _ in range(steps):
389 world.step(1.0 / 60.0)
390 world.drain_contact_events()
391 world.drain_overlap_events()
392 return float(world.body_transform(rider.handle)[0][1])
393
394 def height(body) -> float:
395 return float(world.body_transform(body.handle)[0][1])
396
397 # Asleep is frozen: a settled pile's pose stops changing altogether, rather
398 # than drifting on under a position pass that still moves it.
399 frozen = {b: tuple(float(v) for v in world.body_transform(b.handle)[0]) for b in scene._bodies if b.is_sleeping}
400 advance(300)
401 drift = max(
402 (math.dist(tuple(float(v) for v in world.body_transform(b.handle)[0]), pose) for b, pose in frozen.items()),
403 default=1.0,
404 )
405 print(f"largest pose change over 300 steps asleep: {drift:.6f}")
406
407 # Island-atomic wake: the crates above the one pulled out wake in the same
408 # step, and the stack ends up on the ground rather than hanging in the air.
409 stack = scene._yank_bottom_crate()
410 still_asleep = [b.is_sleeping for b in stack[1:]]
411 print(f"after yanking the bottom crate, the crates above it asleep={still_asleep}")
412 advance(300)
413 stack_rest = [height(b) for b in stack[1:]]
414 print("the stack fell to " + ", ".join(f"{y:.3f}" for y in stack_rest))
415
416 # wake=False: the pillar leaves and the rider stays exactly where it was.
417 scene._slide_pillar(wake=False)
418 quiet_asleep = rider.is_sleeping
419 stranded_y = advance(60)
420 print(f"after wake=False slide: rider asleep={quiet_asleep} y={stranded_y:.3f}")
421
422 # And the default: the rider wakes and falls to the ground.
423 rider.wake()
424 fallen_y = advance(300)
425 print(f"after waking it by hand: rider y={fallen_y:.3f} asleep={rider.is_sleeping}")
426
427 # The other direction: a crate handed over parked stays where it was placed.
428 scene._stream_in_crate()
429 streamed = scene._bodies[-1]
430 parked_asleep = streamed.is_sleeping
431 for _ in range(60):
432 world.step(1.0 / 60.0)
433 parked_y = float(world.body_transform(streamed.handle)[0][1])
434 print(f"streamed in parked: asleep={parked_asleep} y={parked_y:.3f}")
435 streamed.wake()
436 for _ in range(300):
437 world.step(1.0 / 60.0)
438 dropped_y = float(world.body_transform(streamed.handle)[0][1])
439 print(f"after waking the streamed crate: y={dropped_y:.3f}")
440 print("screenshot: /tmp/physics_sleeping_test.png")
441
442 ok = (
443 settled == len(scene._bodies) - 1 # every crate but the one streamed in after the settle
444 and abs(rider_y - _RIDER_REST_Y) < 0.1
445 and drift < 1e-6 # asleep is frozen, not merely slow
446 and not any(still_asleep) # the whole island woke, not just the crate above
447 and all(y < 1.6 for y in stack_rest) # and it came down rather than hanging
448 and quiet_asleep
449 and abs(stranded_y - rider_y) < 0.01 # suppressed: it did not move at all
450 and fallen_y < 1.0 # woken: it fell to the ground
451 and parked_asleep
452 and abs(parked_y - _STREAM_Y) < 0.01 # parked: out of the step entirely
453 and dropped_y < 1.0 # and running once woken
454 )
455 print("SELFTEST:", "PASS" if ok else "FAIL")
456 return ok
457
458
459if __name__ == "__main__":
460 import sys
461
462 if "--test" in sys.argv:
463 sys.exit(0 if _selftest() else 1)
464 app = App(title="Sleeping Bodies", width=1280, height=720)
465 app.run(SleepingScene())