Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Text Rendering

stdgba provides a 4bpp BG text-layer renderer.

The core goal is to render formatted strings efficiently - including typewriter effects - without a full-screen redraw each frame.

Features

  • Bitmap fonts embedded from BDF files at compile time via <gba/embed>.
  • Compile-time font variant baking: with_shadow<dx, dy> and with_outline<thickness>.
  • Stream/tokenizer support for incremental rendering:
    • C-string tokenizer streams (cstr_stream).
    • Generator-backed streams from <gba/format> via stream(gen, ...).
  • Word wrapping using a lookahead to the next break character.
  • Incremental rendering via make_cursor(...) and next_visible() for typewriter effects.
  • Bitplane palette profiles for 2-colour, 3-colour, and full-colour (up to 15 colours) text.
  • Inline colour escape sequences for per-character palette switching in full-colour mode.

Quick start

The demo below embeds 9x18.bdf, configures the bitplane palette, and draws one visible glyph per frame.

#include <gba/bios>
#include <gba/embed>
#include <gba/format>
#include <gba/interrupt>
#include <gba/text>

#include <array>

int main() {
    using namespace gba::literals;

    static constexpr auto font = gba::text::with_shadow<1, 1>(gba::embed::bdf([] {
        return std::to_array<unsigned char>({
#embed "9x18.bdf"
        });
    }));
    static constexpr auto fmt = "The frame is: {value}"_fmt;

    gba::irq_handler = {};
    gba::reg_dispstat = {.enable_irq_vblank = true};
    gba::reg_ie = {.vblank = true};
    gba::reg_ime = true;

    gba::reg_dispcnt = {.video_mode = 0, .enable_bg0 = true};
    gba::reg_bgcnt[0] = {.screenblock = 31};

    constexpr auto config = gba::text::bitplane_config{
        .profile = gba::text::bitplane_profile::two_plane_three_color,
        .palbank_0 = 1,
        .palbank_1 = 2,
        .start_index = 1,
    };

    gba::text::set_theme(config, {
                                      .background = "#304060"_clr,
                                      .foreground = "white"_clr,
                                      .shadow = "#102040"_clr,
                                  });
    gba::pal_bg_mem[0] = "#304060"_clr;

    unsigned int frame = 0;

    gba::text::linear_tile_allocator alloc{.next_tile = 1, .end_tile = 512};
    using layer_type = gba::text::bg4bpp_text_layer<240, 160>;
    static layer_type::cell_state_map cell_state{};
    layer_type layer{31, config, alloc, cell_state};

    gba::text::stream_metrics metrics{
        .letter_spacing_px = 1,
        .line_spacing_px = 2,
        .tab_width_px = 32,
        .wrap_width_px = 220,
    };

    auto make_cursor = [&] {
        auto gen = fmt.generator("value"_arg = [&] { return frame; });
        auto s = gba::text::stream(gen, font, metrics);
        return layer.make_cursor(font, s, 0, 0, metrics);
    };

    auto cursor = make_cursor();

    while (true) {
        gba::VBlankIntrWait();
        ++frame;

        if (!cursor.next_visible() && frame % 120 == 0) {
            alloc = {.next_tile = 1, .end_tile = 512};
            layer = layer_type{31, config, alloc, cell_state};
            cursor = make_cursor();
        }
    }
}

Text rendering demo


Bitplane profiles

bg4bpp_text_layer<Width, Height> multiplexes multiple palette layers onto 4bpp VRAM tiles using a mixed-radix encoding scheme. Choose the profile that matches how many colour roles your text needs.

ProfilePlanesPalette entriesColour roles
two_plane_binary24background, foreground
two_plane_three_color29background, foreground, shadow
three_plane_binary38background, foreground
one_plane_full_color116nibble = palette index directly

two_plane_three_color is the most common choice: it provides foreground, shadow (or outline decoration), and background using only two VRAM tiles worth of palette space per 8x8 cell.

one_plane_full_color maps nibble values directly to palette entries, giving up to 15 distinct colours at the cost of one VRAM tile per cell (no cell sharing).


Palette configuration

A bitplane_config binds a profile to concrete palette banks and a starting index:

