From 68e042bd478089019b0ecd018fc71eb1c619c654 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 06:29:02 -0400 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 5 (1M context) --- CMakeLists.txt | 2 + include/akgl/collision.h | 141 +++++++++++ include/akgl/heap.h | 35 +++ src/collision.c | 36 +++ src/collision_grid.c | 467 ++++++++++++++++++++++++++++++++++++ src/collision_shape.c | 20 ++ src/heap.c | 47 ++++ tests/partition.c | 507 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 1255 insertions(+) create mode 100644 src/collision_grid.c create mode 100644 tests/partition.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 5c78aa0..e8b4f75 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -297,6 +297,7 @@ add_library(akgl SHARED src/actor.c src/collision.c src/collision_arena.c + src/collision_grid.c src/collision_shape.c src/actor_state_string_names.c src/audio.c @@ -395,6 +396,7 @@ set(AKGL_TEST_SUITES headers heap json_helpers + partition physics physics_sim registry diff --git a/include/akgl/collision.h b/include/akgl/collision.h index 2876b38..0d3da56 100644 --- a/include/akgl/collision.h +++ b/include/akgl/collision.h @@ -47,6 +47,7 @@ struct akgl_Actor; struct akgl_Tilemap; +struct akgl_CollisionWorld; /** @brief No shape. The actor takes no part in collision. */ #define AKGL_COLLISION_SHAPE_NONE 0 @@ -147,8 +148,30 @@ typedef struct akgl_CollisionProxy { float32_t y; /**< World position #bounds was computed from. */ float32_t z; /**< World position #bounds was computed from. Not represented in #bounds. */ uint32_t stamp; /**< Sweep serial this proxy was last visited on, so a proxy spanning several cells is reported once. Written by the partitioner. */ + int32_t cx0; /**< Grid cell rectangle currently occupied, inclusive. Written by the uniform grid only, and the thing its `move` compares against. */ + int32_t cy0; /**< Grid cell rectangle currently occupied. */ + int32_t cx1; /**< Grid cell rectangle currently occupied. */ + int32_t cy1; /**< Grid cell rectangle currently occupied. */ + int16_t first; /**< Head of this proxy's chain of cell entries, or -1 when it is not in the grid. Written by the uniform grid only. */ } akgl_CollisionProxy; +/** + * @brief One proxy's membership of one grid cell. + * + * Two intrusive chains: one through the cell, so a query can walk what is in it, + * and one through the proxy, so removing a proxy unlinks its whole span without + * searching. Everything is an index rather than a pointer, which keeps the + * structure relocatable and makes clearing it a `memset`. + */ +typedef struct akgl_CollisionCell { + uint8_t refcount; /**< Pool bookkeeping; 0 means the slot is free. */ + int16_t proxy; /**< Index into the proxy pool. */ + int32_t cell; /**< Which cell, as `(row * columns) + column`. */ + int16_t next; /**< Next entry in this cell's chain, or -1. */ + int16_t prev; /**< Previous entry in this cell's chain, or -1, so removal is O(1). */ + int16_t ownernext; /**< Next entry belonging to the same proxy, or -1. */ +} akgl_CollisionCell; + /** * @brief Force every contact normal into the xy plane. * @@ -219,6 +242,111 @@ typedef struct akgl_Contact { */ akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_test(akgl_CollisionProxy *a, akgl_CollisionProxy *b, uint32_t flags, akgl_Contact *dest, bool *hit); +/** @brief Longest partitioner name, including the terminator. */ +#define AKGL_PARTITIONER_MAX_NAME_LENGTH 32 + +/** + * @brief Visits one proxy a query found. Raise `AKERR_ITERATOR_BREAK` to stop early. + * + * A query is allowed to over-report and forbidden to under-report, so a visitor + * will see proxies that turn out not to overlap. Rejecting those is the + * narrowphase's job and it is cheap; missing one is a wall an actor walks + * through. + */ +typedef akerr_ErrorContext AKERR_NOIGNORE *(*akgl_CollisionVisitFunc)(akgl_CollisionProxy *proxy, void *data); + +/** @brief Visits one candidate pair. Raise `AKERR_ITERATOR_BREAK` to stop early. */ +typedef akerr_ErrorContext AKERR_NOIGNORE *(*akgl_CollisionPairFunc)(akgl_CollisionProxy *a, akgl_CollisionProxy *b, void *data); + +/** + * @brief A pluggable broad phase: a record of function pointers and an initializer. + * + * The same shape as akgl_RenderBackend and akgl_PhysicsBackend, for the same + * reason -- behaviour that varies is a slot to fill, not a branch to add. + * + * @section partitioner_move Why `move` is its own slot + * + * It could be spelled remove-then-insert, and that would throw away the entire + * argument for the structure that is installed by default. 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 forever for a static + * one. Collapsing it into remove-and-insert turns the incremental grid into the + * rebuild-every-frame tree that was measured and rejected. + */ +typedef struct akgl_Partitioner { + char name[AKGL_PARTITIONER_MAX_NAME_LENGTH]; /**< Which implementation is installed. Diagnostic, and what the tests use to report which row failed. */ + + akerr_ErrorContext AKERR_NOIGNORE *(*reset)(struct akgl_Partitioner *self, struct akgl_CollisionWorld *world); /**< Drop every proxy and re-derive the structure's geometry from the world. */ + akerr_ErrorContext AKERR_NOIGNORE *(*insert)(struct akgl_Partitioner *self, akgl_CollisionProxy *proxy); /**< Add a proxy at its current bounds. */ + akerr_ErrorContext AKERR_NOIGNORE *(*remove)(struct akgl_Partitioner *self, akgl_CollisionProxy *proxy); /**< Take a proxy out. Removing one that is not in is success, not an error. */ + akerr_ErrorContext AKERR_NOIGNORE *(*move)(struct akgl_Partitioner *self, akgl_CollisionProxy *proxy); /**< The proxy's bounds changed. See the note above on why this is not remove-then-insert. */ + akerr_ErrorContext AKERR_NOIGNORE *(*query)(struct akgl_Partitioner *self, SDL_FRect *area, uint32_t mask, akgl_CollisionVisitFunc visit, void *data); /**< Visit every proxy that may overlap @p area and whose `layermask` intersects @p mask. Each proxy is visited at most once. */ + akerr_ErrorContext AKERR_NOIGNORE *(*each_pair)(struct akgl_Partitioner *self, akgl_CollisionPairFunc visit, void *data); /**< Visit every candidate pair exactly once. */ + + void *state; /**< The implementation's private storage. Never dereferenced outside the file that set it. */ +} akgl_Partitioner; + +/** + * @brief Broad-phase geometry and the partitioner that indexes it. + * + * One of these exists per world. It is reached by pointer and never embedded by + * value in anything, because the structures behind it are measured in tens of + * kilobytes and `akgl_Tilemap` is already large enough to matter. + */ +typedef struct akgl_CollisionWorld { + akgl_Partitioner partitioner; /**< The broad phase. By value: it is a vtable and a `void *`, not storage. */ + float32_t cellwidth; /**< Broad-phase cell width in map pixels. */ + float32_t cellheight; /**< Broad-phase cell height. */ + float32_t originx; /**< World x the cell grid is measured from. 0 for a Tiled map. */ + float32_t originy; /**< World y the cell grid is measured from. */ + uint32_t flags; /**< `AKGL_COLLISION_TEST_*` bits applied to every test this world runs. */ + uint32_t sweep; /**< Serial of the current pass. Stamped onto proxies so one spanning several cells is reported once. */ + uint32_t tests; /**< Narrowphase calls during the last pass. Diagnostic. */ +} akgl_CollisionWorld; + +/** + * @brief Bring up a collision world with a named partitioner. + * + * @param self The world. Required. Zeroed first. + * @param type `"grid"` for the incremental uniform grid, which is the default + * and what a game should use, or `NULL` for the same. Matched on + * leading characters, like akgl_physics_factory. + * @param cellwidth Cell width in map pixels. Pass the map's tile width. Must be positive. + * @param cellheight Cell height in map pixels. Must be positive. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self is `NULL`. + * @throws AKERR_VALUE If either cell dimension is not positive. + * @throws AKERR_KEY If @p type names no partitioner. The message quotes it. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_world_init(akgl_CollisionWorld *self, char *type, float32_t cellwidth, float32_t cellheight); + +/** + * @brief Install a partitioner by name. + * + * Adding an implementation means adding a name here, not a branch anywhere else. + * + * @param self The partitioner to fill in. Required. + * @param type `"grid"`, or `NULL` for the default. Matched on leading characters. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self is `NULL`. + * @throws AKERR_KEY If @p type matches nothing. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_partitioner_factory(akgl_Partitioner *self, char *type); + +/** + * @brief Install the incremental uniform grid. + * + * The default, and the structure the performance record argues for: cells keyed + * on tile size, static storage, and insert and remove that happen only when a + * proxy crosses a cell boundary. A proxy that has not left its cells costs + * nothing to update, and a static one costs nothing ever. + * + * @param self The partitioner to fill in. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_partitioner_init_grid(akgl_Partitioner *self); + /* * The following is part of the internal API. Proxies are created and destroyed * by the library, not by a game. @@ -262,6 +390,19 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_proxy_initialize(akgl_Collisio */ akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_proxy_sync(akgl_CollisionProxy *obj, akgl_CollisionShape *shape, float32_t x, float32_t y, float32_t z); +/** + * @brief Claim a pooled grid cell entry, pointing it at a proxy and a cell. + * + * The other half of the acquire-then-initialize pair, as for proxies. + * + * @param obj The entry. Required. + * @param proxyidx Index of the proxy in the proxy pool. + * @param cell Cell number, or -1 for the oversized chain. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p obj is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_cell_initialize(akgl_CollisionCell *obj, int16_t proxyidx, int32_t cell); + /** * @brief Build an axis-aligned box from the frame-relative rectangle a game has. * diff --git a/include/akgl/heap.h b/include/akgl/heap.h index 598eb5e..ea9de11 100644 --- a/include/akgl/heap.h +++ b/include/akgl/heap.h @@ -63,6 +63,17 @@ #ifndef AKGL_MAX_HEAP_COLLISION_PROXY #define AKGL_MAX_HEAP_COLLISION_PROXY (AKGL_MAX_HEAP_ACTOR * 2) #endif +/** + * @brief The grid cell-entry pool. + * + * One entry per proxy per cell it overlaps. The ceiling is provable rather than + * hopeful: the grid spills anything covering more than + * `AKGL_COLLISION_GRID_MAX_SPAN` cells onto a single chain instead of celling + * it, so no proxy can ever hold more than that many entries. + */ +#ifndef AKGL_MAX_HEAP_COLLISION_CELL +#define AKGL_MAX_HEAP_COLLISION_CELL (AKGL_MAX_HEAP_COLLISION_PROXY * 16) +#endif /** @brief The actor pool. Public so the render and physics sweeps can walk it directly instead of going through the registry. */ extern akgl_Actor akgl_heap_actors[AKGL_MAX_HEAP_ACTOR]; @@ -76,6 +87,8 @@ extern akgl_Character akgl_heap_characters[AKGL_MAX_HEAP_CHARACTER]; extern akgl_String akgl_heap_strings[AKGL_MAX_HEAP_STRING]; /** @brief The collision proxy pool. Only the library allocates from it. */ extern akgl_CollisionProxy akgl_heap_collision_proxies[AKGL_MAX_HEAP_COLLISION_PROXY]; +/** @brief The grid cell-entry pool. Only the uniform grid allocates from it. */ +extern akgl_CollisionCell akgl_heap_collision_cells[AKGL_MAX_HEAP_COLLISION_CELL]; /** * @brief Zero every pool, marking every slot free. @@ -260,4 +273,26 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_next_collision_proxy(akgl_Collision */ akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_collision_proxy(akgl_CollisionProxy *ptr); +/** + * @brief Find a free grid cell entry. Does not claim it; the initialize does. + * @param dest Receives the entry. Required. + * @throws AKGL_ERR_HEAP If every slot is in use. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_next_collision_cell(akgl_CollisionCell **dest); + +/** + * @brief Give a grid cell entry back. + * @param ptr The entry. Required. + * @throws AKERR_NULLPOINTER If @p ptr is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_collision_cell(akgl_CollisionCell *ptr); + +/** + * @brief Drop every grid cell entry without touching any other pool. + * + * The partitioner's `reset` calls this. Sibling of akgl_heap_init_actor, for the + * same reason: a level change should not cost the sprite and character pools. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_init_collision_cells(void); + #endif //_AKGL_HEAP_H_ diff --git a/src/collision.c b/src/collision.c index 9bd5a97..ecbe052 100644 --- a/src/collision.c +++ b/src/collision.c @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -412,3 +413,38 @@ akerr_ErrorContext *akgl_collision_arena_selftest(float32_t separation, bool *hi *depth = (float32_t)ccddepth; SUCCEED_RETURN(errctx); } + +akerr_ErrorContext *akgl_partitioner_factory(akgl_Partitioner *self, char *type) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL partitioner reference"); + + // NULL means "the default", which is the grid. Matched on leading + // characters, the same way akgl_physics_factory matches its backends. + if ( (type == NULL) || (strncmp(type, "grid", 4) == 0) ) { + PASS(errctx, akgl_partitioner_init_grid(self)); + SUCCEED_RETURN(errctx); + } + FAIL_RETURN(errctx, AKERR_KEY, "No partitioner named \"%s\"", type); +} + +akerr_ErrorContext *akgl_collision_world_init(akgl_CollisionWorld *self, char *type, float32_t cellwidth, float32_t cellheight) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference"); + FAIL_NONZERO_RETURN(errctx, (cellwidth <= 0.0f), AKERR_VALUE, + "Cell width %f is not positive", cellwidth); + FAIL_NONZERO_RETURN(errctx, (cellheight <= 0.0f), AKERR_VALUE, + "Cell height %f is not positive", cellheight); + + memset(self, 0x00, sizeof(akgl_CollisionWorld)); + self->cellwidth = cellwidth; + self->cellheight = cellheight; + // Every game today is 2D, so the guard is on by default. A caller with a + // real third axis to resolve on clears it. + self->flags = AKGL_COLLISION_TEST_PLANAR; + + PASS(errctx, akgl_partitioner_factory(&self->partitioner, type)); + PASS(errctx, self->partitioner.reset(&self->partitioner, self)); + SUCCEED_RETURN(errctx); +} diff --git a/src/collision_grid.c b/src/collision_grid.c new file mode 100644 index 0000000..bd2c18f --- /dev/null +++ b/src/collision_grid.c @@ -0,0 +1,467 @@ +/** + * @file collision_grid.c + * @brief The incremental uniform grid, and why it is the default. + * + * A dense array of cell heads over the world, each holding a chain of the + * proxies that overlap it. Insert and remove happen when a proxy crosses a cell + * boundary and at no other time, so an actor walking across a tile touches this + * structure on one frame in sixteen and a static proxy touches it once, ever. + * + * That property is the whole argument for it over a tree, and it is measured + * rather than asserted: `PERFORMANCE.md` records Construct getting a 96% check + * reduction from viewport-sized cells and rejecting quadtrees as more expensive + * to maintain, and Phaser's rebuild-the-tree-every-frame approach capping out + * around five thousand bodies. A tree partitioner ships alongside this one so + * the two can be compared rather than argued about, but this is the default. + * + * Everything here is an `int16_t` index rather than a pointer, which keeps the + * structure relocatable and makes clearing it a `memset`. + */ + +#include +#include +#include + +#include +#include + +#include +#include +#include + +/** @brief Columns of cells. Fixed, so the head array is a compile-time size. */ +#ifndef AKGL_COLLISION_GRID_COLS +#define AKGL_COLLISION_GRID_COLS 128 +#endif +/** @brief Rows of cells. */ +#ifndef AKGL_COLLISION_GRID_ROWS +#define AKGL_COLLISION_GRID_ROWS 128 +#endif + +/** + * @brief Most cells one proxy may occupy before it goes on the oversized chain. + * + * A proxy spanning half the map would otherwise claim thousands of cell entries + * and drain the pool on its own. Anything larger than this is held in one list + * that every query walks, which is the standard escape hatch -- and it is what + * makes the cell pool's ceiling provable rather than hopeful: at most + * `proxies * AKGL_COLLISION_GRID_MAX_SPAN` entries can ever be live. + */ +#define AKGL_COLLISION_GRID_MAX_SPAN 16 + +/** @brief Cell heads, one per cell, each an index into the cell-entry pool or -1. */ +static int16_t grid_heads[AKGL_COLLISION_GRID_ROWS * AKGL_COLLISION_GRID_COLS]; +/** @brief Head of the chain of proxies too large to cell, or -1. */ +static int16_t grid_oversized; +/** @brief Cell size and origin, copied from the world by `reset`. */ +static float32_t grid_cellw; +static float32_t grid_cellh; +static float32_t grid_originx; +static float32_t grid_originy; +/** @brief Bumped by every query, and stamped onto proxies to suppress duplicates. */ +static uint32_t grid_sweep; + +/** @brief Index of a proxy in the pool, or -1 if it is not one of ours. */ +static int16_t grid_proxy_index(akgl_CollisionProxy *proxy) +{ + ptrdiff_t index = 0; + + index = proxy - &akgl_heap_collision_proxies[0]; + if ( (index < 0) || (index >= AKGL_MAX_HEAP_COLLISION_PROXY) ) { + return -1; + } + return (int16_t)index; +} + +/** @brief Index of a cell entry in the pool. */ +static int16_t grid_cell_index(akgl_CollisionCell *cell) +{ + return (int16_t)(cell - &akgl_heap_collision_cells[0]); +} + +/** + * @brief The inclusive rectangle of cells a proxy's bounds cover. + * + * `floorf` and not a cast to `int`, because truncation rounds toward zero and + * puts -0.5 and +0.5 in the same cell. **Today that difference is unobservable** + * -- everything below zero is clamped to cell 0 either way, and swapping in a + * truncating cast passes the whole suite. It is written correctly anyway, + * because the clamp is the only thing hiding it: give this world a negative + * origin, or relax the clamp so an off-map proxy keeps its own cell, and + * truncation becomes a real defect with no test standing in front of it. + */ +static void grid_cellrect(SDL_FRect *bounds, int32_t *cx0, int32_t *cy0, int32_t *cx1, int32_t *cy1) +{ + *cx0 = (int32_t)floorf((bounds->x - grid_originx) / grid_cellw); + *cy0 = (int32_t)floorf((bounds->y - grid_originy) / grid_cellh); + *cx1 = (int32_t)floorf(((bounds->x + bounds->w) - grid_originx) / grid_cellw); + *cy1 = (int32_t)floorf(((bounds->y + bounds->h) - grid_originy) / grid_cellh); + + /* + * Clamp rather than drop. A proxy outside the grid is still a collider -- + * an actor that has walked off the map is exactly the one that needs a wall + * to walk back into -- so it is folded onto the edge cells instead of + * vanishing from the broad phase. + */ + if ( *cx0 < 0 ) { *cx0 = 0; } + if ( *cy0 < 0 ) { *cy0 = 0; } + if ( *cx1 > (AKGL_COLLISION_GRID_COLS - 1) ) { *cx1 = AKGL_COLLISION_GRID_COLS - 1; } + if ( *cy1 > (AKGL_COLLISION_GRID_ROWS - 1) ) { *cy1 = AKGL_COLLISION_GRID_ROWS - 1; } + if ( *cx1 < *cx0 ) { *cx1 = *cx0; } + if ( *cy1 < *cy0 ) { *cy1 = *cy0; } + if ( *cx0 > (AKGL_COLLISION_GRID_COLS - 1) ) { *cx0 = AKGL_COLLISION_GRID_COLS - 1; *cx1 = *cx0; } + if ( *cy0 > (AKGL_COLLISION_GRID_ROWS - 1) ) { *cy0 = AKGL_COLLISION_GRID_ROWS - 1; *cy1 = *cy0; } +} + +/** @brief Unlink every cell entry belonging to a proxy and give them back. */ +static akerr_ErrorContext *grid_unlink(akgl_CollisionProxy *proxy) +{ + akgl_CollisionCell *entry = NULL; + int16_t walk = 0; + int16_t next = 0; + + PREPARE_ERROR(errctx); + + walk = proxy->first; + while ( walk >= 0 ) { + entry = &akgl_heap_collision_cells[walk]; + next = entry->ownernext; + + if ( entry->prev >= 0 ) { + akgl_heap_collision_cells[entry->prev].next = entry->next; + } else if ( entry->cell >= 0 ) { + grid_heads[entry->cell] = entry->next; + } else { + grid_oversized = entry->next; + } + if ( entry->next >= 0 ) { + akgl_heap_collision_cells[entry->next].prev = entry->prev; + } + + PASS(errctx, akgl_heap_release_collision_cell(entry)); + walk = next; + } + proxy->first = -1; + proxy->cx0 = 0; + proxy->cy0 = 0; + proxy->cx1 = -1; + proxy->cy1 = -1; + SUCCEED_RETURN(errctx); +} + +/** @brief Claim one cell entry and push it onto a chain head. */ +static akerr_ErrorContext *grid_link_one(akgl_CollisionProxy *proxy, int16_t proxyidx, int32_t cell, int16_t *head) +{ + akgl_CollisionCell *entry = NULL; + + PREPARE_ERROR(errctx); + PASS(errctx, akgl_heap_next_collision_cell(&entry)); + PASS(errctx, akgl_collision_cell_initialize(entry, proxyidx, cell)); + + entry->next = *head; + entry->prev = -1; + if ( *head >= 0 ) { + akgl_heap_collision_cells[*head].prev = grid_cell_index(entry); + } + *head = grid_cell_index(entry); + + entry->ownernext = proxy->first; + proxy->first = grid_cell_index(entry); + SUCCEED_RETURN(errctx); +} + +/** @brief Put a proxy into every cell its bounds cover. */ +static akerr_ErrorContext *grid_link(akgl_CollisionProxy *proxy, int32_t cx0, int32_t cy0, int32_t cx1, int32_t cy1) +{ + int16_t proxyidx = 0; + int32_t span = 0; + int32_t x = 0; + int32_t y = 0; + + PREPARE_ERROR(errctx); + + proxyidx = grid_proxy_index(proxy); + FAIL_NONZERO_RETURN(errctx, (proxyidx < 0), AKERR_VALUE, + "Proxy is not from the collision proxy pool"); + + span = (cx1 - cx0 + 1) * (cy1 - cy0 + 1); + if ( span > AKGL_COLLISION_GRID_MAX_SPAN ) { + // Too big to cell. One chain, walked by every query. + PASS(errctx, grid_link_one(proxy, proxyidx, -1, &grid_oversized)); + } else { + for ( y = cy0; y <= cy1; y++ ) { + for ( x = cx0; x <= cx1; x++ ) { + PASS(errctx, grid_link_one(proxy, proxyidx, (y * AKGL_COLLISION_GRID_COLS) + x, + &grid_heads[(y * AKGL_COLLISION_GRID_COLS) + x])); + } + } + } + + proxy->cx0 = cx0; + proxy->cy0 = cy0; + proxy->cx1 = cx1; + proxy->cy1 = cy1; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *grid_reset(akgl_Partitioner *self, akgl_CollisionWorld *world) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL partitioner reference"); + FAIL_ZERO_RETURN(errctx, world, AKERR_NULLPOINTER, "NULL collision world reference"); + FAIL_NONZERO_RETURN(errctx, (world->cellwidth <= 0.0f), AKERR_VALUE, + "Cell width %f is not positive", world->cellwidth); + FAIL_NONZERO_RETURN(errctx, (world->cellheight <= 0.0f), AKERR_VALUE, + "Cell height %f is not positive", world->cellheight); + + memset(grid_heads, 0xFF, sizeof(grid_heads)); + grid_oversized = -1; + grid_cellw = world->cellwidth; + grid_cellh = world->cellheight; + grid_originx = world->originx; + grid_originy = world->originy; + grid_sweep = 0; + PASS(errctx, akgl_heap_init_collision_cells()); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *grid_insert(akgl_Partitioner *self, akgl_CollisionProxy *proxy) +{ + int32_t cx0 = 0; + int32_t cy0 = 0; + int32_t cx1 = 0; + int32_t cy1 = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL partitioner reference"); + FAIL_ZERO_RETURN(errctx, proxy, AKERR_NULLPOINTER, "NULL proxy reference"); + + if ( proxy->first >= 0 ) { + PASS(errctx, grid_unlink(proxy)); + } + grid_cellrect(&proxy->bounds, &cx0, &cy0, &cx1, &cy1); + PASS(errctx, grid_link(proxy, cx0, cy0, cx1, cy1)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *grid_remove(akgl_Partitioner *self, akgl_CollisionProxy *proxy) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL partitioner reference"); + FAIL_ZERO_RETURN(errctx, proxy, AKERR_NULLPOINTER, "NULL proxy reference"); + + // Removing something that is not in is success. A caller tearing down does + // not want to track which proxies it already dropped. + if ( proxy->first < 0 ) { + SUCCEED_RETURN(errctx); + } + PASS(errctx, grid_unlink(proxy)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *grid_move(akgl_Partitioner *self, akgl_CollisionProxy *proxy) +{ + int32_t cx0 = 0; + int32_t cy0 = 0; + int32_t cx1 = 0; + int32_t cy1 = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL partitioner reference"); + FAIL_ZERO_RETURN(errctx, proxy, AKERR_NULLPOINTER, "NULL proxy reference"); + + if ( proxy->first < 0 ) { + PASS(errctx, grid_insert(self, proxy)); + SUCCEED_RETURN(errctx); + } + + grid_cellrect(&proxy->bounds, &cx0, &cy0, &cx1, &cy1); + + /* + * The early return is the entire point of the structure. An actor that has + * moved within the cells it already occupies performs no pool operation and + * writes nothing, which is the steady state for a walking character and the + * permanent state for a static proxy. Replacing this with remove-then-insert + * would turn the incremental grid into the rebuild-every-frame tree that was + * measured and rejected. + */ + if ( (cx0 == proxy->cx0) && (cy0 == proxy->cy0) && + (cx1 == proxy->cx1) && (cy1 == proxy->cy1) ) { + SUCCEED_RETURN(errctx); + } + + PASS(errctx, grid_unlink(proxy)); + PASS(errctx, grid_link(proxy, cx0, cy0, cx1, cy1)); + SUCCEED_RETURN(errctx); +} + +/** @brief Walk one chain, reporting each proxy at most once this sweep. */ +static akerr_ErrorContext *grid_walk_chain(int16_t head, SDL_FRect *area, uint32_t mask, + akgl_CollisionVisitFunc visit, void *data, bool *stop) +{ + akgl_CollisionProxy *proxy = NULL; + akgl_CollisionCell *entry = NULL; + akerr_ErrorContext *inner = NULL; + int16_t walk = 0; + + PREPARE_ERROR(errctx); + + walk = head; + while ( (walk >= 0) && (*stop == false) ) { + entry = &akgl_heap_collision_cells[walk]; + walk = entry->next; + proxy = &akgl_heap_collision_proxies[entry->proxy]; + + // A proxy in four cells appears on four chains. The stamp is what makes + // a query report it once, and costs one comparison to do it. + if ( proxy->stamp == grid_sweep ) { + continue; + } + proxy->stamp = grid_sweep; + + if ( (proxy->shape.layermask & mask) == 0 ) { + continue; + } + if ( !SDL_HasRectIntersectionFloat(&proxy->bounds, area) ) { + continue; + } + + /* + * The visitor's status is taken into a local rather than handed to CATCH + * here: CATCH reports by breaking, and a break inside this loop would + * leave the loop rather than the function, so a visitor's failure would + * be swallowed and the walk would look like it finished. + */ + inner = visit(proxy, data); + if ( inner != NULL ) { + *stop = true; + PASS(errctx, inner); + } + } + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *grid_query(akgl_Partitioner *self, SDL_FRect *area, uint32_t mask, + akgl_CollisionVisitFunc visit, void *data) +{ + bool stop = false; + int32_t cx0 = 0; + int32_t cy0 = 0; + int32_t cx1 = 0; + int32_t cy1 = 0; + int32_t x = 0; + int32_t y = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL partitioner reference"); + FAIL_ZERO_RETURN(errctx, area, AKERR_NULLPOINTER, "NULL query area reference"); + FAIL_ZERO_RETURN(errctx, visit, AKERR_NULLPOINTER, "NULL visitor reference"); + + grid_sweep += 1; + grid_cellrect(area, &cx0, &cy0, &cx1, &cy1); + + for ( y = cy0; (y <= cy1) && (stop == false); y++ ) { + for ( x = cx0; (x <= cx1) && (stop == false); x++ ) { + PASS(errctx, grid_walk_chain(grid_heads[(y * AKGL_COLLISION_GRID_COLS) + x], + area, mask, visit, data, &stop)); + } + } + if ( stop == false ) { + PASS(errctx, grid_walk_chain(grid_oversized, area, mask, visit, data, &stop)); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Whether this cell is the one a pair should be reported from. + * + * Two proxies sharing four cells would otherwise be reported four times. The + * rule is to report only from the lowest-numbered cell both occupy, which is + * computable from the two cell rectangles in constant time with no marks and no + * per-pair storage -- and which works when a walk is re-entered, where a stamp + * would not. + */ +static bool grid_owns_pair(akgl_CollisionProxy *a, akgl_CollisionProxy *b, int32_t cell) +{ + int32_t x0 = (a->cx0 > b->cx0) ? a->cx0 : b->cx0; + int32_t y0 = (a->cy0 > b->cy0) ? a->cy0 : b->cy0; + int32_t x1 = (a->cx1 < b->cx1) ? a->cx1 : b->cx1; + int32_t y1 = (a->cy1 < b->cy1) ? a->cy1 : b->cy1; + + if ( (x0 > x1) || (y0 > y1) ) { + // They share no cell, so this pair came off the oversized chain. + return true; + } + return (cell == ((y0 * AKGL_COLLISION_GRID_COLS) + x0)); +} + +static akerr_ErrorContext *grid_each_pair(akgl_Partitioner *self, akgl_CollisionPairFunc visit, void *data) +{ + akgl_CollisionProxy *a = NULL; + akgl_CollisionProxy *b = NULL; + akerr_ErrorContext *inner = NULL; + bool stop = false; + int32_t cell = 0; + int16_t outer = 0; + int16_t walk = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL partitioner reference"); + FAIL_ZERO_RETURN(errctx, visit, AKERR_NULLPOINTER, "NULL visitor reference"); + + for ( cell = 0; (cell < (AKGL_COLLISION_GRID_ROWS * AKGL_COLLISION_GRID_COLS)) && (stop == false); cell++ ) { + outer = grid_heads[cell]; + while ( (outer >= 0) && (stop == false) ) { + a = &akgl_heap_collision_proxies[akgl_heap_collision_cells[outer].proxy]; + walk = akgl_heap_collision_cells[outer].next; + while ( (walk >= 0) && (stop == false) ) { + b = &akgl_heap_collision_proxies[akgl_heap_collision_cells[walk].proxy]; + walk = akgl_heap_collision_cells[walk].next; + if ( !grid_owns_pair(a, b, cell) ) { + continue; + } + inner = visit(a, b, data); + if ( inner != NULL ) { + stop = true; + PASS(errctx, inner); + } + } + outer = akgl_heap_collision_cells[outer].next; + } + } + + // Everything oversized against everything else, since it is in no cell. + outer = grid_oversized; + while ( (outer >= 0) && (stop == false) ) { + a = &akgl_heap_collision_proxies[akgl_heap_collision_cells[outer].proxy]; + walk = akgl_heap_collision_cells[outer].next; + while ( (walk >= 0) && (stop == false) ) { + b = &akgl_heap_collision_proxies[akgl_heap_collision_cells[walk].proxy]; + walk = akgl_heap_collision_cells[walk].next; + inner = visit(a, b, data); + if ( inner != NULL ) { + stop = true; + PASS(errctx, inner); + } + } + outer = akgl_heap_collision_cells[outer].next; + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_partitioner_init_grid(akgl_Partitioner *self) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL partitioner reference"); + + memset(self, 0x00, sizeof(akgl_Partitioner)); + PASS(errctx, aksl_strncpy(self->name, sizeof(self->name), "grid", sizeof(self->name) - 1)); + self->reset = grid_reset; + self->insert = grid_insert; + self->remove = grid_remove; + self->move = grid_move; + self->query = grid_query; + self->each_pair = grid_each_pair; + self->state = NULL; + SUCCEED_RETURN(errctx); +} diff --git a/src/collision_shape.c b/src/collision_shape.c index 158054c..c967b46 100644 --- a/src/collision_shape.c +++ b/src/collision_shape.c @@ -190,6 +190,11 @@ akerr_ErrorContext *akgl_collision_proxy_initialize(akgl_CollisionProxy *obj, st memset(obj, 0x00, sizeof(akgl_CollisionProxy)); obj->owner = owner; + // -1 rather than 0: 0 is a valid cell-entry index, so a zeroed proxy would + // read as already being in the grid at entry zero. + obj->first = -1; + obj->cx1 = -1; + obj->cy1 = -1; /* * The reference is taken here and not in akgl_heap_next_collision_proxy, @@ -217,3 +222,18 @@ akerr_ErrorContext *akgl_collision_proxy_sync(akgl_CollisionProxy *obj, akgl_Col PASS(errctx, akgl_collision_shape_bounds(&obj->shape, x, y, &obj->bounds)); SUCCEED_RETURN(errctx); } + +akerr_ErrorContext *akgl_collision_cell_initialize(akgl_CollisionCell *obj, int16_t proxyidx, int32_t cell) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL collision cell reference"); + + memset(obj, 0x00, sizeof(akgl_CollisionCell)); + obj->refcount = 1; + obj->proxy = proxyidx; + obj->cell = cell; + obj->next = -1; + obj->prev = -1; + obj->ownernext = -1; + SUCCEED_RETURN(errctx); +} diff --git a/src/heap.c b/src/heap.c index 4c6c1b3..3751c85 100644 --- a/src/heap.c +++ b/src/heap.c @@ -20,6 +20,7 @@ akgl_SpriteSheet akgl_heap_spritesheets[AKGL_MAX_HEAP_SPRITESHEET]; akgl_Character akgl_heap_characters[AKGL_MAX_HEAP_CHARACTER]; akgl_String akgl_heap_strings[AKGL_MAX_HEAP_STRING]; akgl_CollisionProxy akgl_heap_collision_proxies[AKGL_MAX_HEAP_COLLISION_PROXY]; +akgl_CollisionCell akgl_heap_collision_cells[AKGL_MAX_HEAP_COLLISION_CELL]; akerr_ErrorContext *akgl_heap_init(void) { @@ -40,7 +41,9 @@ akerr_ErrorContext *akgl_heap_init(void) } for ( i = 0; i < AKGL_MAX_HEAP_COLLISION_PROXY; i++) { memset(&akgl_heap_collision_proxies[i], 0x00, sizeof(akgl_CollisionProxy)); + akgl_heap_collision_proxies[i].first = -1; } + PASS(errctx, akgl_heap_init_collision_cells()); SUCCEED_RETURN(errctx); } @@ -253,3 +256,47 @@ akerr_ErrorContext *akgl_heap_release_collision_proxy(akgl_CollisionProxy *ptr) } SUCCEED_RETURN(errctx); } + +akerr_ErrorContext *akgl_heap_next_collision_cell(akgl_CollisionCell **dest) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination reference"); + for (int i = 0; i < AKGL_MAX_HEAP_COLLISION_CELL; i++ ) { + if ( akgl_heap_collision_cells[i].refcount != 0 ) { + continue; + } + *dest = &akgl_heap_collision_cells[i]; + SUCCEED_RETURN(errctx); + } + FAIL_RETURN(errctx, AKGL_ERR_HEAP, "Unable to find unused collision cell entry on the heap"); +} + +akerr_ErrorContext *akgl_heap_release_collision_cell(akgl_CollisionCell *ptr) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, ptr, AKERR_NULLPOINTER, "NULL collision cell reference"); + if ( ptr->refcount > 0 ) { + ptr->refcount -= 1; + } + if ( ptr->refcount == 0 ) { + memset(ptr, 0x00, sizeof(akgl_CollisionCell)); + ptr->next = -1; + ptr->prev = -1; + ptr->ownernext = -1; + ptr->cell = -1; + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_heap_init_collision_cells(void) +{ + PREPARE_ERROR(errctx); + for ( int i = 0; i < AKGL_MAX_HEAP_COLLISION_CELL; i++ ) { + memset(&akgl_heap_collision_cells[i], 0x00, sizeof(akgl_CollisionCell)); + akgl_heap_collision_cells[i].next = -1; + akgl_heap_collision_cells[i].prev = -1; + akgl_heap_collision_cells[i].ownernext = -1; + akgl_heap_collision_cells[i].cell = -1; + } + SUCCEED_RETURN(errctx); +} diff --git a/tests/partition.c b/tests/partition.c new file mode 100644 index 0000000..0e837a9 --- /dev/null +++ b/tests/partition.c @@ -0,0 +1,507 @@ +/** + * @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 +#include + +#include + +#include +#include +#include + +#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; +}