Body and World Knobs¶
damping and gravity scale per body, the rest per world.
â–¶ Run in browserTags: physics 3d damping gravity materials
A physics knob lives where the thing it describes lives. Damping and gravity
scale describe one object’s motion, so they are Properties of the
PhysicsBody3D: a feather and a cannonball want different drag in the same
air. Gravity, solver iterations, the sleep timer and the contact slop describe
the space everything moves in, so they live on the world a PhysicsRoot owns,
where one number governs every body under it::
root = self.add_child(PhysicsRoot())
root.add_child(PhysicsBody3D(shape=SphereShape3D(radius=0.4), linear_damping=2.0, gravity_scale=-0.2))
root.world.contact_slop = 0.02
Body knobs, each live when written:
linear_dampingandangular_dampingare the per-second rates at which a body sheds speed and spin, applied each step asv = v * max(0, 1 - damping * dt) + a * dt.0coasts forever. Three balls drop from one height with damping 0, 0.05 (the default) and 2.0, and the damped one falls visibly slower. The two are independent: two tops with the same kick, one with no angular damping, one with 1.5, and only one keeps spinning.gravity_scalemultiplies the world’s gravity for one body:1falls,0hangs where it is put (the tops, a pickup), negative rises (the balloon). Releasing the pickup is one write,gravity_scale = 1.0.materialtakes a frozenPhysicsMaterial(friction, restitution and how they combine). It is a resource: the four crates hold one instance, and swapping it changes all four surfaces at once. To change one coefficient, assignreplace(body.material, friction=0.1).
World knobs, on root.world: gravity; solver_iterations, the
velocity solver’s passes every body goes through; position_iterations, the
passes that drain the error left in joints (nothing here is jointed, so it
changes only the readout: features/2d/joints.py shows what it acts on);
sleep_time_threshold, how long a body must be still before it sleeps (the
crates turn grey); and contact_slop, how far a resting contact may overlap
before it is pushed apart, so a looser slop lets the crates rest deeper the
next time they land (the material swap drops them again).
A world knob names no body, so changing one wakes nothing, and a sleeping pile would ignore it. The scene wakes every body itself after each change.
The rack re-drops and the tops are kicked again every few seconds. Every action has a button showing its key. Drag or use the arrow keys to orbit the camera, and scroll to zoom.
Source¶
1# /// simvx
2# tags = ["3d", "physics", "damping", "gravity", "materials"]
3# ///
4"""Body and World Knobs: damping and gravity scale per body, the rest per world.
5
6A physics knob lives where the thing it describes lives. Damping and gravity
7scale describe one object's motion, so they are Properties of the
8``PhysicsBody3D``: a feather and a cannonball want different drag in the same
9air. Gravity, solver iterations, the sleep timer and the contact slop describe
10the space everything moves in, so they live on the world a ``PhysicsRoot`` owns,
11where one number governs every body under it::
12
13 root = self.add_child(PhysicsRoot())
14 root.add_child(PhysicsBody3D(shape=SphereShape3D(radius=0.4), linear_damping=2.0, gravity_scale=-0.2))
15 root.world.contact_slop = 0.02
16
17Body knobs, each live when written:
18
19- ``linear_damping`` and ``angular_damping`` are the per-second rates at which a
20 body sheds speed and spin, applied each step as
21 ``v = v * max(0, 1 - damping * dt) + a * dt``. ``0`` coasts forever. Three
22 balls drop from one height with damping 0, 0.05 (the default) and 2.0, and
23 the damped one falls visibly slower. The two are independent: two tops with
24 the same kick, one with no angular damping, one with 1.5, and only one keeps
25 spinning.
26- ``gravity_scale`` multiplies the world's gravity for one body: ``1`` falls,
27 ``0`` hangs where it is put (the tops, a pickup), negative rises (the
28 balloon). Releasing the pickup is one write, ``gravity_scale = 1.0``.
29- ``material`` takes a frozen ``PhysicsMaterial`` (friction, restitution and how
30 they combine). It is a resource: the four crates hold one instance, and
31 swapping it changes all four surfaces at once. To change one coefficient,
32 assign ``replace(body.material, friction=0.1)``.
33
34World knobs, on ``root.world``: ``gravity``; ``solver_iterations``, the
35velocity solver's passes every body goes through; ``position_iterations``, the
36passes that drain the error left in joints (nothing here is jointed, so it
37changes only the readout: ``features/2d/joints.py`` shows what it acts on);
38``sleep_time_threshold``, how long a body must be still before it sleeps (the
39crates turn grey); and ``contact_slop``, how far a resting contact may overlap
40before it is pushed apart, so a looser slop lets the crates rest deeper the
41next time they land (the material swap drops them again).
42
43A world knob names no body, so changing one wakes nothing, and a sleeping pile
44would ignore it. The scene wakes every body itself after each change.
45
46The rack re-drops and the tops are kicked again every few seconds. Every
47action has a button showing its key. Drag or use the arrow keys to orbit the
48camera, and scroll to zoom.
49"""
50
51import itertools
52
53from simvx.core import (
54 Anchor,
55 Axis2D,
56 BodyMode,
57 BoxShape3D,
58 Button,
59 CanvasLayer,
60 DirectionalLight3D,
61 HBoxContainer,
62 Input,
63 Key,
64 Label,
65 Material,
66 Mesh,
67 MeshInstance3D,
68 Node,
69 OrbitCamera3D,
70 PhysicsBody3D,
71 PhysicsMaterial,
72 PhysicsRoot,
73 SphereShape3D,
74 Timer,
75 VBoxContainer,
76 on_input,
77)
78from simvx.graphics import App
79
80DROP_HEIGHT = 9.0
81# The drop rack, identical but for damping: (x, damping, colour).
82RACK = [(-5.0, 0.0, (0.85, 0.30, 0.25)), (-3.4, 0.05, (0.90, 0.86, 0.35)), (-1.8, 2.0, (0.55, 0.85, 0.95))]
83REPLAY_PERIOD = 3.5 # long enough for the most damped ball to land
84SPIN_KICK = (0.0, 9.0, 0.0)
85PICKUP_HOME = (3.0, 2.6, 0.0)
86BALLOON_HOME = (0.6, 1.0, 0.0)
87BALLOON_CEILING = 9.0
88CRATE_XS = (6.6, 8.2, 9.8, 11.4)
89CRATE_DROP_Y = 2.0
90
91GRIPPY = PhysicsMaterial(friction=0.9, restitution=0.0, friction_combine="max")
92BOUNCY = PhysicsMaterial(friction=0.1, restitution=0.8, restitution_combine="max")
93LOOKS = {
94 GRIPPY: Material(colour=(0.45, 0.40, 0.34), roughness=0.95),
95 BOUNCY: Material(colour=(0.90, 0.45, 0.70), roughness=0.25, metallic=0.1),
96}
97ASLEEP_LOOK = Material(colour=(0.28, 0.30, 0.36), roughness=0.9)
98
99# Each key steps its world knob through these values; the first is the engine's default.
100WORLD_KNOBS = {
101 "gravity": [(0, -9.81, 0), (0, -1.62, 0), (0, 0, 0)],
102 "solver_iterations": [16, 32, 4],
103 "position_iterations": [3, 16, 1],
104 "sleep_time_threshold": [0.5, 2.0, 0.1],
105 "contact_slop": [0.001, 0.02, 0.1],
106}
107
108
109class Crate(PhysicsBody3D):
110 """A box coloured by the surface it holds, and grey while it sleeps."""
111
112 def __init__(self, **kwargs):
113 super().__init__(shape=BoxShape3D(half_extents=(0.5, 0.5, 0.5)), **kwargs)
114 self.mesh = self.add_child(MeshInstance3D(mesh=Mesh.box(1.0)))
115
116 def on_update(self, dt):
117 self.mesh.material = ASLEEP_LOOK if self.is_sleeping else LOOKS[self.material]
118
119
120class BodyKnobs(Node):
121 input_context = {
122 "drop": [Key.KEY_1],
123 "kick": [Key.KEY_2],
124 "pickup": [Key.KEY_3],
125 "material": [Key.KEY_4],
126 "gravity": [Key.G],
127 "solver_iterations": [Key.I],
128 "position_iterations": [Key.P],
129 "sleep_time_threshold": [Key.T],
130 "contact_slop": [Key.K],
131 "rebuild": [Key.R],
132 "orbit": [Axis2D.ARROWS],
133 }
134
135 def on_ready(self):
136 self.camera = self.add_child(OrbitCamera3D(pivot=(3, 3.5, 0), distance=20.5, yaw=0.35, pitch=-0.2))
137 self.add_child(DirectionalLight3D(colour=(1.0, 0.96, 0.88), intensity=3.0, direction=(-5, -14, -10)))
138 self.world_root = self.add_child(PhysicsRoot(name="World"))
139 self.knobs = {knob: itertools.cycle(values) for knob, values in WORLD_KNOBS.items()}
140 for knob in self.knobs:
141 self.step_knob(knob)
142 self.status = "three damping rates, one drop height"
143 self.build()
144 self.replay_timer = self.add_child(Timer(duration=REPLAY_PERIOD, one_shot=False, autostart=True))
145 self.replay_timer.timeout.connect(self.replay)
146 self.build_hud()
147
148 def add_body(self, mesh: Mesh, colour, **kwargs) -> PhysicsBody3D:
149 body = self.world_root.add_child(PhysicsBody3D(**kwargs))
150 body.add_child(MeshInstance3D(mesh=mesh, material=Material(colour=colour, roughness=0.5)))
151 return body
152
153 def build(self):
154 for body in list(self.world_root.children):
155 body.destroy()
156 self.add_body(
157 Mesh.box((22, 1, 10)), (0.20, 0.22, 0.26), mode=BodyMode.STATIC,
158 shape=BoxShape3D(half_extents=(11, 0.5, 5)), position=(3, -0.5, 0),
159 ) # fmt: skip
160 self.balls = [
161 self.add_body(
162 Mesh.sphere(0.4),
163 colour,
164 shape=SphereShape3D(radius=0.4),
165 position=(x, DROP_HEIGHT, 0),
166 linear_damping=damping,
167 angular_damping=damping,
168 )
169 for x, damping, colour in RACK
170 ]
171 # Enough drag that the balloon rises at a watchable terminal speed.
172 self.balloon = self.add_body(
173 Mesh.sphere(0.45), (0.95, 0.35, 0.45), shape=SphereShape3D(radius=0.45), position=BALLOON_HOME,
174 mass=0.2, gravity_scale=-0.2, linear_damping=2.0, can_sleep=False,
175 ) # fmt: skip
176 self.pickup = self.add_top(PICKUP_HOME, angular_damping=0.0, colour=(0.35, 0.90, 0.55))
177 self.spinner = self.add_top((4.6, 2.6, 0), angular_damping=1.5, colour=(0.60, 0.55, 0.95))
178 self.crates = [
179 self.world_root.add_child(Crate(position=(x, CRATE_DROP_Y, 0), material=GRIPPY)) for x in CRATE_XS
180 ]
181 self.kick_tops()
182
183 def add_top(self, position, angular_damping: float, colour) -> PhysicsBody3D:
184 return self.add_body(
185 Mesh.box(0.9), colour, shape=BoxShape3D(half_extents=(0.45, 0.45, 0.45)), position=position,
186 gravity_scale=0.0, linear_damping=0.0, angular_damping=angular_damping, can_sleep=False,
187 ) # fmt: skip
188
189 def drop_rack(self):
190 for ball in self.balls:
191 ball.position = (ball.position.x, DROP_HEIGHT, 0)
192 ball.velocity = (0, 0, 0)
193 ball.spin = (0, 0, 0)
194
195 def kick_tops(self):
196 # The same spin for both, so only angular_damping tells them apart.
197 self.pickup.spin = SPIN_KICK
198 self.spinner.spin = SPIN_KICK
199
200 def replay(self):
201 self.drop_rack()
202 self.kick_tops()
203
204 def step_knob(self, knob: str):
205 setattr(self.world_root.world, knob, next(self.knobs[knob]))
206
207 @on_input("drop")
208 def _on_drop(self, _event):
209 self.drop_rack()
210 self.replay_timer.start()
211 self.status = "dropped: same height, damping 0 / 0.05 / 2.0"
212
213 @on_input("kick")
214 def _on_kick(self, _event):
215 self.kick_tops()
216 self.status = "both tops kicked alike; only one keeps spinning"
217
218 @on_input("pickup")
219 def _on_pickup(self, _event):
220 if self.pickup.gravity_scale:
221 self.pickup.gravity_scale = 0.0
222 self.pickup.position = PICKUP_HOME
223 self.pickup.velocity = (0, 0, 0)
224 self.status = "pickup parked: gravity_scale 0"
225 else:
226 self.pickup.gravity_scale = 1.0
227 self.status = "pickup released: gravity_scale 1"
228
229 @on_input("material")
230 def _on_material(self, _event):
231 surface = BOUNCY if self.crates[0].material == GRIPPY else GRIPPY
232 for crate in self.crates:
233 crate.material = surface
234 crate.position = (crate.position.x, CRATE_DROP_Y, 0)
235 crate.velocity = (0, 0, 0)
236 self.status = f"one material, four crates: all {'bouncy' if surface == BOUNCY else 'grippy'}"
237
238 @on_input(tuple(WORLD_KNOBS))
239 def _on_world_knob(self, event):
240 self.step_knob(event.action)
241 # A world knob wakes no body by itself, so wake them all to show its effect.
242 for body in self.world_root.find_all(PhysicsBody3D):
243 body.wake()
244 self.status = f"{event.action}: {getattr(self.world_root.world, event.action)}"
245
246 @on_input("rebuild")
247 def _on_rebuild(self, _event):
248 self.build()
249 self.status = "rebuilt"
250
251 def on_update(self, dt):
252 turn = Input.value("orbit")
253 self.camera.orbit(-turn.x * 1.5 * dt, turn.y * 0.8 * dt)
254 if self.balloon.position.y > BALLOON_CEILING:
255 self.balloon.position = BALLOON_HOME
256 self.balloon.velocity = (0, 0, 0)
257
258 world = self.world_root.world
259 falls = " ".join(f"damping {d} at {b.position.y:.2f}" for (_, d, _), b in zip(RACK, self.balls, strict=True))
260 asleep = sum(crate.is_sleeping for crate in self.crates)
261 rest = sum(crate.position.y for crate in self.crates) / len(self.crates)
262 self.readout.text = "\n".join([
263 f"ball height: {falls} balloon {self.balloon.position.y:.2f}",
264 f"spin: kept {self.pickup.spin.y:.2f} damped {self.spinner.spin.y:.2f}",
265 f"gravity {world.gravity.y:.2f} solver_iterations {world.solver_iterations} "
266 f"position_iterations {world.position_iterations}",
267 f"sleep_time_threshold {world.sleep_time_threshold} contact_slop {world.contact_slop}",
268 f"crates: {asleep}/{len(self.crates)} asleep, resting at y {rest:.3f}",
269 self.status,
270 ]) # fmt: skip
271
272 def build_hud(self):
273 hud = self.add_child(CanvasLayer())
274 self.readout = hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=12, font_size=15))
275
276 # Example help overlay: not part of the technique shown.
277 footer = hud.add_child(VBoxContainer(anchor=Anchor.BOTTOM_LEFT, inset=12, separation=8))
278 footer.add_child(Label("Drag / arrows: orbit Scroll: zoom"))
279 rows = [
280 [("Drop balls", "drop"), ("Kick tops", "kick"), ("Pickup", "pickup"), ("Material", "material")],
281 [("Gravity", "gravity"), ("Solver", "solver_iterations"), ("Joint passes", "position_iterations"),
282 ("Sleep time", "sleep_time_threshold"), ("Slop", "contact_slop"), ("Rebuild", "rebuild")],
283 ] # fmt: skip
284 for row in rows:
285 bar = footer.add_child(HBoxContainer(separation=8))
286 for text, action in row:
287 bar.add_child(Button(text, action=action))
288 # A container keeps its own size rather than growing to its content, so a
289 # bottom-anchored stack is fitted by hand or it runs off the bottom edge.
290 footer.size = footer.get_minimum_size()
291
292
293if __name__ == "__main__":
294 App(title="Body and World Knobs", width=1280, height=720).run(BodyKnobs())