Rigid collision: contacts and forces#

Every simulation step, Genesis World finds which rigid bodies touch, generates a contact manifold for each touching pair, and hands those contacts to the constraint solver. This page explains how detection works conceptually and how to read the resulting contacts and contact forces back into Python. Force resolution (how contacts turn into accelerations) is covered in Rigid constraint model.

The detection code lives under genesis/engine/solvers/rigid/collider/, driven by the collider’s detection() method.

How contacts are detected#

Detection runs in two phases each step, from a cheap approximate cull to an exact contact manifold.

Broad phase. Each geometry gets a world-space axis-aligned bounding box (AABB), recomputed every step because rigid bodies move. A Sweep-and-Prune pass then reports only the geometry pairs whose boxes overlap, turning an all-pairs comparison into a near-linear one. The same pass drops pairs that cannot physically collide:

  • Adjacency: two geometries on the same link, or on directly connected links.

  • Collision masks: pairs excluded by their contype / conaffinity bitmasks.

  • Static pairs: two geometries both fixed relative to the world.

  • Hibernation: contacts between sleeping bodies, unless one side is awake.

Narrow phase. Each surviving pair is resolved to an exact contact manifold: a contact normal, a penetration depth, and one or more contact points. The algorithm depends on the geometry pair:

Geometry pair

Path

General convex–convex, including plane–convex

Minkowski Portal Refinement (MPR) by default, or GJK with EPA when use_gjk_collision=True; a signed-distance-field query takes over on deep penetration.

Plane–box and box–box

Analytic special case, ported from MuJoCo for stability when a box lies flush on a plane.

Any geometry against height-field terrain

Terrain routine that can emit several contact points per supporting cell.

Non-convex meshes

Signed-distance-field sampling: vertices first (vertex–face), then edges (edge–edge), keeping the deepest penetration.

MPR and GJK both operate through a support function (“which vertex lies farthest along a given direction?”) so they run branch-free on the GPU without face-adjacency caches. GJK additionally reports a separation distance when the geometries are apart and is the differentiable path (it is selected automatically when the scene requires gradients). Both accelerate support queries with a precomputed support field; see Support field for how that structure is built and used. To capture flush faces rather than a single point, Genesis perturbs the pose slightly around the first contact normal and gathers the extra contacts that result.

The number of candidate pairs the broad phase may emit is bounded by the max_collision_pairs option on RigidOptions. Exceeding it at runtime halts the simulation, so raise it for scenes with dense contact.

Reading contacts#

Read the contacts from the most recent scene.step() with get_contacts() on any rigid entity. It returns a dict of parallel arrays, one entry per contact that involves the entity.

import genesis as gs

gs.init(backend=gs.gpu)

scene = gs.Scene()
plane = scene.add_entity(gs.morphs.Plane())
ball = scene.add_entity(gs.morphs.Sphere(radius=0.2, pos=(0.0, 0.0, 0.5)))

scene.build()
for _ in range(200):
    scene.step()

contacts = ball.get_contacts()  # all contacts involving the ball
positions = contacts["position"]  # world-frame contact points, shape ([n_envs,] n_contacts, 3)
forces = contacts["force_a"]  # force on geom A, shape ([n_envs,] n_contacts, 3), N

Each entry shares a leading contact axis. Index and scalar fields have shape ([n_envs,] n_contacts); vector fields have shape ([n_envs,] n_contacts, 3). The fields are:

  • geom_a, geom_b: global geometry indices of the two geometries in the pair. Recover a geometry with scene.rigid_solver.geoms[idx].

  • link_a, link_b: global link indices of the links owning those geometries, recoverable via scene.rigid_solver.links[idx].

  • position: contact point in the world frame, in meters.

  • normal: contact normal, a world-space unit vector.

  • penetration: penetration depth, positive when the geometries overlap.

  • force_a, force_b: contact force on geometry A and on geometry B. They are equal and opposite, in newtons.

  • valid_mask: present only when the scene is parallelized. See the note below.

To restrict the result to contacts against one other entity, pass with_entity. Passing the entity itself returns self-collisions only, and exclude_self_contact=True drops them:

contacts = ball.get_contacts(with_entity=plane)  # only ball–plane contacts

Note

With multiple environments, every field carries a leading n_envs axis and is padded to the largest contact count across environments, so the same array is rectangular. valid_mask (shape (n_envs, n_contacts)) marks which rows are real; filter with it before using the data. A single-environment scene returns the fields already trimmed, with no valid_mask.

When to use a contact sensor instead#

get_contacts() and get_links_net_contact_force() pull the whole contact set on demand, which is convenient for scripting and debugging. For a per-link signal you sample every step in a control or training loop (with history, noise, and delay handled for you), attach a contact sensor instead. ContactForce reports the net force on a link in its own frame, and the tactile probes estimate dense per-taxel forces. See Contact and force sensors.

See also#