Add a pluggable broad phase, and the incremental uniform grid behind it

akgl_Partitioner is a record of function pointers and an initializer, the same
shape as the render and physics backends. `move` is its own slot rather than
remove-then-insert, and that is the whole design: a uniform grid's move is a
comparison and a return when the proxy has not left the cells it was in, which
is the steady state for a walking actor and the permanent state for a static
one. Spelling it as remove-and-insert would turn the incremental grid into the
rebuild-every-frame tree that PERFORMANCE.md already measured and rejected.

The grid is a dense 128x128 array of cell heads over the world, cells keyed on
tile size, with two intrusive chains per entry -- one through the cell so a query
can walk it, one through the proxy so removal unlinks a whole span without
searching. Everything is an int16_t index rather than a pointer, so the
structure is relocatable and clearing it is a memset. A proxy covering more
cells than AKGL_COLLISION_GRID_MAX_SPAN spills onto one chain every query walks,
which is what makes the cell pool's ceiling provable rather than hopeful.

tests/partition.c compares every query against a linear scan over the same
proxies and asserts containment in one direction, not equality. That asymmetry
is the contract: over-reporting costs a narrowphase call, under-reporting is a
wall an actor walks through. The suite is a table over partitioners so the same
assertions run against every implementation, which is what the second one
landing later will be held to.

Three deliberate breaks, and two of them were not caught the first time:

- Removing the min-cell rule so a shared-cell pair reports repeatedly: caught.
- Dropping the unlink in `move`: **not caught**, initially. Stale entries do not
  produce wrong query answers, because the bounds re-check filters them out --
  they leak the cell pool until the grid stops working, on a timescale no unit
  test reaches by accident. Counting live pool entries across 200 moves is the
  only thing that sees it, and now does: 505 entries against an expected 5.
- Swapping floorf for a truncating cast: **not caught, and correctly so.**
  Everything below zero is clamped to cell 0 either way, so today the two are
  indistinguishable. The comment claiming truncation is a defect was overstating
  it and now says what is actually true: the clamp is the only thing hiding it,
  and a negative world origin or a relaxed clamp would make it real with no test
  standing in front of it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 06:29:02 -04:00
parent a3cb6ca79a
commit 68e042bd47
8 changed files with 1255 additions and 0 deletions

View File

@@ -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_