Skip to content

Domino spiral

Many contacts

Dominoes along a spiral, the first one tilted to start the chain.

0.00 s
The steel preset (the default), recordedon an NVIDIA RTX PRO 6000 Blackwell.

Run it

Terminal window
cd examples
# the default preset (steel), then the others
uv run shelf play domino_spiral
uv run shelf play domino_spiral --solver xpbd
uv run shelf play domino_spiral --solver vbd
uv run shelf play domino_spiral --solver mujoco
uv run shelf play domino_spiral --solver featherstone
uv run shelf play domino_spiral --solver kamino
# change solver settings live
uv run shelf tune domino_spiral

Source: examples/shelf/02_many_contacts/domino_spiral/ · ported from newton/examples/contacts/example_domino_spiral.py @ skild-ai/newton 7565a26b1

Presets

PresetSolverStatusChecked
xpbdxpbdworks2026-10-28, newton 7565a26b1, NVIDIA RTX A6000
vbdvbdworks2026-10-28, newton 7565a26b1, NVIDIA RTX A6000
mujocomujocoworks2026-10-28, newton 7565a26b1, NVIDIA RTX A6000
featherstonefeatherstoneworks2026-10-28, newton 7565a26b1, NVIDIA RTX A6000
kaminokaminoworks2026-10-28, newton 7565a26b1, NVIDIA RTX A6000

Solvers with no row are untested on this example. The full settings are inpresets.toml.

Code

examples/shelf/02_many_contacts/domino_spiral/example.py
# 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 Domino Spiral
#
# Places dominoes along a spiral and tilts the first one to start a
# contact-driven chain reaction.
#
# Command: python -m shelf run domino_spiral
# python -m shelf run domino_spiral --num-dominoes 480 --scale 0.28 # about steel-webgpu's spiral
#
###########################################################################
from __future__ import annotations
import math
import warp as wp
import newton
import newton.examples
from common import presets
NUM_DOMINOES = 30
DOMINO_SPACING = 0.12
DOMINO_HALF = (0.06, 0.016, 0.18)
DOMINO_DENSITY = 580.0
SPIRAL_INNER_RADIUS = 0.35
SPIRAL_PITCH = 0.32
INITIAL_TILT = math.radians(15.0)
GRAVITY = -9.81
NATIVE_CONTACT_SOLVERS = {"mujoco", "kamino"}
def _rainbow_color(i: int, count: int) -> wp.vec3:
t = i / max(count - 1, 1)
return wp.vec3(0.9 * (1.0 - t) + 0.2 * t, 0.25 + 0.55 * t, 0.15 + 0.75 * (1.0 - abs(0.5 - t) * 2.0))
def _domino_spiral_pose(index: int, spacing: float = DOMINO_SPACING, half_height: float = DOMINO_HALF[2]) -> tuple[wp.vec3, wp.quat]:
b = SPIRAL_PITCH / (2.0 * math.pi)
theta = 0.0
for _ in range(index):
r = SPIRAL_INNER_RADIUS + b * theta
theta += spacing / math.sqrt(r * r + b * b)
r = SPIRAL_INNER_RADIUS + b * theta
x = r * math.cos(theta)
y = r * math.sin(theta)
tx = b * math.cos(theta) - r * math.sin(theta)
ty = b * math.sin(theta) + r * math.cos(theta)
yaw = math.atan2(-tx, ty)
q_yaw = wp.quat_from_axis_angle(wp.vec3(0.0, 0.0, 1.0), yaw)
return wp.vec3(x, y, half_height), q_yaw
class Example:
def __init__(self, viewer, args):
self.preset = presets.load(__file__)
self.viewer = viewer
self.solver_name = self.preset.name
self.fps = self.preset.fps
self.frame_dt = 1.0 / self.fps
self.sim_time = 0.0
self.sim_substeps = self.preset.substeps
self.sim_dt = self.frame_dt / self.sim_substeps
builder = newton.ModelBuilder(gravity=(0.0, 0.0, GRAVITY))
builder.rigid_gap = 0.001
# Contact stiffness (ke) is solver-dependent and comes from the preset's model table.
self.preset.apply_model(builder.default_shape_cfg)
builder.default_shape_cfg.kd = 0.0
builder.default_shape_cfg.kf = 1.0e3
builder.default_shape_cfg.mu = 1.0
builder.default_shape_cfg.restitution = 0.15
builder.default_shape_cfg.density = DOMINO_DENSITY
builder.add_ground_plane(cfg=builder.default_shape_cfg.copy())
count = int(getattr(args, "num_dominoes", NUM_DOMINOES))
scale = float(getattr(args, "scale", 1.0)) # 0.28: steel-webgpu's 10 cm pieces, spaced to match
hx, hy, hz = (h * scale for h in DOMINO_HALF)
for i in range(count):
pos, q_yaw = _domino_spiral_pose(i, DOMINO_SPACING * scale, hz)
if i == 0:
q = q_yaw * wp.quat_from_axis_angle(wp.vec3(1.0, 0.0, 0.0), -INITIAL_TILT)
else:
q = q_yaw
body = builder.add_body(
xform=wp.transform(p=pos, q=q),
label=f"domino_{i}",
)
builder.add_shape_box(
body,
hx=hx,
hy=hy,
hz=hz,
cfg=builder.default_shape_cfg.copy(),
color=_rainbow_color(i, count),
)
builder.color()
self.model = builder.finalize()
if self.solver_name in NATIVE_CONTACT_SOLVERS:
self.collision_pipeline = None
self.contacts = None
else:
self.collision_pipeline = newton.CollisionPipeline(self.model)
self.contacts = self.collision_pipeline.contacts()
self.solver = self.preset.make_solver(self.model)
self.state_0 = self.model.state()
self.state_1 = self.model.state()
self.control = self.model.control()
self.viewer.set_model(self.model)
self.viewer.set_camera(pos=wp.vec3(-1.16, -1.26, 1.06), pitch=-27.8, yaw=43.6)
self.capture()
def capture(self):
if wp.get_device().is_cuda:
with wp.ScopedCapture() as capture:
self.simulate()
self.graph = capture.graph
else:
self.graph = None
def simulate(self):
for substep in range(self.sim_substeps):
self.state_0.clear_forces()
self.viewer.apply_forces(self.state_0)
if self.solver_name in NATIVE_CONTACT_SOLVERS:
contacts = None
else:
self.collision_pipeline.collide(self.state_0, self.contacts)
contacts = self.contacts
self.solver.step(self.state_0, self.state_1, self.control, contacts, self.sim_dt)
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 self.graph:
wp.capture_launch(self.graph)
else:
self.simulate()
self.sim_time += self.frame_dt
def test_final(self):
newton.examples.test_body_state(
self.model,
self.state_0,
"dominoes remain within scene bounds",
lambda q, qd: abs(q[0]) < 1.0 and abs(q[1]) < 1.0 and 0.0 < q[2] < 0.5,
)
def render(self):
self.viewer.begin_frame(self.sim_time)
self.viewer.log_state(self.state_0)
if self.solver_name not in NATIVE_CONTACT_SOLVERS:
self.viewer.log_contacts(self.contacts, self.state_0)
self.viewer.end_frame()
@staticmethod
def create_parser():
parser = newton.examples.create_parser()
parser.add_argument("--num-dominoes", type=int, default=NUM_DOMINOES, help="Pieces along the spiral.")
parser.add_argument("--scale", type=float, default=1.0, help="Piece size and spacing relative to Newton's 36 cm dominoes (0.28: steel-webgpu's 10 cm pieces).")
return parser
if __name__ == "__main__":
parser = Example.create_parser()
viewer, args = newton.examples.init(parser)
newton.examples.run(Example(viewer, args), args)