Body and World Knobs¶

damping and gravity scale per body, the rest per world.

â–¶ Run in browser

Tags: 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_damping and angular_damping are the per-second rates at which a body sheds speed and spin, applied each step as v = v * max(0, 1 - damping * dt) + a * dt. 0 coasts 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_scale multiplies the world’s gravity for one body: 1 falls, 0 hangs where it is put (the tops, a pickup), negative rises (the balloon). Releasing the pickup is one write, gravity_scale = 1.0.

  • material takes a frozen PhysicsMaterial (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, assign replace(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())