Skip to content

Drive saturation

31 · Joints and drives

An arm with a heavy tip is released 60° below its target and driven by a stiff PD drive limited to 12 N·m, so the drive saturates on the way up. The default augmented drive clamps only its explicit force and leaves the implicit part unbounded. drive_mode="rows" solves the drive as a PGS row, like a PhysX articulation drive, and clamps the whole impulse.

0.00 s

Cost

Time per step, CUDA graph replay, median of 7 interleaved rounds on one NVIDIA RTX PRO 6000 Blackwell Server Edition; ratios are against the first row. Solver allocation is the GPU memory the solver allocates at construction, before warm-up and graph capture. Scene: a driven 6-link chain in each world (target_ke 50, joint friction 0.01).

Option1 world64 worlds1024 worldssolver allocation, 1024 worldsresult in this tutorial
drive_mode="augmented"0.21 ms0.30 ms0.34 ms48 MBsettles in 1.8 s
drive_mode="rows"0.30 ms 1.42×0.40 ms 1.36×0.48 ms 1.40×49 MBsettles in 1.0 s

What to look at

  • Both reach the target.
  • The row drive overshoots less (7.5° vs 9.2°).

Key parameter

drive_mode

"augmented" (default): implicit drives in the mass matrix. "rows": one row per driven DOF, solved with contacts and limits, counted against articulation_constraint_max.

Try changing

  • Lower effort_limit to 6 N·m: can the arm still lift?
  • Add enable_joint_velocity_limits=True with drive rows.

Concept

Drive rows

What it is

drive_mode="rows" solves each drive as a PGS row with the PhysX articulation drive update and clamps the accumulated impulse at effort_limit·h. The drive is then solved together with contacts and limits, and its whole reaction respects the limit.

In this scene

Released 60° below target with a 12 N·m limit, drive rows settle within 1° in 1.0 s; the default augmented drive overshoots more and settles in 1.8 s.

Considerations

Cost
One row per driven DOF, counted against articulation_constraint_max: about +40% per step on a driven 6-link chain (0.34 → 0.47 ms in 1024 worlds).
Interactions
Disables sparse factors; with velocity limits, fuse_joint_velocity_limits applies.
Use it when
Effort limits matter, as in sim-to-real transfer from PhysX.

The script

cd tutorials && python 31_drive_saturation.py prints each variant's final metric; --record out.json also writes the replay. Every tutorial imports replay.py from the same folder.

tutorials/31_drive_saturation.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""Drive saturation: drive_mode="augmented" vs "rows" when a stiff drive hits its effort limit."""
import math
import newton
import warp as wp
from newton.solvers import SolverSteel
from replay import Recorder
DT = 1.0 / 240.0
SUBSTEPS = 8
def tf(x, y, z):
return wp.transform((x, y, z), wp.quat_identity())
def build():
builder = newton.ModelBuilder()
arm = builder.add_link(xform=tf(0.25, 0.0, 0.6))
builder.add_shape_box(
arm, hx=0.25, hy=0.02, hz=0.02, cfg=newton.ModelBuilder.ShapeConfig(density=200.0)
)
builder.add_shape_sphere(
arm,
xform=tf(0.25, 0.0, 0.0),
radius=0.05,
cfg=newton.ModelBuilder.ShapeConfig(density=3000.0),
)
j = builder.add_joint_revolute(
-1,
arm,
axis=(0.0, -1.0, 0.0),
parent_xform=tf(0.0, 0.0, 0.6),
child_xform=tf(-0.25, 0.0, 0.0),
target_ke=3000.0,
target_kd=60.0,
target_q=0.0,
effort_limit=12.0,
)
builder.add_articulation([j])
model = builder.finalize()
model.joint_q.fill_(math.radians(-60.0)) # released 60 degrees below the target
return model
def simulate(rec, label, **options):
model = build()
solver = SolverSteel(model, **options)
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
state_0, state_1, control = model.state(), model.state(), model.control()
newton.eval_fk(model, model.joint_q, model.joint_qd, state_0)
run = rec.variant(label, model)
for _ in range(rec.frames(3.0)):
run.frame(state_0, angle_deg=math.degrees(state_0.joint_q.numpy()[0]))
for _ in range(SUBSTEPS):
pipeline.collide(state_0, contacts)
solver.step(state_0, state_1, control, contacts, DT)
state_0, state_1 = state_1, state_0
run.check(solver)
if __name__ == "__main__":
rec = Recorder()
simulate(rec, 'drive_mode="augmented" (default)')
simulate(rec, 'drive_mode="rows"', drive_mode="rows")
rec.save()

Recorded with SolverSteel on Newton 1.9.0.dev0, Warp 1.19.0, NVIDIA RTX PRO 6000 Blackwell Server Edition. Replays read the state back every frame, so they are not a speed benchmark. Script · page text