Ball tied to cloth
Coupling
A rigid ball tied to the centre of a pinned VBD cloth sheet.
steel_vbd preset (the default), recordedon an NVIDIA RTX PRO 6000 Blackwell.Run it
cd examples# the default preset (steel_vbd), then the othersuv run shelf play ball_tied_to_clothuv run shelf play ball_tied_to_cloth --solver mujoco_vbduv run shelf play ball_tied_to_cloth --solver kamino_vbd# change solver settings liveuv run shelf tune ball_tied_to_clothSource: examples/shelf/09_coupling/ball_tied_to_cloth/ · ported from newton/examples/multiphysics/example_mujoco_vbd_admm_solver.py @ skild-ai/newton 7565a26b1
Presets
| Preset | Solver | Status | Checked |
|---|---|---|---|
steel_vbddefault | coupled | works | 2026-11-04, newton 7565a26b1, NVIDIA GeForce RTX 3090 |
mujoco_vbd | coupled | works | 2026-10-28, newton 7565a26b1, NVIDIA RTX A6000 |
kamino_vbd | coupled | works | 2026-10-28, newton 7565a26b1, NVIDIA RTX A6000 |
Solvers with no row are untested on this example. The full settings are inpresets.toml.
Code
# SPDX-FileCopyrightText: Copyright (c) 2026 The Newton Developers# SPDX-License-Identifier: Apache-2.0# Modified for Steel Shelf: preset-driven execution and shared runner integration.
############################################################################ Example Rigid-VBD ADMM Coupled Solver## A rigid ball is attached to the centre of a pinned VBD cloth sheet# through a model-level particle-body attachment annotation, while a separate# rigid pendulum link carries a VBD rigid payload through a normal model ball# joint. SolverCoupledADMM converts both cross-solver couplings into ADMM# attachment constraints.## This example demonstrates ``SolverCoupledADMM``, an# alternative to proxy-body coupling based on linearised ADMM over model# joints, model attachment annotations, and collision-detected contacts rather# than proxy bodies. See ``docs/plans/2026-04-23-admm-coupling.tex`` for the# algorithm.## Command: python -m shelf run ball_tied_to_cloth############################################################################
from __future__ import annotations
from collections.abc import Callable
import numpy as npimport warp as wpfrom newton.solvers.experimental.coupled import SolverCoupled, SolverCoupledADMM
import newtonimport newton.examplesfrom newton.solvers import SolverKamino, SolverMuJoCo, SolverSteelfrom common import presets
def _register_rigid_solver_custom_attributes(builder: newton.ModelBuilder, rigid_solver: str) -> None: if rigid_solver == "kamino": SolverKamino.register_custom_attributes(builder)
def _make_kamino_config(settings: dict[str, object]) -> SolverKamino.Config: # Start from SolverKamino.Config() (not Config.from_model) and apply the preset's # settings; nested tables set fields of the nested configs. config = SolverKamino.Config() for key, value in settings.items(): if isinstance(value, dict): for sub_key, sub_value in value.items(): setattr(getattr(config, key), sub_key, sub_value) else: setattr(config, key, value) return config
def _rigid_solver_entry_args(rigid_solver: str, settings: dict[str, object]): if rigid_solver == "kamino": return "kamino", SolverKamino, {"config": _make_kamino_config(settings)} if rigid_solver == "mujoco": return "mjc", SolverMuJoCo, dict(settings) if rigid_solver == "steel": return "steel", SolverSteel, dict(settings) raise ValueError(f"Unsupported rigid solver '{rigid_solver}'")
def _capture_frame_graph(model: newton.Model, simulate: Callable[[], None], *, enabled: bool = True): if not enabled: return None
with wp.ScopedDevice(model.device): with wp.ScopedCapture() as capture: simulate()
if capture.graph is None: raise RuntimeError(f"Graph capture failed on device {model.device}") return capture.graph
def _launch_frame_graph(model: newton.Model, graph) -> bool: if graph is None: return False
with wp.ScopedDevice(model.device): wp.capture_launch(graph) return True
class Example: def __init__(self, viewer, args): self.preset = presets.load(__file__) self.viewer = viewer self.sim_time = 0.0 self.fps = self.preset.fps self.frame_dt = 1.0 / self.fps self.sim_substeps = self.preset.substeps self.sim_dt = self.frame_dt / self.sim_substeps # Presets are named "<rigid>_vbd". self.rigid_solver = self.preset.name.split("_")[0]
builder = newton.ModelBuilder() _register_rigid_solver_custom_attributes(builder, self.rigid_solver) builder.add_ground_plane()
dim = 11 cloth_z = 2.0 particle_start = builder.particle_count cloth_tri_ke = 1.0e3 cloth_edge_ke = 0.01 builder.add_cloth_grid( pos=wp.vec3(-0.5, -0.5, cloth_z), rot=wp.quat_identity(), vel=wp.vec3(0.0), fix_left=True, fix_right=True, dim_x=dim, dim_y=dim, cell_x=0.1, cell_y=0.1, mass=0.05, tri_ke=cloth_tri_ke, tri_ka=cloth_tri_ke, tri_kd=1.0e-2 * cloth_tri_ke, edge_ke=cloth_edge_ke, edge_kd=1.0e-2 * cloth_edge_ke, particle_radius=0.01, )
center = dim // 2 self.center_particle = particle_start + center * (dim + 1) + center
ball_radius = 0.08 self.ball_body = builder.add_body( xform=wp.transform(p=wp.vec3(0.0, 0.0, cloth_z - ball_radius), q=wp.quat_identity()), mass=0.5, inertia=wp.mat33(np.eye(3) * 5.0e-3), ) self.ball_joint = builder.joint_count - 1 builder.add_shape_sphere(self.ball_body, radius=ball_radius) builder.add_attachment_particle_body( self.center_particle, self.ball_body, body_point=wp.vec3(0.0, 0.0, ball_radius), stiffness=1.0e3, )
link_hx = 0.28 payload_hx = 0.12 anchor = wp.vec3(1.4, 0.0, 2.2)
self.pendulum_body = builder.add_link( xform=wp.transform(p=anchor + wp.vec3(link_hx, 0.0, 0.0), q=wp.quat_identity()), mass=0.6, inertia=wp.mat33(np.eye(3) * 1.0e-2), ) builder.add_shape_box(self.pendulum_body, hx=link_hx, hy=0.045, hz=0.045) self.pendulum_joint = builder.add_joint_revolute( parent=-1, child=self.pendulum_body, axis=wp.vec3(0.0, 1.0, 0.0), target_kd=0.5, parent_xform=wp.transform(p=anchor, q=wp.quat_identity()), child_xform=wp.transform(p=wp.vec3(-link_hx, 0.0, 0.0), q=wp.quat_identity()), ) builder.add_articulation([self.pendulum_joint], label="pendulum")
self.payload_body = builder.add_body( xform=wp.transform(p=anchor + wp.vec3(2.0 * link_hx + payload_hx, 0.0, 0.0), q=wp.quat_identity()), mass=0.35, inertia=wp.mat33(np.eye(3) * 6.0e-3), ) self.payload_free_joint = builder.joint_count - 1 builder.add_shape_box(self.payload_body, hx=payload_hx, hy=0.09, hz=0.09) builder.add_joint_ball( parent=self.pendulum_body, child=self.payload_body, friction=1.0, parent_xform=wp.transform(p=wp.vec3(link_hx, 0.0, 0.0), q=wp.quat_identity()), child_xform=wp.transform(p=wp.vec3(-payload_hx, 0.0, 0.0), q=wp.quat_identity()), collision_filter_parent=True, )
builder.color() self.model = builder.finalize() # The ADMM attachment binds the ball to the cloth; ordinary soft contact # on the same pair would push against the attachment. self.model.soft_contact_ke = 0.0 self.model.soft_contact_kd = 0.0
self.build_solver()
self.state_0 = self.model.state() self.state_1 = self.model.state() self.collision_pipeline = newton.CollisionPipeline(self.model) self.contacts = self.collision_pipeline.contacts() self.control = self.model.control()
newton.examples.configure_coupled_view(self, args) newton.eval_fk(self.model, self.model.joint_q, self.model.joint_qd, self.state_0)
self.capture()
def build_solver(self): """Build the solver and everything bound to its instance; the tuner calls this again to swap it.""" rigid_name, rigid_solver, rigid_kwargs = _rigid_solver_entry_args( self.rigid_solver, self.preset.entry(self.rigid_solver).solver ) vbd_entry = self.preset.entry("vbd") self.solver = SolverCoupledADMM( model=self.model, entries=[ SolverCoupled.Entry( name=rigid_name, solver=lambda v: rigid_solver(model=v, **rigid_kwargs), bodies=[self.ball_body, self.pendulum_body], joints=[self.ball_joint, self.pendulum_joint], ), SolverCoupled.Entry( name="vbd", solver=lambda v: vbd_entry.make_solver(v), bodies=[self.payload_body], joints=[self.payload_free_joint], particles=list(range(self.model.particle_count)), ), ], coupling=SolverCoupledADMM.Config( **self.preset.solver, joint_proximal_destination_entries=(rigid_name,), ), )
def capture(self): """Graph-capture the per-frame simulate loop on CUDA devices.
The ADMM loop has no Python-side branching on runtime data — the attachment count and all conditionals are construction-time constants — so the launch sequence is identical every frame. """ self.graph = _capture_frame_graph(self.model, self.simulate)
def simulate(self): for substep in range(self.sim_substeps): self.state_0.clear_forces() newton.examples.apply_coupled_viewer_forces(self, self.state_0) # ADMM builds this example's coupling from joints and # particle-body attachments, so keep state_0/contacts empty here # rather than asking collide() to add redundant constraints. # self.collision_pipeline.collide(self.state_0, self.contacts) self.solver.step(self.state_0, self.state_1, self.control, self.contacts, self.sim_dt) newton.eval_ik(self.model, self.state_1, self.state_1.joint_q, self.state_1.joint_qd) if self.sim_substeps % 2 == 1 and substep == self.sim_substeps - 1: # an odd count under graph capture: copy the last result into state_0 instead of swapping, # so every replay of the captured graph starts from the newest state self.state_0.assign(self.state_1) else: self.state_0, self.state_1 = self.state_1, self.state_0
def step(self): if not _launch_frame_graph(self.model, self.graph): self.simulate() self.sim_time += self.frame_dt
def test_final(self): body_q = self.state_0.body_q.numpy() particle_q = self.state_0.particle_q.numpy() z = body_q[self.ball_body, 2] cloth_gap = np.linalg.norm(body_q[self.ball_body, :3] - particle_q[self.center_particle]) pendulum_gap = np.linalg.norm(body_q[self.payload_body, :3] - body_q[self.pendulum_body, :3]) assert np.all(np.isfinite(body_q)) assert np.all(np.isfinite(particle_q)) assert 1.0 < z < 2.0, f"rigid ball z={z:.3f}; expected a hanging motion below the cloth plane" assert cloth_gap < 0.5, f"particle-body attachment drifted too far: gap={cloth_gap:.3f}" assert pendulum_gap < 1.0, f"cross-solver pendulum joint drifted too far: gap={pendulum_gap:.3f}"
def render(self): self.viewer.begin_frame(self.sim_time) newton.examples.log_coupled_view(self, self.contacts) self.viewer.end_frame()
@staticmethod def create_parser(): parser = newton.examples.create_parser() newton.examples.add_coupled_view_args(parser) return parser
if __name__ == "__main__": parser = Example.create_parser() viewer, args = newton.examples.init(parser) example = Example(viewer, args) newton.examples.run(example, args)example = "ball_tied_to_cloth"source = "newton/examples/multiphysics/example_mujoco_vbd_admm_solver.py @ skild-ai/newton 7565a26b1"default = "steel_vbd"
[check]# Arguments `python -m shelf check` passes in test mode (from Newton's test_examples.py).args = ["--num-frames", "30"]
[gallery]short = "A rigid ball tied to the centre of a pinned VBD cloth sheet."
[xpbd]status = "untested"
[vbd]status = "untested"
[mujoco]status = "untested"
[featherstone]status = "untested"
[semi_implicit]status = "untested"
[feather_pgs]status = "untested"
[steel]status = "untested"
[kamino]status = "untested"
[implicit_mpm]status = "untested"
[style3d]status = "untested"
[mujoco_vbd]status = "works"sim = { fps = 60, substeps = 8 }solver = { iterations = 2, rho = 50, gamma = 0.1, baumgarte = 0.01, joint_proximal_bodies = true }entries.mujoco.solver = { use_mujoco_contacts = false, njmax = 32 }entries.vbd.solver = { iterations = 8 }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[kamino_vbd]status = "works"sim = { fps = 60, substeps = 8 }solver = { iterations = 2, rho = 50, gamma = 0.1, baumgarte = 0.01, joint_proximal_bodies = true }entries.kamino.solver = { use_collision_detector = false, use_fk_solver = false, dynamics = { preconditioning = true }, padmm = { max_iterations = 120, primal_tolerance = 1.0e-5, dual_tolerance = 1.0e-5, compl_tolerance = 1.0e-5, rho_0 = 0.1, use_acceleration = true, warmstart_mode = "containers" } }entries.vbd.solver = { iterations = 8 }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[steel_vbd]status = "works"# 6 substeps: fastest setting measured (9.5 ms/frame vs 12.8 at 8 substeps, MuJoCo 13.4) with the test passing;# cloth-ball gap 87.9 mm vs 85.4 (MuJoCo at 8), soft penetration mean unchanged (6.39 vs 6.37 mm), peak 16.2 vs 14.9 mm,# the same as MuJoCo's own result at 6 substeps. 4 substeps (6.4 ms) lets the gap grow to 90 mm and the peak to 17.9 mm.sim = { fps = 60, substeps = 6 }# Steel does not carry ADMM joint proxies; with them on, its result is unchanged.solver = { iterations = 2, rho = 50, gamma = 0.1, baumgarte = 0.01, joint_proximal_bodies = false }# The ball and the pendulum link have no Steel constraint rows (no contacts, no joint limits), so Steel's own# iterations do not change the result (1, 4 and 12 measured identical) and cost under 1%.entries.steel.solver = { solve_mode = "matrix_free" }entries.vbd.solver = { iterations = 8 }checked = "2026-11-04, newton 7565a26b1, NVIDIA GeForce RTX 3090"Steel docs and tutorials are licensed under Apache-2.0. Third-party notices. "Skild", "Skild AI" and the Skild logo are trademarks of Skild AI and are not licensed under Apache-2.0.