Call collide from the simulation, after the move rather than before it
The collide slot has existed unused since the backend was written. It is wired in now, and its signature changes from (self, a1, a2) to (self, actor, dt): the old shape had nowhere to put a normal, a depth, or a piece of map geometry, and no answer to which of the two actors it was supposed to be resolving. **Resolution runs after `move`.** That is the decision everything else follows from. A resolver called from movementlogicfunc runs before gravity, drag and the move, so it has to predict where the actor will end up -- which means re-implementing the integrator's arithmetic in the game, and being wrong whenever the integrator changes. examples/sidescroller/collision.c carries forty lines of exactly that, and says so. Only `move` is subdivided. Gravity, drag, the thrust integration and the speed ellipse all still run once against the whole dt, and sum(subdt) is dt, so an actor with nothing to collide with takes one sub-step of exactly dt -- `dt/1.0f` is dt exactly in IEEE-754 -- and follows the arithmetic path it always did. That is what lets the integrator stay untouched and every recorded physics number stay valid, and tests/physics.c and tests/physics_sim.c pass unchanged with no world attached. Collision is opt-in. A backend with no collision world costs one comparison per actor per step and does nothing else. Also here: - `self->gravity` was never null-checked and is dereferenced unconditionally, so a hand-built backend with only `move` filled in crashed rather than reporting. - Children are re-snapped in a post-pass, gated on collision being on. The in-loop snap uses whatever position the parent had when the child's slot came up in pool order, which is already sometimes a frame stale; that was harmless while a parent only moved by v*dt and is not once a parent can be pushed out of a wall mid-step. - tests/physics.c's deliberate tripwire has been visited. It asserted that arcade collide always raised AKERR_API, with a comment saying it existed so that implementing collision would have to come back here. What replaces it asserts the opt-in contract instead. - physics_sim.c's hard-coded "%d of 7 simulations" is derived now. A literal goes stale the first time somebody adds a simulation, which is this commit. Three new whole-motion simulations, and the third took two attempts to make honest: - Landing and resting: 0.0000 px of drift over 120 steps after settling. - Walking after landing: 107 px in a second, which is the symptom a player sees when a resting actor is caught on the ground it is standing on. - A fast fall not tunnelling. The first version used gravity, so the step size grew every frame and the actor happened to land *inside* the floor rather than stepping over it -- it passed with sub-stepping disabled and proved nothing. It uses a constant 1200 px/s now, so every step is exactly 60 pixels against a 32-pixel window in which an overlap exists, and the samples miss it cleanly. With sub-stepping the actor stops at y=288; without it, y=1300. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <math.h>
|
||||
#include <akstdlib.h>
|
||||
#include <akgl/collision.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/game.h>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user