Bump to 0.8.0, close Target 12, and record what is still missing

Collision changed the collide slot's signature and grew four public structs, so
this is an ABI break and the soname moves to libakgl.so.0.8. The manual's counts
move with it: 195 functions across 23 headers, 183 of which return an error
context.

Target 12 -- "collision for 256 actors under 2 ms without the caller writing a
broad phase" -- is met. A new benchmark times the whole step with a world
attached, which is the number the target is actually about rather than a
narrowphase call in isolation: 15.4 us at 64 actors, and 54.1 us at 256 in a
purpose-built -DAKGL_MAX_HEAP_ACTOR=256 configuration. Over that 4x range the
step grew 3.5x while the all-pairs control grew 15.6x, which is the n-squared
the index exists to avoid. Plan item 7 is rewritten to record what shipped and
why both partitioners exist; the Construct and Phaser citations stay.

Three new TODO entries. Actor rotation, scoped to the five places it touches and
the one place it does not -- collision_support is written so rotating `dir` in
and the answer back is the whole narrowphase change, and rotational *response*
is explicitly a different piece of work. The swept narrowphase FLAG_BULLET
reserves, with the tunnelling arithmetic written out so a game can check its own
numbers against 1280 px/s. And the ccd arena being single-threaded and sized by
measurement at 7,264 bytes per GJK/EPA box pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
2026-08-02 07:53:30 -04:00
parent cbf7c1b6c2
commit 986c80d0ec
7 changed files with 217 additions and 24 deletions

126
TODO.md
View File

