Files
libakgl/docs/17-text-and-fonts.md
Andrew Kesterson bace818998 Renumber chapters 15 to 21 up by one, to open a slot for collision
A pure rename plus link rewrite and nothing else. Collision is a pluggable
subsystem with two implementations, shapes, a response hook and a tile binding;
that is a chapter, and burying it inside the physics chapter is the shape this
manual otherwise avoids -- rendering and drawing are 8 and 9, spritesheets and
characters and actors are 10, 11 and 12.

Kept separate from writing the chapter because **nothing validates a
cross-reference target.** The example harness reads fenced blocks; it does not
follow links. A rename mixed into five hundred lines of new prose is not
reviewable, and a broken link would land silently. As its own commit it is
reviewable as a rename, and a grep for dangling `](NN-` targets is clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:15:25 -04:00

10 KiB

17. Text and fonts

SDL3_ttf owns fonts. It opens the file, rasterizes the glyphs, and computes the metrics; its API and its behaviour are documented in the SDL3_ttf wiki. This chapter covers the three things libakgl adds on top: a name-keyed font registry, a one-call rasterize-and-blit, and two measure functions that need no window.

There is no akgl font type. A font is a TTF_Font *, and what libakgl gives you is a place to keep it.

Fonts live in a registry, keyed by a name you choose

akgl_text_loadfont(name, filepath, size) opens the file and publishes the handle in AKGL_REGISTRY_FONT under name. The name is yours — it is not derived from the file — and filepath is used verbatim, not resolved against SDL_GetBasePath().

The size is baked into the handle, so one file at two sizes is two fonts under two names. That is SDL_ttf's model, not libakgl's, and there is no way around it:

#include <akerror.h>
#include <akstdlib.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akgl/game.h>
#include <akgl/registry.h>
#include <akgl/text.h>

/* Two sizes of one file are two fonts under two names. */
akerr_ErrorContext *hud_load_fonts(char *path)
{
    PREPARE_ERROR(errctx);
    PASS(errctx, akgl_text_loadfont("hud", path, 14));
    PASS(errctx, akgl_text_loadfont("title", path, 32));
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *hud_draw_score(int score)
{
    TTF_Font *font = NULL;
    SDL_Color white = { 255, 255, 255, 255 };
    char line[32];
    int count = 0;

    PREPARE_ERROR(errctx);

    /* The registry hands back a raw TTF_Font *. It is not reference counted:
       nothing here takes a reference and nothing gives one back. */
    font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "hud", NULL);
    FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "No font registered as \"hud\"");

    PASS(errctx, aksl_snprintf(&count, line, sizeof(line), "SCORE %d", score));
    PASS(errctx, akgl_text_rendertextat(font, line, white, 0, 8, 8));
    SUCCEED_RETURN(errctx);
}

Fonts are not reference counted. The four asset pools in Chapter 5 all count references; the font registry does not. It holds a bare pointer. Two consequences:

  • akgl_text_unloadfont(name) closes the font whether or not anything is still using it. Anything holding the TTF_Font * — a caller that fetched it earlier, a hud_draw_score that fetched it last frame and cached it — is left with a dangling pointer.
  • Loading over an existing name replaces the entry and closes the font it displaced. The new font is opened first, so a failed load leaves you with the font you already had.

akgl_text_unloadfont on a name that is not registered raises AKERR_KEY, which makes a double unload an error rather than a double close. akgl_text_loadfont raises AKERR_KEY when it cannot write the registry — in practice, because akgl_registry_init_font() has not run.

The teardown ordering trap

akgl_text_unloadallfonts() must run before TTF_Quit() and SDL_Quit(). This is the one ordering constraint in the subsystem and it is easy to get wrong, because nothing fails loudly when you do.

#include <akerror.h>
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akgl/text.h>

/* Shutdown order. Both of the other two orderings are wrong. */
void shutdown_text(void)
{
    IGNORE(akgl_text_unloadallfonts());
    TTF_Quit();
    SDL_Quit();
}

A font is reachable only by name, through AKGL_REGISTRY_FONT, which is an SDL property set. So:

Order What happens
unloadallfonts, TTF_Quit, SDL_Quit Correct. Every font closed, then the registry destroyed, then SDL
SDL_Quit first SDL_Quit destroys the property registry, taking the last reference to every font with it. They are never closed and are now unreachable — a leak bounded by how many fonts you loaded, with no way to do anything about it
TTF_Quit first The TTF_Font handles are already invalid; unloadallfonts then closes freed pointers

akgl_text_unloadallfonts enumerates the registry, closes every font, destroys the property set and sets AKGL_REGISTRY_FONT to 0. It has no failure path: an uninitialized registry is success rather than an error, because shutdown paths run after partial startups.

Afterwards the registry is gone. akgl_text_loadfont needs akgl_registry_init_font() again before it can register anything, so this is a shutdown call rather than a between-scenes one. To swap fonts mid-game, use akgl_text_unloadfont per name.

Drawing is rasterize, upload, blit, destroy — every call

