Skip to content

[FEATURE] Add analytical collisions for capsule-capsule and capsule-sphere.#2363

Open
hughperkins wants to merge 18 commits intoGenesis-Embodied-AI:mainfrom
hughperkins:hp/narrow-cap-cap
Open

[FEATURE] Add analytical collisions for capsule-capsule and capsule-sphere.#2363
hughperkins wants to merge 18 commits intoGenesis-Embodied-AI:mainfrom
hughperkins:hp/narrow-cap-cap

Conversation

@hughperkins
Copy link
Collaborator

Description

Add analytical collisions for capsule-capsule and capsule-sphere

  • faster than mpr and gjk
  • uses much less memory than gjk

Related Issue

Resolves Genesis-Embodied-AI/Genesis#

Motivation and Context

How Has This Been / Can This Be Tested?

Screenshots (if appropriate):

Checklist:

  • I read the CONTRIBUTING document.
  • I followed the Submitting Code Changes section of CONTRIBUTING document.
  • I tagged the title correctly (including BUG FIX/FEATURE/MISC/BREAKING)
  • I updated the documentation accordingly or no change is needed.
  • I tested my changes and added instructions on how to test it for reviewers.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

(geoms_info.type[i_ga] == gs.GEOM_TYPE.CAPSULE and geoms_info.type[i_gb] == gs.GEOM_TYPE.SPHERE):
if ti.static(__debug__):
ti.atomic_add(collider_state.debug_analytical_sphere_capsule_count[i_b], 1)
# Ensure sphere is always i_ga and capsule is i_gb
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I should move this switch inside the function?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not in favour of adding this debug counters at all. Keep them on your own branch, like I do for timers.

Copy link
Collaborator Author

@hughperkins hughperkins Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're adding your comment on the wrong line, and hiding my comment about the var swap.

But anyway... yeah, I mean, I was in two minds about this. And I understood you'd likely not agree with having the counters. But on the other hand, without counters, I couldn't think of a way to ensure that we were executing really gjk and analytical.

In an earlier codebase, Cursor was comparing analytical with analytical, and surprise they matched 😅 . But Cursor believed that it was comparing analytical with gjk, but it was not, because it had misunderstood the logic in narrowphase.py.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose we could make the test run pytorch profiling perhaps, and run grep (conceptually) on the resulting tracefile.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh we cant, because gjk and analytical are funcs, not kernels, so we can't use pytorch prfoiling for this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on how we can make sure we are really calling gjk and analytical, in each case?

@hughperkins hughperkins marked this pull request as ready for review February 7, 2026 04:27
s = (b * e - c * d) / denom
t = (a * e - b * d) / denom

# Clamp s to [0, 1]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This avoid this kind of comment.


denom = a * c - b * b # Denominator (always >= 0)

# Initialize parameters
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This avoid this kind of comment.

Comment on lines 55 to 57
a = d1.dot(d1) # Squared length of segment A
b = d1.dot(d2) # Dot product of directions
c = d2.dot(d2) # Squared length of segment B
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This avoid this kind of comment.

Comment on lines 51 to 53
d1 = P2 - P1 # Direction vector of segment A
d2 = Q2 - Q1 # Direction vector of segment B
r = P1 - Q1 # Vector between segment origins
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use inline comments except in very few special cases.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you give a llittle more background on the concerns you have with inline comments?

Comment on lines +140 to +145
# Capsules are aligned along local Z-axis by convention
local_z = ti.Vector([0.0, 0.0, 1.0], dt=gs.ti_float)

# Get segment axes in world space
axis_a = gu.ti_transform_by_quat(local_z, quat_a)
axis_b = gu.ti_transform_by_quat(local_z, quat_b)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if using the simplified formula for normalised quaternion + local_z == world_z is improve performance:

    return ti.Vector(
        [2.0 * q_xz + 2.0 * q_wy, -2.0 * q_wx + 2.0 * q_yz, q_ww - q_xx - q_yy + q_zz], dt=gs.ti_float
    )

I guess it won't.


# Compute contact normal (from B to A, pointing into geom A)
if dist > EPS:
normal = -diff / dist # Negative because func_add_contact expects normal from B to A
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No inline comments.

temp_normal = axis_a.cross(axis_b)
if temp_normal.dot(temp_normal) < EPS:
# Axes are parallel, use any perpendicular
if ti.abs(axis_a[0]) < 0.9:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this threshold in particular?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on what we should do here?

if temp_normal.dot(temp_normal) < EPS:
# Axes are parallel, use any perpendicular
if ti.abs(axis_a[0]) < 0.9:
temp_normal = ti.Vector([1.0, 0.0, 0.0], dt=gs.ti_float).cross(axis_a)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same. I wonder if it would be worth it to use the reduce formula. At least in this case it is very simple with no math involved, so I would recommend it here, with the original formula in comment:

