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>
224 lines
10 KiB
Markdown
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.
|