Python bindings for the 3D nexus GPU physics engine and its
viewer, built with PyO3 + maturin.
Published on PyPI as dimforge-nexus3d
(the plain nexus3d name is taken by an unrelated project); the import name is
still nexus3d:
pip install dimforge-nexus3d
python -c "import nexus3d"The API mirrors the Rust one closely, so the Rust examples translate almost
line-for-line into Python. Compare examples3d/boxes3.rs
with examples/boxes3.py.
Requires the Rust toolchain used by the rest of the workspace (rust-gpu shader
compilation runs as part of the build) and maturin.
# From the repo root. Pick a GPU backend feature: metal (macOS), cuda, or cpu.
# webgpu is the default. `extension-module` is a default feature and must stay
# enabled (it is, unless you pass --no-default-features).
#
# Always build with --release: without it the Rust per-frame command encoding
# runs unoptimized and is ~10-40x slower (tens of ms/frame instead of a couple).
maturin develop --release -m crates/nexus_python3d/Cargo.toml --features metal
# or build a wheel:
maturin build --release -m crates/nexus_python3d/Cargo.toml --features metal -i python3
pip install target/wheels/dimforge_nexus3d-*.whlNote: the module is built against the stable ABI (
abi3, CPython ≥ 3.9), so a single wheel works across all supported Python versions — you don't need to match the build interpreter to the run interpreter.
examples/ contains Python ports of the 3D Rust demos in
crates/examples3d/ (each <name>.py mirrors <name>.rs).
First build the module into your active environment (see Building),
then run any example directly — each has an if __name__ == "__main__" entry
point. Paths below assume you are at the repo root:
# After `maturin develop --release -m crates/nexus_python3d/Cargo.toml`
python crates/nexus_python3d/examples/boxes3.pyEach opens a viewer window. Because the viewer must own the main thread, run the scripts directly (not from a REPL).
To play through every example back-to-back (the next launches when you close the current window), use the runner script:
python crates/nexus_python3d/run_examples.py # every example, in order
python crates/nexus_python3d/run_examples.py boxes3 balls3 # only these
python crates/nexus_python3d/run_examples.py --list # list what would run(urdf3 / mujoco_menagerie3 are included but need the asset env vars below,
or they'll exit with an error the runner reports before continuing.)
Two examples need an external asset, supplied via environment variables:
# URDF: NEXUS_URDF defaults to the Rust example's local path, so set your own.
NEXUS_URDF=/path/to/robot.urdf \
python crates/nexus_python3d/examples/urdf3.py
# MJCF: clone google-deepmind/mujoco_menagerie and point at it.
# MUJOCO_MENAGERIE_SCENE picks the model by substring match (default: unitree_a1).
MUJOCO_MENAGERIE_DIR=/path/to/mujoco_menagerie \
MUJOCO_MENAGERIE_SCENE=unitree_a1 \
python crates/nexus_python3d/examples/mujoco_menagerie3.pyThe Rust and Python call sequences are intentionally identical: build bodies and
colliders with the rapier-style builders, insert them into a NexusState,
register their shapes with the NexusViewer, then drive the same render loop.
from nexus3d import (
NexusViewer, NexusPipeline, NexusState,
RigidBodyBuilder, ColliderBuilder, GpuTimestamps, Vec3, Vec4, Pose,
)
viewer = NexusViewer()
pipeline = NexusPipeline()
pipeline.preload_pipelines(viewer)
state = NexusState()
body = RigidBodyBuilder.dynamic().translation(Vec3(0, 5, 0)).build()
collider = ColliderBuilder.cuboid(0.5, 0.5, 0.5).build()
handle = state.insert_rigid_body(body, collider)
viewer.insert_shape(handle, collider.shared_shape(), Pose.IDENTITY)
timestamps = GpuTimestamps(viewer, 2048)
viewer.add_directional_light(Vec3(1.0, -2.0, 3.0))
state.finalize(viewer)
while viewer.render_frame():
if viewer.simulating():
pipeline.simulate(viewer, state, timestamps)
viewer.sync(state, timestamps)These are the only deliberate deviations; everything else matches the Rust names:
- Async is hidden. The Rust GPU calls (
NexusViewer::new,render_frame,sync,NexusState::finalize,NexusPipeline::simulate) areasync; the bindings block on them so Python code stays synchronous. Thewhile viewer.render_frame(): ...loop is written exactly as in Rust. - The backend is passed via the viewer. Where Rust calls
pipeline.simulate(viewer.backend(), &mut state, ...), Python passes the viewer itself:pipeline.simulate(viewer, state, timestamps). Same forstate.finalize(viewer), etc. The binding pullsbackend()out of the viewer internally. viewer.add_directional_light(dir)is a shortcut forviewer.scene3d_mut().add_directional_light(dir).- Backend selection uses
viewer.with_cpu()/viewer.with_metal()/viewer.with_cuda()(thewith_metal/with_cudamethods exist only when the crate is built with that feature).
NexusViewer() opens a window and must be created on the main thread (an OS
windowing requirement), so run viewer scripts on the interpreter's main thread.
Many independent simulations run in parallel as GPU batches. Allocate one with
state.add_environment() and use the *_in(env, ...) inserts:
env = state.add_environment()
handle = state.insert_rigid_body_in(env, body, collider)
viewer.insert_shape_in(env, handle, collider.shared_shape(), Pose.IDENTITY)Loading a robot manipulates rapier's PhysicsWorld directly, so the bindings do
the load + insert in Rust and hand back what you need to render and actuate:
# URDF: returns render shapes + link count.
opts = UrdfLoaderOptions(scale=40.0, make_roots_fixed=True,
create_colliders_from_visual_shapes=True)
robot = state.insert_urdf(path, opts, actuate_angx_motors=True)
for body, shape, pose in robot.render_shapes:
viewer.insert_visual_shape(0, body, shape, pose)
# per-frame: state.set_multibody_motor_velocity(viewer, batch, link, JointAxis.AngX, v)
# MJCF: registers shapes/floor/camera/light with the viewer itself.
viewer.set_up_axis(Vec3.Z) # MJCF is Z-up
info = state.insert_mjcf(viewer, scene_path)
state.finalize(viewer)
state.set_rbd_gravity(viewer, Vec3(0, 0, -9.81))The MJCF port doesn't reproduce the Rust example's runtime egui model picker (closures over the UI context aren't bound); it loads a single scene chosen up front.