Answer sprite collision through libakgl's narrowphase

`spr_collisions()` computed axis-aligned overlaps itself, because at libakgl
0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a
documented corner-containment defect and the physics backend's `collide` slot
raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto
it.

The mask is bit-identical and every test from the previous commit passes
**unmodified**, which was the gate this stage had to clear -- including the two
that were written to be hard to satisfy. Edge-to-edge is still not a collision,
so a tile-aligned program is unaffected. The cross-shaped overlap is still
reported, which is the one that could have regressed: it is the case
`akgl_collide_rectangles()` gets wrong and the reason the hand-written loop
existed, and `akgl_collision_test()`'s box path gets it right.

What it buys is the contact -- a normal, a penetration depth and a point --
which four comparisons cannot produce. Nothing consumes it yet; that is the next
commit. It is here now because the mask and the contact come out of the same
test, and computing them in two places would be two things to keep in step.

**The first attempt was ten times slower and the benchmark caught it.** Syncing
all eight proxies and running the narrowphase on all twenty-eight pairs measured
984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256
scans a frame -- to produce a mask that was bit-identical and a contact that was
thrown away. Two changes fixed it, and both are what a broad phase *is* rather
than workarounds for a slow library:

- **Reject on the bounding boxes first.** The four comparisons that were always
  here now decide which pairs are worth an exact answer. The narrowphase still
  decides the bit -- the box test only says "maybe", which will matter the moment
  a shape is not the whole frame.
