Physics Backend Compare

The builtin solver and Jolt, side by side in one scene.

▶ Run in browser

Tags: physics

Every PhysicsRoot owns an isolated world, and its backend names the solver that world runs. Two roots in one scene run two solvers at once, each stepping only the bodies below it:

left = self.add_child(PhysicsRoot(backend="builtin"))
right = self.add_child(PhysicsRoot(backend="jolt"))

Each side here is the same arena: a walled floor, a CharacterBody3D pad and six bodies dropped from the same heights, five onto the pad and one clear of it onto the floor. WASD or the left stick drives both pads with one velocity, so any difference between the halves comes from the solver, not the game code. The contract is the same on both: the dropped bodies rest on the pad, a pad walked away leaves its load behind, and a pad driven into a body shoves it.

What differs is what each world can report, and a game asks rather than guessing from the backend’s name. world.capabilities() answers once, at setup, whether contacts carry a measured impulse:

measures = Capability.CONTACT_IMPULSE in root.world.capabilities()

Every body’s collided signal hands over a Contact. The builtin solver fills contact.impulse with the normal impulse it applied; Jolt has no hook into its solver and leaves it None rather than inventing a number. Both fill contact.impulse_estimate, the impulse that would stop the two bodies’ approach, computed from their masses and linear velocities by the same formula on every backend. It ignores bounce and spin, so it is the number for hit sounds, shake and damage, not a measure of load. Each side’s readout shows the hardest impulse and estimate it has seen and how many contacts arrived; the two estimates are close rather than equal, because each solver moves its bodies a little differently.

Jolt ships with simvx-physics-jolt. Where it is not installed, backend="jolt" falls back to the builtin solver with a warning, and the right-hand title names the world that actually runs.

Controls: WASD / left stick - Move both pads Drag / arrows - Orbit the camera Scroll / pinch - Zoom R / Re-drop - Drop both batches again

Source

  1"""Physics Backend Compare: The builtin solver and Jolt, side by side in one scene.
  2
  3Every ``PhysicsRoot`` owns an isolated world, and its ``backend`` names the
  4solver that world runs. Two roots in one scene run two solvers at once, each
  5stepping only the bodies below it:
  6
  7    left = self.add_child(PhysicsRoot(backend="builtin"))
  8    right = self.add_child(PhysicsRoot(backend="jolt"))
  9
 10Each side here is the same arena: a walled floor, a ``CharacterBody3D`` pad and
 11six bodies dropped from the same heights, five onto the pad and one clear of it
 12onto the floor. WASD or the left stick drives both pads with one velocity, so
 13any difference between the halves comes from the solver, not the game code.
 14The contract is the same on both: the dropped bodies rest on the pad, a pad
 15walked away leaves its load behind, and a pad driven into a body shoves it.
 16
 17What differs is what each world can report, and a game asks rather than
 18guessing from the backend's name. ``world.capabilities()`` answers once, at
 19setup, whether contacts carry a measured impulse:
 20
 21    measures = Capability.CONTACT_IMPULSE in root.world.capabilities()
 22
 23Every body's ``collided`` signal hands over a ``Contact``. The builtin solver
 24fills ``contact.impulse`` with the normal impulse it applied; Jolt has no hook
 25into its solver and leaves it ``None`` rather than inventing a number. Both fill
 26``contact.impulse_estimate``, the impulse that would stop the two bodies'
 27approach, computed from their masses and linear velocities by the same formula
 28on every backend. It ignores bounce and spin, so it is the number for hit
 29sounds, shake and damage, not a measure of load. Each side's readout shows the
 30hardest impulse and estimate it has seen and how many contacts arrived; the two
 31estimates are close rather than equal, because each solver moves its bodies a
 32little differently.
 33
 34Jolt ships with ``simvx-physics-jolt``. Where it is not installed,
 35``backend="jolt"`` falls back to the builtin solver with a warning, and the
 36right-hand title names the world that actually runs.
 37
 38Controls:
 39    WASD / left stick        - Move both pads
 40    Drag / arrows            - Orbit the camera
 41    Scroll / pinch           - Zoom
 42    R / Re-drop              - Drop both batches again
 43"""
 44
 45import math
 46
 47from simvx.core import (
 48    Anchor,
 49    Axis2D,
 50    BodyMode,
 51    BoxShape3D,
 52    Button,
 53    CanvasLayer,
 54    CharacterBody3D,
 55    Controller,
 56    DirectionalLight3D,
 57    Input,
 58    Key,
 59    Label,
 60    Material,
 61    Mesh,
 62    MeshInstance3D,
 63    Node3D,
 64    OrbitCamera3D,
 65    PhysicsBody3D,
 66    PhysicsRoot,
 67    SphereShape3D,
 68    Vec3,
 69    on_input,
 70)
 71from simvx.core.physics import Capability
 72from simvx.graphics import App
 73
 74PAD_SPEED = 6.0
 75ORBIT_SPEED = 1.5  # radians per second on the arrow keys
 76PAD_DROPS = [(0.0, 0.0), (0.85, 0.0), (-0.85, 0.0), (0.0, 0.85), (0.0, -0.85)]
 77FLOOR_DROP = (3.0, 3.0)
 78# Glass walls round each 10 by 10 floor: (x, z) offset and (width, depth).
 79WALLS = [((0, 5), (10, 0.2)), ((0, -5), (10, 0.2)), ((5, 0), (0.2, 10)), ((-5, 0), (0.2, 10))]
 80
 81FLOOR = Material(colour=(0.24, 0.26, 0.30), roughness=0.9)
 82GLASS = Material(colour=(0.55, 0.75, 0.95, 0.13), blend="alpha", double_sided=True, roughness=0.1)
 83PAD = Material(colour=(1.0, 0.55, 0.15), emissive_colour=(1.0, 0.45, 0.10, 1.2), roughness=0.5)
 84BODY_COLOURS = [(0.90, 0.30, 0.25), (0.30, 0.70, 0.95), (0.95, 0.80, 0.25), (0.55, 0.85, 0.35)]
 85
 86
 87def static_box(position, size, material) -> PhysicsBody3D:
 88    body = PhysicsBody3D(mode=BodyMode.STATIC, position=position, shape=BoxShape3D(half_extents=Vec3(size) / 2))
 89    body.add_child(MeshInstance3D(mesh=Mesh.box(size), material=material))
 90    return body
 91
 92
 93class Arena(PhysicsRoot):
 94    """One backend's world: a walled floor, a character pad, a batch of dropped bodies and a readout."""
 95
 96    def __init__(self, backend: str, x: float, anchor: Anchor):
 97        super().__init__(backend=backend, name=backend.title())
 98        self.x = x
 99        self.readout = Label(anchor=anchor, inset=16, font_size=18)
