Skip to content

Inquiry on Unstable Collision Behavior with Colinear Contact Points in Pymunk #290

@CarlZhangBW

Description

@CarlZhangBW

I hope you are doing well.

I’m encountering an unexpected instability in my Pymunk simulation and would appreciate your insight. In my setup, I have three shapes aligned in a row: two dynamic bodies with 0pi and 1pi moving toward a static body with 0.5pi placed between them. Gravity is set to zero, and each body has mass = 1, so there is no built‑in friction. To emulate friction during collisions, I apply a compensating impulse inside the post_solve callback. In most configurations, this works perfectly; however, whenever the three collision contact points become colinear, the simulation exhibits oscillatory, unstable behavior.

Below is a minimal example of the relevant code:

import numpy as np
import pymunk

# Build 14 discrete direction angles (in degrees)
COLLISION_IMPACT_FORCE = 1.42  # Threshold impact force above which special logic applies
resistance = [
    1.22, 1.09, 1.03, 1.45, 1.43, 1.19, 1.06,
    1.11, 1.06, 1.19, 1.43, 1.45, 1.03, 1.09
]
friction = 1.45

# Define base directions for half the angles (8 cardinal and intercardinal directions)
base_dirs = np.array([
    90.0,   # Upwards (positive Y axis)
    67.6,   # Northeast offset by 22.4 degrees from vertical
    42.1,   # Northeast offset by 47.9 degrees from vertical
    0.0,    # Rightwards (positive X axis)
    -19.5,  # Southeast offset by 19.5 degrees below horizontal
    -42.1,  # Southeast offset by 47.9 degrees below horizontal
    -67.6,  # Southeast offset by 22.4 degrees from vertical downward
    -90.0   # Downwards (negative Y axis)
])

# Reflect base directions about the horizontal axis to get the remaining 6 symmetric angles
reflected = 180.0 - base_dirs
# Exclude duplicate vertical directions at 90° and -90° (270°)
mask = (np.abs(reflected - 90.0) > 1e-6) & (np.abs((reflected % 360) - 270.0) > 1e-6)
reflected = reflected[mask]

# Combine base and reflected directions, wrap to [0, 360)
dirs14 = np.mod(np.concatenate([base_dirs, reflected]), 360.0)

# Construct a lookup table with linear interpolation for resistance at each integer degree from 0° to 359°
angles = np.concatenate([dirs14, [dirs14[0] + 360.0]])  # Close the loop at 360°
vals   = np.concatenate([resistance, [resistance[0]]])
degs   = np.arange(360)
resistance360 = np.interp(degs, angles, vals)  # Array of length 360, resistance for each degree

def get_resistance(local_ang: float) -> float:
    """
    Map any angle in [0, 360) to the nearest integer degree
    and return the corresponding resistance value from the lookup table.
    """
    deg = int(round(local_ang)) % 360
    return resistance360[deg]

def agent_post_solve(arbiter: pymunk.Arbiter, space, data):
    """
    Callback executed after collision resolution.
    Applies compensating impulses or forces to simulate friction
    based on the direction and magnitude of the collision impulse.
    """
    s1, s2 = arbiter.shapes
    impulse = arbiter.total_impulse
    force = impulse * fps  # Convert impulse to force via frame rate

    # Retrieve the first contact point in world coordinates
    cps = arbiter.contact_point_set  # Get ContactPointSet object
    pts = cps.points                # Tuple of ContactPoint objects
    if pts:
        cp = pts[0]  # Use the first contact point
        # Choose either cp.point_a, cp.point_b, or their midpoint for world coordinates
        point = (cp.point_a + cp.point_b) * 0.5
    else:
        # Fallback: apply at the shape's center of mass if no contact points are available
        point = s1.body.position

    # Determine which shape is being hit on its side by converting the contact point to local coordinates
    local_point = s1.body.world_to_local(point)
    if -8 < local_point.x < 8:
        # Contact is near the center: treat as a head-on collision
        act_impulse = impulse
        act_force = force
        s1, s2 = arbiter.shapes
    else:
        # Contact is on the side: reverse impulse direction for compensation
        act_impulse = -impulse
        act_force = -force
        s2, s1 = arbiter.shapes

    id1, id2 = s1.collision_type, s2.collision_type

    # Calculate local impulse angle to determine directional resistance
    local_imp = impulse.rotated(-s1.body.angle)
    local_ang = local_imp.angle_degrees % 360.0
    r = get_resistance(local_ang)

    # Precompute body centers for direct impulse application
    point1 = s1.body.position
    point2 = s2.body.position

    # If resistance exceeds the collision impact threshold, apply full stop logic
    if r > COLLISION_IMPACT_FORCE:
        if agents_state[id1] == 0 or agents_state[id2] == 0:
            # One of the agents is stationary; apply a full compensating impulse at the centers to ensure immediate stop.
            print('Act impulse', impulse)
            s1.body.apply_impulse_at_world_point(-act_impulse, point1)
            s2.body.apply_impulse_at_world_point(-act_impulse, point2)
        else:
            # Both agents are moving; apply opposing impulses at the contact point
            s1.body.apply_impulse_at_world_point(-act_impulse, point)
            s2.body.apply_impulse_at_world_point(act_impulse, point)
    else:
        # Resistance is below threshold; apply partial friction compensation
        friction_impulse = act_impulse * (r / COLLISION_IMPACT_FORCE)
        if agents_state[id1] == 0 or agents_state[id2] == 0:
            # One agent is stationary; apply friction impulse at centers
            s1.body.apply_impulse_at_world_point(-friction_impulse, point1)
            s2.body.apply_impulse_at_world_point(-friction_impulse, point2)
        else:
            # Both agents moving; apply friction impulse at contact point
            s1.body.apply_impulse_at_world_point(-friction_impulse, point)
            s2.body.apply_impulse_at_world_point(friction_impulse, point)

    return

# Simulation parameters
num_agents = 3
num_init_fixed_light_source = 5
center_motion = -1

# Initial positions of agents (x, y)
agents_positions = np.asarray([[100, 200], [300, 200], [200, 200]])
# Initial angles of agents in radians
agents_angle = np.asarray([0* np.pi, 1 * np.pi, 0.5 * np.pi])
# Agent states: 1 = forward motion, 0 = static
agents_state = np.asarray([1, 1, 0])

Screen.Recording.2025-07-16.at.09.43.04.mov

Questions:

Why does aligning the three contact points in a straight line lead to this unstable, oscillatory response?

Would you recommend a different approach to avoid this instability?

Thank you very much for your time and help. Any guidance or references you can share would be extremely valuable.

Best regards,
Carl

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions