Files
libakgl/docs/10-spritesheets-and-sprites.md
Tachikoma eabb9ad376
Some checks failed
libakgl CI Build / cmake_build (push) Successful in 9m7s
libakgl CI Build / performance (push) Successful in 9m44s
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / memory_check (push) Has been cancelled
Move outstanding work from TODO.md into the issue tracker
TODO.md carried two records in one file: what had been done, with the
measurements behind it, and what was left. The second half is what a tracker
is for, and keeping it here has already cost something -- AGENTS.md records a
round where eleven entries described code that had already changed, and this
file admitted to three more.

Every open item is now an issue on source.starfort.tech/andrew/libakgl,
labelled by kind and blast radius and milestoned by what it can land in: 0.9.x
for anything that breaks no ABI, 0.10.0 for new or changed public symbols,
1.0.0 for the design work. Four are epics: the performance plan (#60),
coverage (#61), actor rotation (#62), and the false header comments (#63).

Verified against the tree before filing rather than transcribed. Three entries
were already fixed and were not filed: the akgl_path_relative context leak, the
akgl_draw_background test extension, and the SDL enumeration audit -- keyboards,
gamepads and mappings are all freed in CLEANUP today. Two were reworded because
the code had moved: the fonts item is a missing teardown entry point rather than
a missing API, since akgl_text_unloadallfonts exists, and draw_world's tilemap
call is already bounded by numlayers, so only the per-layer actor rescan remains.

TODO.md keeps the part a tracker has no place for: why a decision went the way
it did, what the measurement was, and which arguments turned out to be wrong.

TODO.txt is deleted. Four of its eight entries had shipped -- actor-to-actor
collision, actor-to-world collision, automatic facing, image layers -- and the
four that had not are #74 through #77, with the GPU renderer's research links
kept because that is the part that took the time.

Every reference that named an item number or a moved section is repointed, in
the manual, the headers, the tests and the examples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:47:34 -04:00

13 KiB

10. Spritesheets and sprites

Two objects, and the split between them is the whole design:

  • An akgl_SpriteSheet owns one SDL_Texture — an image file uploaded to the GPU — and nothing else.
  • An akgl_Sprite is one animation: a frame size, a playlist of frame numbers, a dwell time, two loop flags, and a borrowed pointer to the sheet those frames are cut from.

So a walk cycle, an idle pose and a death animation drawn on the same PNG are three akgl_Sprite objects pointing at one akgl_SpriteSheet, and the image is uploaded once. That is not an optimization you have to ask for; it is what the loader does.

Both are pool objects — akgl_heap_next_sprite, akgl_heap_next_spritesheet, see Chapter 05 — and both publish themselves in a registry under their name, which is how Chapter 11 and the tilemap loader find them. See Chapter 06.

The renderer has to exist before any of this. Loading a sheet uploads a texture, so akgl_render_2d_init (or akgl_render_2d_bind with a live SDL_Renderer on it — see Chapter 08) comes first.

One image, one texture: how sharing actually works

akgl_sprite_load_json does not load an image and hope. It:

  1. resolves the sheet's filename to an absolute, canonical path;
  2. looks that path up in AKGL_REGISTRY_SPRITESHEET;
  3. reuses the sheet it finds, or claims one from the pool and loads it.

The resolved path is the registry key. That is what makes ten sprites cut from one image cost one texture, and it is why the resolution rule below matters more than it looks.

On the reuse path the sheet's reference count is not incremented — the sprite borrows it. akgl_sprite_initialize does not take a reference either. So:

Releasing a spritesheet out from under a live sprite leaves a dangling pointer. Nothing detects it. In practice, release sprites and sheets together at the same lifecycle boundary (a level change), not individually.

Where a sheet filename resolves from

This is a two-step rule and the first step surprises people:

    "spritesheet": { "filename": "hero.png" }   in docs/assets/hero_walk.json

    step 1   realpath("hero.png")
             -> relative to the PROCESS'S CURRENT WORKING DIRECTORY
             -> if that file exists, that is the sheet. Done.

    step 2   only if step 1 raised ENOENT:
             realpath(dirname("docs/assets/hero_walk.json") + "/" + "hero.png")
             -> relative to the SPRITE JSON'S OWN DIRECTORY

A sprite definition can sit next to its image and move with it — that is step 2, and it is the behaviour the format is designed around. But step 1 runs first, so a file of the same name in the working directory shadows the one beside the JSON. If a sprite loads the wrong art, check the working directory before checking the path.

Both steps end in realpath(3), so symlinks and .. are folded out and the key is canonical. Two different spellings of the same file through akgl_sprite_load_json therefore land on one sheet. (Calling akgl_spritesheet_initialize directly skips all of this — it uses whatever string you hand it as the key verbatim, so there two spellings really are two sheets.)

AKERR_KEY from a sheet load usually means the registry is not up. ENOENT means neither step found the file.

Frames are counted left to right, wrapping down

A sheet has no grid metadata that the library uses. The grid comes from the sprite: width and height are both the drawn frame size and the stride used to find a frame on the sheet.

    A 192x96 sheet, with a sprite whose width and height are 48:

    +----+----+----+----+
    |  0 |  1 |  2 |  3 |   frame numbers count left to right from the
    +----+----+----+----+   top-left, then wrap to the next row
    |  4 |  5 |  6 |  7 |
    +----+----+----+----+

    "frames": [ 4, 5, 6, 5 ]   an animation is a playlist of frame numbers.
       index:   0  1  2  3     A frame may repeat; the order is yours.

Two indices, and confusing them is the most common mistake here:

Name What it indexes Where you see it
frame number a cell on the sheet the values inside "frames"
frame id a slot in the "frames" playlist akgl_Actor::curSpriteFrameId, and the frameid argument to akgl_spritesheet_coords_for_frame

akgl_spritesheet_coords_for_frame takes a frame id, looks up frameids[frameid], multiplies by the sprite width, and wraps whole rows off the right-hand edge of the texture, stepping down one sprite height per row. The result is the source rectangle for the blit.

It does not bounds-check frameid. An index past frames — or past AKGL_SPRITE_MAX_FRAMES — reads a neighbouring struct member and yields a nonsense rectangle rather than an error. It also dereferences sheet->texture without checking it, so a registered-but-unloaded sheet is a crash.

akgl_SpriteSheet carries sprite_w and sprite_h fields. They are vestigial. Nothing in the library writes or reads them, and the two arguments akgl_spritesheet_initialize takes for them are accepted and discarded. The grid that is actually used is the sprite's width/height. They are still in the struct and in the signature because removing them is an ABI change.

The sprite JSON format

{
    "spritesheet": {
	"filename": "spritesheet.png",
	"frame_width": 48,
	"frame_height": 48
    },
    "name": "hero walking left",
    "width": 48,
    "height": 48,
    "speed": 100,
    "loop": true,
    "loopReverse": true,
    "frames": [
	12,
	13,
	14
    ]
}
Key Type Meaning
spritesheet.filename string Image path. Resolved as above. Required.
spritesheet.frame_width integer Read only when the sheet is not already loaded, then discarded.
spritesheet.frame_height integer Same.
name string Registry key in AKGL_REGISTRY_SPRITE. Required.
width integer Frame width in pixels, and the horizontal stride. Must be >= 1.
height integer Frame height in pixels, and the vertical stride. Must be >= 1.
speed integer Milliseconds one frame is held. 0 to 4294.
loop boolean Restart at the end rather than holding the last frame.
loopReverse boolean With loop, walk back down instead of jumping to 0 — a ping-pong.
frames array of integers Frame numbers, in playback order. At most 16, each 0..255.

Every one of those keys is required. These are not optional-with-a-default lookups: an absent key is AKERR_KEY naming it, and a key of the wrong JSON type is AKERR_TYPE. speed as a string fails; frames as an object fails.

speed is milliseconds in the file and nanoseconds in the struct

akgl_Sprite::speed is compared against SDL_GetCurrentTime, which counts nanoseconds. Nobody wants to type nanoseconds into an asset file, so the loader reads milliseconds and multiplies by AKGL_TIME_ONEMS_NS (1000000).

If you set speed by hand rather than through the loader, scale it yourself. Writing spr->speed = 100 means 100 nanoseconds, and the animation advances every frame.

The field is uint32_t, so the multiply is what bounds the range: values above UINT32_MAX / AKGL_TIME_ONEMS_NS4294 ms — would overflow, and are refused with AKERR_OUTOFBOUNDS rather than wrapping. A negative speed is refused too.

What the loader actually validates

Read against src/sprite.c, not against the header prose:

Check Status Message says
strlen(filename) >= AKGL_MAX_STRING_LENGTH AKERR_OUTOFBOUNDS the JSON path is too long for a pooled string
width <= 0 AKERR_VALUE "a sprite must be at least one pixel wide"
height <= 0 AKERR_VALUE "a sprite must be at least one pixel high"
speed < 0 or speed > 4294 AKERR_OUTOFBOUNDS the value and the permitted range
frames longer than AKGL_SPRITE_MAX_FRAMES (16) AKERR_OUTOFBOUNDS the declared count and the maximum
a frame number outside 0..255 AKERR_OUTOFBOUNDS which index, its value, and the range

The last two are recent and are worth knowing about because sprite.h still says otherwise. Its @note on akgl_sprite_load_json reads "The frames array is not bounded against AKGL_SPRITE_MAX_FRAMES. A definition with more than 16 frames writes past frameids into the rest of the struct." That is no longer true — the count is bounded before anything is written, and tests/assets/ carries fixtures for both the boundary and the overflow. The same @brief describes speed as "seconds, scaled to milliseconds", which is wrong in both units. Correcting those comments is a separate commit; this chapter documents the code.

On any failure the pooled sprite and any sheet loaded for it are released again, so a bad definition does not strand pool slots.

Loading, and building one by hand

The ordinary path is one call per definition file:

#include <akgl/sprite.h>

akerr_ErrorContext *load_hero_sprites(void)
{
    PREPARE_ERROR(errctx);

    /* Both cut from one image: the second call reuses the first's texture. */
    PASS(errctx, akgl_sprite_load_json("assets/hero_walk_left.json"));
    PASS(errctx, akgl_sprite_load_json("assets/hero_walk_right.json"));
    SUCCEED_RETURN(errctx);
}

You can also assemble one from the pools directly, which is what the loader does underneath. akgl_sprite_initialize sets the name and the sheet and takes the first reference; everything else is left at zero for you to fill in:

#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/sprite.h>

/*
 * Two animations cut from one image, built without a JSON file. One sheet,
 * one texture, two sprites borrowing it.
 */
akerr_ErrorContext *build_hero_sprites(char *imagepath)
{
    akgl_SpriteSheet *sheet = NULL;
    akgl_Sprite *walk = NULL;
    akgl_Sprite *idle = NULL;
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, imagepath, AKERR_NULLPOINTER, "imagepath");

    ATTEMPT {
	CATCH(errctx, akgl_heap_next_spritesheet(&sheet));
	/* The two size arguments are discarded; the sprite's width/height win. */
	CATCH(errctx, akgl_spritesheet_initialize(sheet, 48, 48, imagepath));

	CATCH(errctx, akgl_heap_next_sprite(&walk));
	CATCH(errctx, akgl_sprite_initialize(walk, "hero walking left", sheet));
	walk->width = 48;
	walk->height = 48;
	walk->frames = 3;
	walk->frameids[0] = 12;
	walk->frameids[1] = 13;
	walk->frameids[2] = 14;
	walk->speed = 100 * AKGL_TIME_ONEMS_NS;     /* 100 ms, in nanoseconds */
	walk->loop = true;
	walk->loopReverse = true;

	CATCH(errctx, akgl_heap_next_sprite(&idle));
	CATCH(errctx, akgl_sprite_initialize(idle, "hero standing left", sheet));
	idle->width = 48;
	idle->height = 48;
	idle->frames = 1;
	idle->frameids[0] = 13;
	idle->speed = 1000 * AKGL_TIME_ONEMS_NS;
	idle->loop = false;
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

A string literal is a safe name argument. sprite.h warns that the name is "copied at a fixed AKGL_SPRITE_MAX_NAME_LENGTH bytes, so a shorter string reads past its end" — that was a memcpy of the full field width and it is gone. The copy is aksl_strncpy now, which reads only what is there and always terminates.

Animation is the actor's job, not the sprite's

An akgl_Sprite is a description. Nothing in it moves. The advancing is done by the actor holding it — curSpriteFrameId, curSpriteFrameTimer and curSpriteReversing are actor fields, and changeframefunc is an actor hook. See Chapter 12.

That is why the same sprite can be shared by a hundred actors that are all on different frames.

The three flag combinations, as the default changeframefunc reads them:

loop loopReverse At the last frame
true false wrap to frame id 0
true true turn round and walk back down, turning again at 0
false either wrap to frame id 0 anyway

The last row is not a typo. A sprite with loop clear still wraps rather than holding the final frame; there is no play-once behaviour in the default hook. A game that wants one binds its own changeframefunc.

Known defects worth knowing here

  • Truncated names are registry keys. akgl_Sprite::name is 128 bytes and akgl_SpriteSheet::name is 512. A longer name truncates silently, and two names that truncate the same collide — the second registration replaces the first with no error. Recorded as issue #54; the fix is a contract change, since the headers currently promise truncation.
  • The heap acquire functions are asymmetric. akgl_heap_next_string increments refcount; next_sprite and next_spritesheet do not. Issue #14. See Chapter 05.
  • No test asserts a clean sprite or spritesheet load/release cycle. The tilemap cycle is asserted over 64 iterations; the sprite, spritesheet and character ones are not. TODO.md, "Targets", row 16; issue #13.
  • akgl_spritesheet_coords_for_frame bounds-checks nothing. See above.

Where to look next