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());