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:
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user