temp_normal = ti.Vector([0.0, -axis_a[2], axis_a[1]], dt=gs.ti_float)

if ti.abs(axis_a[0]) < 0.9:
temp_normal = ti.Vector([1.0, 0.0, 0.0], dt=gs.ti_float).cross(axis_a)
else:
temp_normal = ti.Vector([0.0, 1.0, 0.0], dt=gs.ti_float).cross(axis_a)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same:

temp_normal = ti.Vector([axis_a[2], 0.0, -axis_a[0]], dt=gs.ti_float)

else:
# Segments are coincident, use arbitrary perpendicular direction
# Try cross product with axis_a first
temp_normal = axis_a.cross(axis_b)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

temp_normal is not a good variable name. Why not just normal ?

# Segments are coincident, use arbitrary perpendicular direction
# Try cross product with axis_a first
temp_normal = axis_a.cross(axis_b)
if temp_normal.dot(temp_normal) < EPS:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be more efficient to store temp_normal.dot(temp_normal), and use it to normalise, instead of calling gu.ti_normalize that will do this job once again.

Comment on lines +222 to +223
local_z = ti.Vector([0.0, 0.0, 1.0], dt=gs.ti_float)
capsule_axis = gu.ti_transform_by_quat(local_z, capsule_quat)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same. Not sure if we should use the reduce formula here.

Comment on lines +258 to +261
if ti.abs(capsule_axis[0]) < 0.9:
normal = ti.Vector([1.0, 0.0, 0.0], dt=gs.ti_float).cross(capsule_axis)
else:
normal = ti.Vector([0.0, 1.0, 0.0], dt=gs.ti_float).cross(capsule_axis)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same. Using the reduce formula here sounds appropriate.

),
)

with tempfile.TemporaryDirectory() as tmpdir:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use tempfile in pytest. Use the fixture tmp_path instead.

Comment on lines +121 to +142
if (
hasattr(ti.lang._template_mapper.__builtins__, "__debug__")
and ti.lang._template_mapper.__builtins__["__debug__"]
):
analytical_capsule_count = scene_analytical.rigid_solver.collider.collider_state.debug_analytical_capsule_count[
0
]
analytical_gjk_count = scene_analytical.rigid_solver.collider.collider_state.debug_gjk_count[0]
gjk_scene_capsule_count = scene_gjk.rigid_solver.collider.collider_state.debug_analytical_capsule_count[0]
gjk_scene_gjk_count = scene_gjk.rigid_solver.collider.collider_state.debug_gjk_count[0]

# Scene 1 (analytical) should use analytical path, NOT GJK
assert analytical_capsule_count > 0, (
f"Scene 1 should have used analytical capsule path (count={analytical_capsule_count})"
)
assert analytical_gjk_count == 0, f"Scene 1 should NOT have used GJK path (count={analytical_gjk_count})"

# Scene 2 (GJK) should use GJK path, NOT analytical
assert gjk_scene_gjk_count > 0, f"Scene 2 should have used GJK path (count={gjk_scene_gjk_count})"
assert gjk_scene_capsule_count == 0, (
f"Scene 2 should NOT have used analytical capsule path (count={gjk_scene_capsule_count})"
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not in favour of instructions just for the sake of unit testing.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you mean, remove the comments from the asserts?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when I say 'comments', I mean, the strings after the comma, at the end of each assert line.

Comment on lines +201 to +203
mjcf1 = create_capsule_mjcf("capsule1", (0, 0, 0), (0, 0, 0), 0.1, 0.25)
mjcf1_path = os.path.join(tmpdir, "capsule1.xml")
ET.ElementTree(mjcf1).write(mjcf1_path)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about adding write as part of your helper? Sounds more convenient.

Comment on lines +896 to +897
# For perturbed iterations (i_detection > 0), correct contact position and normal
# This applies to ALL collision methods when multi-contact is enabled
Copy link
Collaborator

@duburcqa duburcqa Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it should not apply to all methods, ie not the ones that are mujoco-compatible as mujoco is not doing this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point. So I'll disable this when we are using mujoco compatible algorithm?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(or switch out which methods it applies to, when we are using mujoco compatbiel algorithm?)

@duburcqa duburcqa changed the title [FEATURE] Add analytical collisions for capsule-capsule and capsule-sphere [FEATURE] Add analytical collisions for capsule-capsule and capsule-sphere. Feb 7, 2026
Comment on lines -624 to +629
if geoms_info.type[i_ga] == gs.GEOM_TYPE.PLANE:
if geoms_info.type[i_ga] == gs.GEOM_TYPE.CAPSULE and geoms_info.type[i_gb] == gs.GEOM_TYPE.CAPSULE:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beware the MacOS-specific branches for specialisation that are necessary because of taichi compiler bug:

Image

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that g1 fall is running just fine on my Mac.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants