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>
272 lines
15 KiB
C
272 lines
15 KiB
C
/**
|
|
* @file physics.h
|
|
* @brief The pluggable physics backend, and the two implementations that ship.
|
|
*
|
|
* Same shape as the renderer: a record of function pointers plus an initializer
|
|
* that fills it in. `null` accepts every call and changes nothing, which is what
|
|
* a menu screen or a test harness wants; `arcade` applies gravity, drag, thrust
|
|
* and a speed cap. Selecting between them is akgl_physics_factory, driven by the
|
|
* `physics.engine` configuration property -- not a branch in the simulation.
|
|
*
|
|
* The model an actor is simulated under, and the fields on akgl_Actor that carry
|
|
* it:
|
|
*
|
|
* | Term | Fields | Comes from |
|
|
* |-----------------------|--------------|-----------------------------------------------|
|
|
* | thrust | `tx, ty, tz` | the actor's own acceleration while it is moving |
|
|
* | environmental | `ex, ey, ez` | gravity, less drag |
|
|
* | velocity | `vx, vy, vz` | thrust + environmental |
|
|
* | max speed | `sx, sy, sz` | the character; caps the thrust vector, not velocity |
|
|
* | acceleration | `ax, ay, az` | the character |
|
|
*
|
|
* Each step: movement logic, then gravity, then drag, then velocity, then move.
|
|
* Because the cap is applied to thrust rather than to velocity, gravity can
|
|
* carry an actor faster than its stated top speed -- which is the point, for a
|
|
* falling one.
|
|
*
|
|
* The cap is on the thrust **vector**, not on each axis separately. Capping the
|
|
* axes independently lets the corner of the box through: an actor holding two
|
|
* directions at once got both caps at once and travelled their diagonal, 41%
|
|
* faster than either alone. Scaling to the ellipse instead keeps a character
|
|
* whose horizontal and vertical speeds differ moving at the ratio it asked for.
|
|
*/
|
|
|
|
/**
|
|
* @brief Default bound on one simulation step, in seconds.
|
|
*
|
|
* A twentieth of a second: three frames at 60 Hz, so an ordinary dropped frame
|
|
* or two passes through untouched, and a load or a breakpoint does not.
|
|
*/
|
|
#define AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP 0.05
|
|
|
|
#ifndef _AKGL_PHYSICS_H_
|
|
#define _AKGL_PHYSICS_H_
|
|
|
|
#include <SDL3/SDL.h>
|
|
#include <akerror.h>
|
|
#include <akgl/actor.h>
|
|
#include <akgl/collision.h>
|
|
#include <akgl/iterator.h>
|
|
#include <akgl/staticstring.h>
|
|
|
|
/** @brief Defines a pluggable physics backend and its environmental parameters. */
|
|
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 *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`. */
|
|
float64_t drag_y; /**< Drag along y. From `physics.drag.y`. */
|
|
float64_t drag_z; /**< Drag along z. From `physics.drag.z`. */
|
|
float64_t gravity_x; /**< Acceleration along x, world units per second squared. Applied toward screen left. From `physics.gravity.x`. */
|
|
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;
|
|
|
|
/**
|
|
* @brief Gravity for the null backend: accept the call and change nothing.
|
|
* @param self The backend. Required -- the one thing this does check.
|
|
* @param actor The actor that would have been accelerated. Ignored; `NULL` is
|
|
* accepted.
|
|
* @param dt Seconds since the previous step. Ignored.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
|
/**
|
|
* @brief Collision for the null backend: accept the call and change nothing.
|
|
*
|
|
* 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 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 or @p actor is `NULL`.
|
|
*/
|
|
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.
|
|
*
|
|
* Actors under the null backend do not move on their own. Anything that sets an
|
|
* actor's `x`/`y` directly still works -- this only declines to integrate
|
|
* velocity.
|
|
*
|
|
* @param self The backend. Required.
|
|
* @param actor The actor that would have moved. Ignored; `NULL` is accepted.
|
|
* @param dt Seconds since the previous step. Ignored.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_move(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
|
/**
|
|
* @brief Install the null backend's methods.
|
|
*
|
|
* Reads no configuration and touches no gravity or drag fields, so whatever was
|
|
* in them stays -- harmlessly, since none of the null methods look.
|
|
*
|
|
* @param self The backend to initialize. Required.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_init_null(akgl_PhysicsBackend *self);
|
|
|
|
|
|
/**
|
|
* @brief Accelerate one actor's environmental velocity by gravity.
|
|
*
|
|
* Adds `gravity * dt` to the actor's `ex`/`ey`/`ez` on each axis whose gravity
|
|
* is non-zero, with the sign chosen for screen space: x pulls left, y pulls
|
|
* down, z pulls away from the camera. It touches only the environmental term, so
|
|
* an actor's own thrust is unaffected and the speed cap does not apply -- which
|
|
* is why a falling actor keeps accelerating past its character's top speed.
|
|
*
|
|
* @param self The backend supplying the gravity constants. Required.
|
|
* @param actor The actor to accelerate. Required.
|
|
* @param dt Seconds since the previous step. A `dt` of 0 is a no-op; a
|
|
* negative one accelerates backwards.
|
|
* @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_physics_arcade_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
|
/**
|
|
* @brief Collision for the arcade backend: resolve one actor against the world.
|
|
*
|
|
* 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.
|
|
*
|
|
* **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 *actor, float32_t dt);
|
|
/**
|
|
* @brief Commit an actor's velocity to its position.
|
|
*
|
|
* The last step of a simulation tick: `position += velocity * dt` on all three
|
|
* axes. It does not clamp to the map, consult the tilemap, or test for
|
|
* collisions -- an actor will walk straight through a wall and off the edge of
|
|
* the world.
|
|
*
|
|
* @param self The backend. Required, though nothing on it is read.
|
|
* @param actor The actor to move. Required. Its `vx`/`vy`/`vz` must already have
|
|
* been computed; akgl_physics_simulate does that immediately
|
|
* before calling this.
|
|
* @param dt Seconds since the previous step.
|
|
* @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_physics_arcade_move(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
|
/**
|
|
* @brief Install the arcade backend's methods and read its constants from configuration.
|
|
*
|
|
* Reads `physics.gravity.x`, `.y`, `.z` and `physics.drag.x`, `.y`, `.z` from
|
|
* the property registry, all defaulting to `"0.0"` -- so an arcade backend with
|
|
* no configuration behaves like the null one until something is set.
|
|
*
|
|
* @param self The backend to initialize. Required. Its method pointers are
|
|
* installed before the properties are read, so a failure part-way
|
|
* leaves a usable backend with some constants still at their
|
|
* previous values.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
|
* @throws AKERR_VALUE If one of the six properties is set to something that is
|
|
* not a number.
|
|
* @throws ERANGE If one of them does not fit a `double`.
|
|
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
|
*
|
|
* @note With #AKGL_REGISTRY_PROPERTIES uninitialized every property reads back
|
|
* as its default, so this silently configures zero gravity and zero drag
|
|
* rather than reporting anything. See the warning in registry.h.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_init_arcade(akgl_PhysicsBackend *self);
|
|
|
|
/**
|
|
* @brief Select and initialize a physics backend by name.
|
|
*
|
|
* The dispatch point for the whole subsystem: `"null"` and `"arcade"` are the
|
|
* two names, and akgl_game_init passes whatever the `physics.engine` property
|
|
* holds. Adding a backend means adding a name here, not a branch in the
|
|
* simulation.
|
|
*
|
|
* @param self The backend to initialize. Required.
|
|
* @param type The backend name. Required. Matched on its leading characters, so
|
|
* `"nullify"` selects `null` and `"arcadia"` selects `arcade`.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p self or @p type is `NULL`.
|
|
* @throws AKERR_KEY If @p type matches neither name. The message quotes what was
|
|
* asked for.
|
|
* @throws AKERR_* Whatever the selected initializer raises.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_factory(akgl_PhysicsBackend *self, akgl_String *type);
|
|
|
|
/**
|
|
* @brief Step every live actor forward by the time elapsed since the previous call.
|
|
*
|
|
* Shared by both backends -- what differs is the `gravity` and `move` they point
|
|
* at. For each live actor, in pool order:
|
|
*
|
|
* - a child actor is snapped to its parent's position plus its own `v` as an
|
|
* offset, and skipped entirely -- children do not simulate;
|
|
* - an actor with no character is skipped, since the speed and acceleration
|
|
* constants live there;
|
|
* - thrust accumulates along an axis the actor is moving on, then the thrust
|
|
* *vector* is scaled to the ellipse the character's per-axis maximum speeds
|
|
* describe;
|
|
* - the backend's `gravity` runs, drag is applied to the environmental term,
|
|
* velocity becomes environmental plus thrust, and the backend's `move`
|
|
* commits it.
|
|
*
|
|
* `dt` is measured from `self->gravity_time`, which is stamped at the end and
|
|
* seeded by akgl_physics_init_arcade and akgl_physics_init_null. It is then
|
|
* bounded by `self->max_timestep`: a step longer than that advances the world
|
|
* by `max_timestep` instead of by the whole elapsed time.
|
|
*
|
|
* That bound is not a nicety. Anything that stalls a frame -- a level load
|
|
* between initialization and the first step, a breakpoint, a dragged window, an
|
|
* alt-tab -- otherwise arrives as one enormous `dt`, and one enormous `dt`
|
|
* moves every actor by however far its velocity carries it in that whole
|
|
* interval. A quarter-second load under ordinary platformer gravity is a
|
|
* hundred pixels of fall between the first frame and the second, straight
|
|
* through whatever was underneath. Advancing the world in slow motion during a
|
|
* hitch is the trade every game engine makes here.
|
|
*
|
|
* @param self The backend. Required, along with its `move` pointer.
|
|
* @param opflags Iterator flags. Optional -- `NULL` means "simulate everything".
|
|
* Only #AKGL_ITERATOR_OP_LAYERMASK is honoured, restricting the
|
|
* step to actors on `layerid`; the update, render, and release
|
|
* bits are ignored here.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p self or `self->move` is `NULL`.
|
|
* @throws AKERR_* Whatever an actor's `movementlogicfunc`, or the backend's
|
|
* `gravity` or `move`, raises. The first failure aborts the whole step,
|
|
* leaving the actors already processed advanced and the rest not.
|
|
*
|
|
* @note An actor's `movementlogicfunc` can raise AKGL_ERR_LOGICINTERRUPT to say
|
|
* "skip the rest of the simulation for me this tick". It is handled here
|
|
* and does not propagate -- it is a control signal wearing an error's
|
|
* clothes, not a failure. Only `movementlogicfunc` gets that treatment:
|
|
* the `gravity` and `move` calls return straight out of the loop on any
|
|
* status, LOGICINTERRUPT included, so a backend must not use it to opt an
|
|
* actor out from there.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterator *opflags);
|
|
|
|
#endif // _AKGL_PHYSICS_H_
|