# libakgl performance baseline

This is where the library actually spends its time, measured rather than guessed.
The numbers below are the first recorded baseline: libakgl 0.3.0, at commit
`f35443e` plus the perf suites themselves. Everything here is reproducible with
two commands, and the suites that produced it are checked in as
`tests/perf.c` and `tests/perf_render.c`.

Read it in this order if you only want the short version: **the frame budget** is
the part that matters and **what the numbers say** is the argument. The six
defects the suites turned up on the way — two of them process-killing — are in
`TODO.md` under **Performance**, along with the targets these numbers are
measured against.

## Reproducing it

```sh
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build --parallel
ctest --test-dir build -L perf --output-on-failure
```

Both suites print a table and exit non-zero if any measurement blew its budget.
They are ordinary CTest tests, so `ctest --test-dir build` runs them along with
everything else; `ctest --test-dir build -LE perf` leaves them out when you only
want the unit suites. `AKGL_BENCH_SCALE` scales every iteration count —
`AKGL_BENCH_SCALE=0.1` for a quick look, `10` for a long one. Below 1.0 the
budgets are reported but not enforced, because a short run is a noisy one.

The whole thing takes about 30 seconds: 4 s for `perf`, 25 s for `perf_render`.

## The machine

| | |
|---|---|
| CPU | AMD Ryzen 5 7535HS, 6 cores / 12 threads, 4.6 GHz max |
| Memory | 62 GiB |
| OS | Linux 6.8.0-136-generic |
| Compiler | gcc 13.3.0, `-DCMAKE_BUILD_TYPE=RelWithDebInfo` (`-O2 -g`) |
| SDL | vendored SDL 3.4.8, `dummy` video driver, `software` renderer |
| Target | 640x480, offscreen |

One machine, one build type, one afternoon. Treat the absolute numbers as this
laptop's and the *ratios* as the library's.

## How it is measured

- **Best of five.** Each benchmark runs five times and the harness keeps the
  fastest. The mean is the wrong statistic on a shared machine: every source of
  noise makes a run slower and none makes it faster.
- **No error checking inside the clock.** `PASS` and `CATCH` call
  `akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` — more work than
  several of the calls being measured. The timed loop stashes the context and
  checks it after the clock stops.
- **The renderer is flushed inside the measurement.** SDL batches: a `draw_*`
  call queues a command and returns. The first version of this suite measured
  *queueing* and reported a tilemap frame at 65 µs; the deferred work then took
  114 seconds to come out at teardown, inside `SDL_DestroyTexture`. Every
  drawing benchmark now flushes before it stops the clock, and the numbers below
  are 250x larger and true.
- **SDL's log output goes to a sink that discards it.** The library logs on
  paths this suite calls hundreds of thousands of times; timing a write to a
  terminal measures the terminal. What the *formatting* costs is measured
  deliberately, by the pair of actor-spawn benchmarks.
- **Budgets are set at roughly 10x the measured baseline.** Loose enough that a
  busy machine does not turn CI red, tight enough that a linear scan becoming
  quadratic cannot hide. They are enforced only in an optimized build at full
  scale — a coverage build measures gcov, not libakgl.

**The renderer caveat, stated once and loudly.** These draw benchmarks run
against SDL's *software* renderer. No shipped game does that. What a software
renderer buys is that all the work stays in this process where it can be timed,
and that the per-blit cost is at least honest about how much pixel traffic was
asked for. Read every drawing number as *a count of work libakgl asked for*, not
as a frame rate anyone would ship. That is exactly why the raw-SDL control rows
exist: they do the same pixel work with none of the library in the path, so the
difference between the two is libakgl's share, and that part *does* carry over
to a GPU backend.

## Static footprint

libakgl does not call `malloc`. Every one of these arrays exists from process
start whether the game uses one slot or all of them.

| Pool | Slots | Bytes each | Total |
|---|---:|---:|---:|
| `akgl_heap_actors` | 64 | 400 | 25,600 |
| `HEAP_SPRITE` | 1024 | 176 | 180,224 |
| `HEAP_SPRITESHEET` | 1024 | 536 | 548,864 |
| `HEAP_CHARACTER` | 256 | 184 | 47,104 |
| `akgl_heap_strings` | 256 | 4,100 | 1,049,600 |
| **pools, total** | | | **1,851,392** |
| `akgl_Tilemap` (one, as `_akgl_gamemap`) | 1 | 26,388,008 | 26,388,008 |
| — of which layers | 16 | 1,120,296 | 17,924,736 |
| — of which tilesets | 16 | 528,944 | 8,463,104 |

**28 MB of BSS before `main` runs, and 94% of it is one tilemap.** A layer is
512x512 `int` cells whether the map is 512x512 or 2x2, and a tileset carries a
65,536-entry offset table whether the image holds 65,536 tiles or 1,728. This is
not a hypothetical cost: zeroing that struct is 1.37 ms, and `akgl_tilemap_load`
pays it before it has read a byte of the map file.

## Results: everything that does not draw

`tests/perf.c`, full scale. `ns/op` is the best of five runs; `ops/sec` is its
reciprocal.

| Benchmark | Unit | ns/op | ops/sec |
|---|---|---:|---:|
| `heap_next_actor`, empty pool | call | 4.0 | 248,392,158 |
| `heap_next_actor`, one slot left | call | 36.9 | 27,077,696 |
| heap string claim + release cycle | cycle | 49.8 | 20,084,967 |
| `heap_next_string`, empty pool | call | 3.9 | 255,059,748 |
| `heap_next_string`, one slot left | call | 250.9 | 3,986,007 |
| `heap_release_string`, 4 KiB wipe | call | 47.2 | 21,191,918 |
| `heap_init`, all five pools | call | 45,141 | 22,153 |
| actor spawn + release, library logging on | actor | 202.3 | 4,942,862 |
| actor spawn + release, logging suppressed | actor | 162.5 | 6,153,324 |
| `akgl_actor_set_character`, registry lookup | call | 39.3 | 25,438,092 |
| `akgl_set_property` | call | 96.9 | 10,316,516 |
| `akgl_get_property`, 4 KiB copy | call | 85.3 | 11,722,134 |
| `akgl_character_sprite_get`, state to sprite | call | 37.4 | 26,734,605 |
| `akgl_actor_update`, animation advancing | actor | 68.4 | 14,609,892 |
| `akgl_actor_update`, no sprite for state | actor | 616.5 | 1,621,937 |
| `akgl_physics_simulate`, 64 live actors | frame | 1,216.8 | 821,841 |
| `akgl_physics_simulate`, empty pool | frame | 63.9 | 15,650,101 |
| logic frame: 64 updates + simulate | frame | 5,763.1 | 173,517 |
| `akgl_collide_rectangles`, overlapping | call | 6.1 | 164,345,776 |
| `akgl_collide_rectangles`, disjoint | call | 6.1 | 164,423,708 |
| 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 |
| `akgl_get_json_string_value` | call | 40.7 | 24,552,958 |
| `akgl_get_json_integer_value` | call | 13.8 | 72,704,562 |
| `akgl_path_relative`, `realpath` on an existing file | call | 3,481.5 | 287,232 |

## Results: everything that draws

`tests/perf_render.c`, full scale, 640x480 software renderer. The two **control**
rows are raw `SDL_RenderTexture` loops with no libakgl in the path.

| Benchmark | Unit | ns/op | ops/sec |
|---|---|---:|---:|
| `frame_start` + `frame_end` (clear + present) | frame | 23,161.7 | 43,175 |
| `akgl_draw_point` | call | 132.5 | 7,547,141 |
| `akgl_draw_line`, screen diagonal | call | 605.9 | 1,650,375 |
| `akgl_draw_rect`, 200x150 outline | call | 520.8 | 1,920,055 |
| `akgl_draw_filled_rect`, 200x150 | call | 43,695.1 | 22,886 |
| `akgl_draw_circle`, radius 64 | call | 2,516.2 | 397,423 |
| `akgl_draw_copy_region`, 64x64 readback | call | 954.7 | 1,047,471 |
| `akgl_draw_paste_region`, 64x64 upload | call | 5,352.1 | 186,841 |
| `akgl_draw_flood_fill`, full 640x480 target | call | 1,967,295.6 | 508 |
| `akgl_text_measure`, 15 characters | call | 37.3 | 26,804,657 |
| `akgl_text_rendertextat`, 15 characters | call | 12,601.7 | 79,354 |
| `akgl_sprite_load_json`, sheet already loaded | load | 17,011.3 | 58,784 |
| `akgl_character_load_json`, two mappings | load | 14,506.8 | 68,933 |
| `akgl_tilemap_load` + release, fixture map | load | 11,881,190.8 | 84 |
| zeroing one `akgl_Tilemap` | call | 1,372,396.7 | 729 |
| `akgl_tilemap_compute_tileset_offsets`, 1728 tiles | call | 2,240.8 | 446,264 |
| `akgl_tilemap_draw`, 40x30 tiles, 1 tileset | frame | 16,260,566.6 | 61 |
| `akgl_tilemap_draw`, 40x30 tiles, 8 tilesets | frame | 16,395,182.4 | 61 |
| **control**: raw SDL blits, one source tile | frame | 471,676.6 | 2,120 |
| **control**: raw SDL blits, map order | frame | 16,229,501.0 | 62 |
| `akgl_actor_render`, on camera | actor | 2,992.0 | 334,220 |
| `draw_world`, 1200 tiles + 64 actors | frame | 16,484,493.6 | 61 |
| `akgl_game_update`, full frame | frame | 16,576,902.2 | 60 |

The `akgl_game_update` row is from before the sixteen-times-per-actor defect was
fixed in 0.5.0; see below. It sat about 92 µs above `draw_world` in the same
run, and now sits level with it. The absolute figures in this table have not
been re-taken -- a later run on this machine read every row about 15% high,
including rows nothing has touched, so the table is a record of one machine
state and the honest measurement of that fix is the *gap* between two rows of
the same run, not a new absolute.

## The frame budget

At 60 fps a frame is 16.67 ms. Here is where it goes for a 640x480 game with a
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% |
| 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% |
| Six lines of HUD text | 0.076 ms | 0.5% |

**Everything libakgl decides is free. The pixels are the whole frame.** Every
piece of bookkeeping this library does — pool scans, registry lookups, state-to-
sprite mapping, physics, the error-context machinery — adds up to well under 1%
of a frame that is 97% software rasterization. On a GPU backend those blits get
cheap and libakgl's own share rises, which is exactly why the per-operation
numbers above matter more than the frame totals.

## What the numbers say

### The tilemap draw is SDL, not libakgl — and I can prove it

`akgl_tilemap_draw` takes 16.26 ms for a 1200-tile screen. A raw
`SDL_RenderTexture` loop issuing the *same 1200 blits from the same scattered
source tiles* takes 16.23 ms. The library's own per-tile work — the bounds
arithmetic, the tileset scan, the offset-table lookup, the backend indirection,
the error macros — is **0.03 ms per frame, under 0.2%**.

That took three attempts to measure honestly. A control that walked the sheet
sequentially said libakgl cost 67%; a control that blitted one source tile over
and over said it cost 3400%. Both were wrong, and both were wrong the same way:
they changed the *memory access pattern of the source texture* rather than
isolating the library. A 16x16 tile read from a random place in a 768x576 sheet
costs about 13 µs on this software rasterizer; the same tile read from cache
costs 0.4 µs. That factor of thirty is the whole story, and none of it is
libakgl's.

Related: the `FIXME` in `src/tilemap.c` worrying that the per-tile tileset scan
"is probably not very efficient" is, at eight tilesets, worth **0.8% of the
frame** (16.40 ms vs 16.26 ms). It is a real O(tiles x tilesets) loop and it
should still be fixed, but it is not where the time is, and nobody should
reorganise the loader for it.

### The pools are linear scans, and only the string pool cares

Claiming an actor from an empty pool is 4.0 ns; claiming the last free slot is
36.9 ns — nine times the cost, and still nothing.

The string pool is the exception, and it is instructive. Claiming from an empty
string pool is 3.9 ns; claiming the *last free slot* is 250.9 ns, **64 times**
slower. Same algorithm, same 256-ish entries. The difference is that each
`akgl_String` is `PATH_MAX` + 4 bytes, so the scan touches one reference count
every 4 KiB and takes a cache miss on every candidate. A pool of 256 strings is
a megabyte, and walking it is walking a megabyte.

`akgl_heap_release_string` costs 47.2 ns because it `memset`s all 4,100 bytes
whether the string held a path or one character. Same for `akgl_string_copy`
(32.2 ns) and `akgl_get_property` (85.3 ns), which move `AKGL_MAX_STRING_LENGTH`
bytes unconditionally. None of these is expensive in isolation; all of them are
the same avoidable habit of paying for `PATH_MAX` when you used eleven bytes.

### Errors cost about ten times what success costs

`akgl_actor_update` on an actor whose character has a sprite for its state:
68.4 ns. The same call on an actor whose character does *not*: **616.5 ns**.

That path is not an error in any meaningful sense — the library handles
`AKERR_KEY` and carries on, and `akgl_actor_render` logs it and draws nothing.
It is a normal condition on a partly authored character. But raising it means
claiming a context out of `AKERR_ARRAY_ERROR`, formatting a message with
`vsnprintf`, appending a stack-trace frame, walking the `PROCESS` switch, and
releasing it again. Nine times the cost of the update it replaced.

The design conclusion is not "make errors cheaper". It is that a *routine*
condition should not be reported as an error. A character that has no sprite for
a state should answer that question with a boolean.

### `akgl_game_update` updated every actor sixteen times — fixed in 0.5.0

`akgl_game_update` looped over `AKGL_TILEMAP_MAX_LAYERS` with the actor sweep
nested inside it, and never compared an actor's `layer` to the layer it was on.
Every live actor's `updatefunc` ran **16 times per frame**: at 68.4 ns per
update and 64 actors, 70 µs of work to do 4.4 µs of it — and, more to the
point, every piece of a game's own per-frame actor logic running sixteen times.

The sweep is hoisted out of the layer loop. Updating an actor is not a per-layer
operation; drawing is, and `akgl_render_2d_draw_world` walks the layers itself.
`AKGL_ITERATOR_OP_LAYERMASK` is honoured now rather than ignored, so a caller
that genuinely wants one layer can still ask for it and gets each of those
actors once.

**How it was measured.** `tests/game.c` counts calls into a stub `updatefunc`
and asserts exactly one per live actor per `akgl_game_update`; against the old
code it reports 16. The timing side is the gap between the `akgl_game_update`
and `draw_world` rows of the *same* benchmark run: 92 µs before, and across
three runs afterwards -414 µs, -376 µs and +19 µs — noise in both directions.

It was invisible in the frame total because the tilemap blits are three orders
of magnitude larger, which is exactly why it needed a counting test rather than
a stopwatch. On a GPU backend, where a frame might be 2 ms, it was 3.5% of the
frame doing nothing.

### Text has no cache at all

`akgl_text_rendertextat` is 12.6 µs for fifteen characters: it rasterizes the
string, uploads it as a texture, blits it, and destroys the texture — every
call, every frame, for a score that changes once a second. Measuring the string
first with `akgl_text_measure` is 37.3 ns, i.e. free, which tells you the whole
cost is the rasterize-and-upload.

Six HUD readouts is 76 µs a frame. That is fine at 60 fps on this machine and it
is 4% of a 2 ms GPU frame. A one-line cache keyed on (font, string, colour)
would take it to nothing, and it is the single clearest optimisation in the
library.

### Loading is dominated by things that are not the file

A 2x2 fixture map with one tileset takes **11.9 ms** to load and release. Of
that, 1.37 ms — 11.5% — is `memset`ing the 26 MB `akgl_Tilemap` before anything
is read. Most of the rest is decoding the tileset PNG. The JSON is noise:
parsing a small document is 11.3 µs, and the accessors are 14-41 ns each.

`akgl_path_relative` is 3.5 µs, because it is a `realpath(3)` syscall. A map
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 |
| **a whole physics step with collision, 64 actors** | frame | 15,420.1 | 64,851 |

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.

#### 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
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 overlap test is fine; the all-pairs loop around it was the problem

`akgl_collide_rectangles` is 6.1 ns whether the rectangles overlap or not. It was
24.9 ns overlapping and 57.9 ns disjoint until 0.8.0, when it stopped being eight
corner-containment tests and became four span comparisons -- a change made to fix
the cross case it could not see, with the speed as a side effect rather than the
goal. The disjoint case improved most because it was the one that ran all eight
tests before answering.

**Read that 6.1 as a floor, not a measurement.** It is within this run's
resolution of a function that does almost nothing, and the rows above are not
re-baselined for it; only the rows the change actually moved are.

The cost was never the test. It was the loop a caller had to write around it,
because there was no broad phase: 2016 pairs for 64 actors, **12 µs** a frame,
0.07% of a 60 fps budget. Affordable, and still O(n²) -- at
`AKGL_MAX_HEAP_ACTOR=256` the same loop is 32,640 pairs and **188.5 µs measured**,
1.1% of the frame for a game that has done nothing yet.

**That loop is what 0.8.0 removed the need for.** A whole physics step with a
collision world attached is 54.1 µs at 256 actors, doing strictly more -- see
"Collision, measured" below. The all-pairs row stays as the `control:` it is
labelled, which is what it is measuring now.

### Spawning is cheap, and a third of it is a log line

An actor spawn — pool claim, `memset`, registry insert, release — is 202.3 ns
with the library's logging on and 162.5 ns with SDL's log priority raised so the
message is never formatted. **20% of a spawn is formatting a log line nobody
reads**, and that is with output going to a sink that throws it away; write it to
a terminal and it is far worse. `akgl_actor_initialize` and
`akgl_character_sprite_add` both log unconditionally at `INFO`.

## How other engines spend the same frame

The natural question after a baseline like this is how it compares to the
engines people actually ship 2D games on. The comparison below is against
Construct (2 and 3) and Phaser (3 and 4), because both publish real
engineering detail: Ashley Gullen's Construct tech blogs and the Phaser docs
and source say what they built, what they rejected, and — most usefully — what
they measured. Godot and GameMaker publish similar batching guidance and add
nothing the other two don't. Sources are at the end of the section; the
construct.net posts are Cloudflare-walled to non-browsers and were verified
through Wayback captures.

One caveat governs the whole comparison, so it goes first. **Every number
these engines publish assumes the pixels are on a GPU.** Construct's ~300 CPU
cycles per sprite and Phaser's 16,384-quad batch ceiling are budgets for
*building command lists*, not for touching pixels. Two things follow:

- Ashley's argument that a frame costs max(logic, render) — because the CPU
  builds commands while the GPU rasterizes the previous batch — inverts under
  a software renderer. Here the frame really is logic *plus* render, which is
  why 97.6% of ours is blits.
- What transfers from their playbooks is the **culling and the caching**:
  camera culls, dirty-flagged text textures, collision cells. What does not
  transfer is the **batching**, and the control rows above already proved it:
  libakgl's per-tile submission overhead is 0.2% of the frame. Batching is a
  solution to a problem this library has measured itself not to have — and
  SDL's renderer batches internally anyway.

### Tilemaps: they optimize draw calls; ours cost 0.2%

Construct 2 merged runs of identical tiles into rectangles and drew each
rectangle in one call — 120 draw calls down to 22 in their worked example —
and the Construct 3 runtime renders an entire tilemap layer with zero texture
switches, which they measured at 40x over C2 when every visible tile differs.
Phaser culls tiles to the camera every frame (one-tile padding by default),
and Phaser 4 added an opt-in `TilemapGPULayer` that draws a whole layer as a
single shader quad at a fixed cost per screen pixel, capped at 4096x4096
tiles.

All of that machinery exists to cut draw-call submission cost. libakgl's
equivalent cost is the 0.03 ms gap between `akgl_tilemap_draw` and the raw-SDL
control row. The 16.26 ms is rasterization, which none of those techniques
touch — a merged rectangle still fills the same pixels in software. The one
tilemap idea worth keeping in the back pocket is the one Construct *rejected*
in 2013 on simplicity grounds and Phaser shipped in 2026: push the whole layer
to the GPU. That is a backend decision, not a loop optimization, and the
per-tile bookkeeping measured above (~25 ns) is what would remain of the frame
when it happens.

### Sprites: their per-sprite budget and ours are the same order

Construct measured its C3 runtime at roughly 300 CPU cycles to render one
sprite — about 65 ns on this machine's clock — and got there by replacing
polymorphic dispatch with monomorphic code, which they measured as 70-88%
faster on their quad benchmark. Phaser batches 16,384 quads at a time and
multi-texture batching turned 210 sprites across 15 textures from 212 draw
operations into 2. libakgl's actor render bookkeeping is ~250 ns by
subtraction (target 3), through one `draw_texture` function pointer per actor
with an error scope per call. Same order of magnitude, no batch layer, and at
64 actors the whole sweep is 1.1% of the frame. Their lesson that dispatch
indirection is where per-sprite CPU goes is worth remembering if the actor
ceiling ever rises by an order of magnitude; at 64 it is noise.

### Text: everyone else has the cache

Phaser's `Text` object re-rasterizes its canvas and re-uploads the texture
*only when the content or style changes*; its `BitmapText` never pays even
that — their docs state "you don't incur any speed penalty when updating their
content because the underlying texture doesn't change." Construct's Text
plugin documents the same failure mode this suite measured: automatic-
resolution mode "can cause the text to constantly re-render when being
smoothly scaled," and fixed-resolution mode is the documented escape hatch.
Both engines treat re-rasterizing an unchanged string as a defect. libakgl
does it unconditionally, every call, every frame: 12.6 µs a string. The
dirty-flag cache in targets 8 and 9 is not novel engineering; it is the
industry floor.

### Collision: Construct already ran this experiment

Construct's brute-force collision at 1000-vs-1000 objects was about a million
checks per tick and ran at 10 fps on a desktop; their viewport-sized uniform
collision cells cut that to ~40,000 checks — a 96% reduction, six times
faster — with incremental insert/remove as objects move and zero cost for
static objects. They evaluated quadtrees and rejected them as "more
complicated and can be more expensive to update as objects move around."
Phaser went the other way: Arcade Physics keeps dynamic bodies in an RTree
that is *cleared and rebuilt every frame*, and their own docs say a
"conservative estimate of around 5,000 bodies should be considered the max"
before turning the tree off beats keeping it.

libakgl has no broad phase at all, and at the current 64-actor ceiling it
does not need one: the all-pairs sweep is 115 µs, 0.7% of the frame. The
comparison settles *which* broad phase fits when the ceiling rises — the
incremental uniform grid, which is allocation-free at steady state and simple
enough that its authors chose it over the fancier structure, not the
rebuild-every-frame tree. The design is on record in `TODO.md` under
**Performance -> The plan**.

### Memory: stricter than either, and paying for it wrong

Construct budgets memory layout-by-layout — only the current layout's images
are resident, every image is width x height x 4 bytes regardless of source
format, and peak memory is the worst single layout. Their create/destroy
churn is a first-class engine metric (5x faster in the C3 runtime), and their
anti-jank advice — place one instance in the layout so its texture is loaded,
then destroy it at start — is a warm-the-pool idiom. Phaser's pooling is
`Group` objects with `maxSize` as a hard cap, recycling members by toggling
`active`. Both engines converge on what libakgl starts from: fixed budgets
and recycled objects.

The difference is what the budget buys. Their costs scale with what a scene
*uses*; libakgl's scale with what the build *allows* — 28 MB of BSS with 94%
in one worst-case-sized tilemap, 4 KiB string wipes for eleven-byte strings,
pool scans that walk the ceiling rather than the population. The static-pool
discipline is right, and stricter than either engine. Targets 6, 7, 14 and 15
are all the same correction: keep the fixed ceiling, stop paying for it per
operation.

### The frame split: the same shape as 2012

Ashley wrote in 2012 that "it's not unusual for even a fairly complex game to
spend 10% of the time on the logic, and 90% of the time on rendering — even
when hardware accelerated!" That is a stated profiling experience from the
canvas era, with no published methodology, and it is cited here as exactly
that and nothing more; Phaser publishes no frame-composition profile at all,
and no official Phaser bunnymark number exists to borrow (third-party figures
float around; none are cited here). libakgl's measured 97.6% blits against
under 1% bookkeeping is the same shape, more extreme, because software
rasterization is slower than a GPU and the logic is C rather than JavaScript.
Both lead to the conclusion already in this document: the pixels are the
whole frame, and the per-operation bookkeeping numbers are what matter the
day a GPU backend makes the pixels cheap.

### Sources

