Pool collision proxies, and tie one to the life of its actor
A proxy is what the broad phase will index: an actor's shape, where it is, and the bounds that follow from those. It is a sixth heap layer, two per actor -- one for the actor itself and headroom for static geometry a game registers that is not tile-aligned. Solid *tiles* deliberately get none: the broad phase will read those out of the tilemap's own array, because one proxy per tile is tens of megabytes to index data that is already a grid. The pool follows the existing convention rather than improving on it. akgl_heap_next_collision_proxy finds a free slot and does not claim it; akgl_collision_proxy_initialize claims it. That is TODO.md item 8's asymmetry, and it is kept on purpose: an acquire abandoned before initialization leaks nothing, because the slot still reads as free, and a sixth pool with its own rule would be worse than one defect with five instances. Fixing item 8 means fixing all five together with every call site audited, and that is its own change. The proxy holds a *copy* of the shape rather than a pointer. An actor's own logic may rewrite its shape mid-step -- a character crouching, a projectile arming -- and a broad phase whose bounds came from a shape that has since changed is a bug that only appears when two things happen in the same frame. akgl_heap_release_actor now releases the actor's proxy. Without it the proxy holds a borrowed `owner` into a slot that has just been zeroed, so the broad phase keeps a registration whose owner reads as a free actor, and the next contact against it is a collision with nothing. Verified by removing the release and watching test_proxy_dies_with_its_actor go red. The tests pin the convention as well as the behaviour: that two acquires with no initialize between them return the same slot is asserted, not merely tolerated, so that changing the convention has to change a test that says why it exists. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -124,6 +124,7 @@ typedef struct akgl_Actor {
|
||||
SDL_Time movetimer; /**< Unused. Nothing in the library reads or writes it. */
|
||||
akgl_CollisionShape shape; /**< The collision volume this actor occupies. Copied from the character by akgl_actor_set_character unless #shape_override is set. Zeroed by akgl_actor_initialize, so an actor nobody configured does not collide. */
|
||||
bool shape_override; /**< The game set #shape itself and akgl_actor_set_character must leave it alone. Distinct from "the shape is empty": an actor deliberately given no collider and an actor not yet given one are different states, and only this flag tells them apart. */
|
||||
struct akgl_CollisionProxy *proxy; /**< This actor's registration in the broad phase, or `NULL` when it has none. Owned by the library and released with the actor; a game neither sets nor frees it. */
|
||||
// Velocity. Combined effect of all forces acting on the actor resulting
|
||||
// in energy along an axis.
|
||||
float32_t vx; /**< Velocity along x, world units per second. Recomputed each step as `ex + tx`; writing it directly is overwritten. */
|
||||
|
||||
@@ -125,6 +125,73 @@ typedef struct akgl_CollisionShape {
|
||||
float32_t hz; /**< Half-extent along z. Never 0 after a setter has run; see the extrusion note on this file. */
|
||||
} akgl_CollisionShape;
|
||||
|
||||
/**
|
||||
* @brief One shape's registration in the broad phase.
|
||||
*
|
||||
* A proxy is what the partitioner indexes. It carries a **copy** of the shape
|
||||
* rather than a pointer to it, which is deliberate: an actor's own logic may
|
||||
* legitimately rewrite its shape mid-step -- a character crouching, a projectile
|
||||
* arming -- and a broad phase whose bounds were computed from a shape that has
|
||||
* since changed is a source of bugs that only appear when two things happen in
|
||||
* the same frame. The copy is refreshed once per step, from one place.
|
||||
*
|
||||
* Proxies are pool objects. The library creates and destroys them; a game does
|
||||
* not, and a game that releases an actor gets its proxy released with it.
|
||||
*/
|
||||
typedef struct akgl_CollisionProxy {
|
||||
uint8_t refcount; /**< Pool bookkeeping; 0 means the slot is free. First member, as in every pooled type here. */
|
||||
struct akgl_Actor *owner; /**< The actor this proxy tracks, or `NULL` for static geometry with no actor behind it. Borrowed; no reference is taken. */
|
||||
akgl_CollisionShape shape; /**< A copy of the owner's shape as of the last refresh. */
|
||||
SDL_FRect bounds; /**< Planar world bounds in map pixels, computed from #shape at (#x, #y). */
|
||||
float32_t x; /**< World position #bounds was computed from. */
|
||||
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. */
|
||||
} akgl_CollisionProxy;
|
||||
|
||||
/*
|
||||
* The following is part of the internal API. Proxies are created and destroyed
|
||||
* by the library, not by a game.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @brief Claim a pooled proxy for an actor's shape and take the reference on it.
|
||||
*
|
||||
* Follows the convention every pool here uses: akgl_heap_next_collision_proxy
|
||||
* finds a free slot and does **not** claim it, and this takes the reference.
|
||||
* Write the two adjacent, because until the reference is taken the slot is still
|
||||
* free and the next acquire hands out the same pointer. That is `TODO.md` "Known
|
||||
* and still open" item 8, it applies to four of the five existing pools, and
|
||||
* this one does not depart from it -- a sixth convention would be worse than the
|
||||
* defect.
|
||||
*
|
||||
* @param obj The proxy to initialize. Required.
|
||||
* @param owner The actor it tracks, or `NULL` for static geometry.
|
||||
* @param shape The shape to copy into it. Required.
|
||||
* @param x World position of the owner.
|
||||
* @param y World position of the owner.
|
||||
* @param z World position of the owner.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p shape is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_proxy_initialize(akgl_CollisionProxy *obj, struct akgl_Actor *owner, akgl_CollisionShape *shape, float32_t x, float32_t y, float32_t z);
|
||||
|
||||
/**
|
||||
* @brief Recompute a proxy's bounds from its owner's current shape and position.
|
||||
*
|
||||
* Called once per step per live proxy, before the broad phase is asked anything.
|
||||
* This is the one place the shape copy is refreshed.
|
||||
*
|
||||
* @param obj The proxy. Required.
|
||||
* @param shape The current shape to copy. Required.
|
||||
* @param x Current world position of the owner.
|
||||
* @param y Current world position of the owner.
|
||||
* @param z Current world position of the owner.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p shape is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_proxy_sync(akgl_CollisionProxy *obj, akgl_CollisionShape *shape, float32_t x, float32_t y, float32_t z);
|
||||
|
||||
/**
|
||||
* @brief Build an axis-aligned box from the frame-relative rectangle a game has.
|
||||
*
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
#ifndef _AKGL_HEAP_H_
|
||||
#define _AKGL_HEAP_H_
|
||||
|
||||
#include <akgl/collision.h>
|
||||
#include <akgl/sprite.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
@@ -50,6 +51,18 @@
|
||||
#ifndef AKGL_MAX_HEAP_STRING
|
||||
#define AKGL_MAX_HEAP_STRING 256
|
||||
#endif
|
||||
/**
|
||||
* @brief The collision proxy pool.
|
||||
*
|
||||
* Two per actor. One is the actor's own; the spare headroom is for static
|
||||
* geometry a game registers that is not tile-aligned -- a slope, a Tiled object
|
||||
* rectangle, a moving platform. Solid *tiles* need none of these: the broad
|
||||
* phase reads them out of the tilemap's own array, because one proxy per tile
|
||||
* would be tens of megabytes on a large map to index data that is already a grid.
|
||||
*/
|
||||
#ifndef AKGL_MAX_HEAP_COLLISION_PROXY
|
||||
#define AKGL_MAX_HEAP_COLLISION_PROXY (AKGL_MAX_HEAP_ACTOR * 2)
|
||||
#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];
|
||||
@@ -61,6 +74,8 @@ extern akgl_SpriteSheet akgl_heap_spritesheets[AKGL_MAX_HEAP_SPRITESHEET];
|
||||
extern akgl_Character akgl_heap_characters[AKGL_MAX_HEAP_CHARACTER];
|
||||
/** @brief The string pool. Every entry is PATH_MAX bytes, so this is the largest of the five by a wide margin. */
|
||||
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 Zero every pool, marking every slot free.
|
||||
@@ -218,4 +233,31 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_character(akgl_Character *p
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_string(akgl_String *ptr);
|
||||
|
||||
/**
|
||||
* @brief Find a free collision proxy slot.
|
||||
*
|
||||
* Does **not** take the reference, matching four of the five pools above.
|
||||
* akgl_collision_proxy_initialize takes it; write the two adjacent, because
|
||||
* until it runs the slot still reads as free and the next acquire hands out the
|
||||
* same pointer.
|
||||
*
|
||||
* @param dest Receives the proxy. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_HEAP If every slot is in use. That normally means proxies are
|
||||
* not being released rather than that the pool is too small.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_next_collision_proxy(akgl_CollisionProxy **dest);
|
||||
|
||||
/**
|
||||
* @brief Give a collision proxy back.
|
||||
*
|
||||
* Decrements, and at zero clears the slot. A proxy holds no external resource
|
||||
* and appears in no registry, so there is nothing else to unwind.
|
||||
*
|
||||
* @param ptr The proxy. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p ptr is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_collision_proxy(akgl_CollisionProxy *ptr);
|
||||
|
||||
#endif //_AKGL_HEAP_H_
|
||||
|
||||
Reference in New Issue
Block a user