Files
libakgl/tests/partition.c

508 lines
18 KiB
C
Raw Normal View History

Add a pluggable broad phase, and the incremental uniform grid behind it akgl_Partitioner is a record of function pointers and an initializer, the same shape as the render and physics backends. `move` is its own slot rather than remove-then-insert, and that is the whole design: a uniform grid's move is a comparison and a return when the proxy has not left the cells it was in, which is the steady state for a walking actor and the permanent state for a static one. Spelling it as remove-and-insert would turn the incremental grid into the rebuild-every-frame tree that PERFORMANCE.md already measured and rejected. The grid is a dense 128x128 array of cell heads over the world, cells keyed on tile size, with two intrusive chains per entry -- one through the cell so a query can walk it, one through the proxy so removal unlinks a whole span without searching. Everything is an int16_t index rather than a pointer, so the structure is relocatable and clearing it is a memset. A proxy covering more cells than AKGL_COLLISION_GRID_MAX_SPAN spills onto one chain every query walks, which is what makes the cell pool's ceiling provable rather than hopeful. tests/partition.c compares every query against a linear scan over the same proxies and asserts containment in one direction, not equality. That asymmetry is the contract: over-reporting costs a narrowphase call, under-reporting is a wall an actor walks through. The suite is a table over partitioners so the same assertions run against every implementation, which is what the second one landing later will be held to. Three deliberate breaks, and two of them were not caught the first time: - Removing the min-cell rule so a shared-cell pair reports repeatedly: caught. - Dropping the unlink in `move`: **not caught**, initially. Stale entries do not produce wrong query answers, because the bounds re-check filters them out -- they leak the cell pool until the grid stops working, on a timescale no unit test reaches by accident. Counting live pool entries across 200 moves is the only thing that sees it, and now does: 505 entries against an expected 5. - Swapping floorf for a truncating cast: **not caught, and correctly so.** Everything below zero is clamped to cell 0 either way, so today the two are indistinguishable. The comment claiming truncation is a defect was overstating it and now says what is actually true: the clamp is the only thing hiding it, and a negative world origin or a relaxed clamp would make it real with no test standing in front of it. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:29:02 -04:00
/**
* @file partition.c
* @brief The broad phase, checked against brute force.
*
* A broad phase has one contract and it is asymmetric: it may report a pair that
* does not overlap, and it may never miss one that does. Over-reporting costs a
* narrowphase call; under-reporting is a wall an actor walks through, and it
* fails silently and intermittently, only for the positions where the structure
* happens to be wrong.
*
* So every query here is compared against a linear scan over the same proxies,
* and the assertion is containment in one direction and not equality. That is
* the only check that has any teeth: a broad phase can be tested against itself
* all day and agree with itself all day.
*
* The suite is written as a table over partitioners so the same assertions run
* against every implementation. A second implementation that is not held to the
* first one's contract is not pluggable, it is just present.
*/
#include <math.h>
#include <string.h>
#include <akerror.h>
#include <akgl/collision.h>
#include <akgl/error.h>
#include <akgl/heap.h>
#include "testutil.h"
/** @brief Every partitioner the factory knows, so the same tests run over each. */
static char *partitioners[] = { "grid" };
/** @brief What a visit callback accumulates. */
typedef struct {
int16_t seen[AKGL_MAX_HEAP_COLLISION_PROXY];
int count;
} visit_record;
static akerr_ErrorContext *record_visit(akgl_CollisionProxy *proxy, void *data)
{
visit_record *rec = (visit_record *)data;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, proxy, AKERR_NULLPOINTER, "NULL proxy in a visit");
FAIL_ZERO_RETURN(errctx, rec, AKERR_NULLPOINTER, "NULL record in a visit");
if ( rec->count < AKGL_MAX_HEAP_COLLISION_PROXY ) {
rec->seen[rec->count] = (int16_t)(proxy - &akgl_heap_collision_proxies[0]);
rec->count += 1;
}
SUCCEED_RETURN(errctx);
}
/** @brief How many cell entries are currently claimed from the pool. */
static int live_cells(void)
{
int live = 0;
int i = 0;
for ( i = 0; i < AKGL_MAX_HEAP_COLLISION_CELL; i++ ) {
if ( akgl_heap_collision_cells[i].refcount != 0 ) {
live += 1;
}
}
return live;
}
/** @brief Was this proxy index reported? */
static bool sawit(visit_record *rec, int16_t idx)
{
int i = 0;
for ( i = 0; i < rec->count; i++ ) {
if ( rec->seen[i] == idx ) {
return true;
}
}
return false;
}
/** @brief Claim a proxy at a position and put it in the partitioner. */
static akerr_ErrorContext *spawn(akgl_CollisionWorld *world, akgl_CollisionShape *shape,
float32_t x, float32_t y, akgl_CollisionProxy **dest)
{
PREPARE_ERROR(errctx);
PASS(errctx, akgl_heap_next_collision_proxy(dest));
PASS(errctx, akgl_collision_proxy_initialize(*dest, NULL, shape, x, y, 0.0f));
PASS(errctx, world->partitioner.insert(&world->partitioner, *dest));
SUCCEED_RETURN(errctx);
}
/**
* @brief A query must report every proxy a linear scan would, and may report more.
*
* Positions are chosen to land on cell boundaries, span several cells, and sit
* outside the grid entirely, because those are the three places an index is
* wrong and a linear scan is not.
*/
akerr_ErrorContext *test_query_never_misses(char *name)
{
akgl_CollisionWorld world;
akgl_CollisionShape small;
akgl_CollisionShape big;
akgl_CollisionProxy *proxies[16];
visit_record rec;
SDL_FRect area;
SDL_FRect small_body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
SDL_FRect big_body = { .x = 0.0f, .y = 0.0f, .w = 96.0f, .h = 96.0f };
float32_t spots[][2] = {
{ 0.0f, 0.0f }, { 16.0f, 0.0f }, { 32.0f, 32.0f }, { 8.0f, 8.0f },
{ 100.0f, 100.0f }, { 1000.0f, 40.0f }, { -40.0f, -40.0f }, { 15.9f, 15.9f }
};
int spotcount = (int)(sizeof(spots) / sizeof(spots[0]));
bool missed = false;
float32_t missx = 0.0f;
float32_t missy = 0.0f;
int i = 0;
int q = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
CATCH(errctx, akgl_collision_shape_box(&small, &small_body, 0.0f));
CATCH(errctx, akgl_collision_shape_box(&big, &big_body, 0.0f));
for ( i = 0; i < spotcount; i++ ) {
CATCH(errctx, spawn(&world, &small, spots[i][0], spots[i][1], &proxies[i]));
}
// One deliberately large enough to spill onto the oversized chain, which
// is a separate code path a query must not forget to walk.
CATCH(errctx, spawn(&world, &big, 200.0f, 200.0f, &proxies[spotcount]));
/*
* Sweep a query window across the world and compare against a linear
* scan every time. Recorded into a flag and asserted after the loops: a
* TEST_ASSERT in here reports by breaking, which would leave the loop
* rather than the ATTEMPT block and could not fail the test.
*/
for ( q = 0; q < 40; q++ ) {
area.x = (float32_t)((q * 13) % 300) - 50.0f;
area.y = (float32_t)((q * 7) % 300) - 50.0f;
area.w = 24.0f;
area.h = 24.0f;
memset(&rec, 0x00, sizeof(rec));
if ( world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec) != NULL ) {
missed = true;
break;
}
for ( i = 0; i <= spotcount; i++ ) {
if ( !SDL_HasRectIntersectionFloat(&proxies[i]->bounds, &area) ) {
continue;
}
if ( !sawit(&rec, (int16_t)(proxies[i] - &akgl_heap_collision_proxies[0])) ) {
missed = true;
missx = area.x;
missy = area.y;
}
}
}
TEST_ASSERT(errctx, (missed == false),
"%s missed a proxy that overlaps the query at (%f, %f); a broad phase may "
"over-report and may never under-report", name, missx, missy);
// A query is allowed to report a proxy more than the once, but must not:
// a proxy spanning four cells appears on four chains.
area.x = 0.0f;
area.y = 0.0f;
area.w = 300.0f;
area.h = 300.0f;
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count <= (spotcount + 1)),
"%s reported %d proxies where at most %d exist; a proxy spanning several "
"cells is being reported once per cell", name, rec.count, spotcount + 1);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief The layer mask filters, and filters in the right direction.
*/
akerr_ErrorContext *test_query_respects_mask(char *name)
{
akgl_CollisionWorld world;
akgl_CollisionShape actor;
akgl_CollisionShape scenery;
akgl_CollisionProxy *a = NULL;
akgl_CollisionProxy *b = NULL;
visit_record rec;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
SDL_FRect area = { .x = -50.0f, .y = -50.0f, .w = 200.0f, .h = 200.0f };
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
CATCH(errctx, akgl_collision_shape_box(&actor, &body, 0.0f));
CATCH(errctx, akgl_collision_shape_box(&scenery, &body, 0.0f));
scenery.layermask = AKGL_COLLISION_LAYER_STATIC;
CATCH(errctx, spawn(&world, &actor, 0.0f, 0.0f, &a));
CATCH(errctx, spawn(&world, &scenery, 32.0f, 0.0f, &b));
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_STATIC,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 1), "%s reported %d proxies on the static layer, expected 1",
name, rec.count);
TEST_ASSERT(errctx, sawit(&rec, (int16_t)(b - &akgl_heap_collision_proxies[0])),
"%s filtered out the static proxy and kept the actor", name);
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_NONE,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 0), "%s reported %d proxies for an empty mask", name, rec.count);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Moving a proxy has to update the index, and moving it nowhere has not to.
*
* The first half is the correctness claim: a stale entry in the old cell is the
* classic incremental-structure bug, and it shows up as a collision with where
* something used to be. The second half is the *performance* claim the whole
* structure was chosen for, and it is asserted rather than assumed.
*/
akerr_ErrorContext *test_move_is_incremental(char *name)
{
akgl_CollisionWorld world;
akgl_CollisionShape shape;
akgl_CollisionProxy *proxy = NULL;
visit_record rec;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 8.0f, .h = 8.0f };
SDL_FRect here = { .x = 0.0f, .y = 0.0f, .w = 8.0f, .h = 8.0f };
SDL_FRect there = { .x = 200.0f, .y = 200.0f, .w = 8.0f, .h = 8.0f };
int16_t firstbefore = 0;
int before = 0;
int i = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
CATCH(errctx, spawn(&world, &shape, 0.0f, 0.0f, &proxy));
// Move it a long way and re-query both ends.
CATCH(errctx, akgl_collision_proxy_sync(proxy, &shape, 200.0f, 200.0f, 0.0f));
CATCH(errctx, world.partitioner.move(&world.partitioner, proxy));
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &here, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 0),
"%s still reports a proxy where it used to be; the old cell entry was not "
"removed, and a game would collide with a ghost", name);
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &there, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 1), "%s does not report the proxy where it now is", name);
/*
* A move within the cells already occupied must touch nothing. That is
* the property the uniform grid was chosen for over a tree, so it is
* checked rather than trusted: the proxy's chain head is the cheapest
* observable proof that no entry was released and re-claimed.
*/
firstbefore = proxy->first;
CATCH(errctx, akgl_collision_proxy_sync(proxy, &shape, 201.0f, 201.0f, 0.0f));
CATCH(errctx, world.partitioner.move(&world.partitioner, proxy));
TEST_ASSERT(errctx, (proxy->first == firstbefore),
"%s re-celled a proxy that did not leave its cells; the structure is not "
"incremental and the argument for choosing it does not hold", name);
/*
* Moving repeatedly must not accumulate index entries. A move that links
* into the new cells without unlinking the old ones answers *queries*
* correctly -- the bounds re-check filters the stale entries out -- so
* nothing above catches it. What it does instead is leak the cell pool
* until the grid stops working, on a timescale no unit test reaches by
* accident. Counting the pool is the only thing that sees it.
*/
before = live_cells();
for ( i = 0; i < 200; i++ ) {
CATCH(errctx, akgl_collision_proxy_sync(proxy, &shape,
(float32_t)((i * 37) % 400),
(float32_t)((i * 53) % 400), 0.0f));
CATCH(errctx, world.partitioner.move(&world.partitioner, proxy));
}
TEST_ASSERT(errctx, (live_cells() <= before),
"%s holds %d cell entries for one proxy after 200 moves, up from %d; the "
"old entries are not being unlinked and the pool is leaking",
name, live_cells(), before);
// Removing is idempotent, because a caller tearing down should not have
// to remember what it already dropped.
CATCH(errctx, world.partitioner.remove(&world.partitioner, proxy));
CATCH(errctx, world.partitioner.remove(&world.partitioner, proxy));
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &there, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 0), "%s still reports a removed proxy", name);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/** @brief What each_pair accumulates. */
typedef struct {
int count;
int selfpairs;
} pair_record;
static akerr_ErrorContext *record_pair(akgl_CollisionProxy *a, akgl_CollisionProxy *b, void *data)
{
pair_record *rec = (pair_record *)data;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, a, AKERR_NULLPOINTER, "NULL first proxy in a pair");
FAIL_ZERO_RETURN(errctx, b, AKERR_NULLPOINTER, "NULL second proxy in a pair");
FAIL_ZERO_RETURN(errctx, rec, AKERR_NULLPOINTER, "NULL record in a pair");
if ( a == b ) {
rec->selfpairs += 1;
}
rec->count += 1;
SUCCEED_RETURN(errctx);
}
/**
* @brief Every overlapping pair is reported, and none is reported twice.
*
* Two proxies sharing four cells is the case that produces four reports from a
* naive implementation, and four reports means four impulses for one contact.
*/
akerr_ErrorContext *test_each_pair_is_once(char *name)
{
akgl_CollisionWorld world;
akgl_CollisionShape shape;
akgl_CollisionProxy *proxies[4];
pair_record rec;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 40.0f, .h = 40.0f };
int i = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
// 40 pixels on a 16 pixel grid: each of these covers nine cells, so every
// pair among them shares several.
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
for ( i = 0; i < 4; i++ ) {
CATCH(errctx, spawn(&world, &shape, (float32_t)(i * 4), 0.0f, &proxies[i]));
}
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.each_pair(&world.partitioner, &record_pair, &rec));
// Four mutually overlapping proxies are six distinct pairs.
TEST_ASSERT(errctx, (rec.selfpairs == 0), "%s paired a proxy with itself %d times",
name, rec.selfpairs);
TEST_ASSERT(errctx, (rec.count == 6),
"%s reported %d pairs among four mutually overlapping proxies, expected 6; "
"duplicates mean one contact resolved several times", name, rec.count);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/** @brief An empty world answers nothing, and reset really empties it. */
akerr_ErrorContext *test_reset_and_empty(char *name)
{
akgl_CollisionWorld world;
akgl_CollisionShape shape;
akgl_CollisionProxy *proxy = NULL;
visit_record rec;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
SDL_FRect area = { .x = -100.0f, .y = -100.0f, .w = 400.0f, .h = 400.0f };
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
// The null hypothesis. A broken structure that reports nothing passes
// every other test in this file, so this one is not a formality.
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 0), "%s reported %d proxies in an empty world",
name, rec.count);
CATCH(errctx, spawn(&world, &shape, 0.0f, 0.0f, &proxy));
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
CATCH(errctx, akgl_collision_proxy_sync(proxy, &shape, 0.0f, 0.0f, 0.0f));
CATCH(errctx, world.partitioner.move(&world.partitioner, proxy));
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 1), "%s lost the only proxy in the world", name);
CATCH(errctx, world.partitioner.reset(&world.partitioner, &world));
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 0), "%s still reports %d proxies after a reset",
name, rec.count);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_world_and_factory(void)
{
akgl_CollisionWorld world;
akgl_Partitioner part;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_collision_world_init(&world, NULL, 16.0f, 16.0f));
TEST_ASSERT(errctx, (strcmp(world.partitioner.name, "grid") == 0),
"a NULL partitioner name installed \"%s\" rather than the default",
world.partitioner.name);
TEST_ASSERT(errctx, ((world.flags & AKGL_COLLISION_TEST_PLANAR) != 0),
"a new world does not force planar normals; every game today is 2D");
CATCH(errctx, akgl_partitioner_factory(&part, "grid"));
TEST_ASSERT(errctx, (part.query != NULL), "the grid installed no query");
TEST_ASSERT(errctx, (part.move != NULL), "the grid installed no move");
TEST_EXPECT_STATUS(errctx, AKERR_KEY, akgl_partitioner_factory(&part, "quadtree"),
"a partitioner nobody wrote");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_partitioner_factory(NULL, "grid"),
"the factory with no destination");
TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_world_init(&world, NULL, 0.0f, 16.0f),
"a world with no cell width");
TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_world_init(&world, NULL, 16.0f, -1.0f),
"a world with negative cell height");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_world_init(NULL, NULL, 16.0f, 16.0f),
"a world with no destination");
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void)
{
akerr_ErrorContext *inner = NULL;
int i = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_error_init());
CATCH(errctx, test_world_and_factory());
/*
* Every partitioner is held to the same assertions. Collected rather
* than CATCHed inside the loop, because CATCH reports by breaking and a
* break here would leave the loop rather than the ATTEMPT block, so a
* failing partitioner would be silently skipped.
*/
for ( i = 0; i < (int)(sizeof(partitioners) / sizeof(partitioners[0])); i++ ) {
inner = test_query_never_misses(partitioners[i]);
if ( inner != NULL ) { break; }
inner = test_query_respects_mask(partitioners[i]);
if ( inner != NULL ) { break; }
inner = test_move_is_incremental(partitioners[i]);
if ( inner != NULL ) { break; }
inner = test_each_pair_is_once(partitioners[i]);
if ( inner != NULL ) { break; }
inner = test_reset_and_empty(partitioners[i]);
if ( inner != NULL ) { break; }
}
CATCH(errctx, inner);
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
return 0;
}