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>
333 lines
14 KiB
Markdown
333 lines
14 KiB
Markdown
# 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`.
|