From 986c80d0ecde7d003d458ca24881ce743f120669 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 07:53:30 -0400 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- CMakeLists.txt | 2 +- PERFORMANCE.md | 24 +++++++ TODO.md | 126 +++++++++++++++++++++++++++++++---- docs/01-introduction.md | 24 ++++--- docs/02-design-philosophy.md | 2 +- docs/04-errors.md | 2 +- tests/perf.c | 61 +++++++++++++++++ 7 files changed, 217 insertions(+), 24 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9bba9c0..3a86081 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10) # The single source of truth for the library version. It drives the generated # include/akgl/version.h, the shared library's VERSION and SOVERSION, and the # Version field in akgl.pc. Bump it here and nowhere else. -project(akgl VERSION 0.7.0 LANGUAGES C) +project(akgl VERSION 0.8.0 LANGUAGES C) # Memory checking reuses the suites that already exist -- `ctest -T memcheck` # runs every registered test under valgrind -- rather than adding programs of its diff --git a/PERFORMANCE.md b/PERFORMANCE.md index e1f148a..78e76a0 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -317,6 +317,7 @@ Added in 0.8.0. Every row below is from one run, on the machine described above. | grid query, 64 actors | query | 43.2 | 23,155,384 | | bsp query, 64 actors | query | 113.6 | 8,802,193 | | tile query, actor standing on a floor | query | 15.6 | 64,142,653 | +| **a whole physics step with collision, 64 actors** | frame | 15,420.1 | 64,851 | Three of those are worth reading rather than skimming. @@ -335,6 +336,29 @@ in its row, so the gap in a scene that is actually moving is wider than 2.6x. one number: an actor walking across a tile pays that on most frames and pays the re-cell only when it crosses a boundary. +#### The whole step, which is the number that matters + +**15.4 µs for 64 actors, of which 1.6 µs is the step without collision.** So +collision costs about 13.8 µs a frame at the default pool size — the proxy sync +pass, the broad phase, the narrowphase over whatever it returns, and the response +— which is **0.08% of a 16.67 ms frame**. The all-pairs control over the same +population is 12.1 µs, and it answers a strictly smaller question: it tests pairs +and does not sync, sub-step or resolve anything. + +At `-DAKGL_MAX_HEAP_ACTOR=256`, a separate build measured on the same machine: + +| Operation | Unit | ns per unit | +|---|---|---:| +| a whole physics step with collision, 256 actors | frame | 54,073.3 | +| all-pairs control over the same 256 actors (32,640 pairs) | sweep | 188,530.6 | + +**54.1 µs against Target 12's 2 ms budget**, with the all-pairs control 3.5x more +expensive while still doing less. Population went up 4x and the step went up 3.5x, +which is the grid doing its job: the all-pairs sweep went up 15.6x over the same +range, as `n²` says it must. That measurement is from a `AKGL_BENCH_SCALE=0.3` +run of a purpose-built configuration and is not part of the CI baseline; the 64 +row above is. + ### Two rows moved, and not because collision is running `akgl_physics_simulate` over 64 live actors went from 1,216.8 ns to 1,588.5, and diff --git a/TODO.md b/TODO.md index 5672e3a..ae792f1 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/docs/01-introduction.md b/docs/01-introduction.md index 0d8cdb1..6bb6081 100644 --- a/docs/01-introduction.md +++ b/docs/01-introduction.md @@ -1,9 +1,13 @@ # 01. Introduction -libakgl is a C library for building 2D games on SDL3. This is version **0.7.0**. It ships -156 functions declared across the twenty public headers in `include/akgl/`, plus -`akgl_version()` from the generated `version.h` — 157 exported symbols in -`libakgl.so.0.7`. +libakgl is a C library for building 2D games on SDL3. This is version **0.8.0**. It ships +195 functions declared across the twenty-three public headers in `include/akgl/`, plus +`akgl_version()` from the generated `version.h` — 196 exported `akgl_` symbols in +`libakgl.so.0.8`. + +**0.8.0 is an ABI break.** The `collide` slot on `akgl_PhysicsBackend` changed signature, +four public structs grew, and `akgl_physics_arcade_collide` stopped raising `AKERR_API`. +Collision is the reason; it is [Chapter 15](15-collision.md). It gives you object pools instead of `malloc`, name-based registries instead of pointer plumbing, a per-frame tick that updates and draws every live actor, JSON asset formats for @@ -21,11 +25,12 @@ The refusals are specific, and each one is a design decision documented in | Not here | Instead | |---|---| -| Inheritance, RTTI, dynamic dispatch on a type tag | A record of function pointers you populate — `akgl_RenderBackend`, `akgl_PhysicsBackend` | -| Runtime `malloc` | Five statically sized pools with reference counts; `AKGL_ERR_HEAP` when one is full | +| Inheritance, RTTI, dynamic dispatch on a type tag | A record of function pointers you populate — `akgl_RenderBackend`, `akgl_PhysicsBackend`, `akgl_Partitioner` | +| Runtime `malloc` | Eight statically sized pools with reference counts, plus a static arena for the narrowphase; `AKGL_ERR_HEAP` when a pool is full | | An editor or a project format | Tiled TMJ maps and hand-writable JSON for sprites and characters | | A scripting layer | C. Behaviour attaches as function pointers on `akgl_Actor` | | Two worlds at once | Four swappable globals: `akgl_renderer`, `akgl_physics`, `akgl_camera`, `akgl_gamemap` | +| Collision you cannot switch off | A `collision` pointer on the backend. `NULL` is byte-identical to a build before collision existed | The library also does not own your window. `akgl_render_2d_init` creates one for you, and `akgl_render_2d_bind` is the same job with that half removed, for a host that already has an @@ -117,9 +122,10 @@ text you read *is* the header. ## Reading order -[Chapter 4](04-errors.md) comes before every subsystem chapter, because all 156 functions -return `akerr_ErrorContext AKERR_NOIGNORE *` and you cannot read a single example until you -can read that return value. After that, [Chapter 3](03-getting-started.md) gets a window on +[Chapter 4](04-errors.md) comes before every subsystem chapter, because 183 of those 195 +functions return `akerr_ErrorContext AKERR_NOIGNORE *` and you cannot read a single example +until you can read that return value. The dozen that do not are the `void` SDL enumeration +callbacks and the collision arena's accessors. After that, [Chapter 3](03-getting-started.md) gets a window on the screen, and the subsystem chapters can be read in any order. If you would rather start by building something, the two tutorials — diff --git a/docs/02-design-philosophy.md b/docs/02-design-philosophy.md index d3902bb..58e09c5 100644 --- a/docs/02-design-philosophy.md +++ b/docs/02-design-philosophy.md @@ -214,7 +214,7 @@ in the tree negated it, which is the only reason that was latent rather than liv ## Errors carry context, and callers cannot ignore them -**Every one of libakgl's 156 functions returns `akerr_ErrorContext AKERR_NOIGNORE *`.** +**183 of libakgl's 195 functions return `akerr_ErrorContext AKERR_NOIGNORE *`.** Results come back through pointer parameters; the return value is always the error. The `AKERR_NOIGNORE` attribute makes discarding it a compiler diagnostic. diff --git a/docs/04-errors.md b/docs/04-errors.md index e46311f..cd434ae 100644 --- a/docs/04-errors.md +++ b/docs/04-errors.md @@ -14,7 +14,7 @@ This manual does not restate them, because a copy here would be wrong the day li changes and nothing in this repository would notice. What is genuinely libakgl's, and is written down nowhere else, is **which statuses these -156 functions raise and what each one means here**. That is the three tables below. +195 functions raise and what each one means here**. That is the three tables below. The house rules for *writing* code against the protocol — never a `*_RETURN` inside an `ATTEMPT`, never a bare `return` out of a `HANDLE`, `CLEANUP` before `PROCESS`, an diff --git a/tests/perf.c b/tests/perf.c index b585760..29907c0 100644 --- a/tests/perf.c +++ b/tests/perf.c @@ -643,6 +643,66 @@ static akerr_ErrorContext *bench_physics_simulate(void) SUCCEED_RETURN(errctx); } +/** + * @brief Time a whole physics step with collision attached, over a full pool. + * + * **This is the number Target 12 is about**: collision for a full actor pool + * without the caller writing a broad phase. It is the whole step -- the proxy + * sync pass, the movement logic, gravity, drag, the sub-stepped move, and + * akgl_collision_resolve per actor per sub-step -- against the same pool the + * row above measures without a world, so the two subtract. + * + * The actors are spread on a 24-pixel lattice with 16-pixel boxes, so neighbours + * are close enough to share grid cells and be tested against each other and far + * enough apart that most of those tests answer "no". A population that all + * overlaps measures the resolver; this measures a frame. + */ +static akerr_ErrorContext *bench_physics_collision(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_Character *basechar = NULL; + akgl_CollisionWorld world; + akgl_PhysicsBackend backend; + SDL_FRect body = { .x = -8.0, .y = -8.0, .w = 16.0, .h = 16.0 }; + char label[64]; + int count = bench_iterations(2000); + int i = 0; + int rep = 0; + + PASS(errctx, akgl_heap_init()); + PASS(errctx, akgl_registry_init()); + PASS(errctx, akgl_registry_init_properties()); + PASS(errctx, bench_make_character(&basechar, "benchcollisionchar", 0)); + + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + PASS(errctx, akgl_physics_init_arcade(&backend)); + backend.gravity_y = 9.8; + backend.drag_x = 0.1; + backend.drag_y = 0.1; + + PASS(errctx, bench_fill_actor_pool(basechar, BENCH_ACTOR_COUNT)); + for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) { + PASS(errctx, akgl_collision_shape_box(&akgl_heap_actors[i].shape, &body, 0.0)); + akgl_heap_actors[i].shape_override = true; + akgl_heap_actors[i].shape.collidemask |= AKGL_COLLISION_LAYER_ACTOR; + akgl_heap_actors[i].x = (float32_t)((i % 20) * 24); + akgl_heap_actors[i].y = (float32_t)((i / 20) * 24); + } + PASS(errctx, akgl_collision_world_init(&world, "grid", 16.0, 16.0)); + backend.collision = &world; + + snprintf((char *)&label, sizeof(label), + "physics_simulate + collision, %d live actors", BENCH_ACTOR_COUNT); + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start((char *)&label, "frame", 2000000.0); + BENCH_LOOP(inner, i, count, backend.simulate(&backend, NULL)); + bench_stop(count); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + /** * @brief Time the logic half of a frame: update every actor, then simulate. * @@ -1044,6 +1104,7 @@ int main(void) CATCH(errctx, bench_character_sprite_get()); CATCH(errctx, bench_actor_update()); CATCH(errctx, bench_physics_simulate()); + CATCH(errctx, bench_physics_collision()); CATCH(errctx, bench_logic_frame()); CATCH(errctx, bench_geometry()); CATCH(errctx, bench_collision());