Skip to content

Randomizing terrain friction

78 · Domain randomization

A shared ground plane is a single shape in the global world, so it has one mu for every world. To randomize the floor per world, give each world its own floor shape (here a static box) and write that shape's shape_material_mu, then notify SHAPE_PROPERTIES. Each box starts sliding at 2 m/s. With friction_combine="min" the floor's mu decides and the slide distances match v^2 / (2 mu g). With the default "average" the box's own mu of 1.0 is averaged in and the spread mostly disappears.

0.00 s

What to look at

  • "min": slide distance falls as 1 / mu.
  • "average": the floors look much alike.

Key parameter

SolverSteel(model, friction_combine=...)

How two shapes' mu combine in a contact: average (default), min, multiply or max. Fixed at construction.

Try changing

  • Randomize both the floor and the box mu.
  • Use mesh or heightfield floors per world for rough terrain.

Concept

When Steel sees a randomized value

What it is

Steel keeps a solver-side copy of this input, such as its material table, damping buffer, cached body-to-COM transforms or effective armature. Writing the model array changes nothing until notify_model_changed with the matching flag refreshes that copy.

In this scene

"average": the floors look much alike.

Considerations

Cost
Call only, default solver, six-link chain and four boxes per world, 1024 worlds (1.12 ms per collide + step): SHAPE_PROPERTIES 84 µs (15 µs captured). Flags measured one at a time; combined calls and feature validation can cost more. This notification does not request a mass-matrix refresh.
Graph capture
These flags can be captured in a reset graph. Kinematic-flag changes, sleeping, and shape flags with sparse factors plus restitution run eagerly; captured calls skip some value checks.
Interactions
world_mask limits which worlds lose their warm start; cached data is refreshed for every world.
Use it when
Locomotion over floors of different grip; give each world its own floor shape.

The script

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

tutorials/dr_terrain_friction.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""Terrain friction randomization: one floor shape per world, each with its own mu."""
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
WORLDS = 5
SPEED = 2.0 # m/s, initial slide speed
def build():
world = newton.ModelBuilder()
# a shared ground plane is one shape in world -1, so it has one mu for every world
world.add_shape_box(
-1, xform=wp.transform((0.8, 0.0, -0.05), wp.quat_identity()), hx=1.2, hy=0.12, hz=0.05
)
box = world.add_body(xform=wp.transform((-0.3, 0.0, 0.05), wp.quat_identity()))
world.add_shape_box(box, hx=0.05, hy=0.05, hz=0.05, cfg=newton.ModelBuilder.ShapeConfig(mu=1.0))
world.joint_qd[0] = SPEED # free joint qd = (v_com, omega)
builder = newton.ModelBuilder()
builder.replicate(world, WORLDS, spacing=(0.0, 0.3, 0.0))
return builder.finalize()
def simulate(rec, label, mu, combine):
model = build()
solver = SolverSteel(model, friction_combine=combine)
floors = model.shape_body.numpy() == -1
values = model.shape_material_mu.numpy()
values[floors] = mu[model.shape_world.numpy()[floors]]
model.shape_material_mu.assign(values)
solver.notify_model_changed(newton.ModelFlags.SHAPE_PROPERTIES)
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
state_0, state_1, control = model.state(), model.state(), model.control()
start = state_0.body_q.numpy()[:, 0].copy()
run = rec.variant(label, model)
for _ in range(rec.frames(3.0)):
slide = state_0.body_q.numpy()[:, 0] - start
run.frame(state_0, **{f"floor mu {m:.3f}": d for m, d in zip(mu, slide)})
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__":
rng = np.random.default_rng(12)
mu = np.sort(rng.uniform(0.15, 0.8, WORLDS))
rec = Recorder()
# "min" lets the floor's mu decide against the box's 1.0
simulate(rec, 'friction_combine="min"', mu, "min")
# the default averages the pair: the effective mu is (floor + 1.0) / 2
simulate(rec, 'friction_combine="average" (default)', mu, "average")
rec.save()
print("expected slide, m:", np.round(SPEED**2 / (2.0 * 9.81 * mu), 2))

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