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

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

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:29:02 -04:00

240 lines
8.9 KiB
C

/**
* @file collision_shape.c
* @brief Building collision shapes, and the one invariant that keeps 2D flat.
*
* Nothing here touches the narrowphase. A shape is data, and this file is the
* only place that decides what a well-formed one looks like -- which is why the
* extrusion rule lives in the setters rather than at the point of use, where
* every caller would have to remember it.
*/
#include <string.h>
#include <akgl/collision.h>
#include <akgl/error.h>
/**
* @brief Fill in the depth, unless the caller asked for a specific one.
*
* Called last by every setter, once `hx` and `hy` are known. See collision.h for
* why the ratio is what it is; the short version is that a thin extrusion makes
* z the cheapest axis to separate on, and an actor pushed along z goes nowhere a
* player can see while falling through the floor.
*/
static akerr_ErrorContext *shape_extrude(akgl_CollisionShape *dest, float32_t depth)
{
float32_t largest = 0.0f;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference");
FAIL_NONZERO_RETURN(errctx, (depth < 0.0f), AKERR_VALUE,
"Shape depth %f is negative", depth);
if ( depth > 0.0f ) {
dest->hz = depth;
SUCCEED_RETURN(errctx);
}
largest = dest->hx;
if ( dest->hy > largest ) {
largest = dest->hy;
}
dest->hz = AKGL_COLLISION_DEPTH_RATIO * largest;
SUCCEED_RETURN(errctx);
}
/**
* @brief Zero a shape and give it the defaults every setter shares.
*
* The masks are the interesting part. An actor that has been given a shape and
* nothing else collides with map geometry and with no other actor, because
* "everything with a hitbox shoves everything else" is a surprising default for
* a town full of scenery and a painful one to discover after the fact. Opting
* in to actor-versus-actor is one added bit; opting out of it would have been a
* hunt through every NPC a game spawns.
*/
static akerr_ErrorContext *shape_defaults(akgl_CollisionShape *dest, uint8_t kind)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference");
memset(dest, 0x00, sizeof(akgl_CollisionShape));
dest->kind = kind;
dest->layermask = AKGL_COLLISION_LAYER_ACTOR;
dest->collidemask = AKGL_COLLISION_LAYER_STATIC;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_shape_box(akgl_CollisionShape *dest, SDL_FRect *body, float32_t depth)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference");
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "NULL rectangle reference");
FAIL_NONZERO_RETURN(errctx, (body->w <= 0.0f), AKERR_VALUE,
"Box width %f is not positive", body->w);
FAIL_NONZERO_RETURN(errctx, (body->h <= 0.0f), AKERR_VALUE,
"Box height %f is not positive", body->h);
PASS(errctx, shape_defaults(dest, AKGL_COLLISION_SHAPE_BOX));
// Top-left-and-size in, centre-and-half-extent out. Done once here so that
// nothing on the frame path has to do it per test.
dest->hx = body->w / 2.0f;
dest->hy = body->h / 2.0f;
dest->ox = body->x + dest->hx;
dest->oy = body->y + dest->hy;
PASS(errctx, shape_extrude(dest, depth));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_shape_circle(akgl_CollisionShape *dest, float32_t ox, float32_t oy, float32_t radius, float32_t depth)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference");
FAIL_NONZERO_RETURN(errctx, (radius <= 0.0f), AKERR_VALUE,
"Circle radius %f is not positive", radius);
PASS(errctx, shape_defaults(dest, AKGL_COLLISION_SHAPE_CIRCLE));
dest->ox = ox;
dest->oy = oy;
dest->hx = radius;
dest->hy = radius;
PASS(errctx, shape_extrude(dest, depth));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_shape_capsule(akgl_CollisionShape *dest, SDL_FRect *body, uint8_t axis, float32_t depth)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference");
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "NULL rectangle reference");
FAIL_NONZERO_RETURN(
errctx,
((axis != AKGL_COLLISION_SHAPE_CAPSULE_X) && (axis != AKGL_COLLISION_SHAPE_CAPSULE_Y)),
AKERR_VALUE,
"Capsule axis %u is neither AKGL_COLLISION_SHAPE_CAPSULE_X nor _Y",
(unsigned int)axis
);
FAIL_NONZERO_RETURN(errctx, (body->w <= 0.0f), AKERR_VALUE,
"Capsule width %f is not positive", body->w);
FAIL_NONZERO_RETURN(errctx, (body->h <= 0.0f), AKERR_VALUE,
"Capsule height %f is not positive", body->h);
/*
* The capped axis has to be the longer one. A capsule whose caps are wider
* than the body is long is a circle with extra steps, and accepting it would
* mean the shape a caller described and the shape the narrowphase built were
* different -- silently, and only for some sizes.
*/
if ( axis == AKGL_COLLISION_SHAPE_CAPSULE_X ) {
FAIL_NONZERO_RETURN(errctx, (body->w <= body->h), AKERR_VALUE,
"An x-axis capsule is %fx%f; the capped axis must be the longer one",
body->w, body->h);
} else {
FAIL_NONZERO_RETURN(errctx, (body->h <= body->w), AKERR_VALUE,
"A y-axis capsule is %fx%f; the capped axis must be the longer one",
body->w, body->h);
}
PASS(errctx, shape_defaults(dest, axis));
dest->hx = body->w / 2.0f;
dest->hy = body->h / 2.0f;
dest->ox = body->x + dest->hx;
dest->oy = body->y + dest->hy;
PASS(errctx, shape_extrude(dest, depth));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_shape_bounds(akgl_CollisionShape *shape, float32_t x, float32_t y, SDL_FRect *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "NULL shape reference");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL rectangle reference");
dest->x = x + shape->ox - shape->hx;
dest->y = y + shape->oy - shape->hy;
dest->w = shape->hx * 2.0f;
dest->h = shape->hy * 2.0f;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_shape_interacts(akgl_CollisionShape *self, akgl_CollisionShape *other, bool *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL shape reference");
FAIL_ZERO_RETURN(errctx, other, AKERR_NULLPOINTER, "NULL other shape reference");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL result reference");
*dest = false;
if ( (self->kind == AKGL_COLLISION_SHAPE_NONE) ||
(other->kind == AKGL_COLLISION_SHAPE_NONE) ) {
SUCCEED_RETURN(errctx);
}
if ( ((self->flags & AKGL_COLLISION_FLAG_DISABLED) != 0) ||
((other->flags & AKGL_COLLISION_FLAG_DISABLED) != 0) ) {
SUCCEED_RETURN(errctx);
}
// One direction only. The mirrored question is asked separately, and may
// legitimately have a different answer.
*dest = ((other->layermask & self->collidemask) != 0);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_proxy_initialize(akgl_CollisionProxy *obj, struct akgl_Actor *owner, akgl_CollisionShape *shape, float32_t x, float32_t y, float32_t z)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL proxy reference");
FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "NULL shape reference");
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,
* matching four of the five pools that came before. Until this runs the slot
* still reads as free -- which is also the property that keeps an acquire
* abandoned before this point from leaking the slot.
*/
obj->refcount = 1;
PASS(errctx, akgl_collision_proxy_sync(obj, shape, x, y, z));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_proxy_sync(akgl_CollisionProxy *obj, akgl_CollisionShape *shape, float32_t x, float32_t y, float32_t z)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL proxy reference");
FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "NULL shape reference");
// A copy, not a pointer. See the note on akgl_CollisionProxy.
obj->shape = *shape;
obj->x = x;
obj->y = y;
obj->z = z;
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);
}