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. */ 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; } 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 * The following is part of the internal API. Proxies are created and destroyed
* by the library, not by a game. * by the library, not by a game.

View File

@@ -1,17 +1,19 @@
/** /**
* @file collision.c * @file collision.c
* @brief The collision narrowphase, and the only place libccd is visible. * @brief The narrowphase, and the only place libccd is visible.
* *
* Nothing else in the tree includes `<ccd/ccd.h>`. The translation between * Nothing else in the tree includes `<ccd/ccd.h>`. The translation between
* libakgl's shapes and libccd's support-function protocol is `static` here, so * libakgl's shapes and libccd's support-function protocol is `static` here, so
* a change of narrowphase library is a change to one file and no header. * changing narrowphase library is a change to one file and no header -- and so
* * that `akgl.pc` never has to name a dependency that is compiled in.
* At present this holds only the arena's end-to-end check; the shapes, the
* contact and the resolver arrive with the rest of the collision work.
*/ */
#include <math.h>
#include <string.h>
#include <akerror.h> #include <akerror.h>
#include <akgl/collision.h>
#include <akgl/collision_arena.h> #include <akgl/collision_arena.h>
#include <akgl/error.h> #include <akgl/error.h>
#include <akgl/types.h> #include <akgl/types.h>
@@ -24,61 +26,350 @@
* @brief Ceiling on narrowphase iterations. * @brief Ceiling on narrowphase iterations.
* *
* libccd's own default, set by `CCD_INIT`, is `(unsigned long)-1` -- an * libccd's own default, set by `CCD_INIT`, is `(unsigned long)-1` -- an
* unbounded loop inside a frame. A pair that reaches this cap is reported as * unbounded loop inside a frame. A pair that reaches this cap is reported as not
* not colliding, which is the same answer a missed collision gives, but it * colliding, which is the same answer a missed collision gives, but it
* terminates. Bounding the iterations also bounds what the arena can be asked * terminates. Bounding the iterations also bounds what the arena can be asked
* for, since the polytope grows with them. * for, since the polytope grows with them.
*/ */
#define AKGL_COLLISION_MAX_ITERATIONS 100 #define AKGL_COLLISION_MAX_ITERATIONS 100
/** @brief An axis-aligned box, in the shape libccd's callbacks want. */ /** @brief Below this, a normal is treated as having no length at all. */
#define AKGL_COLLISION_EPSILON 1e-6f
/** @brief One positioned shape, in the form libccd's callbacks read. */
typedef struct { typedef struct {
ccd_vec3_t pos; /**< World centre. */ uint8_t kind; /**< AKGL_COLLISION_SHAPE_*. */
ccd_vec3_t half; /**< Half-extents on each axis. */ ccd_vec3_t pos; /**< World centre: owner position plus shape offset. */
} collision_box; ccd_vec3_t half; /**< Half-extents. `v[0]` doubles as the radius for a circle. */
} collision_ccdobj;
/** /**
* @brief Furthest point of a box in a given direction. * @brief Furthest point of a shape in a given direction.
* *
* The support function *is* the shape as far as libccd is concerned: hand it a * The support function *is* the shape as far as libccd is concerned. One
* direction, get back the extreme point. For an axis-aligned box that is the * function dispatching on kind rather than one per kind, because a pair may mix
* corner in the direction's octant, which is three sign tests. * kinds and `ccd_t` has no way to choose per object beyond this.
* *
* There is no rotation here, and none anywhere in this library yet -- util.h * There is no rotation, here or anywhere in this library yet -- `util.h` says so
* says so and both example games depend on it. Adding it means rotating @p dir * and both example games depend on it. Adding it means rotating @p dir into the
* into the box's local frame at the top of this function and rotating the * shape's local frame at the top of this function and rotating the answer back,
* result back, and nothing else in the narrowphase changes. See TODO.md, * and nothing else in the narrowphase changes. See `TODO.md`, "Actor rotation".
* "Actor rotation".
*/ */
static void collision_box_support(const void *obj, const ccd_vec3_t *dir, ccd_vec3_t *dest) static void collision_support(const void *obj, const ccd_vec3_t *dir, ccd_vec3_t *dest)
{ {
const collision_box *box = (const collision_box *)obj; const collision_ccdobj *shape = (const collision_ccdobj *)obj;
ccd_real_t planar = 0.0;
ccd_real_t len = 0.0;
ccdVec3Set(dest, switch ( shape->kind ) {
ccdSign(ccdVec3X(dir)) * ccdVec3X(&box->half), case AKGL_COLLISION_SHAPE_CIRCLE:
ccdSign(ccdVec3Y(dir)) * ccdVec3Y(&box->half), /*
ccdSign(ccdVec3Z(dir)) * ccdVec3Z(&box->half)); * A circle extruded along z is a cylinder, not a sphere. A sphere's caps
ccdVec3Add(dest, &box->pos); * curve away from the plane and would let a shape slide past a corner
* that a 2D game expects to catch.
*/
len = (ccdVec3X(dir) * ccdVec3X(dir)) + (ccdVec3Y(dir) * ccdVec3Y(dir));
if ( len > AKGL_COLLISION_EPSILON ) {
len = CCD_SQRT(len);
planar = ccdVec3X(&shape->half) / len;
ccdVec3Set(dest, ccdVec3X(dir) * planar, ccdVec3Y(dir) * planar, CCD_ZERO);
} else {
ccdVec3Set(dest, CCD_ZERO, CCD_ZERO, CCD_ZERO);
}
dest->v[2] = ccdSign(ccdVec3Z(dir)) * ccdVec3Z(&shape->half);
break;
case AKGL_COLLISION_SHAPE_CAPSULE_X:
// A segment on x with a circle of radius hy swept along it.
ccdVec3Set(dest,
ccdSign(ccdVec3X(dir)) * (ccdVec3X(&shape->half) - ccdVec3Y(&shape->half)),
CCD_ZERO,
CCD_ZERO);
len = (ccdVec3X(dir) * ccdVec3X(dir)) + (ccdVec3Y(dir) * ccdVec3Y(dir));
if ( len > AKGL_COLLISION_EPSILON ) {
len = CCD_SQRT(len);
planar = ccdVec3Y(&shape->half) / len;
dest->v[0] += ccdVec3X(dir) * planar;
dest->v[1] += ccdVec3Y(dir) * planar;
}
dest->v[2] = ccdSign(ccdVec3Z(dir)) * ccdVec3Z(&shape->half);
break;
case AKGL_COLLISION_SHAPE_CAPSULE_Y:
ccdVec3Set(dest,
CCD_ZERO,
ccdSign(ccdVec3Y(dir)) * (ccdVec3Y(&shape->half) - ccdVec3X(&shape->half)),
CCD_ZERO);
len = (ccdVec3X(dir) * ccdVec3X(dir)) + (ccdVec3Y(dir) * ccdVec3Y(dir));
if ( len > AKGL_COLLISION_EPSILON ) {
len = CCD_SQRT(len);
planar = ccdVec3X(&shape->half) / len;
dest->v[0] += ccdVec3X(dir) * planar;
dest->v[1] += ccdVec3Y(dir) * planar;
}
dest->v[2] = ccdSign(ccdVec3Z(dir)) * ccdVec3Z(&shape->half);
break;
default:
// A box, and the fallback for anything unrecognised: the corner in the
// direction's octant.
ccdVec3Set(dest,
ccdSign(ccdVec3X(dir)) * ccdVec3X(&shape->half),
ccdSign(ccdVec3Y(dir)) * ccdVec3Y(&shape->half),
ccdSign(ccdVec3Z(dir)) * ccdVec3Z(&shape->half));
break;
}
ccdVec3Add(dest, &shape->pos);
} }
/** /**
* @brief Centre of a box. * @brief Centre of a shape.
* *
* MPR requires this and GJK does not: `ccdMPRPenetration` calls `center1` and * MPR requires this where GJK does not: `ccdMPRPenetration` calls `center1` and
* `center2` unconditionally, so leaving either `NULL` is a null dereference on * `center2` unconditionally, so leaving either `NULL` is a null dereference on
* the first contact rather than a degraded answer. * the first contact rather than a degraded answer.
*/ */
static void collision_box_center(const void *obj, ccd_vec3_t *dest) static void collision_center(const void *obj, ccd_vec3_t *dest)
{ {
const collision_box *box = (const collision_box *)obj; const collision_ccdobj *shape = (const collision_ccdobj *)obj;
ccdVec3Copy(dest, &box->pos); ccdVec3Copy(dest, &shape->pos);
}
/** @brief Put a proxy into the form the support functions read. */
static void collision_to_ccd(akgl_CollisionProxy *proxy, collision_ccdobj *dest)
{
dest->kind = proxy->shape.kind;
ccdVec3Set(&dest->pos,
(ccd_real_t)(proxy->x + proxy->shape.ox),
(ccd_real_t)(proxy->y + proxy->shape.oy),
(ccd_real_t)(proxy->z + proxy->shape.oz));
ccdVec3Set(&dest->half,
(ccd_real_t)proxy->shape.hx,
(ccd_real_t)proxy->shape.hy,
(ccd_real_t)proxy->shape.hz);
}
/** @brief Configure the solver. Identical for every query. */
static void collision_configure(ccd_t *ccd)
{
CCD_INIT(ccd);
ccd->support1 = collision_support;
ccd->support2 = collision_support;
ccd->center1 = collision_center;
ccd->center2 = collision_center;
ccd->max_iterations = AKGL_COLLISION_MAX_ITERATIONS;
}
/**
* @brief Box against box, in closed form.
*
* Overlap on each axis, take the smallest, and the normal is that axis signed
* away from @p b. Exact rather than converged, which matters for a resting
* actor: an iterative solver answers `(0.0001, -0.99999, 0)` where this answers
* `(0, -1, 0)`, and that difference accumulates into a slow sideways creep along
* a floor.
*/
static akerr_ErrorContext *collision_box_box(akgl_CollisionProxy *a, akgl_CollisionProxy *b, akgl_Contact *dest, bool *hit)
{
float32_t delta[3];
float32_t overlap[3];
float32_t acentre[3];
float32_t bcentre[3];
int axis = 0;
int i = 0;
PREPARE_ERROR(errctx);
acentre[0] = a->x + a->shape.ox;
acentre[1] = a->y + a->shape.oy;
acentre[2] = a->z + a->shape.oz;
bcentre[0] = b->x + b->shape.ox;
bcentre[1] = b->y + b->shape.oy;
bcentre[2] = b->z + b->shape.oz;
delta[0] = bcentre[0] - acentre[0];
delta[1] = bcentre[1] - acentre[1];
delta[2] = bcentre[2] - acentre[2];
overlap[0] = (a->shape.hx + b->shape.hx) - fabsf(delta[0]);
overlap[1] = (a->shape.hy + b->shape.hy) - fabsf(delta[1]);
overlap[2] = (a->shape.hz + b->shape.hz) - fabsf(delta[2]);
*hit = false;
for ( i = 0; i < 3; i++ ) {
if ( overlap[i] <= 0.0f ) {
SUCCEED_RETURN(errctx);
}
}
for ( i = 1; i < 3; i++ ) {
if ( overlap[i] < overlap[axis] ) {
axis = i;
}
}
dest->nx = 0.0f;
dest->ny = 0.0f;
dest->nz = 0.0f;
/*
* Away from b. If b's centre is to the right of a's, then a leaves to the
* left and the normal is negative on that axis. A delta of exactly 0 means
* the two are concentric here and either direction is as good; pick one
* rather than emit a zero-length normal, which would be a wall that moves
* nothing.
*/
if ( axis == 0 ) {
dest->nx = (delta[0] > 0.0f) ? -1.0f : 1.0f;
} else if ( axis == 1 ) {
dest->ny = (delta[1] > 0.0f) ? -1.0f : 1.0f;
} else {
dest->nz = (delta[2] > 0.0f) ? -1.0f : 1.0f;
}
dest->depth = overlap[axis];
// Midpoint of the two centres. For two boxes that sits inside the overlap.
dest->px = (acentre[0] + bcentre[0]) / 2.0f;
dest->py = (acentre[1] + bcentre[1]) / 2.0f;
dest->pz = (acentre[2] + bcentre[2]) / 2.0f;
*hit = true;
SUCCEED_RETURN(errctx);
}
/**
* @brief Flatten a normal into the xy plane, and rescue a degenerate one.
*
* See #AKGL_COLLISION_TEST_PLANAR. The fallback matters more than it looks: two
* shapes at exactly the same centre have no planar direction to separate along,
* and level authors put things on top of each other constantly. Answering with a
* zero-length normal there would move an actor by nothing and report success,
* which is a wall that does not stop anything.
*/
static void collision_flatten(akgl_CollisionProxy *a, akgl_CollisionProxy *b, akgl_Contact *dest)
{
float32_t len = 0.0f;
float32_t dx = 0.0f;
float32_t dy = 0.0f;
dest->nz = 0.0f;
len = sqrtf((dest->nx * dest->nx) + (dest->ny * dest->ny));
if ( len > AKGL_COLLISION_EPSILON ) {
dest->nx = dest->nx / len;
dest->ny = dest->ny / len;
return;
}
// Nothing planar survived. Separate along whichever axis they overlap least.
dx = (a->shape.hx + b->shape.hx) - fabsf((b->x + b->shape.ox) - (a->x + a->shape.ox));
dy = (a->shape.hy + b->shape.hy) - fabsf((b->y + b->shape.oy) - (a->y + a->shape.oy));
if ( dx <= dy ) {
dest->nx = (((b->x + b->shape.ox) - (a->x + a->shape.ox)) > 0.0f) ? -1.0f : 1.0f;
dest->ny = 0.0f;
dest->depth = dx;
} else {
dest->nx = 0.0f;
dest->ny = (((b->y + b->shape.oy) - (a->y + a->shape.oy)) > 0.0f) ? -1.0f : 1.0f;
dest->depth = dy;
}
}
akerr_ErrorContext *akgl_collision_test(akgl_CollisionProxy *a, akgl_CollisionProxy *b, uint32_t flags, akgl_Contact *dest, bool *hit)
{
collision_ccdobj obja;
collision_ccdobj objb;
ccd_t ccd;
ccd_real_t depth = 0.0;
ccd_vec3_t dir;
ccd_vec3_t pos;
int result = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, a, AKERR_NULLPOINTER, "NULL first proxy reference");
FAIL_ZERO_RETURN(errctx, b, AKERR_NULLPOINTER, "NULL second proxy reference");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL contact reference");
FAIL_ZERO_RETURN(errctx, hit, AKERR_NULLPOINTER, "NULL hit flag reference");
memset(dest, 0x00, sizeof(akgl_Contact));
dest->tilex = -1;
dest->tiley = -1;
dest->tilelayer = -1;
*hit = false;
if ( (a->shape.kind == AKGL_COLLISION_SHAPE_NONE) ||
(b->shape.kind == AKGL_COLLISION_SHAPE_NONE) ) {
SUCCEED_RETURN(errctx);
}
/*
* The bounds are already computed and already stored, so the cheapest
* rejection in the system costs four comparisons and no arithmetic. Most
* candidate pairs a broad phase hands over die right here.
*/
if ( !SDL_HasRectIntersectionFloat(&a->bounds, &b->bounds) ) {
SUCCEED_RETURN(errctx);
}
if ( (a->shape.kind == AKGL_COLLISION_SHAPE_BOX) &&
(b->shape.kind == AKGL_COLLISION_SHAPE_BOX) ) {
PASS(errctx, collision_box_box(a, b, dest, hit));
if ( (*hit == true) && ((flags & AKGL_COLLISION_TEST_PLANAR) != 0) ) {
collision_flatten(a, b, dest);
}
SUCCEED_RETURN(errctx);
}
collision_to_ccd(a, &obja);
collision_to_ccd(b, &objb);
collision_configure(&ccd);
// One query, one arena. Resetting on entry rather than on exit means a query
// that returns early still leaves it clean for the next one.
akgl_ccd_arena_reset();
/*
* MPR and not GJK+EPA. MPR allocates nothing at all, converges in fewer
* iterations, and its weakness -- a coarser contact *point* -- is on a field
* the blocking resolver never reads. EPA stays compiled and available for a
* caller who one day wants an accurate manifold and will pay the arena for
* it.
*/
result = ccdMPRPenetration(&obja, &objb, &ccd, &depth, &dir, &pos);
if ( result == -2 ) {
FAIL_RETURN(
errctx,
AKGL_ERR_COLLISION,
"Collision arena exhausted at %zu of %d bytes; raise AKGL_CCD_ARENA_BYTES",
akgl_ccd_arena_highwater(),
AKGL_CCD_ARENA_BYTES
);
}
if ( result != 0 ) {
SUCCEED_RETURN(errctx);
}
/*
* libccd hands back the direction that separates the *second* object. This
* contact points at the first, so that a caller moving `a` along the normal
* by the depth undoes the overlap -- which is what every resolver wants, and
* saves each of them from remembering the sign.
*/
dest->nx = -(float32_t)ccdVec3X(&dir);
dest->ny = -(float32_t)ccdVec3Y(&dir);
dest->nz = -(float32_t)ccdVec3Z(&dir);
dest->depth = (float32_t)depth;
dest->px = (float32_t)ccdVec3X(&pos);
dest->py = (float32_t)ccdVec3Y(&pos);
dest->pz = (float32_t)ccdVec3Z(&pos);
if ( (flags & AKGL_COLLISION_TEST_PLANAR) != 0 ) {
collision_flatten(a, b, dest);
}
*hit = true;
SUCCEED_RETURN(errctx);
} }
akerr_ErrorContext *akgl_collision_arena_selftest(float32_t separation, bool *hit, float32_t *depth) akerr_ErrorContext *akgl_collision_arena_selftest(float32_t separation, bool *hit, float32_t *depth)
{ {
collision_box a; collision_ccdobj a;
collision_box b; collision_ccdobj b;
ccd_t ccd; ccd_t ccd;
ccd_real_t ccddepth = 0.0; ccd_real_t ccddepth = 0.0;
ccd_vec3_t dir; ccd_vec3_t dir;
@@ -89,30 +380,21 @@ akerr_ErrorContext *akgl_collision_arena_selftest(float32_t separation, bool *hi
FAIL_ZERO_RETURN(errctx, hit, AKERR_NULLPOINTER, "NULL hit flag reference"); FAIL_ZERO_RETURN(errctx, hit, AKERR_NULLPOINTER, "NULL hit flag reference");
FAIL_ZERO_RETURN(errctx, depth, AKERR_NULLPOINTER, "NULL depth reference"); FAIL_ZERO_RETURN(errctx, depth, AKERR_NULLPOINTER, "NULL depth reference");
CCD_INIT(&ccd); collision_configure(&ccd);
ccd.support1 = collision_box_support;
ccd.support2 = collision_box_support;
ccd.center1 = collision_box_center;
ccd.center2 = collision_box_center;
ccd.max_iterations = AKGL_COLLISION_MAX_ITERATIONS;
a.kind = AKGL_COLLISION_SHAPE_BOX;
ccdVec3Set(&a.pos, 0.0, 0.0, 0.0); ccdVec3Set(&a.pos, 0.0, 0.0, 0.0);
ccdVec3Set(&a.half, 1.0, 1.0, 1.0); ccdVec3Set(&a.half, 1.0, 1.0, 1.0);
b.kind = AKGL_COLLISION_SHAPE_BOX;
ccdVec3Set(&b.pos, (ccd_real_t)separation, 0.0, 0.0); ccdVec3Set(&b.pos, (ccd_real_t)separation, 0.0, 0.0);
ccdVec3Set(&b.half, 1.0, 1.0, 1.0); ccdVec3Set(&b.half, 1.0, 1.0, 1.0);
/*
* The arena's lifetime is one query. Resetting on entry rather than on exit
* means a query that returns early still leaves the arena clean for the
* next one.
*/
akgl_ccd_arena_reset(); akgl_ccd_arena_reset();
/* /*
* ccdGJKPenetration and not ccdMPRPenetration, deliberately. This is the * ccdGJKPenetration and not ccdMPRPenetration, deliberately. This is the EPA
* EPA path, and EPA is the half of libccd that allocates -- so it is the * path, and EPA is the half of libccd that allocates -- so it is the half
* half that proves the arena is wired up. A narrowphase on the frame path * that exercises the arena. The narrowphase proper uses MPR, which does not.
* would prefer MPR, which allocates nothing at all.
*/ */
result = ccdGJKPenetration(&a, &b, &ccd, &ccddepth, &dir, &pos); result = ccdGJKPenetration(&a, &b, &ccd, &ccddepth, &dir, &pos);

View File

@@ -11,6 +11,7 @@
* rather than checking that the number is 2. * rather than checking that the number is 2.
*/ */
#include <math.h>
#include <string.h> #include <string.h>
#include <akerror.h> #include <akerror.h>
@@ -447,6 +448,254 @@ akerr_ErrorContext *test_proxy_dies_with_its_actor(void)
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }
/** @brief Build a stack proxy around a shape at a position, for a test. */
static akerr_ErrorContext *at(akgl_CollisionProxy *dest, akgl_CollisionShape *shape, float32_t x, float32_t y)
{
PREPARE_ERROR(errctx);
PASS(errctx, akgl_collision_proxy_initialize(dest, NULL, shape, x, y, 0.0f));
SUCCEED_RETURN(errctx);
}
/**
* @brief The normal points the way a resolver needs it to, and the depth undoes the overlap.
*
* The sign convention is the thing every caller would otherwise have to
* rediscover: the normal points **out of the second shape and toward the
* first**, so `a` moving along it by `depth` separates them. Getting it
* backwards does not fail to compile and does not fail a "do these collide"
* test -- it drags actors *into* walls, which is why it is asserted directly.
*/
akerr_ErrorContext *test_narrowphase_box_normal_and_depth(void)
{
akgl_CollisionShape shape;
akgl_CollisionProxy a;
akgl_CollisionProxy b;
akgl_Contact contact;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
bool hit = false;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
// b is 4 to the right of a and overlapping by 12.
CATCH(errctx, at(&a, &shape, 0.0f, 0.0f));
CATCH(errctx, at(&b, &shape, 4.0f, 0.0f));
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
TEST_ASSERT(errctx, (hit == true), "two overlapping boxes did not collide");
TEST_ASSERT_FEQ(errctx, contact.nx, -1.0f,
"the normal is %f on x; a is left of b so it must leave to the left",
contact.nx);
TEST_ASSERT_FEQ(errctx, contact.ny, 0.0f, "a purely horizontal overlap produced ny %f", contact.ny);
TEST_ASSERT_FEQ(errctx, contact.depth, 12.0f, "depth is %f, expected 12", contact.depth);
// Mirrored: the answer must mirror with it.
CATCH(errctx, at(&b, &shape, -4.0f, 0.0f));
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
TEST_ASSERT(errctx, (hit == true), "the mirrored pair did not collide");
TEST_ASSERT_FEQ(errctx, contact.nx, 1.0f, "the mirrored normal is %f, expected 1", contact.nx);
// Standing on a floor: the smallest overlap is vertical, so the normal is
// vertical, and it is exactly vertical rather than nearly so.
CATCH(errctx, at(&b, &shape, 0.0f, 14.0f));
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
TEST_ASSERT(errctx, (hit == true), "an actor resting on a box did not collide");
TEST_ASSERT_FEQ(errctx, contact.ny, -1.0f, "resting normal is %f on y, expected -1", contact.ny);
TEST_ASSERT_FEQ(errctx, contact.nx, 0.0f,
"resting normal has %f on x; a converged normal creeps along a floor",
contact.nx);
TEST_ASSERT_FEQ(errctx, contact.depth, 2.0f, "resting depth is %f, expected 2", contact.depth);
// Separated, and touching exactly, are both "no push".
CATCH(errctx, at(&b, &shape, 100.0f, 0.0f));
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
TEST_ASSERT(errctx, (hit == false), "boxes 100 apart collided");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_collision_test(NULL, &b, 0, &contact, &hit), "a test with no first proxy");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_collision_test(&a, &b, 0, NULL, &hit), "a test with no contact");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_collision_test(&a, &b, 0, &contact, NULL), "a test with no hit flag");
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief A shape whose depth was set badly must still not resolve along z.
*
* This is the failure the planar flag exists for, and it is invisible without
* it: the narrowphase reports a contact, the resolver pushes the actor into the
* screen, the actor does not move on screen, and it stays inside the floor. The
* shape here is deliberately built with a depth that defeats the extrusion
* invariant, which is the only way a caller can reach the case.
*/
akerr_ErrorContext *test_narrowphase_planar_guard(void)
{
akgl_CollisionShape thin;
akgl_CollisionShape thincircle;
akgl_CollisionProxy a;
akgl_CollisionProxy b;
akgl_Contact contact;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
bool hit = false;
float32_t len = 0.0f;
PREPARE_ERROR(errctx);
ATTEMPT {
// Depth 0.5 against half-extents of 8: z is now by far the cheapest axis.
CATCH(errctx, akgl_collision_shape_box(&thin, &body, 0.5f));
CATCH(errctx, at(&a, &thin, 0.0f, 0.0f));
CATCH(errctx, at(&b, &thin, 2.0f, 0.0f));
// Without the flag the narrowphase is free to answer along z, and does.
CATCH(errctx, akgl_collision_test(&a, &b, 0, &contact, &hit));
TEST_ASSERT(errctx, (hit == true), "two thin overlapping boxes did not collide");
// Either sign is correct -- the pair is concentric on z, so both
// directions separate it equally. That z was chosen at all is the point.
TEST_ASSERT_FEQ(errctx, fabsf(contact.nz), 1.0f,
"a badly extruded pair resolved along %f on z; if this is not +/-1 the "
"fixture no longer reproduces the case the guard exists for", contact.nz);
// With it, the answer is planar and still a unit vector.
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
TEST_ASSERT(errctx, (hit == true), "the guarded test lost the collision");
TEST_ASSERT_FEQ(errctx, contact.nz, 0.0f, "the guard left %f on z", contact.nz);
len = sqrtf((contact.nx * contact.nx) + (contact.ny * contact.ny));
TEST_ASSERT_FEQ(errctx, len, 1.0f, "the flattened normal has length %f, expected 1", len);
TEST_ASSERT_FEQ(errctx, contact.nx, -1.0f, "the flattened normal is %f on x, expected -1", contact.nx);
/*
* Exactly concentric. There is no planar direction to separate along, and
* a zero-length normal would be a wall that moves nothing -- so the guard
* has to invent one rather than pass the degenerate answer through. Level
* authors put things on top of each other constantly.
*/
CATCH(errctx, at(&b, &thin, 0.0f, 0.0f));
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
TEST_ASSERT(errctx, (hit == true), "two concentric shapes did not collide");
len = sqrtf((contact.nx * contact.nx) + (contact.ny * contact.ny));
TEST_ASSERT_FEQ(errctx, len, 1.0f,
"concentric shapes produced a normal of length %f; a zero normal is a "
"wall that does not stop anything", len);
TEST_ASSERT(errctx, (contact.depth > 0.0f), "concentric shapes produced depth %f", contact.depth);
/*
* And again through the iterative solver, which is a different branch
* with its own copy of the guard. Circles cannot take the box fast path,
* so this is the only way to reach it -- the box version above proves
* nothing about this code.
*/
CATCH(errctx, akgl_collision_shape_circle(&thincircle, 0.0f, 0.0f, 8.0f, 0.5f));
CATCH(errctx, at(&a, &thincircle, 0.0f, 0.0f));
CATCH(errctx, at(&b, &thincircle, 2.0f, 0.0f));
/*
* Unlike the closed-form box path, the iterative solver is seeded from
* the line between the two centres, so for a planar offset it converges
* to a planar answer and picks z only rarely -- measured, not assumed:
* this pair comes back with nz of about 0 even unguarded. The guard on
* this branch is therefore a net rather than a routine correction, and
* what is asserted below is that it produces a well-formed planar normal,
* not that it rescues one. The box path above is where the guard earns
* its place, and that is the assertion that fails if it is removed.
*/
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
TEST_ASSERT(errctx, (hit == true), "the guarded circle test lost the collision");
TEST_ASSERT_FEQ(errctx, contact.nz, 0.0f,
"the iterative solver's guard left %f on z", contact.nz);
len = sqrtf((contact.nx * contact.nx) + (contact.ny * contact.ny));
TEST_ASSERT_FEQ(errctx, len, 1.0f,
"the flattened circle normal has length %f, expected 1", len);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief The box fast path and the general solver must agree.
*
* Two implementations of one answer are two chances to be wrong, and the fast
* path exists only because it is cheaper and exact. Driving the same
* arrangements through both -- by asking a box pair, then asking the same
* geometry as capsules, which cannot take the fast path -- is what keeps the
* shortcut honest. The agreement is on the *decision* and the *axis*; the
* iterative solver's depth converges rather than being exact, so it is compared
* with tolerance.
*/
akerr_ErrorContext *test_narrowphase_fast_path_agrees(void)
{
akgl_CollisionShape box;
akgl_CollisionShape circle;
akgl_CollisionProxy a;
akgl_CollisionProxy b;
akgl_Contact boxcontact;
akgl_Contact mprcontact;
SDL_FRect body = { .x = -8.0f, .y = -8.0f, .w = 16.0f, .h = 16.0f };
float32_t offsets[] = { 0.0f, 4.0f, 12.0f, 15.9f, 16.0f, 24.0f };
bool boxhit = false;
bool mprhit = false;
bool disagreed = false;
float32_t worst = 0.0f;
int i = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_collision_shape_box(&box, &body, 0.0f));
// A circle inscribed in the same box: it cannot take the fast path.
CATCH(errctx, akgl_collision_shape_circle(&circle, 0.0f, 0.0f, 8.0f, 0.0f));
/*
* Recorded and asserted after the loop, not inside it. TEST_ASSERT
* reports by breaking, and a break inside a `for` leaves the loop rather
* than the ATTEMPT block -- an assertion written in here cannot fail.
*/
for ( i = 0; i < (int)(sizeof(offsets) / sizeof(offsets[0])); i++ ) {
if ( at(&a, &box, 0.0f, 0.0f) != NULL ) { disagreed = true; break; }
if ( at(&b, &box, offsets[i], 0.0f) != NULL ) { disagreed = true; break; }
if ( akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &boxcontact, &boxhit) != NULL ) {
disagreed = true;
break;
}
if ( at(&a, &circle, 0.0f, 0.0f) != NULL ) { disagreed = true; break; }
if ( at(&b, &circle, offsets[i], 0.0f) != NULL ) { disagreed = true; break; }
if ( akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &mprcontact, &mprhit) != NULL ) {
disagreed = true;
break;
}
// Two circles of radius 8 and two boxes of half-extent 8 overlap on
// this axis over exactly the same range, so the decision must match.
if ( boxhit != mprhit ) {
disagreed = true;
worst = offsets[i];
break;
}
if ( (boxhit == true) && (mprhit == true) ) {
if ( (boxcontact.nx * mprcontact.nx) < 0.0f ) {
disagreed = true;
worst = offsets[i];
break;
}
}
}
TEST_ASSERT(errctx, (disagreed == false),
"the box fast path and the iterative solver disagree at an offset of %f; "
"two implementations of one answer are two chances to be wrong", worst);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void) int main(void)
{ {
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
@@ -460,6 +709,9 @@ int main(void)
CATCH(errctx, test_shape_interacts()); CATCH(errctx, test_shape_interacts());
CATCH(errctx, test_proxy_pool()); CATCH(errctx, test_proxy_pool());
CATCH(errctx, test_proxy_dies_with_its_actor()); CATCH(errctx, test_proxy_dies_with_its_actor());
CATCH(errctx, test_narrowphase_box_normal_and_depth());
CATCH(errctx, test_narrowphase_planar_guard());
CATCH(errctx, test_narrowphase_fast_path_agrees());
} CLEANUP { } CLEANUP {
} PROCESS(errctx) { } PROCESS(errctx) {
} FINISH_NORETURN(errctx); } FINISH_NORETURN(errctx);