Physics Backend Compare¶
The builtin solver and Jolt, side by side in one scene.
▶ Run in browserTags: 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())