Skip to content

Joint springs and damping

36 · Joints and drives

Four hinged arms without gravity, started at 60° with −1 rad/s. The first is free. The others have a passive spring toward rest_q = 0 (stiffness 6 N·m/rad) with no, light or heavy viscous damping. Springs and damping are applied as explicit joint forces, like SolverFeatherstone, independently of drives and limits.

0.00 s

What to look at

  • The free arm keeps turning.
  • The undamped spring oscillates forever.
  • Light damping rings down, and heavy damping returns to rest without overshoot.

Key parameter

stiffness / rest_q / damping

Joint arguments, applied explicitly: springs stay stable for roughly dt·√(k/m) < 2, and damping while dt·d·λmax(H⁻¹) < 2, where λmax(H⁻¹) is 1/I for one link but much larger for chains of light links. For stiff springs or heavy damping on light links use target_ke and target_kd, which Steel integrates implicitly.

Try changing

  • Set stiffness to 600 and see the explicit limit.
  • Use target_ke instead for the same spring, applied implicitly.

Concept

Explicit springs and damping

What it is

Joint stiffness and damping are applied as forces from the step's starting state, like SolverFeatherstone. Explicit forces stay stable only while they are weak relative to the inertia they act on.

τ = k (q_rest − q) − d q̇

In this scene

The undamped spring oscillates at constant amplitude; damping 0.3 rings down; damping 2 returns without overshoot.

Considerations

Stability
Keep h·√(k/m) < 2 and h·d·λmax(H⁻¹) < 2. A six-link chain of 0.1 kg capsules has λmax(H⁻¹) ≈ 4×10⁴, so at 240 Hz damping above about 0.012 diverges; while recording, damping 0.05 on it went to NaN within 0.06 s. SolverFeatherstone has the same limit; MuJoCo treats joint damping implicitly.
Use it when
Soft passive springs. For stiff springs or strong damping on light links use target_ke and target_kd, which Steel integrates implicitly.

The script

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

tutorials/36_springs_damping.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""Passive joint springs (stiffness, rest_q) and viscous joint damping."""
import math
import newton
import numpy as np
import warp as wp
from newton.solvers import SolverSteel
from replay import Recorder
DT = 1.0 / 240.0
SUBSTEPS = 8
JOINTS = [(0.0, 0.0), (6.0, 0.0), (6.0, 0.3), (6.0, 2.0)] # (stiffness N m/rad, damping N m s/rad)
def tf(x, y, z):
return wp.transform((x, y, z), wp.quat_identity())
def build():
builder = newton.ModelBuilder(gravity=(0.0, 0.0, 0.0))
for i, (stiffness, damping) in enumerate(JOINTS):
y = 0.3 * i - 0.45 # each arm turns in its own xz plane
link = builder.add_link(xform=tf(0.2, y, 0.4))
builder.add_shape_box(
link, hx=0.2, hy=0.02, hz=0.02, cfg=newton.ModelBuilder.ShapeConfig(density=400.0)
)
builder.add_shape_sphere(
link,
xform=tf(0.2, 0.0, 0.0),
radius=0.04,
cfg=newton.ModelBuilder.ShapeConfig(density=2000.0),
)
j = builder.add_joint_revolute(
-1,
link,
axis=newton.Axis.Y,
parent_xform=tf(0.0, y, 0.4),
child_xform=tf(-0.2, 0.0, 0.0),
stiffness=stiffness,
damping=damping,
rest_q=0.0,
)
builder.add_articulation([j])
model = builder.finalize()
model.joint_q.fill_(math.radians(60.0))
model.joint_qd.fill_(-1.0)
return model
def simulate(rec, label):
model = build()
solver = SolverSteel(model)
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(5.0)):
q = np.degrees(state_0.joint_q.numpy())
run.frame(state_0, **{f"k{k:g}_d{d:g}": q[i] for i, (k, d) in enumerate(JOINTS)})
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, "free | spring | spring + light damping | spring + heavy damping")
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