Two contact points sharing a feature id warm-start from the same impulse, and a resting stack leans
Fact
An impulse-based solver carries accumulated contact impulses from one step to the next, matched by a per-contact feature id — the pair of edges that produced the point. Face-to-face contact between two polygons is generated by clipping the incident edge against the reference face, and clipping can hand both surviving points the same originating edge:
// The clipped point inherits ids[0], so both contacts can end up with one id.
if (d0 * d1 < 0) { out.push(lerp(p0, p1, t)); outIds.push(ids[0]); }
Both points then look up the same stored impulse. One side of a resting box is loaded harder than the other, every step, and the error is signed — so it integrates rather than cancelling.
Why it matters
The manifold is correct: two points, right positions, right penetrations, right normal. Nothing is NaN and no single collision looks wrong. The symptom is a slow, steady lean — under a degree per second — that only becomes visible once enough bodies are stacked for it to accumulate, and it reads as a friction or convergence problem rather than as bookkeeping.
Raising the solver's iteration count does not help, which is itself the clue that the input to the solve is wrong rather than the solve.
How to apply
- Include the contact's position along the reference face in the id, not just the edge indices: sort the manifold's points by their projection onto the face tangent and stamp the ordinal in. That ordering stays stable while the incident body slides.
- Assert it where it is cheap: every manifold's ids must be distinct.
new Set(contacts.map(c => c.id)).size === contacts.length. - Test on a stack tall enough to integrate the error, not on a single box.
Related: [[friction-must-scale-with-normal-impulse]].