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.
Before finalize, select one model for all environments:
state.set_rbd_solver_params(friction_model="simplified")
assert state.rbd_solver_params()["friction_model"] == "simplified""coulomb" is the existing default, with friction at each contact point.
"simplified" uses Rapier's central sliding-friction and angular twist-friction
constraints. Normal constraints stay per point; single-point contacts have no twist
constraint. Multibody contacts always use Coulomb. Invalid model names raise
ValueError before any solver parameters change.
Beyond the demo-oriented API, the module exposes what a robotics environment needs to drive Nexus as its physics engine (the AISLE bridge is the first consumer):
NexusState.load_urdf_robot(env, path, options)/load_mjcf_robot(viewer, env, path)return aRobot: link and joint names in the multibody's own order, per-DoF limits, and the PD gains (kp,kv,max_force) thatset_robot_targetsapplies as force-based position motors. MJCF position actuators seed the gains; URDF joints start at zero, andset_robot_joint_dynamicsgives them armature and damping beforefinalize.robot_state(viewer, robot)reads joint coordinates and link poses and velocities back from the GPU;set_robot_qposteleports the joints (and zeroes their velocities) between steps.robot_forward_kinematics/robot_inverse_kinematicsrun on the CPU kinematic model (damped least squares, joint limits clamped, a per-axis world-frame constraint mask).read_body_poses/read_body_velocities/set_body_pose/set_body_velocitydo the same for free rigid bodies;set_rbd_timestepfixes the step and substep count beforefinalize.NexusViewer.add_sensor_camera(w, h, fov_y_deg)creates an offscreen camera;set_sensor_camera_pose(OpenGL frame: -Z forward, +Y up) orattach_sensor_camera(follow a body with a mount pose) place it, andrender_sensor_camera(id, rgb, depth, segmentation)returns an RGB image, linear metric depth and per-body segmentation ids. Callset_sensor_rendering(True)before inserting shapes so every body gets its own render node (the depth and segmentation passes ignore GPU instances), andset_body_segmentation_idto choose the ids.insert_sensor_shaperegisters a per-body node with an optional UV-mapped texture.- Several
NexusStates can share one viewer:begin_scene()opens a render generation for the next scene andset_active_scene(gen)switches which scene's nodes and cameras are live.
The URDF loader relies on the rapier3d-urdf roll-pitch-yaw fix on the rapier
fix-urdf-rpy branch (patched in the workspace Cargo.toml).
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_backend(name)with"webgpu"(the default),"cpu","metal"or"cuda";nexus3d.available_backends()lists the names the installed wheel supports (the macOS wheels include Metal).viewer.with_cpu()/with_metal()/with_cuda()remain as shortcuts, the last two only in builds 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.