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

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 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.

    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_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".

#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_region returning owned memory is the exception rather than the rule.