Files
libakgl/docs/08-rendering.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

14 KiB

08. Rendering

SDL3 already owns the renderer. SDL_Renderer, SDL_Texture, SDL_FRect, blend modes and the batching behaviour behind SDL_RenderPresent are documented in the SDL3 wiki, and this chapter does not restate any of it.

What libakgl adds is three things, and only three:

  1. A backend struct — a record of function pointers, so a renderer can be replaced without a branch anywhere in the library.
  2. A frame contractframe_start, then any number of draw_* calls, then frame_end.
  3. A scene walkdraw_world, which interleaves tilemap layers with the actors standing on them.

Per-function reference is generated from the headers by Doxygen and is CI-gated; this chapter teaches the shape and links there for signatures.

The backend is a record of function pointers

There is no renderer base class, no vtable pointer, and no dynamic dispatch. There is a struct with an SDL_Renderer * and six function pointers on it:

typedef struct akgl_RenderBackend {
    SDL_Renderer *sdl_renderer;
    akerr_ErrorContext AKERR_NOIGNORE *(*shutdown)(struct akgl_RenderBackend *self);
    akerr_ErrorContext AKERR_NOIGNORE *(*frame_start)(struct akgl_RenderBackend *self);
    akerr_ErrorContext AKERR_NOIGNORE *(*frame_end)(struct akgl_RenderBackend *self);
    akerr_ErrorContext AKERR_NOIGNORE *(*draw_texture)(struct akgl_RenderBackend *self, SDL_Texture *texture, SDL_FRect *src, SDL_FRect *dest, double angle, SDL_FPoint *center, SDL_FlipMode flip);
    akerr_ErrorContext AKERR_NOIGNORE *(*draw_mesh)(struct akgl_RenderBackend *self);
    akerr_ErrorContext AKERR_NOIGNORE *(*draw_world)(struct akgl_RenderBackend *self, akgl_Iterator *opflags);
} akgl_RenderBackend;

A backend is therefore an initializer that fills those six slots in. One is shipped — the 2D SDL one, whose entry points are the akgl_render_2d_* functions — and a second renderer would be a second initializer pointing the same six slots at different functions. Nothing in the library tests "which renderer is this".

Call through the pointers, not the names. akgl_render_2d_frame_start(b) and b->frame_start(b) do the same thing today and stop doing the same thing the moment anybody swaps a backend in. Every call site inside libakgl goes through the pointer, and so should yours:

#include <akgl/game.h>
#include <akgl/renderer.h>

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

    PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
    PASS(errctx, akgl_renderer->draw_world(akgl_renderer, NULL));
    PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
    SUCCEED_RETURN(errctx);
}

akgl_renderer is the global the rest of the library draws through. It is a pointer into akgl_default_renderer, set by akgl_game_init; see Chapter 7. Reassigning akgl_renderer is how a program installs its own backend — there is exactly one world, so there is exactly one of these.

Every entry point returns akerr_ErrorContext AKERR_NOIGNORE *. Statuses and what they mean are Chapter 4; the protocol itself is libakerror's and is documented in deps/libakerror.

The frame contract

    frame_start(self)           clear the target to opaque black
        |
        +--> draw_world(self, opflags)      the tilemap and its actors
        +--> draw_texture(self, ...)        one blit
        +--> akgl_draw_* (chapter 09)       lines, boxes, circles, fills
        |
    frame_end(self)             present

frame_start sets the draw colour to opaque black and calls SDL_RenderClear itself. You do not need to clear again. The FAQ half of the old README.md showed this:

PASS(e, akgl_renderer->frame_start(akgl_renderer));
SDL_RenderClear(akgl_renderer->sdl_renderer);

The second line is redundant — it clears an already-cleared target, unchecked, and at whatever draw colour was left behind. Drop it. (norun because it is quoted as an example of what not to write; it compiles, and that is the problem.)

Both check self and then self->sdl_renderer before touching either, so a NULL backend and a backend that was bound but never given a renderer both report AKERR_NULLPOINTER rather than crashing. renderer.h says the opposite — its @param self for frame_start, frame_end and draw_texture reads "dereferenced before it is checked -- a NULL @p self is a crash, not an error". Read src/renderer.c: the first statement in each is a FAIL_ZERO_RETURN on self.

The camera

