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:
@@ -11,6 +11,7 @@
|
||||
* rather than checking that the number is 2.
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <akerror.h>
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user