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
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 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.
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.
#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.
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.
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));
}
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 atx = 10.5is a circle atx = 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.
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:
#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_fillis 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_Rendereris, 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".
#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.
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.
#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 — the backend these all take, and the frame the calls sit inside.
- Chapter 16 — text, which is textures rather than primitives.
- Chapter 05 — the pools, and why
copy_regionreturning owned memory is the exception rather than the rule.
