Skip to content

GJK never early-stops below the contact threshold (no placeholder contacts) - #915

Merged
jorisv merged 3 commits into
coal-library:develfrom
quentinll:fix/placeholder-contacts
Nov 5, 2026
Merged

jorisv merged 3 commits into
coal-library:develfrom
quentinll:fix/placeholder-contacts

Conversation

@quentinll

@quentinll quentinll commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Description

collide can return a contact made of the placeholder values GJK writes when it stops early: a normal at DBL_MAX, a point at infinity and a tiny positive depth. Anything built from such a contact, such as a contact patch, is then non-finite.

Cause

Two places decide whether the shapes are separated, with different thresholds:

Where Test With distance_upper_bound = security_margin = 0
GJK early stop (GJK::evaluate) stops when the lower bound $\omega$ exceeds max(0, distance_upper_bound, security_margin) stops as soon as $\omega > 0$
Contact registration (collision functions) adds a contact when d - security_margin <= collision_distance_threshold adds a contact when $d \le 10^{-12}$

GJKSolver::set builds GJK's upper bound without collision_distance_threshold. In the window $(0, 10^{-12}]$, GJK can stop early and return its placeholders with $\omega$ as the distance, and the collision functions register that as a contact: they never look at GJK's status.

Fix

  • GJKSolver::set gives GJK an upper bound of at least security_margin + collision_distance_threshold. Every pair that can be registered as a contact then runs GJK to convergence and gets real witness points and a real normal. All collision functions (shapes, BVHs, height fields, octrees) share the solver, so this covers all of them.
  • The early-stop branch of GJKSolver::runGJKAndEPA raises the returned distance to just above distance_upper_bound (std::nextafter), so that rounding errors cannot bring an early stop back below the contact threshold.
  • CollisionRequest::distance_upper_bound is documented accordingly.

Checking GJK's status in the collision functions instead was rejected: some specialized pairs only run GJK sometimes, so the status can be stale, and it would need a check at every call site.

Test

early_stop_within_collision_distance_threshold in test/security_margin.cpp reproduces an early stop inside the window, and checks that every contact returned has a finite point and depth and a unit normal. It fails without the fix and passes with it; the full test suite passes.

Checklist

  • I have read the contributing guidelines
  • I have run pre-commit run --all-files or pixi run lint
  • I have reviewed my own code
  • I have commented my code where necessary
  • I have made corresponding changes to the Doxygen documentation
  • I have added tests that prove my fix or feature works
  • I have updated the CHANGELOG or added the "no changelog" label for CI or infra changes
  • I have updated the README credits section
  • I have used an AI assistant

@quentinll
quentinll requested a review from lmontaut October 30, 2026 09:29
Comment thread test/security_margin.cpp Resolved
Comment thread test/security_margin.cpp Resolved
Comment thread CHANGELOG.md Resolved
lmontaut
lmontaut previously approved these changes Oct 30, 2026
@lmontaut

lmontaut commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Thanks @quentinll
More generally, the problem stems from CollisionRequest and DistanceRequest objects having a bunch of options which should be dispatched in the lower levels of collision detection but which sometimes are not. It's honestly hard for a human to keep track of all these options so we should probably have an agent verify that what's provided in the request is actually guaranteed/respected in the internal code of Coal.

Here the collision_distance_threshold option was not given to GJK. When the distance was above 0 but below collision_distance_threshold, GJK would early stop because its distance_upper_bound is 0 (when it should be collision_distance_threshold. In such a case, GJK returns nans for p1, p2 and normal. However, in the level above GJK, the points and normal are added if the distance returned by GJK is below collision_distance_threshold.

quentinll added a commit to quentinll/coal that referenced this pull request Oct 30, 2026
Build the convex from the points and triangles computed by
ConvexBase32::convexHull, so that the test runs without Qhull, and use
std::make_shared.
@quentinll

Copy link
Copy Markdown
Collaborator Author

Hello @jorisv, thanks for the review! I pushed the changes that should resolve your comments.
@lmontaut agreed. I am currently looking into this to see if the issue happens somewhere else in the code.

@jorisv

jorisv commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Approved on my side. @lmontaut If you want this PR to address other issues you mentioned (And I think it make sense to address them in the same PR) request some changes to avoid someone merge it.

@lmontaut

Copy link
Copy Markdown
Contributor

Approved on my side. @lmontaut If you want this PR to address other issues you mentioned (And I think it make sense to address them in the same PR) request some changes to avoid someone merge it.

Ok thanks @jorisv!

@lmontaut

lmontaut commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@jorisv I think we can merge this PR. If we find other stuff like that on our side, we will let you know. Thanks!

@jorisv

jorisv commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Ok, it will be merged after #885

A cylinder against a convex, with distance_upper_bound = security_margin = 0
and a cached GJK guess. GJK early-stops at a distance of 3.4e-13, below
collision_distance_threshold (1e-12), and collide() registers a contact whose
normal is DBL_MAX and point is infinite.
The collision functions register a contact when
distance - security_margin <= collision_distance_threshold, but GJK early-stopped
as soon as the distance was above max(distance_upper_bound, security_margin).
In between, an early stop, whose witness points and normal are placeholders,
became a contact.

GJKSolver::set now gives GJK an upper bound of at least
security_margin + collision_distance_threshold, which fixes every collider
sharing the solver. The early-stop branch also raises the returned distance
to just above the upper bound, so that rounding errors cannot bring it back
below the collider's threshold.
Build the convex from the points and triangles computed by
ConvexBase32::convexHull, so that the test runs without Qhull, and use
std::make_shared.
@jorisv
jorisv force-pushed the fix/placeholder-contacts branch from b367f38 to eb8532a Compare November 4, 2026 16:38
@jorisv
jorisv enabled auto-merge November 4, 2026 16:39
@jorisv
jorisv merged commit 83cdaa6 into coal-library:devel Nov 5, 2026
111 of 121 checks passed
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.

3 participants