Domino spiral
Many contacts
Dominoes along a spiral, the first one tilted to start the chain.
steel preset (the default), recordedon an NVIDIA RTX PRO 6000 Blackwell.Run it
cd examples# the default preset (steel), then the othersuv run shelf play domino_spiraluv run shelf play domino_spiral --solver xpbduv run shelf play domino_spiral --solver vbduv run shelf play domino_spiral --solver mujocouv run shelf play domino_spiral --solver featherstoneuv run shelf play domino_spiral --solver kamino# change solver settings liveuv run shelf tune domino_spiralSource: examples/shelf/02_many_contacts/domino_spiral/ · ported from newton/examples/contacts/example_domino_spiral.py @ skild-ai/newton 7565a26b1
Presets
| Preset | Solver | Status | Checked |
|---|---|---|---|
steeldefault | steel | works | 2026-11-05, newton 7565a26b1, NVIDIA GeForce RTX 3090 |
xpbd | xpbd | works | 2026-10-28, newton 7565a26b1, NVIDIA RTX A6000 |
vbd | vbd | works | 2026-10-28, newton 7565a26b1, NVIDIA RTX A6000 |
mujoco | mujoco | works | 2026-10-28, newton 7565a26b1, NVIDIA RTX A6000 |
featherstone | featherstone | works | 2026-10-28, newton 7565a26b1, NVIDIA RTX A6000 |
kamino | kamino | 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 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 newtonimport newton.examplesfrom common import presets
NUM_DOMINOES = 30DOMINO_SPACING = 0.12DOMINO_HALF = (0.06, 0.016, 0.18)DOMINO_DENSITY = 580.0SPIRAL_INNER_RADIUS = 0.35SPIRAL_PITCH = 0.32INITIAL_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)example = "domino_spiral"source = "newton/examples/contacts/example_domino_spiral.py @ skild-ai/newton 7565a26b1"default = "steel"
[check]# Arguments `python -m shelf check` passes in test mode (from Newton's test_examples.py).args = []
[gallery]short = "Dominoes along a spiral, the first one tilted to start the chain."frame = 300
[xpbd]status = "works"sim = { fps = 60, substeps = 10 }solver = { iterations = 20, enable_restitution = true }model = { ke = 1.0e4 }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[vbd]status = "works"sim = { fps = 60, substeps = 10 }solver = { iterations = 10 }model = { ke = 1.0e5 }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[mujoco]status = "works"sim = { fps = 60, substeps = 10 }solver = { njmax = 2048, nconmax = 1024, cone = "elliptic" }model = { ke = 1.0e4 }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[featherstone]status = "works"sim = { fps = 60, substeps = 100 }solver = { angular_damping = 0.0 }model = { ke = 1.0e4 }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[semi_implicit]status = "untested"
[feather_pgs]status = "untested"
[steel]status = "works"# 6 substeps x 16 iterations: the fastest setting whose mean and per-frame-max penetration match XPBD's# (peaks are single impacts and vary 0.5-2.5 mm run to run for both); fewer substeps or iterations sink# the dominoes deeper. The immediate and split responses are 4-6x slower on this contact chain.# No model.ke: Steel's rigid contact rows do not read the contact stiffness (a run with ke = 1e4 matched).# propagation_constraint_max 16384 for the 480-domino variant (`--num-dominoes 480 --scale 0.28`, ~3,300 contacts):# the default 544 rows dropped most of them and the spiral exploded. With it all 480 fall, as fast as a 12 x 32 run;# the default 30-domino scene runs the same (4.36 vs 4.35 ms).sim = { fps = 60, substeps = 6 }solver = { solve_mode = "matrix_free", iterations = 16, articulated_contact_response = "propagation_colored", propagation_constraint_max = 16384 }checked = "2026-11-05, newton 7565a26b1, NVIDIA GeForce RTX 3090"
[kamino]status = "works"sim = { fps = 60, substeps = 5 }solver = { use_collision_detector = true }model = { ke = 1.0e4 }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[implicit_mpm]status = "untested"
[style3d]status = "untested"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.