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

224 lines
10 KiB
Markdown

# 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](https://wiki.libsdl.org/SDL3_ttf/). 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:
```c
#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](05-the-heap.md) 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.
```c
#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:
```c
#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](10-spritesheets-and-sprites.md). Two names pointing at the same `.ttf` at
the same size are two open fonts, not one shared one.