akgl_camera is an SDL_FRect * in map coordinates: x/y are the top-left corner of the view into the world, w/h its size. It is a plain pointer into akgl_default_camera, and everything that draws world content subtracts it.

akgl_render_2d_init points it at the full screen rectangle at startup. Scrolling is just writing to it:

#include <akgl/actor.h>
#include <akgl/game.h>

/* Centre the view on an actor, and do not scroll past the top-left of the map. */
akerr_ErrorContext *camera_follow(akgl_Actor *obj)
{
    PREPARE_ERROR(errctx);

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

    akgl_camera->x = obj->x - (akgl_camera->w / 2.0f);
    akgl_camera->y = obj->y - (akgl_camera->h / 2.0f);
    if ( akgl_camera->x < 0.0f ) {
	akgl_camera->x = 0.0f;
    }
    if ( akgl_camera->y < 0.0f ) {
	akgl_camera->y = 0.0f;
    }
    SUCCEED_RETURN(errctx);
}

There is no camera object and no camera API — a rectangle is the whole model. A game that wants two views draws twice with the pointer moved between them.

draw_world walks sixteen layers

draw_world is the one call that draws a scene rather than a thing. For each layer index i from 0 to AKGL_TILEMAP_MAX_LAYERS (16), it:

  1. draws layer i of the global akgl_gamemap through akgl_camera, if the map has that many layers; then
  2. sweeps all AKGL_MAX_HEAP_ACTOR (64) actor pool slots and calls renderfunc on every live actor whose layer equals i.
    layer 0   tilemap layer 0 ....... actors with layer == 0
    layer 1   tilemap layer 1 ....... actors with layer == 1
    layer 2   (map has only 2)        actors with layer == 2
      ...                               ...
    layer 15  --                      actors with layer == 15

That interleaving is the point: it is what puts an actor in front of the ground it stands on and behind the archway it walks under. An actor's layer field is set from the object layer it was placed in — see Chapter 12 and Chapter 13.

The map layer is skipped once i reaches akgl_gamemap->numlayers, but the actor sweep is not: an actor on layer 9 of a two-layer map still draws. It just draws over bare background.

draw_world reads akgl_gamemap, akgl_camera and akgl_heap_actors directly rather than taking them as arguments. There is one world.

opflags is accepted and ignored

The akgl_Iterator *opflags argument is part of the backend signature and is currently ignored entirely. NULL substitutes a zeroed iterator, and neither that nor a set of flags you built yourself reaches anything: draw_world sweeps the actor pool itself instead of going through akgl_registry_iterate_actor, so no AKGL_ITERATOR_OP_* bit — including AKGL_ITERATOR_OP_LAYERMASK — has any effect here. Pass NULL. When layer masking is wired up, the parameter is where it will land.

Two things draw_world does not check

  • akgl_gamemap is dereferenced unguarded. Calling draw_world before akgl_tilemap_load is a crash, not an error context.
  • A live actor's renderfunc is called unguarded. A hand-built actor that was never run through akgl_actor_initialize has a NULL there. Always initialize actors through the library.

The first failure aborts the frame and propagates unchanged; there is no draw-what-you-can behaviour.

