Answer whether two shapes overlap, and how to undo it

akgl_collision_test takes two proxies and fills in a contact: a unit normal, a
depth, and a point. Three paths, cheapest first.

The proxies' bounds reject most pairs in four comparisons and no arithmetic,
which is free because the bounds were computed when the proxy was synced.

A box against a box is answered in closed form. That is not only cheaper than
the general solver, it is *exact*, and the difference shows up where it matters
most: an actor resting on a floor wants a normal of precisely (0, -1, 0), and an
iterative solver converges to something like (0.0001, -0.99999, 0), which
accumulates into a slow sideways creep. A tile game is almost entirely boxes, so
this is the path that runs.

Everything else goes to ccdMPRPenetration. MPR rather than GJK+EPA because MPR
allocates nothing at all, converges in fewer iterations, and its one weakness --
a coarser contact *point* -- is on a field the blocking resolver never reads.
EPA stays compiled and available behind the arena for a caller who one day wants
an accurate manifold. The header says the point is approximate so nobody builds
a damage falloff on it.

The normal points out of the second shape and toward the first, so a caller
moving `a` along it by `depth` separates the pair. libccd hands back the
opposite convention; converting once here saves every resolver from remembering
the sign, and getting it backwards would compile, would pass any "do these
collide" test, and would drag actors into walls. There is a test that asserts
the direction, and inverting the conversion turns it red.

AKGL_COLLISION_TEST_PLANAR flattens the normal into the xy plane. The extrusion
the setters apply already makes z the most expensive axis, so this is a net for
a caller who set a depth by hand -- and the failure it catches is silent: the
narrowphase reports a contact, the resolver pushes the actor into the screen,
and the actor does not move on screen while remaining inside the floor. It also
rescues the degenerate case, two shapes at exactly the same centre, where there
is no planar direction at all and a zero-length normal would be a wall that
stops nothing. Level authors stack things constantly.

Three things the tests found rather than assumed:

- The guard has two copies, one per path, and the box one is where it earns its
  place. Removing both is caught; removing only the iterative one is not,
  because the iterative solver is seeded from the line between the two centres
  and so converges to a planar answer on its own for a planar offset. That is
  measured and written down in the test rather than papered over with a fixture
  contrived to force it.
- A concentric pair has no preferred sign on the degenerate axis, so the test
  asserts the magnitude of the normal rather than its direction. The first
  version asserted -1 and failed against an equally correct +1.
- The box fast path and the iterative solver are two implementations of one
  answer, so they are driven over the same arrangements and required to agree on
  the decision and the axis. A shortcut nothing cross-checks is a shortcut
  waiting to diverge.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 06:18:09 -04:00
parent d0b057f47a
commit a3cb6ca79a
3 changed files with 653 additions and 49 deletions

View File

@@ -149,6 +149,76 @@ typedef struct akgl_CollisionProxy {
uint32_t stamp; /**< Sweep serial this proxy was last visited on, so a proxy spanning several cells is reported once. Written by the partitioner. */
} akgl_CollisionProxy;
/**
* @brief Force every contact normal into the xy plane.
*
* A safety net rather than a mode. The extrusion the setters apply already makes
* z the most expensive axis to separate on, so a normal along z should be
* unreachable -- but a caller who set a depth by hand has opted out of that
* guarantee, and the failure it produces is silent: the resolver pushes the
* actor into the screen, which moves it nowhere a player can see while leaving
* it inside whatever it hit.
*
* Pass it for a 2D game, which is every game today. When there is a real third
* axis to resolve on, leave it off.
*/
#define AKGL_COLLISION_TEST_PLANAR 0x00000001u
/**
* @brief One overlap, described well enough to undo it.
*
* The narrowphase fills in the geometry. Which actor, which tile, and whether
* either side was a sensor are filled in by the layer above, which is the only
* one that knows.
*/
typedef struct akgl_Contact {
struct akgl_Actor *self; /**< The actor being told about this contact. Filled in by the resolver, not the narrowphase. */
struct akgl_Actor *other; /**< The other actor, or `NULL` when the hit was map geometry with no actor behind it. */
int32_t tilex; /**< Tile column that was hit, or -1 when the other side was not a tile. */
int32_t tiley; /**< Tile row, or -1. */
int32_t tilelayer; /**< Index into the tilemap's layers, or -1. */
int32_t tilegid; /**< Global tile id that was hit, or 0. This is what tells a spike from a floor without a second lookup. */
float32_t nx; /**< Contact normal, unit length. **Points out of the other shape and toward this one**, so moving along it by #depth separates them. */
float32_t ny; /**< Normal along y. Negative means the surface is *below*, since y grows downward. */
float32_t nz; /**< Normal along z. Always 0 when #AKGL_COLLISION_TEST_PLANAR was used. */
float32_t depth; /**< How far along the normal to move to stop overlapping, in map pixels. Never negative. */
float32_t px; /**< A point on the overlap, in map pixels. Approximate for a non-box pair; see below. */
float32_t py; /**< Contact point along y. */
float32_t pz; /**< Contact point along z. */
float32_t dt; /**< Length of the sub-step this contact was found in, in seconds. Filled in by the resolver. */
bool sensor; /**< Either side carries #AKGL_COLLISION_FLAG_SENSOR: report, do not push. Filled in by the resolver. */
bool statichit; /**< The other side does not move -- a tile, or a proxy flagged #AKGL_COLLISION_FLAG_STATIC. */
} akgl_Contact;
/**
* @brief Test two positioned shapes, and describe the overlap if there is one.
*
* Three paths, cheapest first. The proxies' bounds reject most pairs outright.
* A box against a box is answered in closed form -- exact depth, exactly
* axis-aligned normal, no iteration -- which matters because a tile game is
* almost entirely boxes, and because a resting actor wants a normal that is
* precisely `(0, -1, 0)` rather than one converged to within a tolerance, or it
* creeps. Everything else goes to the iterative narrowphase.
*
* @note **The contact point is approximate for anything but a box pair.** The
* iterative solver returns a point on the portal it converged to, which
* for a deep off-centre overlap can sit noticeably away from the deepest
* point. The depth and the normal are not approximate, and the blocking
* resolver uses only those. Do not build a damage falloff on the point.
*
* @param a First proxy. Required.
* @param b Second proxy. Required.
* @param flags Bitwise OR of the `AKGL_COLLISION_TEST_*` values.
* @param dest Receives the contact geometry when @p hit comes back `true`.
* Required. The normal points out of @p b and toward @p a.
* @param hit Receives whether the two overlap. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If any pointer argument is `NULL`.
* @throws AKGL_ERR_COLLISION If the solver could not characterise an
* intersection it found -- a degenerate shape, or the arena running out.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_test(akgl_CollisionProxy *a, akgl_CollisionProxy *b, uint32_t flags, akgl_Contact *dest, bool *hit);
/*
* The following is part of the internal API. Proxies are created and destroyed
* by the library, not by a game.