Seven new benchmark rows, budgets set from a measured full-scale run rather than guessed, and the three existing rows the work moved re-recorded from that same run. The all-pairs sweep stays and is relabelled `control:` -- it is the cost a caller paid before the library had a broad phase, measured on the same machine in the same run, which is the only honest way to read a reduction. What the numbers say: - The box fast path is two to seven times cheaper than the general solver, and the disjoint case is cheaper still because the proxies' bounds reject it before any shape arithmetic runs. 9 to 20 ns is what a tile game actually pays. - The grid beats the tree by 2.6x on the same population, which is the argument for the default measured here rather than cited from another engine. The tree also rebuilds on every move and that is not in its row, so a moving scene is worse than 2.6x. - A grid `move` that changes nothing is 11.5 ns. That is the incremental claim in one number. Two rows moved on a backend with **no collision world attached**, so no collision code runs in either, and the commit says so rather than letting the feature take credit: - akgl_Actor grew from 415 to 464 bytes -- a 40-byte shape, an override flag and a proxy pointer. The step sweeps the whole pool, so that is about 3 KB more working set per frame. The empty-pool row is unchanged at 58.7 ns, which is what identifies the cost as per live actor rather than per slot. - Reaching the collision check through CATCH cost more than the check. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR, so an early-returning function is not free when it is reached through one. The no-collision path now routes around the machinery entirely, which took the 64-actor sweep from 2,018.7 ns back to 1,588.5. AGENTS.md records that lesson for writing benchmarks; this is the same thing in production code. The remainder is the struct, and it is paid whether or not a game uses collision. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
373 lines
14 KiB
C
373 lines
14 KiB
C
/**
|
|
* @file physics.c
|
|
* @brief Implements the physics subsystem.
|
|
*/
|
|
|
|
#include <math.h>
|
|
#include <akstdlib.h>
|
|
#include <akgl/collision.h>
|
|
#include <akgl/physics.h>
|
|
#include <akgl/actor.h>
|
|
#include <akgl/game.h>
|
|
#include <akgl/error.h>
|
|
#include <akgl/heap.h>
|
|
#include <akgl/registry.h>
|
|
|
|
akerr_ErrorContext *akgl_physics_null_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
|
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
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, 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);
|
|
}
|
|
|
|
akerr_ErrorContext *akgl_physics_null_move(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
|
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext *akgl_physics_init_null(akgl_PhysicsBackend *self)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
|
|
|
self->gravity = akgl_physics_null_gravity;
|
|
self->collide = akgl_physics_null_collide;
|
|
self->move = akgl_physics_null_move;
|
|
self->simulate = akgl_physics_simulate;
|
|
|
|
// Set for the same reason as the arcade backend: the null backend still
|
|
// goes through akgl_physics_simulate, which still computes a dt and still
|
|
// commits velocity to position through akgl_physics_null_move.
|
|
self->gravity_time = SDL_GetTicksNS();
|
|
self->max_timestep = AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP;
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
|
|
akerr_ErrorContext *akgl_physics_arcade_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
|
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
|
|
|
if ( self->gravity_x != 0 ) {
|
|
// Assume the X origin is - (screen left)
|
|
actor->ex -= (self->gravity_x * dt);
|
|
}
|
|
if ( self->gravity_y != 0 ) {
|
|
// Assume Y origin is + (down screen)
|
|
actor->ey += (self->gravity_y * dt);
|
|
}
|
|
if ( self->gravity_z != 0 ) {
|
|
// Assume Z origin is - (behind the camera)
|
|
actor->ez -= (self->gravity_z * dt);
|
|
}
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
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_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);
|
|
}
|
|
|
|
akerr_ErrorContext *akgl_physics_arcade_move(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
|
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
|
actor->x += actor->vx * dt;
|
|
actor->y += actor->vy * dt;
|
|
actor->z += actor->vz * dt;
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext *akgl_physics_init_arcade(akgl_PhysicsBackend *self)
|
|
{
|
|
akgl_String *tmp;
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
|
PASS(errctx, akgl_heap_next_string(&tmp));
|
|
|
|
self->gravity = akgl_physics_arcade_gravity;
|
|
self->collide = akgl_physics_arcade_collide;
|
|
self->move = akgl_physics_arcade_move;
|
|
self->simulate = akgl_physics_simulate;
|
|
|
|
// The epoch the first step's dt is measured from. Nothing set it, so it
|
|
// stayed at whatever the backend's storage held -- zero, for the default
|
|
// backend in BSS -- and the first akgl_physics_simulate() measured dt as
|
|
// the entire time since SDL started. A host that spends a quarter of a
|
|
// second loading before its first frame got a quarter-second step: 101
|
|
// pixels of fall where a 60 Hz frame is 0.44.
|
|
self->gravity_time = SDL_GetTicksNS();
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_get_property("physics.gravity.x", &tmp, "0.0"));
|
|
CATCH(errctx, aksl_atof(tmp->data, &self->gravity_x));
|
|
CATCH(errctx, akgl_get_property("physics.gravity.y", &tmp, "0.0"));
|
|
CATCH(errctx, aksl_atof(tmp->data, &self->gravity_y));
|
|
CATCH(errctx, akgl_get_property("physics.gravity.z", &tmp, "0.0"));
|
|
CATCH(errctx, aksl_atof(tmp->data, &self->gravity_z));
|
|
CATCH(errctx, akgl_get_property("physics.drag.x", &tmp, "0.0"));
|
|
CATCH(errctx, aksl_atof(tmp->data, &self->drag_x));
|
|
CATCH(errctx, akgl_get_property("physics.drag.y", &tmp, "0.0"));
|
|
CATCH(errctx, aksl_atof(tmp->data, &self->drag_y));
|
|
CATCH(errctx, akgl_get_property("physics.drag.z", &tmp, "0.0"));
|
|
CATCH(errctx, aksl_atof(tmp->data, &self->drag_z));
|
|
CATCH(errctx, akgl_get_property("physics.max_timestep", &tmp, "0.05"));
|
|
CATCH(errctx, aksl_atof(tmp->data, &self->max_timestep));
|
|
} CLEANUP {
|
|
IGNORE(akgl_heap_release_string(tmp));
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterator *opflags)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
akgl_Iterator defflags = {
|
|
.flags = 0,
|
|
.layerid = 0
|
|
};
|
|
SDL_Time curtime = 0;
|
|
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.
|
|
curtime = SDL_GetTicksNS();
|
|
dt = (float32_t)(curtime - self->gravity_time) / (float32_t)AKGL_TIME_ONESEC_NS;
|
|
|
|
// Bound the step. Everything below multiplies by dt, so one enormous dt
|
|
// moves every actor by however far its velocity carries it over the whole
|
|
// stall -- a quarter-second load is a hundred pixels of fall under
|
|
// platformer gravity, straight through whatever was underneath. Advancing
|
|
// the world in slow motion through a hitch is the trade to make here.
|
|
//
|
|
// A zero or negative max_timestep disables the bound, for a caller who
|
|
// would rather have the real elapsed time and handle it themselves.
|
|
if ( (self->max_timestep > 0.0) && (dt > (float32_t)self->max_timestep) ) {
|
|
dt = (float32_t)self->max_timestep;
|
|
}
|
|
// A clock that went backwards is not a step. SDL_GetTicksNS is monotonic,
|
|
// but gravity_time is a public field and a caller can put anything in it.
|
|
if ( dt < 0.0f ) {
|
|
dt = 0.0f;
|
|
}
|
|
|
|
if ( opflags == NULL ) {
|
|
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];
|
|
if ( actor->refcount == 0 ) {
|
|
continue;
|
|
}
|
|
if ( actor->parent != NULL ) {
|
|
// Children don't move independently of their parents, they just have an offset
|
|
actor->x = actor->parent->x + actor->vx;
|
|
actor->y = actor->parent->y + actor->vy;
|
|
actor->z = actor->parent->z + actor->vz;
|
|
continue;
|
|
} else if ( actor->basechar == NULL ) {
|
|
continue;
|
|
}
|
|
if ( AKGL_BITMASK_HAS(opflags->flags, AKGL_ITERATOR_OP_LAYERMASK) ) {
|
|
if ( actor->layer != opflags->layerid ) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// thrust is a function of acceleration on a given axis
|
|
if ( AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_LEFT) ||
|
|
AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) {
|
|
actor->tx += actor->ax * dt;
|
|
}
|
|
if ( AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_UP) ||
|
|
AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_DOWN) ) {
|
|
actor->ty += actor->ay * dt;
|
|
}
|
|
|
|
// Cap the thrust *vector* against the ellipse the character's per-axis
|
|
// top speeds describe, rather than each axis against its own cap.
|
|
//
|
|
// 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, which measured 41% faster than either alone.
|
|
// Scaling to the ellipse keeps a character whose horizontal and vertical
|
|
// speeds differ moving at the ratio it asked for, and makes them equal
|
|
// where the speeds are.
|
|
//
|
|
// An axis with a top speed of zero cannot be thrust along at all, which
|
|
// is what the old per-axis clamp did with it, and it stays out of the
|
|
// magnitude entirely -- dividing by it would not end well.
|
|
overshoot = 0.0f;
|
|
if ( actor->sx != 0.0f ) {
|
|
overshoot += (actor->tx / actor->sx) * (actor->tx / actor->sx);
|
|
} else {
|
|
actor->tx = 0.0f;
|
|
}
|
|
if ( actor->sy != 0.0f ) {
|
|
overshoot += (actor->ty / actor->sy) * (actor->ty / actor->sy);
|
|
} else {
|
|
actor->ty = 0.0f;
|
|
}
|
|
if ( actor->sz != 0.0f ) {
|
|
overshoot += (actor->tz / actor->sz) * (actor->tz / actor->sz);
|
|
} else {
|
|
actor->tz = 0.0f;
|
|
}
|
|
if ( overshoot > 1.0f ) {
|
|
thrustscale = 1.0f / sqrtf(overshoot);
|
|
actor->tx *= thrustscale;
|
|
actor->ty *= thrustscale;
|
|
actor->tz *= thrustscale;
|
|
}
|
|
ATTEMPT {
|
|
CATCH(errctx, actor->movementlogicfunc(actor, dt));
|
|
PASS(errctx, self->gravity(self, actor, dt));
|
|
|
|
// Counteract velocity with atmospheric drag
|
|
if ( self->drag_x != 0 ) {
|
|
actor->ex -= actor->ex * self->drag_x * dt;
|
|
}
|
|
if ( self->drag_y != 0 ) {
|
|
actor->ey -= actor->ey * self->drag_y * dt;
|
|
}
|
|
if ( self->drag_z != 0 ) {
|
|
actor->ez -= actor->ez * self->drag_z * dt;
|
|
}
|
|
|
|
actor->vx = actor->ex + actor->tx;
|
|
actor->vy = actor->ey + actor->ty;
|
|
actor->vz = actor->ez + actor->tz;
|
|
|
|
/*
|
|
* 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.
|
|
*/
|
|
if ( (self->collision == NULL) || (self->collide == NULL) ) {
|
|
/*
|
|
* No collision: exactly the call this has always made, with
|
|
* nothing added around it. Not merely equivalent -- CATCH and
|
|
* PASS walk AKERR_ARRAY_ERROR to validate the context, which
|
|
* costs more than several of the calls being measured, so even
|
|
* a check that answers "no" instantly is not free if it is
|
|
* reached through one. Measured: routing the off path through
|
|
* one extra CATCH took the 64-actor sweep from 1.2 us to 2.0.
|
|
*/
|
|
PASS(errctx, self->move(self, actor, dt));
|
|
} else {
|
|
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));
|
|
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);
|
|
}
|
|
|
|
akerr_ErrorContext *akgl_physics_factory(akgl_PhysicsBackend *self, akgl_String *type)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
|
FAIL_ZERO_RETURN(errctx, type, AKERR_NULLPOINTER, "type");
|
|
|
|
if ( strncmp(type->data, "null", 4) == 0) {
|
|
PASS(errctx, akgl_physics_init_null(self));
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
if ( strncmp(type->data, "arcade", 6) == 0) {
|
|
PASS(errctx, akgl_physics_init_arcade(self));
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
FAIL_RETURN(errctx, AKERR_KEY, "Invalid physics engine %s", type->data);
|
|
}
|