diff --git a/include/akgl/collision.h b/include/akgl/collision.h index a0beaac..a55810c 100644 --- a/include/akgl/collision.h +++ b/include/akgl/collision.h @@ -454,6 +454,61 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_query_box(akgl_CollisionWorld */ akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_settle(akgl_CollisionWorld *self, akgl_CollisionShape *shape, float32_t *x, float32_t *y, int maxsteps); +/** + * @brief Resolve one actor against everything it currently overlaps. + * + * Called by the physics step after each sub-move, so it looks at where the actor + * *is* rather than predicting where it will be. That is what lets a game delete + * the arithmetic it would otherwise have to duplicate from the integrator. + * + * Tiles first, then proxies. Each contact goes to the actor's `collidefunc`, and + * the index entry is re-synced after every response, because a response that + * moves the actor invalidates everything computed before it. + * + * @param self The world. Required. + * @param actor The actor. Required. Returns success unchanged if it has no + * shape, no proxy or no response hook. + * @param dt Length of the sub-step, in seconds. Carried on each contact. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self or @p actor is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_resolve(akgl_CollisionWorld *self, struct akgl_Actor *actor, float32_t dt); + +/** + * @brief How many pieces this actor's move has to be taken in. + * + * Bounds how far an actor travels between collision tests to less than the + * thinnest thing it could pass through -- a cell, or its own extent, whichever + * is smaller, because a small fast actor is the one that tunnels. + * + * Answers 1 when there is no world or the actor has no shape, and the caller + * then takes exactly the step it always did: `dt / 1.0f` is `dt` exactly, so a + * non-colliding actor follows a bit-identical arithmetic path. + * + * @param self The world, or `NULL` for no collision. + * @param actor The actor. Required. + * @param dt Length of the whole step, in seconds. + * @param dest Receives the count, never below 1. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p actor or @p dest is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_substeps(akgl_CollisionWorld *self, struct akgl_Actor *actor, float32_t dt, int *dest); + +/** + * @brief Bring every live actor's proxy into agreement with the actor. + * + * One pass over the actor pool at the top of a step: create a proxy for anything + * that has gained a shape, release one from anything that has lost its shape, + * and refresh the rest. The only place a proxy is created or destroyed during a + * step, so nothing downstream has to reason about lifetimes. + * + * @param self The world. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self is `NULL`. + * @throws AKGL_ERR_HEAP If the proxy pool is exhausted. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_sync_actors(akgl_CollisionWorld *self); + /* * The following is part of the internal API. Proxies are created and destroyed * by the library, not by a game. diff --git a/include/akgl/physics.h b/include/akgl/physics.h index 54b44ea..0546050 100644 --- a/include/akgl/physics.h +++ b/include/akgl/physics.h @@ -45,6 +45,7 @@ #include #include #include +#include #include #include @@ -52,7 +53,7 @@ typedef struct akgl_PhysicsBackend { akerr_ErrorContext AKERR_NOIGNORE *(*simulate)(struct akgl_PhysicsBackend *self, akgl_Iterator *opflags); /**< Step the whole world. Both backends point this at akgl_physics_simulate. */ akerr_ErrorContext AKERR_NOIGNORE *(*gravity)(struct akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt); /**< Apply environmental acceleration to one actor. */ - akerr_ErrorContext AKERR_NOIGNORE *(*collide)(struct akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2); /**< Resolve a collision between two actors. Not called by the simulation loop yet. */ + akerr_ErrorContext AKERR_NOIGNORE *(*collide)(struct akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt); /**< Resolve one actor against this backend's collision world for one sub-step. Called by akgl_physics_simulate after every sub-move. A `NULL` slot, or a `NULL` #collision, means nothing collides. */ akerr_ErrorContext AKERR_NOIGNORE *(*move)(struct akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt); /**< Commit one actor's velocity to its position. */ float64_t drag_x; /**< Fraction of environmental velocity shed per second along x. 0 disables. From `physics.drag.x`. */ @@ -62,6 +63,7 @@ typedef struct akgl_PhysicsBackend { float64_t gravity_y; /**< Acceleration along y. Applied down the screen, since y grows downward. From `physics.gravity.y`. */ float64_t gravity_z; /**< Acceleration along z. Applied away from the camera. From `physics.gravity.z`. */ SDL_Time gravity_time; /**< Timestamp of the previous step, in nanoseconds. Stamped by akgl_physics_simulate and seeded by the initializers; the simulation's `dt` is measured from this. */ + struct akgl_CollisionWorld *collision; /**< The collision world this backend resolves against, or `NULL` for none. A backend with no world behaves exactly as one did before collision existed, which is what makes collision opt-in and what keeps the recorded physics baseline honest. */ float64_t max_timestep; /**< Longest step the simulation will take, in seconds. A hitch longer than this advances the world by this much instead. 0 disables the bound. From `physics.max_timestep`, default #AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP. */ } akgl_PhysicsBackend; @@ -81,13 +83,15 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_gravity(akgl_PhysicsBackend * Note that this *succeeds* where the arcade backend's collide refuses -- the * null backend's contract is "nothing collides", which is an answer, not a gap. * - * @param self The backend. Required. - * @param a1 First actor. Ignored; `NULL` is accepted. - * @param a2 Second actor. Ignored; `NULL` is accepted. + * @param self The backend. Required. + * @param actor The actor that would have been resolved. Required, and checked, + * because a caller passing rubbish should find out regardless of + * which backend happens to be installed. + * @param dt Length of the sub-step, in seconds. Ignored. * @return `NULL` on success, otherwise an error context owned by the caller. - * @throws AKERR_NULLPOINTER If @p self is `NULL`. + * @throws AKERR_NULLPOINTER If @p self or @p actor is `NULL`. */ -akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2); +akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_collide(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt); /** * @brief Movement for the null backend: accept the call and change nothing. * @@ -133,20 +137,25 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_init_null(akgl_PhysicsBackend *s */ akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_arcade_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt); /** - * @brief Collision for the arcade backend. Not implemented. + * @brief Collision for the arcade backend: resolve one actor against the world. * - * Deliberately loud rather than silently permissive: unlike - * akgl_physics_null_collide, which means "nothing collides", this means "nobody - * has written this yet", and a caller that reaches it should find out. + * Called by akgl_physics_simulate after each sub-move, so it resolves against + * where the actor is rather than where it was predicted to be. * - * @param self The backend. Required, and checked before the refusal. - * @param a1 First actor. Never examined. - * @param a2 Second actor. Never examined. - * @return Never `NULL`. - * @throws AKERR_NULLPOINTER If @p self is `NULL`. - * @throws AKERR_API Otherwise, always, with the message "Not implemented". + * **A backend with no #akgl_PhysicsBackend::collision returns success having + * changed nothing.** That is what makes collision opt-in: a program written + * before any of this existed installs a backend, never attaches a world, and + * runs exactly as it did. + * + * @param self The backend. Required. + * @param actor The actor to resolve. Required. + * @param dt Length of the sub-step, in seconds. Carried on each contact so a + * response can scale with it. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self or @p actor is `NULL`. + * @throws AKGL_ERR_COLLISION If a narrowphase query could not be answered. */ -akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_arcade_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2); +akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_arcade_collide(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt); /** * @brief Commit an actor's velocity to its position. * diff --git a/src/collision.c b/src/collision.c index 654085a..b18293f 100644 --- a/src/collision.c +++ b/src/collision.c @@ -17,6 +17,8 @@ #include #include #include +#include +#include #include #include @@ -56,6 +58,24 @@ /** @brief Tiles akgl_collision_settle will lift a shape before giving up. */ #define AKGL_COLLISION_SETTLE_STEPS 4 +/** + * @brief Fraction of the thinnest passable thickness one sub-step may cover. + * + * Half. Anything that moves less than half the thinnest thing it could pass + * through cannot be on both sides of it between two samples. + */ +#define AKGL_COLLISION_SUBSTEP_FRACTION 0.5f + +/** + * @brief Hard ceiling on sub-steps per actor per step. + * + * A cost bound. Above roughly `MAX_SUBSTEPS * FRACTION * cellsize` per step an + * actor can still pass through a wall -- about 1280 px/s on 16-pixel tiles at + * the default step, which is a projectile and not a walker. The real answer is + * a swept narrowphase; #AKGL_COLLISION_FLAG_BULLET reserves the bit for it. + */ +#define AKGL_COLLISION_MAX_SUBSTEPS 8 + /** @brief One positioned shape, in the form libccd's callbacks read. */ typedef struct { uint8_t kind; /**< AKGL_COLLISION_SHAPE_*. */ @@ -675,3 +695,293 @@ akerr_ErrorContext *akgl_collision_settle(akgl_CollisionWorld *self, akgl_Collis maxsteps ); } + +/** @brief Carries one actor's resolution through the broad-phase visitor. */ +typedef struct { + akgl_CollisionWorld *world; + akgl_Actor *actor; + akgl_CollisionProxy *proxy; + float32_t dt; + akerr_ErrorContext *failure; +} collision_resolve_state; + +/** + * @brief Test one candidate against the actor being resolved, and answer it. + * + * The visitor cannot use CATCH: a `break` here would leave the broad phase's own + * walk rather than this function, so a failure would look like the end of the + * list. The status is carried out on the state instead and handed over by the + * caller. + */ +static akerr_ErrorContext *collision_resolve_visit(akgl_CollisionProxy *other, void *data) +{ + collision_resolve_state *state = (collision_resolve_state *)data; + akgl_Contact contact; + bool interacts = false; + bool hit = false; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, other, AKERR_NULLPOINTER, "NULL proxy in a resolve"); + FAIL_ZERO_RETURN(errctx, state, AKERR_NULLPOINTER, "NULL resolve state"); + + if ( other == state->proxy ) { + SUCCEED_RETURN(errctx); + } + + /* + * Nested rather than written with early returns. A *_RETURN inside an + * ATTEMPT block leaves past CLEANUP, which is a real defect even where the + * CLEANUP happens to be empty today -- and scripts/check_error_protocol.py + * refuses it, correctly. + */ + ATTEMPT { + CATCH(errctx, akgl_collision_shape_interacts(&state->proxy->shape, &other->shape, &interacts)); + if ( interacts == true ) { + state->world->tests += 1; + CATCH(errctx, akgl_collision_test(state->proxy, other, state->world->flags, &contact, &hit)); + if ( hit == true ) { + contact.self = state->actor; + contact.other = other->owner; + contact.dt = state->dt; + contact.sensor = (((state->proxy->shape.flags & AKGL_COLLISION_FLAG_SENSOR) != 0) || + ((other->shape.flags & AKGL_COLLISION_FLAG_SENSOR) != 0)); + contact.statichit = ((other->shape.flags & AKGL_COLLISION_FLAG_STATIC) != 0); + + CATCH(errctx, state->actor->collidefunc(state->actor, &contact)); + + // The actor moved, so its own proxy is stale for the rest of the pass. + CATCH(errctx, akgl_collision_proxy_sync(state->proxy, &state->actor->shape, + state->actor->x, state->actor->y, + state->actor->z)); + } + } + } CLEANUP { + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Resolve one actor against the tiles its shape overlaps. + * + * Tiles are not proxies, so they are scanned rather than queried: a stack-local + * proxy is synthesized for each solid cell and handed to the ordinary + * narrowphase, which keeps one code path for "what does a contact look like" + * rather than two. + */ +static akerr_ErrorContext *collision_resolve_tiles(akgl_CollisionWorld *self, akgl_Actor *actor, + akgl_CollisionProxy *proxy, float32_t dt) +{ + akgl_CollisionProxy tile; + akgl_CollisionShape tileshape; + akgl_Contact contact; + SDL_FRect body; + bool hit = false; + int32_t tx = 0; + int32_t ty = 0; + int32_t x0 = 0; + int32_t y0 = 0; + int32_t x1 = 0; + int32_t y1 = 0; + int layer = 0; + + PREPARE_ERROR(errctx); + + if ( (self->tilesource == NULL) || (self->tilelayers == 0) ) { + SUCCEED_RETURN(errctx); + } + if ( (proxy->shape.collidemask & self->tilelayermask) == 0 ) { + SUCCEED_RETURN(errctx); + } + + x0 = (int32_t)floorf(proxy->bounds.x / (float32_t)self->tilesource->tilewidth); + y0 = (int32_t)floorf(proxy->bounds.y / (float32_t)self->tilesource->tileheight); + x1 = (int32_t)floorf(((proxy->bounds.x + proxy->bounds.w) - AKGL_COLLISION_TILE_EPSILON) / + (float32_t)self->tilesource->tilewidth); + y1 = (int32_t)floorf(((proxy->bounds.y + proxy->bounds.h) - AKGL_COLLISION_TILE_EPSILON) / + (float32_t)self->tilesource->tileheight); + + body.w = (float32_t)self->tilesource->tilewidth; + body.h = (float32_t)self->tilesource->tileheight; + body.x = 0.0f; + body.y = 0.0f; + + for ( ty = y0; ty <= y1; ty++ ) { + for ( tx = x0; tx <= x1; tx++ ) { + if ( !collision_tile_solid(self, tx, ty) ) { + continue; + } + + ATTEMPT { + CATCH(errctx, akgl_collision_shape_box(&tileshape, &body, 0.0f)); + tileshape.layermask = self->tilelayermask; + tileshape.flags |= AKGL_COLLISION_FLAG_STATIC; + CATCH(errctx, akgl_collision_proxy_initialize( + &tile, NULL, &tileshape, + (float32_t)(tx * self->tilesource->tilewidth), + (float32_t)(ty * self->tilesource->tileheight), proxy->z)); + + CATCH(errctx, akgl_collision_test(proxy, &tile, self->flags, &contact, &hit)); + self->tests += 1; + if ( hit == true ) { + contact.self = actor; + contact.other = NULL; + contact.dt = dt; + contact.statichit = true; + contact.tilex = tx; + contact.tiley = ty; + contact.tilegid = 0; + contact.tilelayer = -1; + // Which layer and which tile, so a game can tell a spike from + // a floor without looking it up again. + for ( layer = 0; layer < self->tilesource->numlayers; layer++ ) { + if ( (self->tilelayers & (1u << layer)) == 0 ) { + continue; + } + if ( self->tilesource->layers[layer].data[(ty * self->tilesource->width) + tx] != 0 ) { + contact.tilelayer = layer; + contact.tilegid = self->tilesource->layers[layer].data[(ty * self->tilesource->width) + tx]; + break; + } + } + + CATCH(errctx, actor->collidefunc(actor, &contact)); + CATCH(errctx, akgl_collision_proxy_sync(proxy, &actor->shape, + actor->x, actor->y, actor->z)); + } + } CLEANUP { + } PROCESS(errctx) { + } FINISH(errctx, true); + } + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_collision_resolve(akgl_CollisionWorld *self, akgl_Actor *actor, float32_t dt) +{ + collision_resolve_state state; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference"); + FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "NULL actor reference"); + + if ( (actor->proxy == NULL) || (actor->shape.kind == AKGL_COLLISION_SHAPE_NONE) ) { + SUCCEED_RETURN(errctx); + } + if ( actor->collidefunc == NULL ) { + SUCCEED_RETURN(errctx); + } + + // The actor has just moved, so the index and the copy of its shape are both + // a step out of date. + PASS(errctx, akgl_collision_proxy_sync(actor->proxy, &actor->shape, actor->x, actor->y, actor->z)); + PASS(errctx, self->partitioner.move(&self->partitioner, actor->proxy)); + + PASS(errctx, collision_resolve_tiles(self, actor, actor->proxy, dt)); + + memset(&state, 0x00, sizeof(state)); + state.world = self; + state.actor = actor; + state.proxy = actor->proxy; + state.dt = dt; + PASS(errctx, self->partitioner.query(&self->partitioner, &actor->proxy->bounds, + actor->shape.collidemask, &collision_resolve_visit, &state)); + + // Whatever the responses did, the index has to end the pass agreeing with + // where the actor actually is. + PASS(errctx, akgl_collision_proxy_sync(actor->proxy, &actor->shape, actor->x, actor->y, actor->z)); + PASS(errctx, self->partitioner.move(&self->partitioner, actor->proxy)); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_collision_substeps(akgl_CollisionWorld *self, akgl_Actor *actor, float32_t dt, int *dest) +{ + float32_t span = 0.0f; + float32_t limit = 0.0f; + float32_t thin = 0.0f; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "NULL actor reference"); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination reference"); + + /* + * One, unless there is something to collide with. A step of exactly `dt` + * takes the same arithmetic path it always has -- `dt / 1.0f` is `dt` + * exactly in IEEE-754 -- which is what lets every existing assertion about + * a non-colliding actor hold unchanged. + */ + *dest = 1; + if ( (self == NULL) || (actor->shape.kind == AKGL_COLLISION_SHAPE_NONE) ) { + SUCCEED_RETURN(errctx); + } + + span = fabsf(actor->vx); + if ( fabsf(actor->vy) > span ) { span = fabsf(actor->vy); } + if ( fabsf(actor->vz) > span ) { span = fabsf(actor->vz); } + span = span * dt; + + /* + * Do not travel further in one sub-step than the thinnest thing that could + * be passed through. That is a cell *or the actor's own extent*, whichever + * is smaller: a small fast actor is the one that tunnels, and bounding only + * by cell size misses it entirely. + */ + thin = self->cellwidth; + if ( self->cellheight < thin ) { thin = self->cellheight; } + if ( (2.0f * actor->shape.hx) < thin ) { thin = 2.0f * actor->shape.hx; } + if ( (2.0f * actor->shape.hy) < thin ) { thin = 2.0f * actor->shape.hy; } + limit = AKGL_COLLISION_SUBSTEP_FRACTION * thin; + + if ( limit <= 0.0f ) { + SUCCEED_RETURN(errctx); + } + *dest = (int)(span / limit) + 1; + if ( *dest > AKGL_COLLISION_MAX_SUBSTEPS ) { + *dest = AKGL_COLLISION_MAX_SUBSTEPS; + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_collision_sync_actors(akgl_CollisionWorld *self) +{ + akgl_Actor *actor = NULL; + int i = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference"); + + /* + * One pass over the pool: give a proxy to anything that has gained a shape, + * take one back from anything that has lost one, and refresh the rest. This + * is the only place a proxy is created or destroyed during a step, so + * nothing below has to think about lifetimes. + */ + for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) { + actor = &akgl_heap_actors[i]; + if ( actor->refcount == 0 ) { + continue; + } + + if ( actor->shape.kind == AKGL_COLLISION_SHAPE_NONE ) { + if ( actor->proxy != NULL ) { + PASS(errctx, self->partitioner.remove(&self->partitioner, actor->proxy)); + PASS(errctx, akgl_heap_release_collision_proxy(actor->proxy)); + actor->proxy = NULL; + } + continue; + } + + if ( actor->proxy == NULL ) { + PASS(errctx, akgl_heap_next_collision_proxy(&actor->proxy)); + PASS(errctx, akgl_collision_proxy_initialize(actor->proxy, actor, &actor->shape, + actor->x, actor->y, actor->z)); + PASS(errctx, self->partitioner.insert(&self->partitioner, actor->proxy)); + continue; + } + + PASS(errctx, akgl_collision_proxy_sync(actor->proxy, &actor->shape, + actor->x, actor->y, actor->z)); + PASS(errctx, self->partitioner.move(&self->partitioner, actor->proxy)); + } + SUCCEED_RETURN(errctx); +} diff --git a/src/physics.c b/src/physics.c index b425b32..b173a76 100644 --- a/src/physics.c +++ b/src/physics.c @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -20,12 +21,18 @@ akerr_ErrorContext *akgl_physics_null_gravity(akgl_PhysicsBackend *self, akgl_Ac SUCCEED_RETURN(errctx); } -akerr_ErrorContext *akgl_physics_null_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2) +akerr_ErrorContext *akgl_physics_null_collide(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt) { PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self"); - FAIL_ZERO_RETURN(errctx, a1, AKERR_NULLPOINTER, "a1"); - FAIL_ZERO_RETURN(errctx, a2, AKERR_NULLPOINTER, "a2"); + FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor"); + (void)dt; + + /* + * Nothing collides, and that is an answer rather than a gap. A caller who + * wants a world with no collision in it installs this backend and gets it, + * without the simulation having to branch on whether collision exists. + */ SUCCEED_RETURN(errctx); } @@ -79,11 +86,19 @@ akerr_ErrorContext *akgl_physics_arcade_gravity(akgl_PhysicsBackend *self, akgl_ SUCCEED_RETURN(errctx); } -akerr_ErrorContext *akgl_physics_arcade_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2) +akerr_ErrorContext *akgl_physics_arcade_collide(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt) { PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self"); - FAIL_RETURN(errctx, AKERR_API, "Not implemented"); + FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor"); + + // No world means no collision, and costs one comparison. This is what makes + // the feature opt-in: a backend nobody attached a world to runs exactly as + // it did before any of this existed. + if ( self->collision == NULL ) { + SUCCEED_RETURN(errctx); + } + PASS(errctx, akgl_collision_resolve(self->collision, actor, dt)); SUCCEED_RETURN(errctx); } @@ -152,10 +167,17 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat float32_t dt = 0; float32_t overshoot = 0.0f; float32_t thrustscale = 0.0f; + float32_t subdt = 0.0f; + int substeps = 1; + int s = 0; akgl_Actor *actor = NULL; FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self"); FAIL_ZERO_RETURN(errctx, self->move, AKERR_NULLPOINTER, "self->move"); + // `gravity` was never checked, and is dereferenced unconditionally below. A + // backend built by hand with only `move` filled in crashed here rather than + // reporting; `collide` is allowed to be NULL and means "nothing collides". + FAIL_ZERO_RETURN(errctx, self->gravity, AKERR_NULLPOINTER, "self->gravity"); // Reading the elapsed time requires self, so it cannot be hoisted above // the null check. @@ -183,6 +205,12 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat opflags = &defflags; } + // One pass to give every shaped actor a proxy and take one back from + // anything that lost its shape, so the loop below never has to. + if ( self->collision != NULL ) { + PASS(errctx, akgl_collision_sync_actors(self->collision)); + } + for ( int i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) { actor = &akgl_heap_actors[i]; @@ -268,13 +296,49 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat actor->vy = actor->ey + actor->ty; actor->vz = actor->ez + actor->tz; - PASS(errctx, self->move(self, actor, dt)); + /* + * Only `move` is subdivided. Gravity, drag, the thrust integration + * and the speed-ellipse cap above all ran once against the whole dt, + * and sum(subdt) is dt -- so an actor with nothing to collide with + * takes exactly one sub-step of exactly dt and follows the arithmetic + * path it always did. That is what lets the integrator stay untouched + * and every recorded physics number stay valid. + */ + CATCH(errctx, akgl_collision_substeps(self->collision, actor, dt, &substeps)); + subdt = dt / (float32_t)substeps; + for ( s = 0; s < substeps; s++ ) { + PASS(errctx, self->move(self, actor, subdt)); + if ( (self->collide != NULL) && (self->collision != NULL) ) { + PASS(errctx, self->collide(self, actor, subdt)); + } + } } CLEANUP { } PROCESS(errctx) { } HANDLE(errctx, AKGL_ERR_LOGICINTERRUPT) { // noop } FINISH(errctx, true); } + /* + * Re-snap children after everything has moved, but only when collision is + * on. The in-loop snap uses whatever position the parent had when the + * child's own slot came up in pool order, which is already sometimes a + * frame stale -- harmless while a parent only ever moved by `v * dt`, and + * not harmless once a parent can also be pushed out of a wall part way + * through its own sub-steps. Gating it keeps a collision-free world + * byte-for-byte what it was. + */ + if ( self->collision != NULL ) { + for ( int i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) { + actor = &akgl_heap_actors[i]; + if ( (actor->refcount == 0) || (actor->parent == NULL) ) { + continue; + } + actor->x = actor->parent->x + actor->vx; + actor->y = actor->parent->y + actor->vy; + actor->z = actor->parent->z + actor->vz; + } + } + self->gravity_time = curtime; SUCCEED_RETURN(errctx); } diff --git a/tests/physics.c b/tests/physics.c index 3e2daf7..37fc9d0 100644 --- a/tests/physics.c +++ b/tests/physics.c @@ -134,7 +134,7 @@ akerr_ErrorContext *test_physics_null_backend_is_inert(void) TEST_EXPECT_OK(e, backend.gravity(&backend, &actor, 1.0f), "null gravity"); TEST_EXPECT_OK(e, backend.move(&backend, &actor, 1.0f), "null move"); - TEST_EXPECT_OK(e, backend.collide(&backend, &actor, &reference), "null collide"); + TEST_EXPECT_OK(e, backend.collide(&backend, &actor, 1.0f), "null collide"); TEST_ASSERT(e, memcmp(&actor, &reference, sizeof(akgl_Actor)) == 0, "the null backend modified the actor"); @@ -143,7 +143,7 @@ akerr_ErrorContext *test_physics_null_backend_is_inert(void) "null gravity with NULL self"); TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_move(NULL, &actor, 1.0f), "null move with NULL self"); - TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(NULL, &actor, &reference), + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(NULL, &actor, 1.0f), "null collide with NULL self"); // The actor arguments too. The arcade backend has always checked these; @@ -153,10 +153,8 @@ akerr_ErrorContext *test_physics_null_backend_is_inert(void) "null gravity with a NULL actor"); TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_move(&backend, NULL, 1.0f), "null move with a NULL actor"); - TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(&backend, NULL, &reference), - "null collide with a NULL first actor"); - TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(&backend, &actor, NULL), - "null collide with a NULL second actor"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(&backend, NULL, 1.0f), + "null collide with a NULL actor"); } CLEANUP { } PROCESS(e) { } FINISH(e, true); @@ -272,29 +270,50 @@ akerr_ErrorContext *test_physics_arcade_move(void) SUCCEED_RETURN(e); } -akerr_ErrorContext *test_physics_arcade_collide_unimplemented(void) +/** + * @brief The arcade backend resolves, and does nothing when there is no world. + * + * This function used to assert that akgl_physics_arcade_collide always raised + * AKERR_API, with a comment saying it existed so that implementing collision + * would have to come back here. It has, and this is that visit. + * + * What replaces it is the opt-in contract: a backend with no collision world + * attached returns success having changed nothing, which is what keeps every + * program written before collision existed running exactly as it did. + */ +akerr_ErrorContext *test_physics_arcade_collide(void) { PREPARE_ERROR(e); akgl_PhysicsBackend backend; - akgl_Actor a1; - akgl_Actor a2; + akgl_Actor actor; + akgl_Actor reference; ATTEMPT { memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); - memset(&a1, 0x00, sizeof(akgl_Actor)); - memset(&a2, 0x00, sizeof(akgl_Actor)); + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.x = 3.0f; + actor.y = 5.0f; + memcpy(&reference, &actor, sizeof(akgl_Actor)); - // Pins the stub so that implementing collision has to update this test. - TEST_EXPECT_STATUS(e, AKERR_API, akgl_physics_arcade_collide(&backend, &a1, &a2), - "arcade collide is still a stub"); - TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(NULL, &a1, &a2), + // No world: success, and the actor is untouched. + TEST_EXPECT_OK(e, akgl_physics_arcade_collide(&backend, &actor, 1.0f), + "arcade collide with no collision world"); + TEST_ASSERT(e, memcmp(&actor, &reference, sizeof(akgl_Actor)) == 0, + "arcade collide moved an actor with no collision world attached"); + + // Both pointers checked. The old stub returned AKERR_API before looking + // at either, so which arguments were validated depended on the backend. + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(NULL, &actor, 1.0f), "arcade collide with NULL self"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(&backend, NULL, 1.0f), + "arcade collide with a NULL actor"); } CLEANUP { } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } + akerr_ErrorContext *test_physics_factory(void) { PREPARE_ERROR(e); @@ -792,7 +811,7 @@ int main(void) CATCH(errctx, test_physics_null_backend_is_inert()); CATCH(errctx, test_physics_arcade_gravity()); CATCH(errctx, test_physics_arcade_move()); - CATCH(errctx, test_physics_arcade_collide_unimplemented()); + CATCH(errctx, test_physics_arcade_collide()); CATCH(errctx, test_physics_factory()); CATCH(errctx, test_physics_init_arcade_properties()); CATCH(errctx, test_physics_simulate_skips_inactive()); diff --git a/tests/physics_sim.c b/tests/physics_sim.c index fe80162..1d59004 100644 --- a/tests/physics_sim.c +++ b/tests/physics_sim.c @@ -44,7 +44,9 @@ #include #include #include +#include #include +#include #include #include @@ -606,10 +608,205 @@ static int sim_run(const char *name, akerr_ErrorContext *(*simulation)(void)) return 1; } +/** + * @brief A floor across the bottom, with a two-tile hole in it. + * + * Static, not local: sizeof(akgl_Tilemap) is about 26 MB and a local one + * overflows the stack before the first assertion runs. + */ +static akgl_Tilemap sim_map; +static akgl_CollisionWorld sim_world; + +static akerr_ErrorContext *sim_collision_world(void) +{ + int x = 0; + + PREPARE_ERROR(errctx); + + memset(&sim_map, 0x00, sizeof(akgl_Tilemap)); + sim_map.tilewidth = 16; + sim_map.tileheight = 16; + sim_map.width = 40; + sim_map.height = 20; + sim_map.numlayers = 1; + sim_map.layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES; + for ( x = 0; x < sim_map.width; x++ ) { + if ( (x == 20) || (x == 21) ) { + continue; // a hole to fall through + } + sim_map.layers[0].data[(19 * sim_map.width) + x] = 1; + } + sim_map.collidablelayers = 1u; + + PASS(errctx, akgl_collision_world_init(&sim_world, NULL, 16.0f, 16.0f)); + PASS(errctx, akgl_collision_bind_tilemap(&sim_world, &sim_map)); + sim_physics.collision = &sim_world; + SUCCEED_RETURN(errctx); +} + +/** + * @brief An actor dropped on a floor lands on it and stays there. + * + * The number that matters is the drift after it has settled. A resolver that + * zeroes the environmental term rather than accounting for the gravity the next + * step is about to add commits `gravity * dt^2` of penetration per step -- a + * quarter of a pixel at 60 Hz, invisible, and cumulative. + */ +static akerr_ErrorContext *test_sim_lands_and_rests(void) +{ + akgl_Actor *actor = NULL; + akgl_CollisionShape shape; + SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f }; + float32_t settled = 0.0f; + float32_t drift = 0.0f; + int i = 0; + + PREPARE_ERROR(errctx); + + ATTEMPT { + sim_arcade(900.0, 0.0, 0.0); + CATCH(errctx, sim_collision_world()); + CATCH(errctx, sim_actor(&actor, "faller", 600.0f, 200.0f)); + CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f)); + actor->shape = shape; + actor->shape_override = true; + actor->x = 32.0f; + actor->y = 100.0f; + + for ( i = 0; i < 120; i++ ) { + CATCH(errctx, sim_step(SIM_DT)); + } + settled = actor->y; + + for ( i = 0; i < 120; i++ ) { + CATCH(errctx, sim_step(SIM_DT)); + } + drift = actor->y - settled; + printf(" landed at y=%.3f, drift over 120 further steps %.4f px\n", settled, drift); + + TEST_ASSERT(errctx, (settled < 304.0f), + "the actor came to rest at y=%f; the floor is at 304 and it is inside it", + settled); + TEST_ASSERT(errctx, (fabsf(drift) < 1.0f), + "a resting actor drifted %f px over 120 steps; it is sinking through the floor", + drift); + } CLEANUP { + sim_physics.collision = NULL; + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +/** + * @brief And it can still walk once it has landed. + * + * The consequence a player sees. A quarter pixel of sink is invisible until the + * horizontal test starts reporting blocked wherever the actor stands, and then + * the character jerks backwards every time it tries to move. + */ +static akerr_ErrorContext *test_sim_walks_after_landing(void) +{ + akgl_Actor *actor = NULL; + akgl_CollisionShape shape; + SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f }; + float32_t startx = 0.0f; + int i = 0; + + PREPARE_ERROR(errctx); + + ATTEMPT { + sim_arcade(900.0, 0.0, 0.0); + CATCH(errctx, sim_collision_world()); + CATCH(errctx, sim_actor(&actor, "walker", 600.0f, 120.0f)); + CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f)); + actor->shape = shape; + actor->shape_override = true; + actor->x = 32.0f; + actor->y = 100.0f; + + for ( i = 0; i < 120; i++ ) { + CATCH(errctx, sim_step(SIM_DT)); + } + startx = actor->x; + + AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT); + for ( i = 0; i < 60; i++ ) { + CATCH(errctx, sim_step(SIM_DT)); + } + printf(" walked from x=%.2f to x=%.2f in one second\n", startx, actor->x); + + TEST_ASSERT(errctx, (actor->x > (startx + 30.0f)), + "an actor resting on the floor walked from %f to %f in a second; it is " + "caught on the ground it is standing on", startx, actor->x); + } CLEANUP { + sim_physics.collision = NULL; + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +/** + * @brief A fast fall does not pass through a one-tile floor. + * + * At 900 px/s^2 and the default 0.05 s bound a single step covers about 30 + * pixels -- nearly two 16-pixel tiles. Without sub-stepping the actor is above + * the floor at one sample and below it at the next, and no test between them + * ever sees an overlap. + */ +static akerr_ErrorContext *test_sim_fast_fall_does_not_tunnel(void) +{ + akgl_Actor *actor = NULL; + akgl_CollisionShape shape; + SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f }; + int i = 0; + + PREPARE_ERROR(errctx); + + ATTEMPT { + /* + * Zero gravity and a constant 1200 px/s, so every step is exactly 60 + * pixels and the arithmetic is the same every time. With gravity on, the + * step size grows and the actor lands *inside* the floor by luck rather + * than passing through it -- which made the first version of this test + * pass with sub-stepping disabled and prove nothing. + * + * 60 pixels a step against a 16-pixel floor and a 16-pixel actor: the + * window in which an overlap exists is 32 pixels wide, so from y=100 the + * samples land at 160, 220, 280 and 340 and step clean over it. Sub- + * stepping cuts that to 7.5 pixels, which cannot miss. + */ + sim_arcade(0.0, 0.0, 0.0); + CATCH(errctx, sim_collision_world()); + CATCH(errctx, sim_actor(&actor, "bullet", 600.0f, 200.0f)); + CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f)); + actor->shape = shape; + actor->shape_override = true; + actor->x = 32.0f; + actor->y = 100.0f; + actor->ey = 1200.0f; + + for ( i = 0; i < 20; i++ ) { + CATCH(errctx, sim_step(0.05f)); + } + printf(" 60 px per step at a 16 px floor: ended at y=%.2f\n", actor->y); + + TEST_ASSERT(errctx, (actor->y < 320.0f), + "an actor moving 60 px a step ended at y=%f, past the floor at 304; it " + "stepped straight over the 32 px window in which an overlap exists", + actor->y); + } CLEANUP { + sim_physics.collision = NULL; + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + + int main(void) { PREPARE_ERROR(errctx); int failures = 0; + int total = 0; SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); @@ -637,8 +834,16 @@ int main(void) failures += sim_run("top-down walk", &test_sim_topdown_walk); failures += sim_run("top-down diagonal speed", &test_sim_topdown_diagonal_speed); failures += sim_run("sudden reversal", &test_sim_sudden_reversal); + total = 7; - printf("\n%d of 7 simulations failed\n", failures); + failures += sim_run("lands and rests", &test_sim_lands_and_rests); + failures += sim_run("walks after landing", &test_sim_walks_after_landing); + failures += sim_run("fast fall does not tunnel", &test_sim_fast_fall_does_not_tunnel); + total += 3; + + // Derived, not written out. The literal that used to be here went stale + // the first time a simulation was added, which is what a literal does. + printf("\n%d of %d simulations failed\n", failures, total); FAIL_NONZERO_BREAK(errctx, failures, AKGL_ERR_BEHAVIOR, "%d physics simulations do not behave as a game needs", failures); } CLEANUP {