Files
libakgl/docs/08-rendering.md
Andrew Kesterson b938460127 Add the manual: nineteen chapters and a corrected README
docs/ is a narrative manual, not a second reference. Every header already
carries a substantial @file/@brief block and Doxyfile sets WARN_IF_UNDOCUMENTED
with WARN_AS_ERROR, so an undocumented symbol already fails CI. The gap was
navigation and worked examples. Chapters teach a task and link to the Doxygen
output; where a declaration or a constant table has to be in front of the
reader it arrives as a `c excerpt=` block, so the text *is* the header and
cannot diverge from it. That also preserves the hand-aligned bit-flag tables
scripts/reindent.el goes out of its way not to destroy.

The manual does not re-document its dependencies. libakerror owns the
ATTEMPT/CLEANUP/PROCESS/HANDLE/FINISH protocol, SDL3 owns renderers and events,
Tiled owns the map format, jansson owns json_t. A chapter that restated any of
them would be wrong the day upstream changed and nothing here would notice --
the same drift this work exists to fix, arriving from a different direction. So
each chapter says what libakgl adds or constrains and links out for the rest.

Chapter 4 is the exception and the reason for it: libakerror documents the
mechanism, but only libakgl can say which statuses its own functions raise and
what they mean here, and that was written down nowhere. It carries three tables
-- libakgl's five status codes, the libakerror statuses libakgl actually raises
with their meaning in this library, and the exit-status trap where
`exit(AKGL_ERR_SDL)` is a wait status of 0 because the band starts at 256.

Every chapter was written against src/ rather than against the header comments,
which is how 27 false claims in those comments came to light. Where a chapter
documents a known defect rather than a design decision it says so and points at
TODO.md.

README.md keeps the development process and hands the reader to docs/. Its
task-oriented FAQ is deleted rather than moved, because one source of truth per
topic is the whole point and that FAQ's examples did not compile.

Census: 39 compiled snippets, 89 verbatim header excerpts, 4 JSON documents run
through the real loaders, one linked-and-executed program with its output
compared byte for byte, one generated figure. 11 norun blocks, each justified.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:58:37 -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 (TODO.md, "Performance", item 6). 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.