akgl_text_rendertextat(font, text, color, wraplength, x, y) does the whole job in one call: renders blended through SDL_ttf, uploads the surface as a texture, draws it through akgl_renderer->draw_texture, and destroys both the texture and the surface before it returns. There is no cache. Every call pays the full cost.

PERFORMANCE.md puts numbers on it, on a 640x480 software-rasterized frame:

Operation Cost
akgl_text_rendertextat, 15 characters 12,601.7 ns
akgl_text_measure, 15 characters 37.3 ns
Six lines of HUD text 0.076 ms — 0.5% of a 16.67 ms frame

Measuring is 340 times cheaper than drawing, which tells you the whole cost is the rasterize and the upload. Six HUD readouts is 76 µs a frame. That is fine at 60 fps on a laptop and it is 4% of a 2 ms GPU frame — and it is being paid every frame for a score that changes once a second. A one-line cache keyed on (font, string, colour) would take it to nothing; PERFORMANCE.md calls it the single clearest optimisation in the library, and TODO.md carries it as a target.

So: fine for a HUD line, wrong for a static body of text redrawn every frame. If you are drawing a page of dialogue that does not change, rasterize it yourself once with SDL3_ttf, keep the texture, and blit it through akgl_renderer->draw_texture.

Two more properties of the draw:

  • Coordinates are screen coordinates, not world ones. This does not go through akgl_camera, so a HUD stays put while the world scrolls under it. x and y are the top-left corner of the text, not a centre.
  • wraplength greater than 0 wraps on word boundaries at that pixel width. 0 or less draws a single line and breaks only on newlines in text.

The renderer guards are worth knowing because of what they catch. akgl_text_rendertextat raises AKERR_NULLPOINTER if akgl_renderer, its sdl_renderer, or its draw_texture is NULL — and that last one is the state a backend is in between being allocated and being run through akgl_render_2d_bind(). Reusing AKERR_NULLPOINTER for a failed rasterize or a failed texture upload is the same status doing double duty; the message carries SDL_GetError() and tells you which.

Measuring needs no renderer and no window

akgl_text_measure and akgl_text_measure_wrapped touch nothing but the font. They work before akgl_render_2d_init, and in a program that never creates a window at all. A caller building a character grid measures one cell and derives the rest from it:

#include <akerror.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akgl/text.h>

/* Measuring touches no renderer and no window, so a grid can be derived
   before anything is on screen. */
akerr_ErrorContext *cell_size(TTF_Font *font, int *cellw, int *cellh)
{
    PREPARE_ERROR(errctx);
    PASS(errctx, akgl_text_measure(font, "M", cellw, cellh));
    SUCCEED_RETURN(errctx);
}

/* A negative wrap length is refused rather than passed through: SDL_ttf reads
   it as a very large unsigned width and silently stops wrapping. */
akerr_ErrorContext *box_size(TTF_Font *font, char *body, int width, int *w, int *h)
{
    PREPARE_ERROR(errctx);
    PASS(errctx, akgl_text_measure_wrapped(font, body, width, w, h));
    SUCCEED_RETURN(errctx);
}

The two differ in exactly the way akgl_text_rendertextat's wraplength argument suggests:

Function Height reported Width reported
akgl_text_measure One line, whatever the text contains The whole string on one line
akgl_text_measure_wrapped Every line the text wraps onto The longest line, not wraplength

A negative wraplength is refused with AKERR_OUTOFBOUNDS, and that is a deliberate libakgl decision rather than a passthrough. SDL_ttf takes the wrap width as an int and reads a negative one as a very large unsigned width, which silently disables wrapping — returning a measurement that is wrong rather than an error. A wraplength of 0 is legal and wraps on newlines only.

The empty string

Both halves accept it. akgl_text_measure("") returns 0 wide by one line high, so a cursor sitting on an empty line has somewhere to be, and akgl_text_rendertextat with "" returns success without rasterizing anything.

That is worth stating because it was not always true and the header still says otherwise. SDL_ttf refuses the empty string from both rasterizers with "Text has zero width", and until 0.5.0 akgl_text_rendertextat passed that on as AKERR_NULLPOINTER — so the two halves of one header disagreed about one string, and a caller drawing a line that might be empty had to check for it. It is a SUCCEED_RETURN now, placed after the font, text and backend guards, so drawing nothing still refuses everything drawing something refuses.

Stale header prose. include/akgl/text.h:123-127 still documents the empty string as refused, and text.h:120-122 still warns that a failure after rasterizing leaks the surface and the texture. Both describe pre-0.5.0 behaviour. src/text.c:110-112 returns success for "", and src/text.c:140-146 destroys both objects in a CLEANUP block that runs on every path. TODO.md items 27 and 23 record both as fixed. The header comments want correcting in their own commit.

Fonts, the pools, and what is not shared

Nothing about fonts goes through the akgl heap. A TTF_Font is about ten kilobytes once FreeType's own structures are counted, it is allocated by SDL_ttf, and it is freed by TTF_CloseFont. The registry stores a pointer and nothing else.

That is different from spritesheets, which are pooled and are shared by resolved path — see Chapter 10. Two names pointing at the same .ttf at the same size are two open fonts, not one shared one.