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>
This commit is contained in:
2026-08-01 20:58:37 -04:00
parent 8ac291d2dd
commit b938460127
22 changed files with 6306 additions and 264 deletions

332
docs/08-rendering.md Normal file
View File

@@ -0,0 +1,332 @@
# 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](https://wiki.libsdl.org/SDL3/CategoryRender), 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 contract**`frame_start`, then any number of `draw_*` calls, then
`frame_end`.
3. **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:
```c excerpt=include/akgl/renderer.h
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:
```c
#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](07-the-game-and-the-frame.md). 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](04-errors.md); the protocol itself is libakerror's and is
documented in `deps/libakerror`.
## The frame contract
```text
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:
```c norun
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:
```c
#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`.
```text
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](12-actors.md) and
[Chapter 13](13-tilemaps.md).
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](06-the-registry.md).
**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.
```c
#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:
```c
#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](09-drawing.md) — the immediate-mode primitives that draw through
the same backend.
- [Chapter 12](12-actors.md) — `renderfunc`, `layer`, and what `draw_world`
actually calls.
- [Chapter 13](13-tilemaps.md) — layers, and what `akgl_tilemap_draw` does with
the camera.
- [Chapter 07](07-the-game-and-the-frame.md) — where `frame_start` and `frame_end`
sit inside `akgl_game_update`.