100        self.bodies: list[PhysicsBody3D] = []
101
102    def on_ready(self):
103        self.add_child(static_box((self.x, 0, 0), (10, 1, 10), FLOOR))
104        for (dx, dz), (width, depth) in WALLS:
105            self.add_child(static_box((self.x + dx, 2, dz), (width, 3, depth), GLASS))
106        # Resting on the floor: its top is at 0.5 and the pad is 0.7 tall.
107        self.pad = self.add_child(
108            CharacterBody3D(position=(self.x, 0.85, 0), shape=BoxShape3D(half_extents=(1.2, 0.35, 1.2)))
109        )
110        self.pad.add_child(MeshInstance3D(mesh=Mesh.box((2.4, 0.7, 2.4)), material=PAD))
111
112        self.measures_impulse = Capability.CONTACT_IMPULSE in self.world.capabilities()
113        self.add_child(CanvasLayer()).add_child(self.readout)
114        self.drop()
115
116    def drop(self):
117        for body in self.bodies:
118            body.destroy()
119        self.bodies = []
120        self.hardest_impulse = self.hardest_estimate = 0.0
121        self.contacts = 0
122        for i, (dx, dz) in enumerate([*PAD_DROPS, FLOOR_DROP]):
123            if i % 2 == 0:
124                shape, mesh = SphereShape3D(radius=0.4), Mesh.sphere(0.4)
125            else:
126                shape, mesh = BoxShape3D(half_extents=(0.4, 0.4, 0.4)), Mesh.box(0.8)
127            body = self.add_child(PhysicsBody3D(position=(self.x + dx, 4.0 + i * 0.5, dz), mass=1.0, shape=shape))
128            colour = BODY_COLOURS[i % len(BODY_COLOURS)]
129            body.add_child(MeshInstance3D(mesh=mesh, material=Material(colour=colour, roughness=0.4, metallic=0.1)))
130            body.collided.connect(self.on_contact)
131            self.bodies.append(body)
132        self.show()
133
134    def on_contact(self, contact):
135        self.contacts += 1
136        self.hardest_estimate = max(self.hardest_estimate, contact.impulse_estimate)
137        if contact.impulse is not None:
138            self.hardest_impulse = max(self.hardest_impulse, contact.impulse)
139        self.show()
140
141    def show(self):
142        measured = f"{self.hardest_impulse:.2f} N*s" if self.measures_impulse else "not measured"
143        self.readout.text = (
144            f"{type(self.world).__name__}\n"
145            f"impulse: {measured}\n"
146            f"estimate: {self.hardest_estimate:.2f} N*s\n"
147            f"contacts: {self.contacts}"
148        )
149
150    def drive(self, velocity: Vec3, dt: float):
151        self.pad.velocity = velocity
152        self.pad.move_and_slide(dt)
153
154
155class BackendCompare(Node3D):
156    input_context = {
157        "move": [Axis2D.WASD, Controller.LEFT_STICK],
158        "orbit": [Axis2D.ARROWS],
159        "drop": [Key.R],
160    }
161
162    def on_ready(self):
163        self.camera = self.add_child(
164            OrbitCamera3D(
165                pivot=(0, 2.5, 0),
166                distance=22.0,
167                yaw=0.0,
168                pitch=math.radians(-26),
169                min_pitch=math.radians(-75),
170                max_pitch=math.radians(-3),
171            )
172        )
173        self.add_child(DirectionalLight3D(colour=(1.0, 0.96, 0.85), direction=(-6, -14, -10)))
174        self.arenas = [
175            self.add_child(Arena("builtin", -7.0, Anchor.TOP_LEFT)),
176            self.add_child(Arena("jolt", 7.0, Anchor.TOP_RIGHT)),
177        ]
178
179        # Example help overlay: not part of the technique shown.
180        hud = self.add_child(CanvasLayer())
181        hud.add_child(
182            Label("WASD: move both pads   Drag / arrows: orbit   R: re-drop", anchor=Anchor.BOTTOM_LEFT, inset=16)
183        )
184        hud.add_child(Button("Re-drop", action="drop", anchor=Anchor.BOTTOM_RIGHT, inset=12))
185
186    @on_input("drop")
187    def drop(self, _event):
188        for arena in self.arenas:
189            arena.drop()
190
191    def on_fixed_update(self, dt):
192        move = Input.value("move") * PAD_SPEED
193        for arena in self.arenas:
194            arena.drive(Vec3(move.x, 0.0, move.y), dt)
195
196    def on_update(self, dt):
197        orbit = Input.value("orbit") * ORBIT_SPEED * dt
198        self.camera.orbit(orbit.x, orbit.y)
199
200
201if __name__ == "__main__":
202    App(title="Physics Backend Compare", width=1280, height=720).run(BackendCompare())