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>
240 lines
8.9 KiB
C
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);
|
|
}
|