- **Do not sync a proxy that has not moved.** The scan runs at the top of every
  interpreter step and a sprite moves at most once in that time, so almost every
  sync would rewrite a proxy with what it already holds. Compared against the
  last synced rectangle rather than flagged by the verbs, because a host game can
  move a BASIC sprite through the actor registry and a flag would miss that.

Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the
96.3 ns it replaced -- boxes are now built eight times a scan instead of
fifty-six. The number that matters is the new benchmark row for the arrangement
`examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or
4.5% of a frame. That game reaches it because two of its eight sprites are the
screen -- a captured HUD strip and a captured play field -- so the field's box
covers everything and those pairs can never be rejected. Roughly double the old
cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes
either side of it as a bracket.

The eight proxies are claimed once at init and held, so exhaustion of the pool
shared with an embedding host is an initialization failure that names the pool
rather than a collision scan refusing halfway through somebody's game. The shape
is built before the proxy is spawned from it and the acquire sits adjacent to the
initialize, which are two traps libakgl hit itself and documents.

`tests/akgl_backends.c` now tears the sprite backend down between cases. It never
did, and got away with it while init claimed nothing; eight proxies apiece across
twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight.
A host releases what it took, and so does the harness.

Both games run forty seconds headless with no error line. 111 with akgl, 110
without.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
This commit is contained in:
2026-08-02 09:48:11 -04:00
parent 1da37cf374
commit f06c53110d
5 changed files with 335 additions and 56 deletions

View File

@@ -115,6 +115,20 @@ static akerr_ErrorContext AKERR_NOIGNORE *start_runtime(const char *source)
static void stop_runtime(void)
{
/*
* The sprite backend has to be torn down, not just dropped.
*
* This used to close the output file and nothing else, and got away with it
* because akbasic_sprite_init_akgl() claimed no pooled objects -- the actors
* and textures a program had created leaked, but at two or three per case
* and 64 actor slots nothing ever ran out. Claiming eight collision proxies
* per backend changed the arithmetic: twenty cases at eight apiece is a
* hundred and sixty against a pool of a hundred and twenty-eight, and the
* suite started failing in whichever case happened to be twelfth.
*
* A host releases what it took. So does this.
*/
akbasic_sprite_akgl_shutdown(&SPRITES);
if ( OUT != NULL ) {
fclose(OUT);
OUT = NULL;

View File

@@ -88,14 +88,17 @@ static akerr_ErrorContext AKERR_NOIGNORE *load(const char *source)
}
/**
* @brief A program defining @p n sprites, all overlapping at the origin.
* @brief A program defining @p n sprites, stacked or spread out.
*
* Overlapping rather than scattered on purpose: it is the worst case for the
* pair loop, since every pair that passes the visibility guards goes on to the
* four comparisons and sets two bits. A scattered arrangement measures the
* early-out instead, and the early-out is not the thing anybody is worried about.
* **Both arrangements are worth a row, and they measure different things.**
* Stacked, every pair survives the bounding-box reject and pays for the
* narrowphase -- the worst case the scan can be put in, and not a state a real
* game sits in. Spread out, every pair is rejected on four comparisons, which is
* what a game looks like on almost every frame. The gap between the two rows is
* the prefilter earning its place, and libakgl's own table splits its overlap
* and disjoint rows for the same reason.
*/
static void sprite_program(char *dest, size_t size, int n)
static void sprite_program(char *dest, size_t size, int n, bool stacked)
{
char line[128];
int i = 0;
@@ -106,17 +109,68 @@ static void sprite_program(char *dest, size_t size, int n)
"30 P#(I#) = 255\n"
"40 NEXT I#\n");
for ( i = 1; i <= n; i++ ) {
/* 40 apart is wider than a 24x21 sprite, so no two boxes can touch. */
int x = (stacked ? 20 : 20 + ((i - 1) * 40));
snprintf(line, sizeof(line),
"%d SPRSAV P#, %d\n%d SPRITE %d, 1\n%d MOVSPR %d, 20, 20\n",
"%d SPRSAV P#, %d\n%d SPRITE %d, 1\n%d MOVSPR %d, %d, 20\n",
100 + (i * 10), i,
101 + (i * 10), i,
102 + (i * 10), i);
102 + (i * 10), i, x);
strncat(dest, line, size - strlen(dest) - 1);
}
}
/** @brief Time the collision scan with @p n sprites live. */
static akerr_ErrorContext AKERR_NOIGNORE *bench_scan(int n)
/**
* @brief The arrangement examples/breakout/sprites/breakout.bas actually has.
*
* Neither synthetic row is what that game looks like, and it is the most
* demanding program in the repository. Two of its eight sprites are the *screen*
* -- a captured HUD strip and a captured play field, installed as sprites because
* a sprite is the one thing this interpreter draws for nothing -- so the field's
* box covers everything and every moving sprite overlaps it permanently. The
* bounding-box reject can never throw those pairs out, which puts this game
* between the two synthetic rows rather than at the cheap end of them.
*/
static akerr_ErrorContext AKERR_NOIGNORE *bench_breakout_shaped(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
uint16_t mask = 0;
int iterations = bench_iterations(200000);
int i = 0;
PASS(errctx, load("10 GRAPHIC 1, 1\n"
"20 SSHAPE H$, 0, 0, 799, 59\n"
"30 SSHAPE F$, 0, 60, 799, 599\n"
"40 SSHAPE S$, 0, 0, 22, 22\n"
"50 SPRSAV H$, 1\n"
"60 SPRSAV F$, 2\n"
"70 SPRSAV S$, 3\n"
"80 SPRSAV S$, 5\n"
"90 SPRSAV S$, 6\n"
"100 SPRSAV S$, 7\n"
"110 SPRSAV S$, 8\n"
"120 FOR I# = 1 TO 8\n"
"130 SPRITE I#, 1\n"
"140 NEXT I#\n"
"150 MOVSPR 1, 0, 0\n"
"160 MOVSPR 2, 0, 60\n"
"170 MOVSPR 3, 300, 540\n"
"180 MOVSPR 5, 100, 300\n"
"190 MOVSPR 6, 400, 200\n"
"200 MOVSPR 7, 600, 400\n"
"210 MOVSPR 8, 200, 150\n"));
bench_start("spr_collisions, breakout's own layout", "call", 0.0);
BENCH_LOOP(inner, i, iterations, SPRITES.collisions(&SPRITES, &mask));
bench_stop((uint64_t)iterations);
PASS(errctx, inner);
SUCCEED_RETURN(errctx);
}
/** @brief Time the collision scan with @p n sprites live, stacked or spread. */
static akerr_ErrorContext AKERR_NOIGNORE *bench_scan(int n, bool stacked)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
@@ -127,10 +181,11 @@ static akerr_ErrorContext AKERR_NOIGNORE *bench_scan(int n)
int i = 0;
memset(source, 0, sizeof(source));
sprite_program(source, sizeof(source), n);
sprite_program(source, sizeof(source), n, stacked);
PASS(errctx, load(source));
snprintf(name, sizeof(name), "spr_collisions, %d sprites", n);
snprintf(name, sizeof(name), "spr_collisions, %d sprites, %s", n,
(stacked ? "all overlapping" : "spread out"));
bench_start(name, "call", 0.0);
BENCH_LOOP(inner, i, iterations, SPRITES.collisions(&SPRITES, &mask));
bench_stop((uint64_t)iterations);
@@ -154,7 +209,7 @@ static akerr_ErrorContext AKERR_NOIGNORE *bench_frame(void)
int i = 0;
memset(source, 0, sizeof(source));
sprite_program(source, sizeof(source), 8);
sprite_program(source, sizeof(source), 8, false);
/* A screenful of text, so the sink renders what a real program's sink renders. */
strncat(source,
"900 FOR R# = 0 TO 30\n"
@@ -215,10 +270,12 @@ int main(void)
FAIL_ZERO_BREAK(errctx, font, AKGL_ERR_SDL,
"Couldn't open %s: %s", AKBASIC_TEST_FONT, SDL_GetError());
CATCH(errctx, bench_scan(0));
CATCH(errctx, bench_scan(2));
CATCH(errctx, bench_scan(4));
CATCH(errctx, bench_scan(8));
CATCH(errctx, bench_scan(0, false));
CATCH(errctx, bench_scan(2, false));
CATCH(errctx, bench_scan(4, false));
CATCH(errctx, bench_scan(8, false));
CATCH(errctx, bench_scan(8, true));
CATCH(errctx, bench_breakout_shaped());
CATCH(errctx, bench_frame());
BENCH_REPORT_BREAK(errctx);
} CLEANUP {