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>
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 theTTF_Font *— a caller that fetched it earlier, ahud_draw_scorethat 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.xandyare the top-left corner of the text, not a centre. wraplengthgreater than 0 wraps on word boundaries at that pixel width. 0 or less draws a single line and breaks only on newlines intext.
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-127still documents the empty string as refused, andtext.h:120-122still warns that a failure after rasterizing leaks the surface and the texture. Both describe pre-0.5.0 behaviour.src/text.c:110-112returns success for"", andsrc/text.c:140-146destroys both objects in aCLEANUPblock that runs on every path.TODO.mditems 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.