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>
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:
- A backend struct — a record of function pointers, so a renderer can be replaced without a branch anywhere in the library.
- A frame contract —
frame_start, then any number ofdraw_*calls, thenframe_end. - A scene walk —
draw_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:
- draws layer
iof the globalakgl_gamemapthroughakgl_camera, if the map has that many layers; then - sweeps all
AKGL_MAX_HEAP_ACTOR(64) actor pool slots and callsrenderfuncon every live actor whoselayerequalsi.
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_gamemapis dereferenced unguarded. Callingdraw_worldbeforeakgl_tilemap_loadis a crash, not an error context.- A live actor's
renderfuncis called unguarded. A hand-built actor that was never run throughakgl_actor_initializehas aNULLthere. 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 bynumlayersand 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.hendsakgl_render_2d_init's documentation with a@notesaying 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 aCLEANUPblock that runs on both paths, andsrc/renderer.ccarries 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 12 —
renderfunc,layer, and whatdraw_worldactually calls. - Chapter 13 — layers, and what
akgl_tilemap_drawdoes with the camera. - Chapter 07 — where
frame_startandframe_endsit insideakgl_game_update.