249 lines
12 KiB
Markdown
249 lines
12 KiB
Markdown
|
|
# 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.
|