Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
06e5af1
fix joint control in the mujoco-menagerie demo
sebcrozet Aug 30, 2026
81f433a
fix(rbd): make the number of pgs iterations per substep configurable
sebcrozet Aug 30, 2026
849cab1
feat: robot state, sensor cameras and joint control for the Python bi…
sebcrozet Aug 27, 2026
7feee06
feat(python): expose the rigid-body contact-solver parameters
sebcrozet Aug 27, 2026
31b4f26
feat(python): expose per-step run statistics
sebcrozet Aug 27, 2026
8d0433c
fix(rbd): solve multibody contacts once and give friction the full PG…
sebcrozet Aug 28, 2026
5846287
feat(python): contact impulse readbacks, friction combine rule and fr…
sebcrozet Aug 28, 2026
febdf8c
feat(viewer): antialiased sensor renders with hard, near-fit shadows
sebcrozet Aug 29, 2026
da9c4da
feat(viewer): 4096-texel shadow maps over a four-layer atlas
sebcrozet Aug 29, 2026
4e8dad8
feat(viewer): soft shadow penumbra default, mipmapped anisotropic tex…
sebcrozet Aug 29, 2026
09e6e30
feat(python): expose the implicit Coriolis toggle
sebcrozet Aug 30, 2026
2c2933a
feat(python): expose the multibody substep refresh cadence
sebcrozet Aug 30, 2026
2e2be80
fix(rbd): warmstart each contact point from its nearest previous point
sebcrozet Aug 30, 2026
bb1182e
feat(python): generalized velocity readback and per-channel sensor am…
sebcrozet Sep 3, 2026
f6bb363
build: patch rapier and kiss3d from their pushed branches by rev
sebcrozet Sep 20, 2026
e71563a
build: move to the published rapier 0.36.0
sebcrozet Sep 25, 2026
fadd124
feat(viewer): remove sensor cameras and free their GPU resources
sebcrozet Sep 25, 2026
2e357cb
fix(rbd): reset the multibody contact-index cursors after the scatter
sebcrozet Sep 26, 2026
4b0c2ca
refactor(rbd): count the contact-index cursors down with atomic_sub_u32
sebcrozet Sep 27, 2026
a6a63ac
style: rustfmt the mujoco menagerie example
sebcrozet Oct 1, 2026
a1066be
fix: gate 3D-only state and doc links so the 2D crates build without …
sebcrozet Oct 1, 2026
d2d1cda
fix(rbd): stop bodies resting on trimeshes from being ejected
sebcrozet Oct 1, 2026
96db3d0
fix(rbd): enlarge the polyline broad-phase AABB by the segment capsul…
sebcrozet Oct 1, 2026
2dfab44
fix(rbd): match 2D warmstart manifolds by collider pair and segment
sebcrozet Oct 1, 2026
d199733
ci: install cargo-gpu 0.10.0 to match the rust-gpu 0.10.0 release
sebcrozet Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
run: sudo apt-get update; sudo apt-get install --no-install-recommends build-essential curl wget file libssl-dev

- name: Install cargo-gpu (rust-gpu shader compiler)
run: cargo install cargo-gpu --version 0.10.0-alpha.1
run: cargo install cargo-gpu --version 0.10.0

- name: Install the rust-gpu toolchain
run: cargo gpu install --auto-install-rust-toolchain
Expand Down Expand Up @@ -78,7 +78,7 @@ jobs:
run: sudo apt-get update; sudo apt-get install --no-install-recommends build-essential curl wget file libssl-dev

- name: Install cargo-gpu (rust-gpu shader compiler)
run: cargo install cargo-gpu --version 0.10.0-alpha.1
run: cargo install cargo-gpu --version 0.10.0

- name: Install the rust-gpu toolchain
run: cargo gpu install --auto-install-rust-toolchain
Expand Down Expand Up @@ -116,7 +116,7 @@ jobs:
libegl1-mesa-dev libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers

- name: Install cargo-gpu (rust-gpu shader compiler)
run: cargo install cargo-gpu --version 0.10.0-alpha.1
run: cargo install cargo-gpu --version 0.10.0