@@ -1179,7 +1179,7 @@ fit in 5% of a frame, leaving 95% for the pixels and the game's own logic. At
| 9 | Zero texture creation or destruction per frame in steady state | one create + one destroy per line of text per frame | **missed** |
| 10 | A handled, routine condition costs no more than twice the path that succeeds | 616.5 ns vs 68.4 ns — 9x | **missed** |
| 11 | 256 actors simulated, updated and made ready to draw in under 1 ms | ~22 µs at 64 actors (5.8 µs measured logic + estimated render bookkeeping); linear, so ~90 µs extrapolated | **met, untested at that size** |
| 12 | Collision for 256 actors under 2 ms without the caller writing a broad phase | no broad phase exists; the naive loop is 1.9 ms at 256 actors | **missed** |
| 12 | Collision for 256 actors under 2 ms without the caller writing a broad phase | 54.1 µs for a whole physics step with collision at `-DAKGL_MAX_HEAP_ACTOR=256` | **met** |
| 13 | Level load under 100 ms for a map with 8 tilesets, 4 layers and 64 actors | 11.9 ms for a 2x2 map with one tileset | **unknown at that size** |
| 14 | Fixed per-load overhead under 1% of a level load | 11.5% — zeroing 26 MB of `akgl_Tilemap` | **missed** |
| 15 | Static footprint under 4 MB in the default configuration | 28 MB, 94% of it one tilemap | **missed** |
@@ -1320,17 +1320,28 @@ engines spend the same frame**.
Invisible at 60 fps under the software renderer; measurable on a 2 ms GPU
frame. Counting test, same as item 5.
7. **Target 12 stays deferred; the design goes on record.** At the 64-actor
ceiling the all-pairs sweep is 0.7% of a frame and a spatial index is
solving a problem we do not have. When `AKGL_MAX_HEAP_ACTOR` rises, the
structure is the incremental uniform grid, not the tree: cells keyed on
tile size, static arrays, insert/remove as actors move, wired into the
`collide` slot of `akgl_PhysicsBackend` that the simulation loop does not
call yet (`include/akgl/physics.h:55`). Construct measured this design at
a 96% check reduction over brute force and rejected quadtrees as costlier
to maintain; Phaser's rebuild-the-RTree-every-frame approach carries a
documented ~5,000-body ceiling and is the counterexample. Citations in
`PERFORMANCE.md`.
7. **Target 12: done in 0.8.0, and the prediction held.** The structure is
the incremental uniform grid, not the tree — cells keyed on tile size,
static arrays, insert/remove as actors move, wired into the `collide` slot
of `akgl_PhysicsBackend` that the simulation loop now calls. Construct
measured that design at a 96% check reduction over brute force and rejected
quadtrees as costlier to maintain; Phaser's rebuild-the-RTree-every-frame
approach carries a documented ~5,000-body ceiling and is the counterexample.
Citations stay in `PERFORMANCE.md`.
Measured here rather than cited: **the grid beats the BSP by 2.6x on the
same 64-proxy population** (44.3 ns against 116.1 ns per query), before
counting that the tree rebuilds whenever a proxy moves and the grid's `move`
is 11.9 ns when the cell rectangle has not changed. A whole physics step
with collision is 15.4 µs at 64 actors and 54.1 µs at 256, against a 2 ms
target. Over that 4x range the step grew 3.5x and the all-pairs control grew
15.6x, which is the `n²` the index exists to avoid.
**The BSP ships anyway.** A vtable with one implementation behind it has
never been asked to be a vtable; `tests/partition.c` runs its whole contract
against a table of both, so the seam is proven rather than asserted. It
would earn its place in a world with wildly non-uniform object sizes or one
larger than the grid's 128x128 cell array covers.
8. **Target 13 needs the fixture before it needs anything else.** A
128x128, four-layer, multi-tileset JSON map fixture, so
@@ -2415,3 +2426,94 @@ region authored as a concave Tiled polygon is the case, and tg answers it at
out. A vendored dependency with no caller is a question every future reader has
to answer, and 6 MB of it is cloned by every recursive checkout in CI. Closing
it means either writing the region-query API or `git rm` -- not leaving it here.
## Actor rotation, and collision under it
**No actor carries an angle.** `akgl_actor_render` hard-codes `SDL_FLIP_NONE`,
every collision shape is axis-aligned, and both example games depend on that
being true. Adding rotation is a small change in five named places and a large
change in one, and this entry exists so the large one is not a surprise.
What it touches, in the order it would have to be done:
1. **`akgl_Actor` grows an `angle`**, in radians, about z. Render consumes it:
`SDL_RenderTextureRotated` takes an angle in *degrees* and a centre, so
`akgl_actor_render` converts and picks a centre. The centre is a decision,
not a detail — a sprite rotating about its frame centre and one rotating
about its feet look nothing alike.
2. **`collision_support` rotates `dir` into local space** at the top and rotates
the answer back. That one function is written so this is *the whole
narrowphase change*: every arm of its switch already answers in the shape's
own frame, and the world centre is added by `collision_ccdobj` rather than
inside the switch. A `ccd_quat_t` on `collision_ccdobj`, one `ccdQuatRotVec`
in and one `ccdQuatRotVec` out, and MPR and GJK both work on rotated shapes
with nothing else touched. `collision_center` is unaffected — the centre of a
shape rotated about its own centre is the same point.
3. **The box fast path stops being valid for a rotated pair.** `collision_box_box`
is a closed form that assumes both boxes are axis-aligned; a rotated pair has
to fall through to MPR. That is a branch on `(a->angle != 0 || b->angle != 0)`
and a measurable cost — the fast path is 2-7x cheaper (`PERFORMANCE.md`,
"Collision, measured"), so a game that rotates everything pays for it.
4. **The broad-phase bound becomes the rotated bound.** `akgl_collision_shape_bounds`
returns the shape's own AABB; a rotated shape needs the AABB *of the rotated
shape*, which is larger. The function grows an angle parameter, and every
caller — `akgl_collision_settle`, the grid's `insert`/`move`, `ss_grounded` in
the sidescroller — passes one. A broad phase that keeps returning the
unrotated bound under-reports, which is the one failure mode the partitioner
contract forbids.
5. **`akgl_collision_proxy_sync` copies the angle** alongside the shape and the
position, since that is the one place the proxy's copy is refreshed.
**The z-extrusion invariant is unaffected.** Rotation is about z, so a shape's
half-extent along z does not change and `AKGL_COLLISION_DEPTH_RATIO` still makes
z overlap exceed any achievable planar penetration. See
[Chapter 15](docs/15-collision.md), "Why a 2D shape has a depth".
**What it does not touch:** the response. `akgl_actor_collide_block` works on a
normal and a velocity and does not care how the normal was found. Angular
velocity, torque and rotational response are a *different* piece of work and are
not implied by any of the above — a rotated shape that resolves linearly is a
perfectly coherent intermediate state, and it is the one a top-down shooter
actually wants.
## Swept narrowphase for fast movers
`AKGL_COLLISION_FLAG_BULLET` is defined, documented as reserved, and **does
nothing**. Sub-stepping bounds how far an actor travels between tests and it is
capped at `AKGL_COLLISION_MAX_SUBSTEPS` (8) sub-steps of at most
`AKGL_COLLISION_SUBSTEP_FRACTION` (0.5) of a cell, so the speed above which
tunnelling returns is:
```
v_max = substeps * fraction * cellsize / max_timestep
= 8 * 0.5 * 16 / 0.05
= 1280 px/s on 16-pixel tiles at the default max_timestep
```
Check your own numbers against that: a game with 8-pixel tiles halves it, and a
game that raises `physics.max_timestep` lowers it proportionally. A walking
character is nowhere near it; a projectile is a projectile.
The fix is a swept test rather than more sub-steps — sweeping the shape's AABB
over the sub-step's motion, testing that, and resolving at the time of first
contact rather than at the end position. It belongs behind the flag so it is
paid for only by the shapes that need it.
## The ccd arena is single-threaded and sized by measurement
`src/collision_arena.c` is one static arena of `AKGL_CCD_ARENA_BYTES` (64 KB)
with a bump pointer, reset at the top of every `akgl_collision_test`. Two things
follow that are true today and would not survive a change:
- **It is not thread-safe.** libakgl is single-threaded behind
`akgl_game.statelock`, and two threads in the narrowphase at once would
interleave allocations in the same arena. Threading libakgl means an arena per
thread, or a lock, before anything else.
- **The size is a measurement, not a guess.** One GJK/EPA box pair measures
7,264 bytes. Exhaustion is reported, not crashed: `ccdGJKPenetration` returns
-2 and `akgl_collision_test` raises `AKGL_ERR_COLLISION` with the high-water
mark in the message, so the failure names its own fix. MPR — the default on
the frame path — allocates nothing at all, so the arena is only under pressure
from a caller who has explicitly asked for EPA's manifold.
`akgl_ccd_arena_highwater()` is public so a game can assert its own ceiling.