Document collision, and correct what it made false elsewhere
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
This commit is contained in:
248
docs/15-collision.md
Normal file
248
docs/15-collision.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# 15. Collision
|
||||
|
||||
Collision is **opt-in**. A physics backend with no collision world attached behaves exactly
|
||||
as one did before any of this existed and costs one comparison per actor per step, so a game
|
||||
that does not want it pays nothing and a game written before it existed keeps working.
|
||||
|
||||
Turning it on is three things: a world, a shape on anything that should collide, and — for
|
||||
map geometry — a `collidable` property on the tile layers that are solid.
|
||||
|
||||
```c
|
||||
#include <akgl/collision.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/physics.h>
|
||||
|
||||
static akgl_CollisionWorld world;
|
||||
|
||||
/* After the map is loaded and the physics backend is initialized. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *turn_collision_on(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akgl_collision_world_init(&world, NULL, 16.0f, 16.0f));
|
||||
PASS(errctx, akgl_collision_bind_tilemap(&world, akgl_gamemap));
|
||||
akgl_physics->collision = &world;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`NULL` for the partitioner name means the default, which is the one to use. Binding a map
|
||||
takes the cell size from its tiles, so the two arguments above are overwritten immediately
|
||||
— they matter only for a world with no map.
|
||||
|
||||
## Where it runs in the step
|
||||
|
||||
Collision resolves **after** the move, not before it.
|
||||
|
||||
```text
|
||||
movementlogicfunc
|
||||
v
|
||||
gravity
|
||||
v
|
||||
drag
|
||||
v
|
||||
v = e + t
|
||||
v
|
||||
+--------------------------------+
|
||||
| move(subdt) | x substeps
|
||||
| resolve(subdt) |
|
||||
+--------------------------------+
|
||||
```
|
||||
|
||||
That ordering is the whole reason a game can stop duplicating the integrator. 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 arithmetic above, in
|
||||
the game, and being wrong whenever that arithmetic changes.
|
||||
|
||||
**Only `move` is subdivided.** Gravity, drag, the thrust integration and the speed ellipse
|
||||
all still run once against the whole `dt`, and the sub-steps sum to `dt`. An actor with
|
||||
nothing to collide with takes exactly one sub-step of exactly `dt`.
|
||||
|
||||
## Shapes
|
||||
|
||||
A shape is a convex volume positioned relative to an actor's origin. It lives on the
|
||||
[character](11-characters.md), so every goblin sharing a character shares one definition —
|
||||
and an individual actor can override it.
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/collision.h>
|
||||
|
||||
/* A hitbox inset into a 32x32 sprite frame, because art does not reach the
|
||||
* edges of its cell and a full-frame box catches on doorways the character
|
||||
* visibly clears. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *give_player_a_body(akgl_Actor *player)
|
||||
{
|
||||
SDL_FRect body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player");
|
||||
|
||||
PASS(errctx, akgl_collision_shape_box(&player->shape, &body, 0.0f));
|
||||
player->shape_override = true;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`shape_override` is not redundant with an empty shape. "This actor deliberately has no
|
||||
collider" and "this actor has not been configured yet" are different states, and without
|
||||
somewhere to record the difference `akgl_actor_set_character` cannot tell whether it is
|
||||
allowed to overwrite what it finds.
|
||||
|
||||
Three kinds: box, circle, and a capsule on either axis. A box against a box is answered in
|
||||
closed form, which is both cheaper and *exact* — an actor resting on a floor gets a normal of
|
||||
precisely `(0, -1, 0)` rather than one converged to a tolerance, and the difference between
|
||||
those accumulates into a slow sideways creep.
|
||||
|
||||
### Why a 2D shape has a depth
|
||||
|
||||
The narrowphase is three-dimensional, which is what lets these shapes carry into a 3D game
|
||||
later. It answers with the **minimum** translation that separates two volumes — and if a 2D
|
||||
shape were extruded into a thin slab, the cheapest way to separate two of them would be
|
||||
along z. The narrowphase would report a contact, the resolver would push the actor into the
|
||||
screen, and **on screen nothing would happen at all while the actor sank through the floor.**
|
||||
|
||||
So the setters give every shape a depth of `AKGL_COLLISION_DEPTH_RATIO` times its largest
|
||||
planar half-extent unless told otherwise. That is not a comfortable-looking number; it is the
|
||||
smallest ratio for which the z overlap of any two shapes built this way provably exceeds any
|
||||
planar penetration they can reach. Pass a depth by hand only if you know what that costs, and
|
||||
leave `AKGL_COLLISION_TEST_PLANAR` on if you do.
|
||||
|
||||
## Masks: what collides with what
|
||||
|
||||
Every shape carries two words, and they are asymmetric on purpose.
|
||||
|
||||
| Field | Means |
|
||||
|---|---|
|
||||
| `layermask` | which layers this shape *is on* — what others test against |
|
||||
| `collidemask` | which layers this shape *responds to* |
|
||||
|
||||
`a` is resolved against `b` when `b->layermask & a->collidemask`. The reverse is a separate
|
||||
question with its own answer, which is how a player blocks against a pushable crate while the
|
||||
crate ignores everything, and how a bullet collides with a wall while the wall ignores the
|
||||
bullet.
|
||||
|
||||
**The defaults matter more than the mechanism.** A new shape is on
|
||||
`AKGL_COLLISION_LAYER_ACTOR` and responds to `AKGL_COLLISION_LAYER_STATIC` — so **an actor
|
||||
given a shape and nothing else collides with map geometry and with no other actor.**
|
||||
"Everything with a hitbox shoves everything else" is a surprising default for a town full of
|
||||
scenery and a painful one to discover after the fact. Opting in to actor-versus-actor is one
|
||||
added bit:
|
||||
|
||||
```c norun
|
||||
player->shape.collidemask |= AKGL_COLLISION_LAYER_ENEMY | AKGL_COLLISION_LAYER_PICKUP;
|
||||
```
|
||||
|
||||
## Tiles
|
||||
|
||||
A tile layer is solid when it carries a `collidable` boolean custom property in Tiled. The
|
||||
map declares what is solid; before this a game had to hard-code a layer index, and
|
||||
`akgl_TilemapLayer` does not retain the name Tiled wrote, so that index broke the moment
|
||||
somebody reordered layers in the editor.
|
||||
|
||||
Solid tiles are **not** given proxies. At the maximum map size that would be a quarter of a
|
||||
million per layer — tens of megabytes of index describing data that is already a dense grid
|
||||
sitting in the tilemap. The world keeps a borrowed pointer and reads `layers[i].data[]`
|
||||
directly over whatever cell range a query covers: nine array reads for a 32-pixel actor on
|
||||
16-pixel tiles, nothing built at level load, nothing maintained per frame.
|
||||
|
||||
Static geometry that is *not* tile-aligned — a slope, a Tiled object rectangle, a platform
|
||||
that only moves between levels — is still an ordinary proxy carrying
|
||||
`AKGL_COLLISION_FLAG_STATIC`. Both mechanisms exist; tiles use the free one because there are
|
||||
a hundred thousand of them.
|
||||
|
||||
## Responding to a contact
|
||||
|
||||
Every actor gets a seventh behaviour hook, `collidefunc`, defaulting to
|
||||
`akgl_actor_collide_block`. Contacts arrive one actor at a time, with the normal already
|
||||
pointing the way *that* actor has to move — so a game overriding one actor's response never
|
||||
has to reason about the other's.
|
||||
|
||||
The default pushes the actor out along the normal and removes the component of its motion
|
||||
going into the surface. Three details of that are worth knowing, because each is a way the
|
||||
same function is easy to write wrongly:
|
||||
|
||||
- **It writes `e` and `t`, never `v`.** The step recomputes `v = e + t` at the top of every
|
||||
frame, so a write to `v` is discarded before anything reads it. An actor holding a
|
||||
direction into a wall would otherwise accumulate thrust while standing still and leave at
|
||||
speed the instant the wall ended.
|
||||
- **It removes the component, not the axis.** Zeroing the whole thrust vector costs the
|
||||
motion *along* the surface too. Sliding along a wall is the difference between a wall and
|
||||
glue.
|
||||
- **A separating contact is left alone.** An actor already moving out of a surface whose
|
||||
velocity gets zeroed is an actor stuck to a wall it is walking away from, and it reads to a
|
||||
player as the collision grabbing them.
|
||||
|
||||
A contact carries the global tile id, layer and cell when the other side was a tile, which is
|
||||
what tells a spike from a floor without a second lookup.
|
||||
|
||||
### Sensors
|
||||
|
||||
A shape flagged `AKGL_COLLISION_FLAG_SENSOR` reports a contact and never pushes. Coins,
|
||||
trigger volumes, damage zones. The default response returns having changed nothing — walking
|
||||
through a coin is not being blocked by it.
|
||||
|
||||
## Asking without being pushed
|
||||
|
||||
Four queries answer a question and resolve nothing. They are what a game reaches for when it
|
||||
wants to *know*: a ledge probe ahead of a walking enemy, a check that a doorway is clear, a
|
||||
spawn point that needs validating.
|
||||
|
||||
| Function | Answers |
|
||||
|---|---|
|
||||
| `akgl_collision_solid_at` | is the tile under this point solid |
|
||||
| `akgl_collision_box_blocked` | would this rectangle overlap anything solid, tiles and proxies both |
|
||||
| `akgl_collision_query_box` | visit every proxy that may overlap this rectangle |
|
||||
| `akgl_collision_settle` | lift a shape out of geometry it was placed inside |
|
||||
|
||||
`akgl_collision_settle` deserves the explanation. Resolution stops a shape *entering*
|
||||
geometry and has nothing to say about one that began inside it — what it does instead is
|
||||
refuse every move, so an actor spawned in a wall is simply stuck. Level authors produce that
|
||||
constantly, because an editor rounds an object onto a step. Settling walks the shape up a
|
||||
tile at a time and refuses loudly rather than searching forever.
|
||||
|
||||
## Partitioners
|
||||
|
||||
The broad phase is pluggable, the same way the renderer and the physics backend are: a record
|
||||
of function pointers plus an initializer.
|
||||
|
||||
**Use the grid.** It is the default, and `PERFORMANCE.md` measures it at 2.6 times faster
|
||||
than the tree on the same population — before counting that the tree also rebuilds whenever
|
||||
anything moves. A grid `move` for a proxy that has not left its cells is 11.5 ns and touches
|
||||
nothing.
|
||||
|
||||
The BSP ships alongside it so that "pluggable" means something: a vtable with one
|
||||
implementation behind it has never been asked to be a vtable, and the partitioner suite runs
|
||||
its entire contract against every entry in a table. It would earn its place in a world with
|
||||
wildly non-uniform object sizes, or one larger than the grid's fixed cell array covers.
|
||||
|
||||
```c norun
|
||||
/* Only if you have measured your own scene and the tree wins. */
|
||||
akgl_collision_world_init(&world, "bsp", 16.0f, 16.0f);
|
||||
```
|
||||
|
||||
## What is not implemented
|
||||
|
||||
- **No rotation.** No actor carries an angle, `akgl_actor_render` hard-codes
|
||||
`SDL_FLIP_NONE`, and every shape is axis-aligned. The support functions are written so that
|
||||
adding it touches one static function in the narrowphase; see `TODO.md`.
|
||||
- **No restitution and no friction.** The default response blocks. Anything bouncier is your
|
||||
`collidefunc`.
|
||||
- **No continuous collision.** Sub-stepping bounds how far an actor travels between tests, and
|
||||
it is capped: above roughly `8 x 0.5 x cellsize` per step — about 1280 px/s on 16-pixel
|
||||
tiles at the default `max_timestep` — an actor can still pass through a wall. That is a
|
||||
projectile, not a walker. `AKGL_COLLISION_FLAG_BULLET` reserves the bit for the swept
|
||||
narrowphase that would fix it, and does nothing today.
|
||||
- **No concave shapes.** The narrowphase is convex-only, always. A concave collider is
|
||||
several convex ones.
|
||||
- **Resolution is pool-order dependent.** An actor low in the pool sees the world one
|
||||
sub-step stale. This is the arcade bargain, and it is the same one Construct and Phaser
|
||||
make.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 14](14-physics.md) — the step this runs inside, and the four gaps in the feel that
|
||||
collision does not close.
|
||||
- [Chapter 12](12-actors.md) — the other six behaviour hooks.
|
||||
- [Chapter 13](13-tilemaps.md) — the map, and what else its custom properties can say.
|
||||
- [Chapter 20](20-tutorial-sidescroller.md) — a game that uses all of this.
|
||||
Reference in New Issue
Block a user