Draw actors at their sprite's height, and update each one once a frame

Closes Defects item 26 and Performance item 32.

akgl_actor_render set dest.h from curSprite->width, so every actor was drawn
square and a non-square sprite was stretched or squashed. Invisible in the
fixtures because they are all square, so tests/actor.c gets a 48x24 sprite and
a render backend whose draw_texture records the rectangle it is handed instead
of drawing it. That recording backend is the first coverage akgl_actor_render
has had at all -- every other test in the file stubs renderfunc out.

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, so
every live actor's updatefunc ran sixteen times a frame. The sweep is hoisted
out: updating an actor is not a per-layer operation, and
akgl_render_2d_draw_world already walks the layers for the half that is.
AKGL_ITERATOR_OP_LAYERMASK is honoured rather than ignored now, so a caller who
wants one layer can still ask, and gets each of those actors once.

Counting is the assertion, deliberately. The defect was invisible in the frame
total because the tilemap blits are three orders of magnitude larger, so a
timing test would have measured the rasterizer. tests/game.c counts calls into
a stub updatefunc and reports 16 against the old code.

PERFORMANCE.md records the timing side as what it honestly is: the gap between
the akgl_game_update and draw_world rows of the same run, 92 us before and
noise in both directions after. The absolute table is not re-taken -- a later
run on this machine read every row about 15% high, including rows nothing has
touched.

25/25 pass, reindent --check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 06:45:34 -04:00
parent 3ee8c60491
commit 913834a3af
8 changed files with 379 additions and 60 deletions

View File

@@ -167,6 +167,14 @@ rows are raw `SDL_RenderTexture` loops with no libakgl in the path.
| `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
@@ -247,16 +255,30 @@ 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` updates every actor sixteen times
### `akgl_game_update` updated every actor sixteen times — fixed in 0.5.0
`src/game.c:617` loops over `AKGL_TILEMAP_MAX_LAYERS`, and the actor sweep
nested inside it does not filter by layer. Every live actor's `updatefunc` runs
**16 times per frame**. At 68.4 ns per update and 64 actors that is 70 µs of
work to do 4.4 µs of work.
`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.
It is invisible in the frame total here because the tilemap blits are three
orders of magnitude larger. On a GPU backend, where the frame might be 2 ms, it
is 3.5% of the frame doing nothing. Filed in `TODO.md`.
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