The new chapter 15 covers the opt-in setup, where resolution runs in the step and why it runs after the move, shapes and the z-extrusion trap, the asymmetric masks and the defaults that matter more than the mechanism, tiles as a source rather than proxies, the response hook and the three ways it is easy to write wrongly, sensors, the four queries, both partitioners, and what is still not implemented. Collision falsified claims in eight other chapters. The physics chapter said "There is no collision detection, at all" and that collide always raises AKERR_API; actors had six behaviour hooks; the heap had five pools and four non-claiming acquires; the status band held five codes; and the design philosophy had exactly two pluggable subsystems. The appendix gains a collision.h status section, the three new pool ceilings and a table of the collision constants that are deliberately not in a public header. Also fixes chapter labels the renumbering commit left pointing at their old numbers -- "18. Utilities" linked to 19-utilities.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
359 lines
18 KiB
Markdown
359 lines
18 KiB
Markdown
# 14. Physics
|
|
|
|
The physics subsystem has the same shape as the renderer: a record of function pointers
|
|
plus an initializer that fills it in. Two backends ship. `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, which is what a 2D game wants.
|
|
|
|
**There is no "physics body" type.** The body *is* the actor plus the character it
|
|
instantiates. An actor carries the position and the three velocity terms; its character
|
|
supplies the accelerations and the top speeds. Nothing else participates. If you have read
|
|
[Chapter 11](11-characters.md) and [Chapter 12](12-actors.md) you have already met every
|
|
field this chapter uses.
|
|
|
|
## The model
|
|
|
|
```text
|
|
thrust (tx, ty, tz) the actor's own effort, while it is moving
|
|
+
|
|
environ (ex, ey, ez) gravity, less drag -- accumulates across steps
|
|
=
|
|
velocity(vx, vy, vz) recomputed every step; writing it directly is overwritten
|
|
(a CHILD actor is the exception -- see below)
|
|
|
|
caps (sx, sy, sz) from the character. Caps the THRUST VECTOR, not velocity
|
|
accel (ax, ay, az) from the character, signed by the direction of travel
|
|
```
|
|
|
|
Two properties of that arrangement carry all the consequences.
|
|
|
|
**The cap is on thrust, not on velocity.** `sx`/`sy`/`sz` are the character's own top
|
|
speed, not a speed limit on the world, and `akgl_physics_arcade_gravity` touches only the
|
|
environmental term. So a falling actor accelerates straight past its stated top speed —
|
|
which is the point, for a falling one. It also means there is no terminal velocity; see
|
|
below.
|
|
|
|
**The cap is on the thrust *vector*, scaled to an ellipse, not on each axis separately.**
|
|
|
|
```text
|
|
ty
|
|
| Capping each axis against its own limit
|
|
......|...... admits the corner of the box: an actor
|
|
..' | '.. holding two directions got both caps
|
|
.' +---+---+ '. at once and travelled their
|
|
: | | | : diagonal -- 41% faster than either
|
|
: ----+---+---+---- : alone. Scaling the vector to the
|
|
----:------+---+---+------:---- tx ellipse keeps a character
|
|
: | | | : whose horizontal and vertical
|
|
:. +---+---+ .: speeds differ moving at the ratio
|
|
'.. | sx ..' it asked for, and makes them equal
|
|
''|.......''' where the speeds are.
|
|
|
|
|
dotted ellipse: the cap solid box: the old per-axis clamp
|
|
```
|
|
|
|
```c excerpt=src/physics.c
|
|
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;
|
|
}
|
|
```
|
|
|
|
An axis whose top speed is zero cannot be thrust along at all — that is what the old
|
|
per-axis clamp did with it — and it stays out of the magnitude entirely, because dividing
|
|
by it would not end well.
|
|
|
|
**Only x and y ever accumulate thrust.** `akgl_physics_simulate` reads the
|
|
`AKGL_ACTOR_STATE_MOVING_LEFT`/`RIGHT` bits into `tx` and `MOVING_UP`/`DOWN` into `ty`.
|
|
`AKGL_ACTOR_STATE_MOVING_IN` and `MOVING_OUT` exist and nothing reads them, so `tz` is only
|
|
ever zeroed or scaled — the z axis has gravity, drag and movement but **no thrust an actor
|
|
can apply**. Set `tz` yourself from a `movementlogicfunc` if you need depth motion.
|
|
|
|
## One step, in order
|
|
|
|
`akgl_physics_simulate` is shared by both backends; what differs is the `gravity` and
|
|
`move` they point at. It walks the whole actor pool in index order, and per actor:
|
|
|
|
```text
|
|
refcount == 0 ? ------------------------------> skip (free pool slot)
|
|
parent != NULL ? -> x,y,z = parent's + own v, -> skip (never simulates)
|
|
basechar == NULL ? ------------------------------> skip (no speeds)
|
|
LAYERMASK set and actor->layer != layerid ? -------> skip
|
|
|
|
tx += ax * dt if MOVING_LEFT or MOVING_RIGHT
|
|
ty += ay * dt if MOVING_UP or MOVING_DOWN
|
|
scale (tx,ty,tz) to the (sx,sy,sz) ellipse if it is outside
|
|
|
|
+- ATTEMPT ----------------------------------------------------------+
|
|
| actor->movementlogicfunc(actor, dt) |
|
|
| AKGL_ERR_LOGICINTERRUPT raised here is swallowed: the actor |
|
|
| is skipped for the rest of the tick and the loop continues |
|
|
| self->gravity(self, actor, dt) |
|
|
| ex -= ex * drag_x * dt (and y, z, each only if drag is nonzero) |
|
|
| vx = ex + tx (and y, z) |
|
|
| self->move(self, actor, dt) |
|
|
+--------------------------------------------------------------------+
|
|
|
|
after the loop: self->gravity_time = curtime
|
|
```
|
|
|
|
**A child actor is snapped and skipped.** An actor with a `parent` has its position set to
|
|
the parent's plus its own `vx`/`vy`/`vz`, read as a fixed offset rather than as a velocity,
|
|
and then it is skipped entirely. Children do not simulate. That is the mechanism a party
|
|
member or a held item is built on — see [Chapter 12](12-actors.md).
|
|
|
|
**The thrust accumulation happens *before* `movementlogicfunc`, which is what sets `ax` and
|
|
`sx`.** So the `ax` and `sx` a step uses are the ones the *previous* step's movement logic
|
|
installed, and an actor's very first step sees `sx == 0` and has its thrust zeroed. The
|
|
header's summary — "movement logic, then gravity, then drag, then velocity, then move" —
|
|
leaves that out. It is one frame of lag at the start of a movement and no player will see
|
|
it, but it is the reason a unit test that calls `simulate` once and checks `tx` gets zero.
|
|
|
|
**`gravity_time` is stamped after the loop, not before it.** A `gravity` or `move` that
|
|
raises returns straight out of `akgl_physics_simulate` without stamping, so the *next*
|
|
step measures `dt` from the older epoch and takes a double-length step — bounded by
|
|
`max_timestep`, but still a visible jump. Treat a raised status out of `simulate` as
|
|
needing a re-stamp of `gravity_time` before you continue.
|
|
|
|
## `dt` is not yours to supply
|
|
|
|
`akgl_physics_simulate` takes no timestep. It calls `SDL_GetTicksNS()` itself and measures
|
|
against `self->gravity_time`, then bounds the result:
|
|
|
|
```text
|
|
dt = (SDL_GetTicksNS() - self->gravity_time) / 1e9
|
|
if ( max_timestep > 0 and dt > max_timestep ) dt = max_timestep
|
|
if ( dt < 0 ) dt = 0
|
|
```
|
|
|
|
`max_timestep` comes from the `physics.max_timestep` property and defaults to
|
|
`AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP` — a twentieth of a second, three frames at 60 Hz, so an
|
|
ordinary dropped frame passes through untouched and a level load does not.
|
|
|
|
**That bound is not a nicety.** Everything below it 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 under platformer gravity is a hundred pixels of fall between the first
|
|
frame and the second, straight through whatever was underneath. That was a live defect
|
|
until 0.6.0: `gravity_time` was never seeded, so the first step measured the entire time
|
|
since SDL started. Both initializers seed it now, and the bound catches the rest. Advancing
|
|
the world in slow motion through a hitch is the trade every engine makes 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.
|
|
|
|
### Stepping deterministically in a test
|
|
|
|
Because `dt` is measured rather than passed, **there is no way to step the engine by an
|
|
exact interval — but there is a way to step it by an exact bound.** `tests/physics_sim.c`
|
|
documents the only technique that holds up under load:
|
|
|
|
```text
|
|
sim_physics.max_timestep = dt; /* the step you want */
|
|
sim_physics.gravity_time = 0; /* measured interval = whole process uptime */
|
|
sim_physics.simulate(&sim_physics, NULL);
|
|
```
|
|
|
|
Zeroing `gravity_time` makes the measured interval the process's whole uptime, which is
|
|
always past the bound, so the step taken is `max_timestep` and nothing else. The scheduler
|
|
cannot get in the way of that. The first version of that helper set `gravity_time` to
|
|
`now - dt` and let the real clock supply the step; a machine busy enough to deschedule the
|
|
process between that store and the `SDL_GetTicksNS()` inside `simulate` produced a longer
|
|
step and a red suite, exactly once, under a parallel `ctest`. **That the engine cannot be
|
|
stepped exactly is itself a finding**, recorded in `TODO.md` under "Arcade physics feel".
|
|
|
|
Two more rules from that suite, both learned the hard way. `main` must call `SDL_Init` —
|
|
without it SDL sets its clock epoch on first use, `SDL_GetTicksNS()` returns something
|
|
around 130 ns, and every `dt`-dependent assertion passes for a reason unrelated to the
|
|
code. And read the numbers each simulation prints even when it is green: a change that
|
|
keeps every assertion passing while moving the apex of a jump by 30 px is a change worth
|
|
noticing.
|
|
|
|
## Choosing a backend
|
|
|
|
```c excerpt=src/physics.c
|
|
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);
|
|
```
|
|
|
|
**The match is on leading characters.** `"nullify"` selects `null` and `"arcadia"` selects
|
|
`arcade`; anything else raises `AKERR_KEY` quoting what was asked for. Adding a backend
|
|
means adding a name here, not a branch in the simulation.
|
|
|
|
**`akgl_game_init` does not call the factory, and there is no `physics.engine` property.**
|
|
The `@file` block and the `akgl_physics_factory` doc comment in `include/akgl/physics.h`
|
|
both say otherwise. They are wrong: `akgl_game_init` points `akgl_physics` at
|
|
`akgl_default_physics` — zeroed storage whose four method pointers are all `NULL` — and
|
|
never initializes it, and the string `physics.engine` appears nowhere in `src/`. The
|
|
startup sequence in `include/akgl/game.h` has it right: **the application calls an
|
|
initializer or the factory itself**, at step 4, after the configuration properties are set
|
|
and before the first frame. Skip that and the first `akgl_game_update` calls through a
|
|
`NULL` `simulate`.
|
|
|
|
The only caller of `akgl_physics_factory` inside the library is
|
|
`akgl_tilemap_load_physics`, driven by a map's `physics.model` custom property — see
|
|
[Chapter 13](13-tilemaps.md). `util/charviewer.c` shows the direct form,
|
|
`akgl_physics_init_null(akgl_physics)`. Correcting the header comments is filed separately;
|
|
until then, believe `game.h` and this chapter.
|
|
|
|
```c
|
|
#include <akgl/error.h>
|
|
#include <akgl/game.h>
|
|
#include <akgl/physics.h>
|
|
#include <akgl/registry.h>
|
|
|
|
/*
|
|
* Bring up the arcade backend. akgl_game_init does NOT do this: it points
|
|
* akgl_physics at akgl_default_physics, which is zeroed storage whose method
|
|
* pointers are all NULL. Call an initializer, or the first akgl_game_update
|
|
* calls through a NULL simulate.
|
|
*
|
|
* Properties first, because akgl_physics_init_arcade reads them.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *bring_up_physics(void)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
|
|
PASS(errctx, akgl_set_property("physics.gravity.y", "900.0"));
|
|
/* There is no terminal-velocity setting. Drag is the mechanism: ey
|
|
* approaches gravity_y / drag_y, so 900 / 1.8 caps the fall at 500 px/s. */
|
|
PASS(errctx, akgl_set_property("physics.drag.y", "1.8"));
|
|
|
|
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
```
|
|
|
|
`akgl_physics_init_arcade` reads seven properties — `physics.gravity.x`/`.y`/`.z`,
|
|
`physics.drag.x`/`.y`/`.z` and `physics.max_timestep` — all with defaults, so an arcade
|
|
backend with no configuration behaves like the null one until something is set. **Set the
|
|
properties first.** With `AKGL_REGISTRY_PROPERTIES` uninitialized every read comes back as
|
|
its default and this silently configures zero gravity and zero drag rather than reporting
|
|
anything; see [Chapter 6](06-the-registry.md).
|
|
|
|
The gravity signs are chosen for screen space: x pulls toward screen left, y pulls down
|
|
because y grows downward, z pulls away from the camera.
|
|
|
|
## `AKGL_ERR_LOGICINTERRUPT`
|
|
|
|
The one status in libakgl that is not a failure. Raised from an actor's
|
|
`movementlogicfunc`, it means **"skip the rest of this tick for me"** — no gravity, no drag,
|
|
no move for that actor this step — and `akgl_physics_simulate` handles it and carries on to
|
|
the next actor. It is a control signal wearing an error's clothes.
|
|
|
|
**Only `movementlogicfunc` gets that treatment.** The `gravity` and `move` calls return
|
|
straight out of the loop on any status, `AKGL_ERR_LOGICINTERRUPT` included, so a backend
|
|
must never use it to opt an actor out from there — there it aborts the whole step, leaving
|
|
the actors already processed advanced and the rest not.
|
|
|
|
```c
|
|
#include <akgl/actor.h>
|
|
#include <akgl/error.h>
|
|
#include <akgl/physics.h>
|
|
|
|
/*
|
|
* A movement logic function that pins an actor to a floor at y = 400.
|
|
*
|
|
* With no collision world attached the backend consults nothing, so standing
|
|
* on something is the caller's job. Doing it here rather than after the step is
|
|
* what keeps the actor from being drawn inside the floor for one frame. A game
|
|
* that attaches a world (Chapter 15) deletes this function instead of writing
|
|
* it.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *stand_on_floor(akgl_Actor *obj, float32_t dt)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
|
|
/* The default logic still has to run: it is what copies the character's
|
|
* speeds and accelerations onto the actor. */
|
|
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
|
|
|
if ( obj->y >= 400.0f ) {
|
|
obj->y = 400.0f;
|
|
/* ey is where gravity accumulates. Zero it, or the actor keeps
|
|
* "falling" into the floor and cannot jump out of it. */
|
|
obj->ey = 0.0f;
|
|
}
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/* Raising this from movement logic means "skip the rest of this tick for me".
|
|
* akgl_physics_simulate handles it and moves on to the next actor; the actor
|
|
* gets no gravity, no drag and no move this step. */
|
|
akerr_ErrorContext AKERR_NOIGNORE *frozen_in_cutscene(akgl_Actor *obj, float32_t dt)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
(void)dt;
|
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "cutscene");
|
|
}
|
|
```
|
|
|
|
## What is not implemented
|
|
|
|
Stated plainly, because all of it is reachable by an ordinary-looking call and none of it
|
|
announces itself at compile time.
|
|
|
|
**Collision is [Chapter 15](15-collision.md), and it is opt-in.** A backend with no
|
|
collision world attached does exactly what it did before collision existed: `move` is
|
|
`position += velocity * dt` and consults nothing. Attach a world and the step resolves after
|
|
every sub-move.
|
|
|
|
What remains missing here is the *feel*, below, and that is a different list from the one
|
|
this section used to carry.
|
|
|
|
### Four gaps in the feel
|
|
|
|
Found by `tests/physics_sim.c`, which runs the arcade backend the way a game does — a
|
|
Mario-esque jump and fall, Zelda-style top-down walking, and a run reversed at full speed —
|
|
and prints what the actor actually did. Three other defects that suite found *are* fixed in
|
|
0.6.0. These four are not. All are in `TODO.md`, "Arcade physics feel".
|
|
|
|
| Gap | What you see | The workaround today |
|
|
|---|---|---|
|
|
| **No terminal velocity** | Gravity adds `gravity_y * dt` to `ey` every step and nothing bounds it. The jump simulation reaches 560 px/s in 0.7 s and would keep going | `physics.drag.y`. It makes `ey` approach `gravity_y / drag_y` instead of diverging. That is the only brake, and it is not documented as one anywhere else |
|
|
| **Releasing a direction stops the actor dead** | `akgl_actor_cmhf_*_off` sets `tx` (or `ty`) to zero, so a character at full speed stops within one frame — 0.0 px of drift measured in the second after release | Correct for Zelda, wrong for Mario. There is no friction or deceleration anywhere in the backend; `ax` is the only rate and it applies only while a direction is held. Decay `tx` yourself in `movementlogicfunc` |
|
|
| **Explicit Euler, so trajectories are frame-rate dependent** | The same jump traces a slightly different arc at 30 Hz than at 144 Hz. The reversal measures 0.500 s to turn around where the closed form predicts 0.489 s — about 2% at 60 Hz, growing with the step | Nothing. 2% is not a bug a player can see. It is recorded because the fix (a fixed-step accumulator) is the same piece of work as removing the slow-motion trade `max_timestep` makes |
|
|
| **A large drag coefficient inverts velocity** | `ex -= ex * drag_x * dt` is a first-order decay, so it only decays for `drag * dt < 1`. Past that it overshoots zero; past `2` it diverges. **Nothing rejects it** | With `dt` bounded to the default 0.05 s that needs a drag above 20, which is not a plausible setting. But `max_timestep` is caller-settable, so keep `drag * max_timestep` below 1 |
|
|
|
|
The `_off` handlers used to be worse: they zeroed `ay`, `ey`, `ty` and `vy` together, and
|
|
`ey` is where gravity accumulates, so tapping down mid-jump stopped the character in the
|
|
air. They clear `ax`/`tx` (or `ay`/`ty`) and nothing else now. Velocity was never theirs to
|
|
clear either — `simulate` recomputes `v` as `e + t` every step.
|
|
|
|
## Statuses
|
|
|
|
`akgl_physics_simulate` propagates whatever an actor's `movementlogicfunc`, or the
|
|
backend's `gravity`, `move` or `collide`, raises, and **the first failure aborts the whole
|
|
step** —
|
|
the actors already processed are advanced and the rest are not. The status meanings are in
|
|
[Chapter 4](04-errors.md); the ones you will meet here are `AKERR_NULLPOINTER` (a `NULL`
|
|
`self` or `self->move`), `AKERR_VALUE` and `ERANGE` (a
|
|
`physics.*` property that is not a number, or does not fit a `double`), and
|
|
`AKGL_ERR_LOGICINTERRUPT`, which is not a failure.
|