- Ashley Gullen, [How the Construct 2 WebGL renderer works](https://www.construct.net/en/blogs/ashleys-blog-2/construct-webgl-renderer-works-917) (2014)
- Ashley Gullen, [Tech blog: Tilemap tidbits](https://www.construct.net/en/blogs/ashleys-blog-2/tech-blog-tilemap-tidbits-913) (2013)
- Ashley Gullen, [Collision cell optimisation in r155](https://www.construct.net/en/blogs/ashleys-blog-2/collision-cell-optimisation-914) (2013)
- Ashley Gullen, [How render cells work](https://www.construct.net/en/blogs/ashleys-blog-2/render-cells-work-921) (2014)
- Ashley Gullen, [Optimisation: don't waste your time](https://www.construct.net/en/blogs/construct-official-blog-1/optimisation-dont-waste-time-768) (2012)
- Scirra, [Announcing the Construct 3 runtime](https://www.construct.net/en/blogs/construct-official-blog-1/announcing-construct-runtime-904) (2018)
- Construct 3 manual: [Performance tips](https://www.construct.net/en/make-games/manuals/construct-3/tips-and-guides/performance-tips), [Memory usage](https://www.construct.net/en/make-games/manuals/construct-3/tips-and-guides/memory-usage), [Text](https://www.construct.net/en/make-games/manuals/construct-3/plugin-reference/text), [Sprite Font](https://www.construct.net/en/make-games/manuals/construct-3/plugin-reference/sprite-font)
- Phaser, [Phaser 4 rendering concepts](https://phaser.io/tutorials/phaser-4-rendering-concepts)
- Phaser, [How to render thousands of sprites in Phaser 4](https://phaser.io/news/2026/05/phaser4-spritegpulayer-performance)
- Phaser, [Advanced rendering tutorial, part 2: multi-texture batching](https://phaser.io/tutorials/advanced-rendering-tutorial/part2)
- Phaser docs: [Text](https://docs.phaser.io/phaser/concepts/gameobjects/text), [BitmapText](https://docs.phaser.io/phaser/concepts/gameobjects/bitmap-text), [Group](https://docs.phaser.io/phaser/concepts/gameobjects/group)
- Phaser source: [physics/arcade/World.js](https://github.com/phaserjs/phaser/blob/master/src/physics/arcade/World.js), [tilemaps/components/CullTiles.js](https://github.com/phaserjs/phaser/blob/master/src/tilemaps/components/CullTiles.js), [core/Config.js](https://github.com/phaserjs/phaser/blob/master/src/core/Config.js)

## Defects these tests found

Six, and they are filed where defects live: `TODO.md`, under **Performance ->
Defects the perf suites found**, items 28-33, each with its file, line,
functional consequence, and what fixing it would touch. They are not repeated
here.

Worth saying in this document, because it is the argument for having written the
suites at all: stress testing breaks things unit tests do not reach. Two of the
six are process-killing — an error-context leak that aborted on the 129th path
resolution (fixed, with a regression test), and a pooled-string leak that turns
into a segfault rather than an `AKGL_ERR_HEAP` around the 52nd map load. Neither
is reachable by a suite that calls each function a handful of times, and neither
had anything to do with speed. They came out of loops that ran the same call ten
thousand times in one process, which is what a running game does and what
nothing else in this tree did.

## What this report does not cover

- **One machine.** No ARM, no Raspberry Pi, no Windows, no macOS. The ratios
  should travel; the absolute numbers will not.
- **No GPU backend.** Everything drawing-related is a software rasterizer under
  the dummy video driver. The share of a frame that belongs to libakgl is a
  floor, not an estimate.
- **No audio.** `src/audio.c` is not benchmarked at all; the mixer under the
  dummy driver does not do the work a real device would.
- **No controller input.** The event path is driven by SDL and has no gamepad to
  drive it under the dummy driver.
- **No heap profiling beyond the static footprint.** Nothing here measures peak
  or steady-state heap usage. Correctness of the heap *is* covered now, by
  `cmake --build build --target memcheck`, which runs these same binaries under
  valgrind — `tests/benchutil.h` cuts the iteration counts by three orders of
  magnitude when it finds itself there, so a benchmark becomes a path-coverage
  program. That run found six more defects, including a leak of the parsed JSON
  document in every asset loader; they are in `TODO.md` under **Memory
  checking**. What is still missing is a profile: how much a running game
  actually holds at once, and how that grows over an hour.
- **Single-threaded throughout.** The library is not thread-safe by design
  (`akgl_game_state_lock` guards one field), and nothing here tests contention.