constexpr auto config = gba::text::bitplane_config{
    .profile    = gba::text::bitplane_profile::two_plane_three_color,
    .palbank_0  = 1,   // plane 0 uses palette bank 1
    .palbank_1  = 2,   // plane 1 uses palette bank 2
    .start_index = 1,  // first occupied entry within each bank
};

Apply colours to palette RAM with set_theme:

gba::text::set_theme(config, {
    .background = "#304060"_clr,
    .foreground = "white"_clr,
    .shadow     = "#102040"_clr,
});

set_theme fills all active planes in one call. Call it again any time to change the entire colour scheme without re-rendering text.


Font variants

Font variants bake visual effects into the glyph bitmap data at compile time. The renderer then uses a separate decoration bitmap for the shadow/outline colour role, so no extra per-effect bitmap generation is done at runtime.

Drop shadow

// 1px shadow shifted right and down
static constexpr auto font_shadowed = gba::text::with_shadow<1, 1>(base_font);

The template arguments are <ShadowDX, ShadowDY>. The shadow pixels are only drawn where they do not overlap the foreground glyph, so they never occlude text.

Outline

// 1px outline around every glyph
static constexpr auto font_outlined = gba::text::with_outline<1>(base_font);

The template argument is <OutlineThickness>. Each glyph is expanded by thickness pixels in every direction; the outline pixels form a separate decoration mask that is drawn in the shadow colour role.

Both variants return a new font type compatible with all drawing functions - pass them wherever a plain font is accepted.


Streams

A stream wraps a text source and exposes single-character iteration plus a lookahead used by the word-wrap algorithm.

C-string stream

gba::text::stream_metrics metrics{.letter_spacing_px = 1};
auto s = gba::text::cstr_stream{gba::text::cstr_source{"HP: 42/99"}};

Format generator stream

static constexpr auto fmt = "HP: {hp}/{max}"_fmt;

auto gen = fmt.generator("hp"_arg = hp, "max"_arg = max_hp);
auto s   = gba::text::stream(gen, font, metrics);

The generator is copied for lookahead, so it must be copyable (all format generators are).

There is currently no stream(const char*, ...) convenience overload; use cstr_stream{cstr_source{...}} for C-strings.

Inline colour escapes

In one_plane_full_color mode, embed palette switches directly in the text using the literal escape sequence \x1B followed by a hex digit (0-F).

// Hex digit = palette nibble: 0-9 = nibbles 0-9, A-F = nibbles 10-15
std::string msg = "Status: \x1B2Error\x1B3 - \x1B1OK";
//                         ^^         ^^       ^^
//                         red        yellow   white

The escape code is consumed silently; it never appears as text and does not affect glyph counts or word-wrap measurements. The active nibble resets to 1 (foreground) at the start of each draw_stream or cursor call.

See Full-colour mode for how to configure the palette and the layer to use one_plane_full_color.


Drawing

draw_stream - batch rendering

Renders a full stream in one call, with layout, word wrapping, and optional character limit for partial reveals:

gba::text::stream_metrics metrics{
    .letter_spacing_px = 1,
    .line_spacing_px   = 2,
    .tab_width_px      = 32,
    .wrap_width_px     = 220,
};

// Draw everything
auto count = layer.draw_stream(font, "HP: 42/99", /*x=*/8, /*y=*/16, metrics);

// Draw only the first 10 characters (typewriter snapshot)
auto count = layer.draw_stream(font, "HP: 42/99", 8, 16, metrics, /*max_chars=*/10);

Returns the number of emitted characters (including whitespace/newlines). Inline colour escape sequences are consumed and are not included in the count.

draw_char - single glyph

// Returns the advance width in pixels
auto advance = layer.draw_char(font, static_cast<unsigned int>('A'), pen_x, baseline_y);

make_cursor + cursor object - incremental typewriter

make_cursor(...) returns a cursor object that draws one character per next() call, maintaining cursor position between calls. Use next_visible() to skip whitespace and advance the cursor in the same call, so a typewriter effect never wastes a frame on a space:

auto cursor = layer.make_cursor(font, s, /*start_x=*/0, /*start_y=*/0, metrics);

