diff --git a/include/akgl/collision.h b/include/akgl/collision.h index 28361fb..2876b38 100644 --- a/include/akgl/collision.h +++ b/include/akgl/collision.h @@ -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. diff --git a/src/collision.c b/src/collision.c index d1fba42..9bd5a97 100644 --- a/src/collision.c +++ b/src/collision.c @@ -1,17 +1,19 @@ /** * @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 ``. The translation between * 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. - * - * 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. + * 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. */ +#include +#include + #include +#include #include #include #include @@ -24,61 +26,350 @@ * @brief Ceiling on narrowphase iterations. * * 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 - * not colliding, which is the same answer a missed collision gives, but it + * unbounded loop inside a frame. A pair that reaches this cap is reported as not + * colliding, which is the same answer a missed collision gives, but it * terminates. Bounding the iterations also bounds what the arena can be asked * for, since the polytope grows with them. */ #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 { - ccd_vec3_t pos; /**< World centre. */ - ccd_vec3_t half; /**< Half-extents on each axis. */ -} collision_box; + uint8_t kind; /**< AKGL_COLLISION_SHAPE_*. */ + ccd_vec3_t pos; /**< World centre: owner position plus shape offset. */ + 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 - * direction, get back the extreme point. For an axis-aligned box that is the - * corner in the direction's octant, which is three sign tests. + * The support function *is* the shape as far as libccd is concerned. One + * function dispatching on kind rather than one per kind, because a pair may mix + * 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 - * says so and both example games depend on it. Adding it means rotating @p dir - * into the box's local frame at the top of this function and rotating the - * result back, and nothing else in the narrowphase changes. See TODO.md, - * "Actor rotation". + * There is no rotation, here or anywhere in this library yet -- `util.h` says so + * and both example games depend on it. Adding it means rotating @p dir into the + * shape's local frame at the top of this function and rotating the answer back, + * and nothing else in the narrowphase changes. See `TODO.md`, "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, - ccdSign(ccdVec3X(dir)) * ccdVec3X(&box->half), - ccdSign(ccdVec3Y(dir)) * ccdVec3Y(&box->half), - ccdSign(ccdVec3Z(dir)) * ccdVec3Z(&box->half)); - ccdVec3Add(dest, &box->pos); + switch ( shape->kind ) { + case AKGL_COLLISION_SHAPE_CIRCLE: + /* + * A circle extruded along z is a cylinder, not a sphere. A sphere's caps + * 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 * 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) { - collision_box a; - collision_box b; + collision_ccdobj a; + collision_ccdobj b; ccd_t ccd; ccd_real_t ccddepth = 0.0; 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, depth, AKERR_NULLPOINTER, "NULL depth reference"); - CCD_INIT(&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; + collision_configure(&ccd); + a.kind = AKGL_COLLISION_SHAPE_BOX; ccdVec3Set(&a.pos, 0.0, 0.0, 0.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.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(); /* - * ccdGJKPenetration and not ccdMPRPenetration, deliberately. This is the - * EPA path, and EPA is the half of libccd that allocates -- so it is the - * half that proves the arena is wired up. A narrowphase on the frame path - * would prefer MPR, which allocates nothing at all. + * ccdGJKPenetration and not ccdMPRPenetration, deliberately. This is the EPA + * path, and EPA is the half of libccd that allocates -- so it is the half + * that exercises the arena. The narrowphase proper uses MPR, which does not. */ result = ccdGJKPenetration(&a, &b, &ccd, &ccddepth, &dir, &pos); diff --git a/tests/collision.c b/tests/collision.c index 9e8a793..5cdb060 100644 --- a/tests/collision.c +++ b/tests/collision.c @@ -11,6 +11,7 @@ * rather than checking that the number is 2. */ +#include #include #include @@ -447,6 +448,254 @@ akerr_ErrorContext *test_proxy_dies_with_its_actor(void) 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) { PREPARE_ERROR(errctx); @@ -460,6 +709,9 @@ int main(void) CATCH(errctx, test_shape_interacts()); CATCH(errctx, test_proxy_pool()); 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 { } PROCESS(errctx) { } FINISH_NORETURN(errctx);