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; +}