- name: Install the rust-gpu toolchain
run: cargo gpu install --auto-install-rust-toolchain
Expand Down Expand Up @@ -159,7 +159,7 @@ jobs:
libegl1-mesa-dev libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers

- name: Install cargo-gpu (rust-gpu shader compiler)
run: cargo install cargo-gpu --version 0.10.0-alpha.1
run: cargo install cargo-gpu --version 0.10.0

- name: Install the rust-gpu toolchain
run: cargo gpu install --auto-install-rust-toolchain
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/python-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ env:
# Passed to every `maturin build`. Default features (webgpu + extension-module)
# give portable wheels; wgpu picks the native GPU API at runtime.
MATURIN_ARGS: --release -m crates/nexus_python3d/Cargo.toml
CARGO_GPU_VERSION: 0.10.0-alpha.1
CARGO_GPU_VERSION: 0.10.0

jobs:
# One abi3 wheel per platform (see the `abi3-py39` pyo3 feature): a single
Expand Down
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,51 @@
## Unreleased

### Added

- `RbdSimParams::friction_in_bias_pass`: also solve the contact friction rows during the biased
PGS pass, instead of only during the once-per-substep stabilization sweep (rapier's
`friction_in_bias_pass`). Off by default; on, friction gets as many iterations as the normal
rows, which keeps pinch grasps and resting stacks from drifting.
- Viewer: sensor cameras render with 4x MSAA by default (`set_sensor_antialiasing`) and expose
the shadow-edge softness (`set_sensor_shadow_softness`, `set_shadow_softness` for the window;
kiss3d's penumbra stays the default); the sharpest directional cascade covers the first 3 m instead of
12 (`set_sensor_shadow_range`, `set_shadow_range`), the shadow map is 4096 texels over a
4-layer atlas instead of 2048 over 16 (`set_sensor_shadow_resolution`, `set_shadow_resolution`),
`set_body_casts_shadows` excludes a body from the shadow map, and textures load with mip
chains and 16x anisotropic filtering. Needs kiss3d's offscreen MSAA, cascade, atlas-layer and
anisotropy controls.
- `NexusState::set_rbd_implicit_coriolis` and `set_rbd_substep_refresh` (Python:
`set_rbd_implicit_coriolis`, `set_rbd_substep_refresh`), also honored when the state is
finalized after the call; `rbd_solver_params` reports them.
- Python: `NexusState.set_rbd_solver_params(friction_in_bias_pass=...)`,
`ColliderBuilder.friction_combine_rule`, and the `debug_contacts` /
`debug_multibody_contact_impulses` GPU readbacks for contact diagnostics.
- `GpuMultibodySet::read_dof_velocities` and `NexusState::multibody_joint_velocities` read the
generalized velocities of a batch (resp. of one multibody) back from the GPU, in the same
assembly order `read_dof_coords` and `multibody_joint_positions` use. Python: `robot_qvel`,
the joint-velocity counterpart of `robot_state`'s `qpos`.
- Viewer: `SensorCamera::set_ambient_color` (Python: `set_sensor_camera_ambient_color`) tints a
sensor camera's ambient fill light, whose brightness `set_sensor_camera_ambient` already set.

### Fixed

- Contact warmstarting matched each new contact point to the *first* old point of the same pair
within 10 cm, so every point of a small manifold (a fingertip pad) inherited the first point's
normal and friction impulses and the solver had to redistribute them each step. Both the
rigid-body and multibody transfers now take the nearest old point.
- Contacts between a multibody link and a rigid body were solved twice: by the multibody
contact solver and, again, by the rigid-body solver against a zero-inverse-mass copy of the
link. The second copy saw the link as never moving, so a robot lifting a grasped object had
its friction cancelled by a "ghost" of its own fingers. The rigid-body constraint builder now
leaves multibody-owned manifolds to the multibody solver.

### Modified

- `RbdSimParams::num_internal_pgs_iterations` now also drives the rigid-body contact and joint
sweeps of the biased pass, interleaved one iteration at a time with the multibody sweeps (it
used to loop the multibody solver alone, leaving a box pinched by a robot against a board with
eight multibody iterations against one rigid-body iteration per substep).

## v0.5.0 (16 August 2026)

### Breaking changes
Expand Down
23 changes: 11 additions & 12 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ bvh = "0.7"

# Physics / geometry. default-features off for the no_std shader crates; host
# crates opt back in with `features = ["default"]`.
rapier2d = { version = "0.35.3", default-features = false }
rapier3d = { version = "0.35.3", default-features = false }
rapier3d-urdf = "0.35.3"
rapier3d-mjcf = { version = "0.35.3", features = ["stl", "wavefront", "msh"] }
parry2d = { version = "0.30", default-features = false }
parry3d = { version = "0.30", default-features = false }
rapier2d = { version = "0.36.0", default-features = false }
rapier3d = { version = "0.36.0", default-features = false }
rapier3d-urdf = "0.36.0"
rapier3d-mjcf = { version = "0.36.0", features = ["stl", "wavefront", "msh"] }
parry2d = { version = "0.31", default-features = false }
parry3d = { version = "0.31", default-features = false }

# Viewer / examples deps
kiss3d = "0.46.0"
Expand Down Expand Up @@ -102,11 +102,6 @@ khal-std = { git = "https://github.com/dimforge/khal", branch = "main" }
khal-builder = { git = "https://github.com/dimforge/khal", branch = "main" }
vortx = { git = "https://github.com/dimforge/vortx", branch = "main" }
vortx-shaders = { git = "https://github.com/dimforge/vortx", branch = "main" }
#rapier2d = { path = "../rapier/crates/rapier2d" }
#rapier3d = { path = "../rapier/crates/rapier3d" }
#rapier3d-mjcf = { path = "../rapier/crates/rapier3d-mjcf" }
#rapier3d-urdf = { path = "../rapier/crates/rapier3d-urdf" }
#rapier3d-meshloader = { path = "../rapier/crates/rapier3d-meshloader" }
## Local glam clone with SPIR-V vector-arithmetic intrinsics (Vec3 add/sub/mul/scale).
#glam = { path = "../glam-rs" }
# 30% faster for loop in P2G
Expand All @@ -124,7 +119,11 @@ vortx-shaders = { git = "https://github.com/dimforge/vortx", branch = "main" }
#vortx-shaders = { git = "https://github.com/dimforge/vortx", branch = "int-reduce" }

#glamx = { path = "../glamx" }
#kiss3d = { path = "../kiss3d" }
# kiss3d's shared-manager fix for a second offscreen surface, its offscreen
# MSAA and shadow controls (branch fix-shared-window-managers), pending a
# kiss3d release. Pinned by rev, not branch: a moving head would make this
# build irreproducible.
kiss3d = { git = "https://github.com/dimforge/kiss3d", rev = "28cdddde9e920fdb5462e9c24e44b57e55dd7bbe" }
#parry2d = { path = "../parry/crates/parry2d" }
#parry3d = { path = "../parry/crates/parry3d" }
#rapier2d = { path = "../rapier/crates/rapier2d" }
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Nexus uses [`cargo gpu`](https://github.com/Rust-GPU/cargo-gpu) to compile its R
the build. **You must install it before building**, otherwise the shader compilation step will fail:

```sh
cargo install cargo-gpu --version 0.10.0-alpha.1
cargo install cargo-gpu --version 0.10.0
cargo gpu install # Install the toolchain needed by cargo-gpu
```

Expand Down
72 changes: 29 additions & 43 deletions crates/examples3d/rbd_mujoco_menagerie3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ use khal::backend::GpuTimestamps;
use kiss3d::egui;
use nexus_viewer3d::{NexusViewer, RenderMaterial};
use nexus3d::prelude::{NexusPipeline, NexusState, RbdCoupling};
use nexus3d::rbd::dynamics::convert_joint_motor;
use nexus3d::rbd::shaders::dynamics::JointMotor;
use rapier3d::prelude::*;
use rapier3d_mjcf::{MjcfLoaderOptions, MjcfMultibodyOptions, MjcfRobot, MjcfRobotHandles};
use std::fs;
Expand Down Expand Up @@ -131,6 +129,9 @@ struct Settings {
enable_controls: bool,
enable_springs: bool,
actuator_strength: f32,
/// Multibody PGS iterations per substep. Servo-driven robots resting on
/// contacts need more than one to stop the motor and contact rows fighting.
pgs_iterations: u32,
/// Index into the keyframe picker: 0 is "(none)", `i + 1` is keyframe `i`.
keyframe: usize,
}
Expand All @@ -146,6 +147,7 @@ impl Default for Settings {
enable_controls: true,
enable_springs: true,
actuator_strength: 1.0,
pgs_iterations: 4,
keyframe: 0,
}
}
Expand All @@ -160,8 +162,8 @@ impl Settings {
}

/// Whether moving from `self` to `next` requires rebuilding the scene.
/// Actuator strength is read live every step, and so is the keyframe while
/// the servos are driving.
/// Actuator strength is read live every step, the PGS iteration count is
/// pushed live, and so is the keyframe while the servos are driving.
fn needs_reload(&self, next: &Self) -> bool {
self.use_multibody != next.use_multibody
|| self.render_colliders != next.render_colliders
Expand Down Expand Up @@ -497,7 +499,9 @@ async fn load_scene(
// multibody path instead raises the PGS iterations per substep. Mirrors the
// reference example.
let mut sim_params = nexus3d::rbd::shaders::dynamics::RbdSimParams::default();
if !settings.use_multibody {
if settings.use_multibody {
sim_params.num_internal_pgs_iterations = settings.pgs_iterations;
} else {
sim_params.dt = 1.0 / 240.0;
sim_params.num_solver_iterations = 12;
}
Expand All @@ -506,9 +510,6 @@ async fn load_scene(
state.finalize(viewer.backend()).await?;
state.set_rbd_gravity(viewer.backend(), [0.0, 0.0, gravity]);
if let Some(rbd) = state.rbd.as_mut() {
if settings.use_multibody {
rbd.multibodies_mut().set_num_internal_pgs_iterations(4);
}
// MuJoCo-style explicit coriolis: a single plain mass matrix, with
// coriolis / gyroscopic forces applied explicitly on the rhs.
rbd.set_implicit_coriolis(viewer.backend(), false);
Expand All @@ -523,54 +524,29 @@ async fn load_scene(
/// Drives the model's actuators toward `ctrl`, scaled by `gain`.
///
/// The motor configuration is baked into the GPU state at finalization, so this
/// runs the MJCF actuator model on the CPU-side joints and then pushes each
/// touched motor across.
/// runs the MJCF actuator model on the CPU-side joints and then re-syncs the
/// GPU link records from them.
///
/// `control_multibody_motors` does that sync itself, in the multibody traversal
/// order the GPU link ids follow. Pushing the motors by hand through
/// `set_motors` would need those same link ids, which are NOT the rapier body
/// indices as soon as the scene holds a body that is not a multibody link
/// (aloha's table, for instance) ahead of the robot.
fn apply_controls(
state: &mut NexusState,
backend: &khal::backend::GpuBackend,
controls: &Controls,
ctrl: &[Real],
gain: Real,
) {
let mut updates: Vec<(u32, usize, JointMotor)> = Vec::new();
{
// Untracked: the rapier sets are only the scratch the MJCF actuator
// model writes into. Marking them dirty would rebuild the GPU buffers
// from the authored poses and reset the model every step.
let world = state.rbd_world_mut_untracked(0);
let _ = state.control_multibody_motors(backend, |_, world| {
controls.handles.apply_controls_multibody_scaled(
&mut world.bodies,
&mut world.multibody_joints,
ctrl,
gain,
);
for ah in &controls.handles.actuators {
let Some(Some(handle)) = ah.joint else {
continue;
};
let Some((mb, link_id)) = world.multibody_joints.get(handle) else {
continue;
};
let Some(link) = mb.links().nth(link_id) else {
continue;
};
// The GPU link id is the body index (see `GpuMultibodySet::set_motor`).
let body_idx = link.rigid_body_handle().into_raw_parts().0;
let axes = link.joint().data.motor_axes.bits();
for axis in 0..6 {
if axes & (1 << axis) != 0 {
updates.push((
body_idx,
axis,
convert_joint_motor(link.joint().data.motors[axis]),
));
}
}
}
}
if let Some(rbd) = state.rbd.as_mut() {
let _ = rbd.multibodies_mut().set_motors(backend, 0, &updates);
}
});
}

/// Picks a scene: first runs the cheap DoF pre-check (no mesh I/O); if the model
Expand Down Expand Up @@ -713,6 +689,11 @@ pub async fn run(
ui.checkbox(&mut next.disable_collisions, "Disable collisions");
ui.checkbox(&mut next.enable_controls, "Enable joint controls");
ui.checkbox(&mut next.enable_springs, "Enable joint springs");
ui.add_enabled(
next.use_multibody,
egui::Slider::new(&mut next.pgs_iterations, 1..=16)
.text("PGS iterations / substep"),
);
ui.add(
egui::Slider::new(&mut next.actuator_strength, 0.02..=2.0)
.text("Actuator strength"),
Expand Down Expand Up @@ -779,7 +760,12 @@ pub async fn run(
let mut reload = false;
if let Some(next) = pending_settings.take() {
reload = settings.needs_reload(&next);
let pgs_changed = settings.pgs_iterations != next.pgs_iterations;
settings = next;
if pgs_changed && !reload {
state
.set_rbd_num_internal_pgs_iterations(viewer.backend(), settings.pgs_iterations);
}
}
// A model change always rebuilds, and resets the keyframe to the new
// model's default.
Expand Down
38 changes: 38 additions & 0 deletions crates/nexus_python3d/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,44 @@ pip install target/wheels/dimforge_nexus3d-*.whl
> so a single wheel works across all supported Python versions — you don't need
> to match the build interpreter to the run interpreter.

## Robots, state access and sensor cameras

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 a `Robot`: link and joint names in the multibody's own
order, per-DoF limits, and the PD gains (`kp`, `kv`, `max_force`) that
`set_robot_targets` applies as force-based position motors. MJCF position
actuators seed the gains; URDF joints start at zero, and
`set_robot_joint_dynamics` gives them armature and damping before
`finalize`.
- `robot_state(viewer, robot)` reads joint coordinates and link poses and
velocities back from the GPU; `set_robot_qpos` teleports the joints (and
zeroes their velocities) between steps.
- `robot_forward_kinematics` / `robot_inverse_kinematics` run 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_velocity` do the same for free rigid bodies; `set_rbd_timestep`
fixes the step and substep count before `finalize`.
- `NexusViewer.add_sensor_camera(w, h, fov_y_deg)` creates an offscreen
camera; `set_sensor_camera_pose` (OpenGL frame: -Z forward, +Y up) or
`attach_sensor_camera` (follow a body with a mount pose) place it, and
`render_sensor_camera(id, rgb, depth, segmentation)` returns an RGB image,
linear metric depth and per-body segmentation ids. Call
`set_sensor_rendering(True)` before inserting shapes so every body gets its
own render node (the depth and segmentation passes ignore GPU instances),
and `set_body_segmentation_id` to choose the ids. `insert_sensor_shape`
registers a per-body node with an optional UV-mapped texture.
- Several `NexusState`s can share one viewer: `begin_scene()` opens a render
generation for the next scene and `set_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

[`examples/`](examples) contains Python ports of the 3D Rust demos in
Expand Down
2 changes: 2 additions & 0 deletions crates/nexus_python3d/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod math;
pub mod mpm;
pub mod nexus;
pub mod rbd;
pub mod robot;
pub mod viewer;

/// The `nexus3d` Python module.
Expand Down Expand Up @@ -61,6 +62,7 @@ fn nexus3d(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<loaders::UrdfLoaderOptions>()?;
m.add_class::<loaders::UrdfRobotHandles>()?;
m.add_class::<loaders::MjcfSceneInfo>()?;
m.add_class::<robot::Robot>()?;

// MPM
m.add_class::<mpm::SimulationParams>()?;
Expand Down
Loading
Loading