// In the update loop - one visible glyph per frame:
if (!cursor.next_visible()) {
    // stream exhausted - restart or do something else
}

The cursor also exposes:

MethodDescription
next()Draws the next character step; returns true while characters remain
next_visible()Draws the next non-whitespace character; skips layout whitespace in one call
emitted()Total processed characters so far
done()true when the stream is exhausted
operator()()Shorthand for next()

To restart a typewriter sequence, re-create the layer (to clear tile state) and construct a fresh cursor:

// Reset tile allocator and layer, then create a new cursor
alloc = {.next_tile = 1, .end_tile = 512};
layer = layer_type{31, config, alloc, cell_state};
cursor = layer.make_cursor(font, new_stream, 0, 0, metrics);

Full-colour mode

one_plane_full_color maps nibble values directly to palette entries, giving access to up to 15 distinct foreground colours in a single bg4bpp_text_layer.

constexpr auto config = gba::text::bitplane_config{
    .profile    = gba::text::bitplane_profile::one_plane_full_color,
    .palbank_0  = 3,
    .start_index = 0,   // must be 0 so nibble 0 = transparent
};

Inline colour escapes

Use the text-format palette extension (:pal) to emit inline colour escapes in generated text (see Streams – Inline colour escapes above for the escape semantics). At present, the :pal argument is emitted as a single character and decoded as a hex digit, so pass '1'..'9' or 'A'..'F' ('0' remains reserved for transparent).

using namespace gba::literals;

constexpr gba::text::text_format<"HP {fg:pal}{hp}/{max}"> fmt{};
auto gen = fmt.generator("fg"_arg = '2', "hp"_arg = hp, "max"_arg = max_hp);
auto s = gba::text::stream(gen, font, metrics);

Make sure the corresponding palette entries are populated. set_theme fills nibbles 1 (foreground) and 2 (shadow); write additional entries directly:

gba::text::set_theme(config, {
    .background = {},             // nibble 0 = transparent
    .foreground = "white"_clr,   // nibble 1
    .shadow     = "#FF4444"_clr, // nibble 2 -- repurposed as accent red
});

// Extra colours beyond the three theme roles
gba::pal_bg_mem[config.palbank_0 * 16 + 3] = "#FFFF00"_clr; // nibble 3 = yellow
gba::pal_bg_mem[config.palbank_0 * 16 + 4] = "#88FF88"_clr; // nibble 4 = green

API reference

bitplane_config

FieldTypeDescription
profilebitplane_profilePlane/colour role layout
palbank_0unsigned charPalette bank for plane 0 (255 = unused)
palbank_1unsigned charPalette bank for plane 1 (255 = unused)
palbank_2unsigned charPalette bank for plane 2 (255 = unused)
start_indexunsigned charFirst occupied entry within each bank

stream_metrics

FieldDefaultDescription
letter_spacing_px0Extra pixels between glyphs
line_spacing_px0Extra pixels between lines
tab_width_px32Width of a tab character in pixels
wrap_width_px0xFFFFMaximum line width before wrapping

linear_tile_allocator

Simple bump allocator over a VRAM tile range. Reset it by re-assigning the struct:

alloc = {.next_tile = 1, .end_tile = 512};

bg4bpp_text_layer<Width, Height>

MethodDescription
draw_char(font, encoding, x, y)Draw a single glyph; returns advance width
draw_stream(font, const char* str, x, y, metrics [, max_chars])Draw a full C-string with layout
make_cursor(font, s, x, y, metrics)Create an incremental cursor object
clear()Reset all tile allocations and clear the tilemap to background
uses_full_color()true when the profile is one_plane_full_color

Notes

  • Word wrapping only occurs at word starts (after a break character). Long tokens are allowed to overflow rather than wrapping one character per line.
  • The bitplane renderer uses mixed-radix encoding so multiple planes can share a 4bpp tile while selecting different palette banks.
  • start_index = 0 is required when using one_plane_full_color so that nibble 0 maps to palette index 0 (transparent in 4bpp tile mode).
  • with_shadow and with_outline bake the effect into separate decoration bitmaps at compile time; rendering cost is the same as a plain font plus one extra pass per glyph for the decoration pixels.