/** * @file collision.c * @brief Collision shapes: construction, defaults, and the extrusion invariant. * * The invariant is the one worth staring at. A 2D shape handed to a 3D * narrowphase has to be thick enough that separating two of them along z is * never cheaper than separating them in the plane -- otherwise the narrowphase * answers "push it into the screen", the resolver does, and the actor sinks * through the floor while every test that only asks "did they collide" passes. * test_shape_extrusion_beats_planar_penetration checks the inequality directly * rather than checking that the number is 2. */ #include #include #include #include #include #include #include #include #include "testutil.h" akerr_ErrorContext *test_shape_box(void) { akgl_CollisionShape shape; SDL_FRect body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f }; PREPARE_ERROR(errctx); ATTEMPT { CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f)); TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_BOX), "a box is not a box"); // Top-left-and-size in, centre-and-half-extent out. TEST_ASSERT_FEQ(errctx, shape.hx, 8.0f, "half width is %f, expected 8", shape.hx); TEST_ASSERT_FEQ(errctx, shape.hy, 16.0f, "half height is %f, expected 16", shape.hy); TEST_ASSERT_FEQ(errctx, shape.ox, 16.0f, "centre x is %f, expected 16", shape.ox); TEST_ASSERT_FEQ(errctx, shape.oy, 16.0f, "centre y is %f, expected 16", shape.oy); // The defaults exist so that giving an actor a hitbox does not silently // make it shove every other actor on the map. TEST_ASSERT(errctx, (shape.layermask == AKGL_COLLISION_LAYER_ACTOR), "a new shape is not on the actor layer"); TEST_ASSERT(errctx, (shape.collidemask == AKGL_COLLISION_LAYER_STATIC), "a new shape responds to something other than map geometry"); TEST_ASSERT(errctx, (shape.flags == AKGL_COLLISION_FLAG_NONE), "a new shape carries flags nobody set"); TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_box(NULL, &body, 0.0f), "a box with no destination"); TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_box(&shape, NULL, 0.0f), "a box with no rectangle"); body.w = 0.0f; TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, 0.0f), "a box with zero width"); body.w = -4.0f; TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, 0.0f), "a box with negative width"); body.w = 16.0f; body.h = 0.0f; TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, 0.0f), "a box with zero height"); body.h = 32.0f; TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, -1.0f), "a box with negative depth"); } CLEANUP { } PROCESS(errctx) { } FINISH(errctx, true); SUCCEED_RETURN(errctx); } akerr_ErrorContext *test_shape_circle_and_capsule(void) { akgl_CollisionShape shape; SDL_FRect wide = { .x = 0.0f, .y = 0.0f, .w = 40.0f, .h = 10.0f }; SDL_FRect tall = { .x = 0.0f, .y = 0.0f, .w = 10.0f, .h = 40.0f }; PREPARE_ERROR(errctx); ATTEMPT { CATCH(errctx, akgl_collision_shape_circle(&shape, 16.0f, 16.0f, 8.0f, 0.0f)); TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_CIRCLE), "a circle is not a circle"); TEST_ASSERT_FEQ(errctx, shape.hx, 8.0f, "circle radius is %f, expected 8", shape.hx); TEST_ASSERT_FEQ(errctx, shape.hy, 8.0f, "a circle's hy should mirror its radius, got %f", shape.hy); TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_circle(&shape, 0.0f, 0.0f, 0.0f, 0.0f), "a circle of zero radius"); TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_circle(NULL, 0.0f, 0.0f, 8.0f, 0.0f), "a circle with no destination"); CATCH(errctx, akgl_collision_shape_capsule(&shape, &wide, AKGL_COLLISION_SHAPE_CAPSULE_X, 0.0f)); TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_CAPSULE_X), "an x capsule is not one"); CATCH(errctx, akgl_collision_shape_capsule(&shape, &tall, AKGL_COLLISION_SHAPE_CAPSULE_Y, 0.0f)); TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_CAPSULE_Y), "a y capsule is not one"); /* * A capsule capped on its *short* axis is a circle described * confusingly. Refusing it is the difference between a caller getting an * error and a caller getting a shape that is not the one they drew. */ TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_capsule(&shape, &tall, AKGL_COLLISION_SHAPE_CAPSULE_X, 0.0f), "a tall rectangle capped on x"); TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_capsule(&shape, &wide, AKGL_COLLISION_SHAPE_CAPSULE_Y, 0.0f), "a wide rectangle capped on y"); TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_capsule(&shape, &wide, AKGL_COLLISION_SHAPE_BOX, 0.0f), "a capsule on an axis that is not an axis"); } CLEANUP { } PROCESS(errctx) { } FINISH(errctx, true); SUCCEED_RETURN(errctx); } /** * @brief The inequality that keeps a 2D game flat, checked rather than assumed. * * For any two shapes the setters produce, the overlap along z when they are * concentric must exceed the largest penetration they can reach in the plane. * If it does not, the narrowphase is entitled to answer "separate along z", and * a resolver acting on that pushes the actor into the screen -- invisible, and * fatal, because the actor is still inside the floor. * * This tests the property over a spread of sizes rather than testing that the * ratio constant equals 2, so that changing the constant to something that does * not work fails here instead of failing in a game. */ akerr_ErrorContext *test_shape_extrusion_beats_planar_penetration(void) { akgl_CollisionShape a; akgl_CollisionShape b; SDL_FRect body; float32_t sizes[] = { 1.0f, 4.0f, 16.0f, 32.0f, 512.0f }; float32_t zoverlap = 0.0f; float32_t planarmax = 0.0f; float32_t worstz = 0.0f; float32_t worstplanar = 0.0f; float32_t worsta = 0.0f; float32_t worstb = 0.0f; bool violated = false; int i = 0; int j = 0; PREPARE_ERROR(errctx); ATTEMPT { /* * The check is recorded into a flag and asserted after the loops rather * than asserted inside them. TEST_ASSERT expands to FAIL_BREAK, which * reports by `break`ing, and a `break` inside a `for` leaves the loop * rather than the ATTEMPT block -- so an assertion written in here * cannot fail the test. AGENTS.md documents that hazard for CATCH; it * applies to every macro in the family, and this test was written with * the bug before it was written without it. */ for ( i = 0; i < (int)(sizeof(sizes) / sizeof(sizes[0])); i++ ) { for ( j = 0; j < (int)(sizeof(sizes) / sizeof(sizes[0])); j++ ) { body.x = 0.0f; body.y = 0.0f; body.w = sizes[i]; body.h = sizes[i]; if ( akgl_collision_shape_box(&a, &body, 0.0f) != NULL ) { violated = true; break; } body.w = sizes[j]; body.h = sizes[j]; if ( akgl_collision_shape_box(&b, &body, 0.0f) != NULL ) { violated = true; break; } // Concentric is the worst case: every axis is fully overlapped. zoverlap = 2.0f * ((a.hz < b.hz) ? a.hz : b.hz); planarmax = 2.0f * ((a.hx < b.hx) ? a.hx : b.hx); if ( zoverlap <= planarmax ) { violated = true; worstz = zoverlap; worstplanar = planarmax; worsta = sizes[i]; worstb = sizes[j]; } } } TEST_ASSERT(errctx, (violated == false), "a %fx%f box against a %fx%f one overlaps %f on z and up to %f in the " "plane; z is the cheaper separation, so the narrowphase will answer " "\"push it into the screen\" and the actor will sink through the floor", worsta, worsta, worstb, worstb, worstz, worstplanar); // A depth given by hand is taken at face value. A caller who does that // has opted out of the guarantee above, which is why the setters choose // for you unless you say otherwise. body.w = 16.0f; body.h = 16.0f; CATCH(errctx, akgl_collision_shape_box(&a, &body, 3.0f)); TEST_ASSERT_FEQ(errctx, a.hz, 3.0f, "an explicit depth of 3 became %f", a.hz); } CLEANUP { } PROCESS(errctx) { } FINISH(errctx, true); SUCCEED_RETURN(errctx); } akerr_ErrorContext *test_shape_bounds(void) { akgl_CollisionShape shape; SDL_FRect body = { .x = 8.0f, .y = 4.0f, .w = 16.0f, .h = 32.0f }; SDL_FRect bounds; PREPARE_ERROR(errctx); ATTEMPT { CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f)); // At the origin the bounds are the rectangle it was built from. That // round trip is the whole contract, and it is easy to get wrong by half // an extent in either direction. CATCH(errctx, akgl_collision_shape_bounds(&shape, 0.0f, 0.0f, &bounds)); TEST_ASSERT_FEQ(errctx, bounds.x, 8.0f, "bounds x is %f, expected 8", bounds.x); TEST_ASSERT_FEQ(errctx, bounds.y, 4.0f, "bounds y is %f, expected 4", bounds.y); TEST_ASSERT_FEQ(errctx, bounds.w, 16.0f, "bounds w is %f, expected 16", bounds.w); TEST_ASSERT_FEQ(errctx, bounds.h, 32.0f, "bounds h is %f, expected 32", bounds.h); // And they translate with the owner rather than staying put. CATCH(errctx, akgl_collision_shape_bounds(&shape, 100.0f, -50.0f, &bounds)); TEST_ASSERT_FEQ(errctx, bounds.x, 108.0f, "bounds x is %f, expected 108", bounds.x); TEST_ASSERT_FEQ(errctx, bounds.y, -46.0f, "bounds y is %f, expected -46", bounds.y); TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_bounds(NULL, 0.0f, 0.0f, &bounds), "bounds of no shape"); TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_bounds(&shape, 0.0f, 0.0f, NULL), "bounds with no destination"); } CLEANUP { } PROCESS(errctx) { } FINISH(errctx, true); SUCCEED_RETURN(errctx); } /** * @brief The mask test, and the asymmetry that is the reason it exists. */ akerr_ErrorContext *test_shape_interacts(void) { akgl_CollisionShape player; akgl_CollisionShape npc; akgl_CollisionShape none; SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f }; bool interacts = false; PREPARE_ERROR(errctx); ATTEMPT { CATCH(errctx, akgl_collision_shape_box(&player, &body, 0.0f)); CATCH(errctx, akgl_collision_shape_box(&npc, &body, 0.0f)); memset(&none, 0x00, sizeof(akgl_CollisionShape)); // Out of the box, two actors ignore each other. This is the default that // keeps a town full of NPCs from shoving itself apart. CATCH(errctx, akgl_collision_shape_interacts(&player, &npc, &interacts)); TEST_ASSERT(errctx, (interacts == false), "two default shapes collide with each other; the default masks are wrong"); // The player opts in to blocking against actors. The NPC does not opt in // to blocking against the player, and must not be dragged along by it -- // that asymmetry is the entire point of two masks instead of one. player.collidemask |= AKGL_COLLISION_LAYER_ACTOR; CATCH(errctx, akgl_collision_shape_interacts(&player, &npc, &interacts)); TEST_ASSERT(errctx, (interacts == true), "the player does not respond to an actor it opted in to"); CATCH(errctx, akgl_collision_shape_interacts(&npc, &player, &interacts)); TEST_ASSERT(errctx, (interacts == false), "the NPC responded to the player because the player responded to it"); // A shape with no kind takes no part, in either direction. CATCH(errctx, akgl_collision_shape_interacts(&player, &none, &interacts)); TEST_ASSERT(errctx, (interacts == false), "a shape of no kind was collided with"); CATCH(errctx, akgl_collision_shape_interacts(&none, &player, &interacts)); TEST_ASSERT(errctx, (interacts == false), "a shape of no kind collided with something"); // And neither does a disabled one, which is the cheap way to switch an // actor out without losing its shape. npc.layermask = AKGL_COLLISION_LAYER_ACTOR; npc.flags |= AKGL_COLLISION_FLAG_DISABLED; CATCH(errctx, akgl_collision_shape_interacts(&player, &npc, &interacts)); TEST_ASSERT(errctx, (interacts == false), "a disabled shape was collided with"); TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_interacts(NULL, &npc, &interacts), "interacts with no self"); TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_interacts(&player, NULL, &interacts), "interacts with no other"); TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_interacts(&player, &npc, NULL), "interacts with no destination"); } CLEANUP { } PROCESS(errctx) { } FINISH(errctx, true); SUCCEED_RETURN(errctx); } /** * @brief The pool convention, and the window it deliberately leaves open. */ akerr_ErrorContext *test_proxy_pool(void) { akgl_CollisionProxy *first = NULL; akgl_CollisionProxy *second = NULL; akgl_CollisionShape shape; SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f }; akerr_ErrorContext *inner = NULL; int claimed = 0; int i = 0; PREPARE_ERROR(errctx); ATTEMPT { CATCH(errctx, akgl_heap_init()); CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f)); /* * Acquiring does not claim the slot, and that is the convention rather * than an oversight: an acquire abandoned before initialization leaks * nothing, because the slot still reads as free. The cost is this window, * where two acquires in a row return the same pointer -- which is why the * library writes the acquire and the initialize adjacent. */ CATCH(errctx, akgl_heap_next_collision_proxy(&first)); TEST_ASSERT(errctx, (first->refcount == 0), "acquiring a proxy claimed the slot; that is not this pool's convention"); CATCH(errctx, akgl_heap_next_collision_proxy(&second)); TEST_ASSERT(errctx, (first == second), "two acquires with no initialize between them returned different slots"); // Initializing is what closes it. CATCH(errctx, akgl_collision_proxy_initialize(first, NULL, &shape, 10.0f, 20.0f, 0.0f)); TEST_ASSERT(errctx, (first->refcount == 1), "initializing did not take the reference"); CATCH(errctx, akgl_heap_next_collision_proxy(&second)); TEST_ASSERT(errctx, (first != second), "an initialized slot was handed out again"); // The proxy carries a copy of the shape, not a pointer to it, so a later // edit to the caller's shape cannot change what the broad phase indexed. shape.hx = 999.0f; TEST_ASSERT_FEQ(errctx, first->shape.hx, 8.0f, "the proxy tracked an edit to the caller's shape; it holds %f", first->shape.hx); // Body {0,0,16,16} centres at (8,8) with half-extents 8, so at owner x=10 // the bounds start at 10 and are 16 wide. TEST_ASSERT_FEQ(errctx, first->bounds.x, 10.0f, "proxy bounds x is %f, expected 10", first->bounds.x); TEST_ASSERT_FEQ(errctx, first->bounds.y, 20.0f, "proxy bounds y is %f, expected 20", first->bounds.y); TEST_ASSERT_FEQ(errctx, first->bounds.w, 16.0f, "proxy bounds w is %f, expected 16", first->bounds.w); CATCH(errctx, akgl_heap_release_collision_proxy(first)); TEST_ASSERT(errctx, (first->refcount == 0), "releasing did not free the slot"); // Exhaustion is an ordinary reported error, not a crash and not a silent // hand-out of a slot somebody else is using. CATCH(errctx, akgl_heap_init()); for ( i = 0; i < (AKGL_MAX_HEAP_COLLISION_PROXY + 4); i++ ) { inner = akgl_heap_next_collision_proxy(&first); if ( inner != NULL ) { break; } inner = akgl_collision_proxy_initialize(first, NULL, &shape, 0.0f, 0.0f, 0.0f); if ( inner != NULL ) { break; } claimed += 1; } TEST_ASSERT(errctx, (inner != NULL), "the proxy pool never ran out"); TEST_EXPECT_STATUS(errctx, AKGL_ERR_HEAP, inner, "exhausting the proxy pool"); inner = NULL; TEST_ASSERT(errctx, (claimed == AKGL_MAX_HEAP_COLLISION_PROXY), "the pool handed out %d of %d slots before refusing", claimed, AKGL_MAX_HEAP_COLLISION_PROXY); TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_heap_next_collision_proxy(NULL), "acquiring into no destination"); TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_heap_release_collision_proxy(NULL), "releasing no proxy"); TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_proxy_initialize(NULL, NULL, &shape, 0.0f, 0.0f, 0.0f), "initializing no proxy"); } CLEANUP { if ( inner != NULL ) { inner->handled = true; inner = akerr_release_error(inner); } } PROCESS(errctx) { } FINISH(errctx, true); SUCCEED_RETURN(errctx); } /** * @brief A proxy must not outlive the actor it points at. * * akgl_heap_release_actor zeroes the actor slot. A proxy left registered would * hold a borrowed `owner` into a slot that now reads as free, and the next * contact against it would be a collision with nothing -- so the release has to * reach through and take the proxy with it. */ akerr_ErrorContext *test_proxy_dies_with_its_actor(void) { akgl_Actor *actor = NULL; akgl_CollisionProxy *proxy = NULL; akgl_CollisionShape shape; SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f }; PREPARE_ERROR(errctx); ATTEMPT { CATCH(errctx, akgl_heap_init()); CATCH(errctx, akgl_registry_init_actor()); CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f)); CATCH(errctx, akgl_heap_next_actor(&actor)); CATCH(errctx, akgl_actor_initialize(actor, "collider")); CATCH(errctx, akgl_heap_next_collision_proxy(&proxy)); CATCH(errctx, akgl_collision_proxy_initialize(proxy, actor, &shape, 0.0f, 0.0f, 0.0f)); actor->proxy = proxy; TEST_ASSERT(errctx, (proxy->refcount == 1), "the proxy was not claimed"); CATCH(errctx, akgl_heap_release_actor(actor)); TEST_ASSERT(errctx, (proxy->refcount == 0), "releasing the actor left its proxy registered, pointing at a freed slot"); TEST_ASSERT(errctx, (proxy->owner == NULL), "the released proxy still names an owner"); // An actor with no proxy releases without complaint, which is every // actor a game written before this existed. CATCH(errctx, akgl_heap_next_actor(&actor)); CATCH(errctx, akgl_actor_initialize(actor, "plain")); CATCH(errctx, akgl_heap_release_actor(actor)); } CLEANUP { } PROCESS(errctx) { } FINISH(errctx, true); 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); ATTEMPT { CATCH(errctx, akgl_error_init()); CATCH(errctx, test_shape_box()); CATCH(errctx, test_shape_circle_and_capsule()); CATCH(errctx, test_shape_extrusion_beats_planar_penetration()); CATCH(errctx, test_shape_bounds()); 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); return 0; }