diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 8276634..e1f148a 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -128,7 +128,7 @@ reciprocal. | `akgl_rectangle_points` | call | 4.0 | 248,412,026 | | `akgl_collide_rectangles`, overlapping | call | 6.1 | 164,345,776 | | `akgl_collide_rectangles`, disjoint | call | 6.1 | 164,423,708 | -| all-pairs collision sweep, 64 actors (2016 pairs) | sweep | 12,241.6 | 81,688 | +| control: all-pairs collision sweep, 64 actors (2016 pairs) | sweep | 11,769.1 | 84,968 | | `akgl_string_initialize` | call | 32.1 | 31,197,432 | | `akgl_string_copy`, full length | call | 32.2 | 31,075,004 | | `json_load_file`, small document | load | 11,338.8 | 88,193 | @@ -183,7 +183,7 @@ full screen of 16-pixel tiles and 64 actors: | Part of the frame | Cost | Share of 16.67 ms | |---|---:|---:| | Logic: 64 actor updates + one physics sweep | 0.006 ms | 0.03% | -| All-pairs collision over 64 actors, if you do it | 0.012 ms | 0.07% | +| Collision over 64 actors, all-pairs, as a control | 0.012 ms | 0.07% | | Clear + present | 0.023 ms | 0.1% | | 64 actor renders (48x48 blits) | 0.191 ms | 1.1% | | 1200 tile blits | 16.26 ms | 97.6% | @@ -304,6 +304,59 @@ parsing a small document is 11.3 µs, and the accessors are 14-41 ns each. naming twenty assets pays 70 µs. Also noise, but worth knowing it is a syscall and not a string operation. +### Collision, measured + +Added in 0.8.0. Every row below is from one run, on the machine described above. + +| Operation | Unit | ns per unit | per second | +|---|---|---:|---:| +| narrowphase box/box, overlapping | call | 20.6 | 48,485,173 | +| narrowphase box/box, disjoint | call | 9.0 | 111,102,445 | +| narrowphase circle/circle, overlapping | call | 67.8 | 14,752,779 | +| grid `move`, proxy has not left its cells | actor | 11.5 | 86,944,486 | +| 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 | + +Three of those are worth reading rather than skimming. + +**The box fast path is two to seven times cheaper than the general solver**, and +the disjoint case is cheaper still because the proxies' bounds reject it before +any shape arithmetic runs. A tile game is almost entirely box against box, so +9-20 ns describes a real frame and 67.8 is what a circle costs when a game asks +for one. + +**The grid beats the tree by 2.6x on the same population.** That is the argument +for which one is the default, measured here rather than cited from somebody +else's engine. The tree also rebuilds whenever a proxy moves and that cost is not +in its row, so the gap in a scene that is actually moving is wider than 2.6x. + +**A `move` that changes nothing costs 11.5 ns.** That is the incremental claim in +one number: an actor walking across a tile pays that on most frames and pays the +re-cell only when it crosses a boundary. + +### 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 +the logic frame moved with it -- **on a backend with no collision world +attached**, so no collision code runs in either. Two things account for it and +neither is the feature doing its job: + +- **`akgl_Actor` grew from 415 to 464 bytes**, gaining a 40-byte collision shape, + an override flag and a proxy pointer. The step sweeps the whole pool, so that + is about 3 KB more working set streamed every frame. The empty-pool row is + unchanged at 58.7 ns, which is what says this is per *live* actor rather than + per slot. +- **Reaching the collision check through `CATCH` cost more than the check.** + `PASS` and `CATCH` call `akerr_valid_error_address`, which walks + `AKERR_ARRAY_ERROR`, so a function that early-returns is not free when it is + reached through one. Routing the no-collision path around the machinery + entirely took this row from 2,018.7 ns back to 1,588.5 -- the same lesson + `AGENTS.md` records for writing benchmarks, arriving in production code. + +What is left is the struct, and it is paid whether or not a game uses collision. +That is worth knowing rather than smoothing over. + ### The collision helpers are fine; the missing broad phase is the problem `akgl_collide_rectangles` is 6.1 ns whether the rectangles overlap or not. It diff --git a/docs/01-introduction.md b/docs/01-introduction.md index 9e13a54..7450aec 100644 --- a/docs/01-introduction.md +++ b/docs/01-introduction.md @@ -39,7 +39,7 @@ software-rasterized. At 60 fps a frame is 16.67 ms: | Part of the frame | Cost | Share of 16.67 ms | |---|---:|---:| | Logic: 64 actor updates + one physics sweep | 0.006 ms | 0.03% | -| All-pairs collision over 64 actors, if you do it | 0.012 ms | 0.07% | +| Collision over 64 actors, all-pairs, as a control | 0.012 ms | 0.07% | | Clear + present | 0.023 ms | 0.1% | | 64 actor renders (48x48 blits) | 0.191 ms | 1.1% | | 1200 tile blits | 16.26 ms | 97.6% | diff --git a/src/physics.c b/src/physics.c index b173a76..1350789 100644 --- a/src/physics.c +++ b/src/physics.c @@ -304,11 +304,22 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat * path it always did. That is what lets the integrator stay untouched * and every recorded physics number stay valid. */ - CATCH(errctx, akgl_collision_substeps(self->collision, actor, dt, &substeps)); - subdt = dt / (float32_t)substeps; - for ( s = 0; s < substeps; s++ ) { - PASS(errctx, self->move(self, actor, subdt)); - if ( (self->collide != NULL) && (self->collision != NULL) ) { + if ( (self->collision == NULL) || (self->collide == NULL) ) { + /* + * No collision: exactly the call this has always made, with + * nothing added around it. Not merely equivalent -- CATCH and + * PASS walk AKERR_ARRAY_ERROR to validate the context, which + * costs more than several of the calls being measured, so even + * a check that answers "no" instantly is not free if it is + * reached through one. Measured: routing the off path through + * one extra CATCH took the 64-actor sweep from 1.2 us to 2.0. + */ + PASS(errctx, self->move(self, actor, dt)); + } else { + CATCH(errctx, akgl_collision_substeps(self->collision, actor, dt, &substeps)); + subdt = dt / (float32_t)substeps; + for ( s = 0; s < substeps; s++ ) { + PASS(errctx, self->move(self, actor, subdt)); PASS(errctx, self->collide(self, actor, subdt)); } } diff --git a/tests/perf.c b/tests/perf.c index 7a6643a..b585760 100644 --- a/tests/perf.c +++ b/tests/perf.c @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -625,7 +626,7 @@ static akerr_ErrorContext *bench_physics_simulate(void) PASS(errctx, bench_fill_actor_pool(basechar, BENCH_ACTOR_COUNT)); for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { - bench_start("physics_simulate, 64 live actors", "frame", 12500.0); + bench_start("physics_simulate, 64 live actors", "frame", 16000.0); BENCH_LOOP(inner, i, count, backend.simulate(&backend, NULL)); bench_stop(count); PASS(errctx, inner); @@ -705,6 +706,135 @@ static akerr_ErrorContext *bench_logic_frame(void) * broad phase the library does not provide: 64 actors is 2016 pairs, and a * caller doing collision at all does that every frame. */ +/** @brief Counts nothing; the query cost is what is being measured. */ +static akerr_ErrorContext *bench_collision_visit(akgl_CollisionProxy *proxy, void *data) +{ + (void)proxy; + (void)data; + return NULL; +} + +/** @brief A map with a floor, for the tile-collision row. */ +static akgl_Tilemap bench_collision_map; + +/** + * @brief Collision: the narrowphase, the broad phase, and a whole sweep. + * + * The all-pairs row in bench_geometry stays where it is and is the control for + * these: it is the cost a caller paid before the library had a broad phase, run + * on the same machine in the same run, which is the only way to read a + * reduction honestly. + */ +static akerr_ErrorContext *bench_collision(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_CollisionWorld world; + akgl_CollisionShape boxshape; + akgl_CollisionShape circleshape; + akgl_CollisionProxy a; + akgl_CollisionProxy b; + akgl_CollisionProxy *proxies[BENCH_ACTOR_COUNT]; + akgl_Contact contact; + SDL_FRect body = { .x = -8.0, .y = -8.0, .w = 16.0, .h = 16.0 }; + SDL_FRect area = { .x = 0.0, .y = 0.0, .w = 48.0, .h = 48.0 }; + bool hit = false; + int count = bench_iterations(500000); + int sweeps = bench_iterations(20000); + int i = 0; + int x = 0; + int rep = 0; + + PASS(errctx, akgl_collision_shape_box(&boxshape, &body, 0.0)); + PASS(errctx, akgl_collision_shape_circle(&circleshape, 0.0, 0.0, 8.0, 0.0)); + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + PASS(errctx, akgl_heap_init()); + PASS(errctx, akgl_collision_world_init(&world, "grid", 16.0, 16.0)); + + PASS(errctx, akgl_collision_proxy_initialize(&a, NULL, &boxshape, 0.0, 0.0, 0.0)); + PASS(errctx, akgl_collision_proxy_initialize(&b, NULL, &boxshape, 4.0, 0.0, 0.0)); + bench_start("narrowphase box/box, overlapping", "call", 210.0); + BENCH_LOOP(inner, i, count, akgl_collision_test(&a, &b, world.flags, &contact, &hit)); + bench_stop(count); + PASS(errctx, inner); + + PASS(errctx, akgl_collision_proxy_initialize(&b, NULL, &boxshape, 900.0, 900.0, 0.0)); + bench_start("narrowphase box/box, disjoint", "call", 90.0); + BENCH_LOOP(inner, i, count, akgl_collision_test(&a, &b, world.flags, &contact, &hit)); + bench_stop(count); + PASS(errctx, inner); + + PASS(errctx, akgl_collision_proxy_initialize(&a, NULL, &circleshape, 0.0, 0.0, 0.0)); + PASS(errctx, akgl_collision_proxy_initialize(&b, NULL, &circleshape, 4.0, 0.0, 0.0)); + bench_start("narrowphase circle/circle, overlapping", "call", 680.0); + BENCH_LOOP(inner, i, bench_iterations(100000), + akgl_collision_test(&a, &b, world.flags, &contact, &hit)); + bench_stop(bench_iterations(100000)); + PASS(errctx, inner); + + // The broad phase, with a realistic population spread over the map. + for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) { + PASS(errctx, akgl_heap_next_collision_proxy(&proxies[i])); + PASS(errctx, akgl_collision_proxy_initialize(proxies[i], NULL, &boxshape, + (float32_t)((i % 20) * 24), + (float32_t)((i / 20) * 24), 0.0)); + PASS(errctx, world.partitioner.insert(&world.partitioner, proxies[i])); + } + + bench_start("grid move, unchanged cells", "actor", 120.0); + BENCH_LOOP(inner, i, count, world.partitioner.move(&world.partitioner, proxies[0])); + bench_stop(count); + PASS(errctx, inner); + + bench_start("grid query, 64 actors", "query", 440.0); + BENCH_LOOP(inner, i, sweeps, + world.partitioner.query(&world.partitioner, &area, + AKGL_COLLISION_LAYER_ALL, &bench_collision_visit, NULL)); + bench_stop(sweeps); + PASS(errctx, inner); + + // The same population through the tree, so the two are comparable. + PASS(errctx, akgl_collision_world_init(&world, "bsp", 16.0, 16.0)); + for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) { + PASS(errctx, world.partitioner.insert(&world.partitioner, proxies[i])); + } + bench_start("bsp query, 64 actors", "query", 1140.0); + BENCH_LOOP(inner, i, sweeps, + world.partitioner.query(&world.partitioner, &area, + AKGL_COLLISION_LAYER_ALL, &bench_collision_visit, NULL)); + bench_stop(sweeps); + PASS(errctx, inner); + + // One actor against a floor of tiles, which is the case a platformer + // runs every frame for every actor. + memset(&bench_collision_map, 0x00, sizeof(akgl_Tilemap)); + bench_collision_map.tilewidth = 16; + bench_collision_map.tileheight = 16; + bench_collision_map.width = 128; + bench_collision_map.height = 32; + bench_collision_map.numlayers = 1; + bench_collision_map.layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES; + for ( x = 0; x < bench_collision_map.width; x++ ) { + bench_collision_map.layers[0].data[(20 * bench_collision_map.width) + x] = 1; + } + bench_collision_map.collidablelayers = 1u; + PASS(errctx, akgl_collision_world_init(&world, "grid", 16.0, 16.0)); + PASS(errctx, akgl_collision_bind_tilemap(&world, &bench_collision_map)); + + area.x = 100.0; + area.y = 310.0; + area.w = 16.0; + area.h = 16.0; + bench_start("tile query, actor on a floor", "query", 160.0); + BENCH_LOOP(inner, i, sweeps, akgl_collision_box_blocked(&world, &area, + AKGL_COLLISION_LAYER_ALL, &hit)); + bench_stop(sweeps); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + static akerr_ErrorContext *bench_geometry(void) { PREPARE_ERROR(errctx); @@ -747,7 +877,7 @@ static akerr_ErrorContext *bench_geometry(void) bench_stop(count); PASS(errctx, inner); - bench_start("all-pairs collision sweep, 64 actors", "sweep", 1200000.0); + bench_start("control: all-pairs collision sweep, 64 actors", "sweep", 1200000.0); for ( k = 0; k < sweeps; k++ ) { for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) { for ( j = i + 1; j < BENCH_ACTOR_COUNT; j++ ) { @@ -916,6 +1046,7 @@ int main(void) CATCH(errctx, bench_physics_simulate()); CATCH(errctx, bench_logic_frame()); CATCH(errctx, bench_geometry()); + CATCH(errctx, bench_collision()); CATCH(errctx, bench_strings()); CATCH(errctx, bench_json()); CATCH(errctx, bench_path_relative());