Known defect (issue #26). The layer loop always runs all 16 iterations and rescans all 64 actor slots on each — 1024 refcount checks per frame for a one-layer map. Invisible at 60 fps under the software renderer, measurable on a 2 ms GPU frame. Bounding the walk by numlayers and building per-layer actor lists in one pool pass is the recorded fix.

The embedding seam: akgl_render_2d_bind vs akgl_render_2d_init

This is a first-class use case, not a footnote. Creating a window and populating a vtable are two separable jobs, and libakgl separates them.

akgl_render_2d_init akgl_render_2d_bind
Reads configuration properties yes — game.screenwidth, game.screenheight no
Creates a window and SDL_Renderer yes no
Touches self->sdl_renderer overwrites it never touches it
Points akgl_camera at the screen yes no
Installs the six function pointers yes, by calling _bind yes
Needs the registry initialized first yes no

akgl_render_2d_init is the ordinary path for a program that owns its whole process. It reads game.screenwidth and game.screenheight from the property registry — both defaulting to the string "0", which asks SDL for a zero-sized window — creates the window and renderer, sets akgl_camera to the full screen rectangle, and then calls akgl_render_2d_bind to fill in the vtable. Because it reads properties, akgl_registry_init_properties and your property writes have to happen first; see Chapter 6.

The window title is akgl_game.uri. Not akgl_game.name — the reverse-DNS identifier is what SDL_CreateWindowAndRenderer is handed. If your title bar reads tech.starfort.mygame, that is why.

akgl_render_2d_bind is the other half on its own. It writes six pointers and returns. It deliberately does not touch sdl_renderer, which is exactly what makes it usable by a host that already has one — an embedded interpreter, a level editor, an application with its own window that wants libakgl to draw inside it. This is what the sibling akbasic consumer uses.

#include <SDL3/SDL.h>
#include <akgl/game.h>
#include <akgl/renderer.h>

/*
 * A host that already owns its window and SDL_Renderer. It never calls
 * akgl_render_2d_init, so libakgl never creates a second window.
 */
akerr_ErrorContext *host_bind_renderer(SDL_Renderer *mine, int w, int h)
{
    PREPARE_ERROR(errctx);

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

    /* _bind never writes sdl_renderer, so put yours there first. */
    akgl_default_renderer.sdl_renderer = mine;
    PASS(errctx, akgl_render_2d_bind(&akgl_default_renderer));

    /* Everything else in the library draws through the global. */
    akgl_renderer = &akgl_default_renderer;

    /* _bind does not set the camera; _init would have. */
    akgl_camera = &akgl_default_camera;
    akgl_camera->x = 0.0f;
    akgl_camera->y = 0.0f;
    akgl_camera->w = (float)w;
    akgl_camera->h = (float)h;
    SUCCEED_RETURN(errctx);
}

Bind the global, not a backend of your own

This is the trap in the seam, and it is worth stating flatly:

draw_world hands self to the tilemap and to each actor's renderfunc, but neither of them uses it. akgl_actor_render and akgl_tilemap_draw both reach straight for the global akgl_renderer. So does akgl_spritesheet_initialize, and so does every texture load in the tilemap loader.

A host that binds a private akgl_RenderBackend and calls mine.draw_world(&mine, NULL) gets its tilemap and its actors drawn through whatever akgl_renderer points at — which, if akgl_game_init ran, is akgl_default_renderer with a NULL sdl_renderer. The failure is an AKERR_NULLPOINTER from deep inside a texture load, and it reads like a missing asset.

Set akgl_renderer to the backend you bound. The example above does.

A backend that has been bound but never given an SDL_Renderer refuses cleanly rather than crashing: every entry point checks self->sdl_renderer and reports AKERR_NULLPOINTER. That is the designed-for state and is what makes _bind safe to call before you have a renderer.

A header claim that is no longer true. renderer.h ends akgl_render_2d_init's documentation with a @note saying the two pooled strings holding the dimensions "are only released on the success path, so each failed initialization leaks two string slots". They are released in a CLEANUP block that runs on both paths, and src/renderer.c carries a comment saying that is exactly why it was moved there. There is no leak on the current code. Correcting the header comment is a separate commit; this chapter documents what the code does.

What the 2D backend refuses

draw_mesh always fails. Every call raises AKERR_API with the message "Not implemented". The hook exists so a 3D backend has a slot to fill. It is not a stub that quietly does nothing — a caller that reaches it finds out on the first call, which is the intent:

#include <akgl/game.h>
#include <akgl/renderer.h>

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

    ATTEMPT {
	CATCH(errctx, akgl_renderer->draw_mesh(akgl_renderer));
    } CLEANUP {
    } PROCESS(errctx) {
    } HANDLE(errctx, AKERR_API) {
	/* The 2D backend has no geometry path. Draw it as textures instead. */
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

akgl_render_2d_shutdown is a documented no-op. It validates self and succeeds. The window and SDL_Renderer are still SDL's and are reclaimed by SDL_Quit, so there is nothing here to release yet. Call it anyway — a backend that acquires anything will grow a failure path, and the hook is where it will go.

Where to look next

  • Chapter 09 — the immediate-mode primitives that draw through the same backend.
  • Chapter 12renderfunc, layer, and what draw_world actually calls.
  • Chapter 13 — layers, and what akgl_tilemap_draw does with the camera.
  • Chapter 07 — where frame_start and frame_end sit inside akgl_game_update.