Files
libakgl/docs/09-drawing.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

333 lines
14 KiB
Markdown

# 09. Drawing
`draw.h` is immediate-mode plotting: put this on the screen *now*, as opposed to
add this to the scene. It sits alongside the actor and tilemap rendering in
[Chapter 08](08-rendering.md) rather than replacing it — a game can draw a world
through `draw_world` and then scribble a debug rectangle over it in the same frame.
The shape comes from a BASIC-style graphics vocabulary. `DRAW`, `BOX`, `CIRCLE`,
`PAINT`, `SSHAPE` and `GSHAPE` all map onto one of these, which is why the set is
what it is and why there is a flood fill in a game library at all.
| Function | Draws | Notable |
|---|---|---|
| `akgl_draw_point` | one pixel | clipping is SDL's, not reported |
| `akgl_draw_line` | a line, endpoints included | coincident endpoints draw one pixel |
| `akgl_draw_rect` | an outline | zero or negative w/h draws nothing, silently |
| `akgl_draw_filled_rect` | a solid box, outline included | same |
| `akgl_draw_circle` | an outline | midpoint algorithm, integer, no AA |
| `akgl_draw_flood_fill` | a connected region | CPU-side, bounded, **not reentrant** |
| `akgl_draw_copy_region` | *reads* a rectangle into a surface | allocates when you ask it to |
| `akgl_draw_paste_region` | a saved surface back onto the target | replaces, does not blend |
| `akgl_draw_background` | an 8x8 grey checkerboard | diagnostic, not a general primitive |
Signatures are in the generated Doxygen. Statuses are [Chapter 04](04-errors.md).
**These are ordinary functions, not backend methods.** Unlike `frame_start` and
`draw_world`, they are not slots on `akgl_RenderBackend` — they take an
`akgl_RenderBackend *` as their first argument and reach through its
`sdl_renderer`. Pass `akgl_renderer` unless you are deliberately drawing somewhere
else. Every one of them validates both `self` and `self->sdl_renderer` before
touching either.
## Colour is an argument, never a global
**Every entry point takes its colour as a parameter, and none of them reads or
writes a current-colour global.**
The reasoning is worth stating because it looks like extra typing. A caller that
has a notion of a current colour — a BASIC `COLOR` statement, a UI theme, a palette
index — *already owns that state*. Giving the library a second copy creates two
places for the answer to live and one opportunity for them to disagree. So there is
no `akgl_draw_set_color`, and there never will be.
The other half of the rule is the one that matters at a call site: **the renderer's
own draw colour is saved before each call and restored after it.** Drawing a red
line does not leave the renderer red. In particular, it does not change what the
next `SDL_RenderClear` — or the next `frame_start`, which clears — paints.
```c
#include <akgl/draw.h>
#include <akgl/game.h>
/* Two rectangles, two colours, no state carried between them. */
akerr_ErrorContext *draw_health_bar(float32_t x, float32_t y, float32_t frac)
{
SDL_Color back = { 0x20, 0x20, 0x20, SDL_ALPHA_OPAQUE };
SDL_Color fill = { 0xd0, 0x30, 0x30, SDL_ALPHA_OPAQUE };
SDL_FRect rect;
PREPARE_ERROR(errctx);
rect.x = x;
rect.y = y;
rect.w = 100.0f;
rect.h = 8.0f;
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &rect, back));
rect.w = 100.0f * frac;
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &rect, fill));
SUCCEED_RETURN(errctx);
}
```
Alpha is in the colour and blending follows the renderer's **current blend mode**,
which these functions do not change. If you want alpha to blend rather than
overwrite, set the blend mode yourself with
[`SDL_SetRenderDrawBlendMode`](https://wiki.libsdl.org/SDL3/SDL_SetRenderDrawBlendMode).
That one *is* renderer state libakgl leaves alone.
Restoring the colour happens in a `CLEANUP` block under `IGNORE()`. A failure to
restore is logged rather than propagated, on the grounds that it must not mask the
failure the cleanup is unwinding from.
## What the primitives look like
Every primitive in one frame, on a 320x240 surface. The picture below is generated by
running exactly this listing — see `MAINTENANCE.md` if you are editing it.
```c screenshot=primitives
SDL_Color red = { 0xd0, 0x30, 0x30, 0xff };
SDL_Color green = { 0x30, 0xc0, 0x50, 0xff };
SDL_Color blue = { 0x40, 0x70, 0xe0, 0xff };
SDL_Color yellow = { 0xe0, 0xc0, 0x40, 0xff };
SDL_FRect filled = { 20.0f, 20.0f, 80.0f, 50.0f };
SDL_FRect framed = { 120.0f, 20.0f, 80.0f, 50.0f };
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &filled, blue));
PASS(errctx, akgl_draw_rect(akgl_renderer, &framed, green));
PASS(errctx, akgl_draw_circle(akgl_renderer, 260.0f, 45.0f, 25.0f, yellow));
PASS(errctx, akgl_draw_line(akgl_renderer, 20.0f, 100.0f, 300.0f, 100.0f, red));
/*
* A row of points, to show that a point is a pixel and not a dot of some
* nominal size. At this scale they read as a dashed line.
*/
for ( float32_t x = 20.0f; x < 300.0f; x += 8.0f ) {
PASS(errctx, akgl_draw_point(akgl_renderer, x, 115.0f, yellow));
}
```
![](images/primitives.png)
The filled rectangle is `akgl_draw_filled_rect`; the outlined one beside it is
`akgl_draw_rect`. Neither left the renderer blue or green — the line drawn afterwards is
red because it asked to be, not because it inherited anything.
## Circles are integer midpoint circles
SDL3 has no circle primitive, so `akgl_draw_circle` plots one: the midpoint circle
algorithm, integer arithmetic throughout, eight-way symmetry — one computed point
in the second octant gives the other seven by reflection.
Three consequences you will see on screen:
- **The centre and the radius are rounded to whole pixels** (`SDL_lroundf`). A
circle at `x = 10.5` is a circle at `x = 11`. There is no sub-pixel placement.
- **The outline is one pixel wide and is not anti-aliased.** There is no
thickness parameter and no smoothing. If you want a thick or smooth circle, draw
concentric ones or blit a texture.
- **There is no filled circle.** Only the outline. A filled disc is a flood fill of
the interior, or a texture.
A radius of exactly 0 draws the centre pixel and nothing else. A negative radius is
`AKERR_OUTOFBOUNDS` and the message reports it.
A failure inside the plotting loop is recorded in a flag and reported once at the
end rather than aborting mid-circle — a `CATCH` there would `break` the loop rather
than leave the function, the hazard `AGENTS.md` describes. **So an
`AKGL_ERR_SDL` from `akgl_draw_circle` may leave a partial arc already on the
target.** The same is true of `akgl_draw_background`.
## Flood fill
`akgl_draw_flood_fill` is the odd one out, and it is worth understanding why before
using it. Every other primitive here is a command queued to the GPU. This one
cannot be: **the region it fills is defined by what is already on the screen**, so
it has to read the render target back, walk the region on the CPU, and blit the
result over the area it touched.
```text
SDL_RenderReadPixels(target) the whole target comes back
|
SDL_ConvertSurface(RGBA32) fixed 32-bit layout, so the walk
| can compare and write whole words
flood_region() scanline fill over a bounded span
| stack; records a dirty bounding box
SDL_RenderTexture(dirty, dirty) only what changed goes back
```
### What counts as the region
**Four-connected**: the fill spreads up, down, left and right, never diagonally. A
region joined only at a corner is two regions.
**The boundary is an exact colour comparison.** Any pixel whose packed RGBA value
differs from the seed's, by any amount, is a boundary. There is no tolerance
parameter.
That second one is the practical trap. **An anti-aliased edge stops the fill at its
first blended pixel and leaves a fringe** — you get the flat interior and a halo of
untouched blend pixels around it. Text rendered through SDL3_ttf, a scaled sprite,
and anything drawn with smoothing all have such edges. Flood fill wants hard-edged
art.
Filling a region that is already the requested colour is a **no-op that succeeds**,
not an error: walking it would compare filled pixels against themselves and find
nothing, so the function says so up front. A seed outside the render target is
`AKERR_OUTOFBOUNDS`, and the message reports both the seed and the target size.
### The span stack, and what it costs you
The fill keeps a fixed stack of horizontal runs still to be examined rather than
recursing per pixel:
```c excerpt=include/akgl/draw.h
#define AKGL_DRAW_MAX_FLOOD_SPANS 4096
```
At 4096 spans that array is 48 KB, which does not belong on the stack of a function
a game may call every frame — so it is **file scope**. Two consequences, and both
are hard rules rather than cautions:
- **`akgl_draw_flood_fill` is not reentrant.** Do not call it from inside a
callback that a flood fill can reach. There is one span stack for the process.
- **It is not thread-safe.** Nothing that touches an `SDL_Renderer` is, so this is
not a new restriction — but the span stack means it stays unsafe even against a
second renderer on a second thread.
An ordinary convex or moderately concave shape needs a few dozen pending spans. A
region complicated enough to need more than 4096 at once reports
`AKERR_OUTOFBOUNDS` — and **leaves the region partially filled**. There is no way
to unwind a partial fill short of keeping a copy of the whole surface, and you
asked for a bounded operation. Treat that status as "redraw the area", not as
"retry".
```c
#include <akgl/draw.h>
#include <akgl/game.h>
akerr_ErrorContext *paint_region(int x, int y, SDL_Color color)
{
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_draw_flood_fill(akgl_renderer, x, y, color));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKERR_OUTOFBOUNDS) {
/*
* Either the seed was off-target, or the region needed more than
* AKGL_DRAW_MAX_FLOOD_SPANS pending spans -- in which case it is
* already PARTIALLY filled. Redraw the area; do not retry the fill.
*/
LOG_ERROR(errctx);
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
```
The colour is **written over** the region rather than blended, since the pixels
going back are the ones just read out of it.
## Saving and restoring a region
`akgl_draw_copy_region` and `akgl_draw_paste_region` are `SSHAPE` and `GSHAPE`: read
a rectangle of the render target into an `SDL_Surface`, and draw one back.
### copy_region allocates only when you ask it to
`dest` is an `SDL_Surface **`, and **`*dest` must be initialized before the call**.
Its value selects between two behaviours:
| `*dest` on entry | What happens | Who owns the surface afterwards |
|---|---|---|
| `NULL` | a surface is allocated and written back through `dest` | **you do** — release it with `SDL_DestroySurface` |
| a surface of *exactly* `src`'s dimensions | the pixels are copied into it | you already did |
| a surface of any other size | `AKERR_OUTOFBOUNDS`, both sets of dimensions in the message | unchanged |
The reuse path exists so a caller saving the same region every frame does not churn
allocations. Note that `akgl_draw_copy_region` is one of the few places in libakgl
that hands you memory you have to free yourself — everything else comes from the
pools in [Chapter 05](05-the-heap.md).
**`src` must fit entirely inside the render target.** This is checked and refused
with `AKERR_OUTOFBOUNDS` rather than clipped, deliberately: SDL would clip the read
and hand back a *smaller* surface than was asked for, and a caller pasting it back
would not notice until the art was visibly wrong. A rectangle with no area is
refused too.
### paste_region replaces, it does not blend
`akgl_draw_paste_region` sets `SDL_BLENDMODE_NONE` on the texture it uploads, so
the saved pixels **overwrite** what is on the target. That is `GSHAPE`'s default
behaviour and it is the one that makes a save/restore pair actually restore: a
saved region's transparent pixels come back as transparent, rather than letting
whatever is underneath show through them.
There is no scaling — the surface is drawn at its own size — and the surface is not
consumed, so it may be pasted as many times as you like. Parts falling outside the
target are clipped by SDL and not reported.
```c
#include <akgl/draw.h>
#include <akgl/game.h>
/* SSHAPE then GSHAPE: save a 32x32 patch, draw over it, put it back. */
akerr_ErrorContext *blink_cursor(int x, int y)
{
SDL_Surface *saved = NULL;
SDL_Rect box;
SDL_FRect cursor;
SDL_Color white = { 0xff, 0xff, 0xff, SDL_ALPHA_OPAQUE };
PREPARE_ERROR(errctx);
box.x = x;
box.y = y;
box.w = 32;
box.h = 32;
cursor.x = (float32_t)x;
cursor.y = (float32_t)y;
cursor.w = 32.0f;
cursor.h = 32.0f;
ATTEMPT {
/* saved is NULL, so copy_region allocates it and we own the result. */
CATCH(errctx, akgl_draw_copy_region(akgl_renderer, &box, &saved));
CATCH(errctx, akgl_draw_filled_rect(akgl_renderer, &cursor, white));
CATCH(errctx, akgl_draw_paste_region(akgl_renderer, saved, cursor.x, cursor.y));
} CLEANUP {
if ( saved != NULL ) {
SDL_DestroySurface(saved);
saved = NULL;
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
```
## The checkerboard backdrop
`akgl_draw_background` paints an 8x8 grey checkerboard — the pattern an image
editor uses to show transparency. It is a **diagnostic backdrop rather than a
general primitive**: `charviewer` uses it so a sprite's transparent pixels are
visible instead of blending into black.
It always starts at the origin. `w` and `h` are the extent to cover, not a
rectangle, and both round *up* to whole 8-pixel cells — a height of 12 paints 16. A
zero or negative extent paints nothing and is not reported.
Until 0.5.0 this was the one function in the library outside the error protocol: it
returned `void`, drew through the global `akgl_renderer` without checking it, and
left the renderer's draw colour changed. It takes a backend now and restores the
colour it found, which is also what makes it testable without a world.
## Where to look next
- [Chapter 08](08-rendering.md) — the backend these all take, and the frame the
calls sit inside.
- [Chapter 16](16-text-and-fonts.md) — text, which is textures rather than
primitives.
- [Chapter 05](05-the-heap.md) — the pools, and why `copy_region` returning owned
memory is the exception rather than the rule.