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:
2026-08-02 07:05:09 -04:00
parent a8526ad55e
commit 5e213061af
6 changed files with 702 additions and 40 deletions

View File

@@ -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.

View File

@@ -45,6 +45,7 @@
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akgl/actor.h>
#include <akgl/collision.h>
#include <akgl/iterator.h>
#include <akgl/staticstring.h>
@@ -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.
*