SpriteFont and DrawString text layout
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Checked by reading SpriteFont.cpp, SpriteBatch::DrawString and Utf8Decode.hpp at 009d40f5; no test was executed. The XNA measurements cited are CNA's own records.
Text in CNA is a SpriteFont — a glyph atlas plus layout tables — drawn by SpriteBatch::DrawString as one sprite per glyph and measured by SpriteFont::MeasureString. The two walk the same layout rules in two different files, and small differences in those rules (the first glyph's bearing, the last glyph's overhang, rounding) decide where text lands. This page gives the exact rules at this snapshot, where CNA follows Microsoft XNA against FNA and why, how UTF-8 text becomes glyphs, how flips, rotation and scale apply to a whole string, and which renderers have pixel tests for it.
What a SpriteFont holds
SpriteFont (SpriteFont.hpp) is not a GraphicsResource: it owns a Texture2D atlas by value, and parallel tables indexed by glyph — glyphBounds (the glyph's rectangle in the atlas), cropping (its offset and cell inside the line), kerning (a Vector3 of left side bearing, width and right side bearing), the character list keyed by charcs (one UTF-16 code unit), LineSpacing, Spacing and an optional DefaultCharacter. SpriteBatch is a friend and reads the tables directly. The usual way to obtain a font is getContentProperty().Load<SpriteFont>("…") from a compiled asset; the XNB reader (SpriteFontContentTypeReader) and the build-time .spritefont processor exist at this snapshot (see Tutorial 09: Drawing text with SpriteFont). The constructor that XNA keeps internal for its reader is public in CNA and marked CNAEXT, with accessors getTextureEXT, getGlyphBoundsEXT, getCroppingEXT and getKerningEXT, so tools and tests can build a font from tables directly.
The default character follows Microsoft XNA's split: the constructor keeps whatever default the content supplied, even one that is not in the character list, while the public setDefaultCharacterProperty rejects such a value with ArgumentException. A character lookup tries the character, then the default character, and otherwise throws System::ArgumentException naming character — also when the stored default itself is missing (UnknownCharWithInvalidContentDefaultThrowsForCharacter in SpriteFontTests.cpp). Both MeasureString and DrawString therefore throw for text the font cannot draw.
Per-glyph advance
For each glyph on a line, DrawString (SpriteBatch.cpp) and MeasureString (SpriteFont.cpp) advance a pen position the same way:
const Vector3& kern = kerning[index]; // (leftBearing, width, rightBearing)
if (firstInLine) {
pen.X += std::max(kern.X, 0.0f); // first glyph: only a POSITIVE left bearing, no Spacing
firstInLine = false;
} else {
pen.X += spacing + kern.X; // later glyphs: Spacing plus the bearing, unclamped
}
// the glyph is placed at pen + cropping offset, sized by its glyph bounds
pen.X += kern.Y + kern.Z; // width plus right bearing
The first-glyph rule is where CNA deliberately departs from FNA. FNA writes Math.Abs(kern.X), which pushes a glyph with a negative left bearing right by the bearing's magnitude; XNA clamps it to zero, so the glyph sits flush at the draw position, and it applies no Spacing before a line's first glyph. CNA's source records this as measured against a live XNA 4.0 build (glyphs with a left bearing of −1 advance 0, one with +1 advances 1, and the single-character width does not change with Spacing). Later negative bearings still pull a glyph toward its predecessor. The tests are FirstGlyphOfALineClampsANegativeLeftSideBearingToZero, ANegativeLeftSideBearingStillAppliesAfterTheFirstGlyph, SpacingDoesNotApplyToTheFirstGlyphOfALine and EveryLineClampsItsOwnFirstGlyph. A '\r' is skipped; a '\n' resets the pen to the line start, moves down by LineSpacing — not by the glyph height — and makes the next glyph a first glyph again.
A worked example with two glyphs
The EasyGL pixel test easygl_spritefont_multiglyph_spacing_test.cpp makes the non-first branch checkable in whole pixels. Its font has two 8 × 8 glyphs, 'A' (white) and 'B' (green), both with kerning (0, 8, 0), and Spacing 4; "AB" is drawn at (2, 2):
// 'A' (first in line): pen.X = max(0, 0) = 0 -> drawn at x = 2 + 0 = 2, covering [2, 10)
// after 'A': pen.X += 8 + 0 = 8
// 'B' (not first): pen.X += 4 + 0 = 12 -> drawn at x = 2 + 12 = 14, covering [14, 22)
// the 4-pixel gap [10, 14) must stay background
A sample inside the gap proves that Spacing was applied, and each glyph's distinct colour at its expected position rules out an index mix-up between the two glyphs — a failure that a single-glyph test cannot detect however many pixels it samples, because there is no second glyph to misplace. With zero bearings the first-glyph rule is not exercised here; the unit tests above cover it.
MeasureString
MeasureString exists for String (UTF-8 std::string) and System::Text::StringBuilder; the latter forwards to the former, since C++ has no garbage-collection argument for FNA's duplicated implementation. An empty string measures (0, 0). Width is the widest line; height is LineSpacing for every line break plus, for the last line, the larger of LineSpacing and the tallest glyph cropping height on that line. Two consequences are easy to miss:
- A trailing newline adds a whole empty line:
"Score\n"measures2 × LineSpacinghigh when no glyph is taller, because both the newline and the final line add their height (TrailingNewlineAddsAnEmptyLastLine; a leading newline likewise adds an empty first line). - A line's last glyph contributes its right bearing only when it is positive. XNA holds each glyph's right bearing and adds it to the next glyph unclamped, but at a line break and at the end it adds only
max(rightBearing, 0): a negative right bearing is an overhang that occupies no width beyond the end of the line. FNA adds width plus right bearing for every glyph, which is right between glyphs and short by the overhang at a line's end; CNA follows XNA (MeasureDoesNotSubtractATrailingOverhang,MeasureCountsAnInteriorOverhangButNotATrailingOne,MeasureClampsTheOverhangOnEveryLineNotJustTheLast,MeasureKeepsAPositiveTrailingBearing).
Measured and drawn placement agree glyph by glyph — both use the first-glyph clamp and the unclamped interior bearings — and differ only in that trailing-overhang rule, which affects the reported width, not where glyphs are drawn. Centring text is the typical use:
const std::string text = "Game Over";
const Vector2 size = font.MeasureString(text); // (width, height) in pixels
const Vector2 centred(screenCentreX - size.X / 2.0f, screenCentreY - size.Y / 2.0f);
spriteBatch_->DrawString(font, text, centred, Color::White);
From UTF-8 to glyphs
CNA's String is a UTF-8 std::string, while a font's glyph keys are single UTF-16 code units (charcs), as in .NET. Both walks decode one code point at a time with CNA::Internal::DecodeUtf8CodePoint (Utf8Decode.hpp). Only Basic Multilingual Plane characters can be glyph keys: a well-formed code point above U+FFFF is consumed whole and decodes to '?'; an invalid lead byte, a bad continuation byte or a sequence truncated at the end of the string also decodes to '?' and advances by at least one byte, so malformed input can never loop forever. The resulting '?' is then looked up like any other character — it is drawn if the font has it, replaced by the default character otherwise, and throws if neither exists. Text with emoji or other supplementary-plane characters therefore needs a font with '?' or a default character, or pre-filtering.
Flips, rotation, scale and sub-pixel placement
SpriteEffects is a flags enum in CNA as in XNA: None (0), FlipHorizontally (1), FlipVertically (2), with |, &, |= and &= defined (SpriteEffects.hpp), so FlipHorizontally | FlipVertically (3) is a real value for sprites and text alike. DrawString masks the value to its two low bits, as FNA does, and indexes four-entry direction tables. When any flip is requested it first measures the whole string and shifts the origin by the measured size on the mirrored axis, so the glyph sequence mirrors around the text block and each glyph is additionally drawn flipped; a combined flip mirrors X like a horizontal flip and Y like a vertical one (CombinedFlipMirrorsXLikeHorizontalAlone, CombinedFlipMirrorsYLikeVerticalAlone). Earlier revisions flipped only each glyph's texture in place, leaving the string's order unmirrored, and sized the tables for three entries, which made the combined value unrepresentable (and, reached by a cast, an out-of-bounds read); both were fixed in the shared layer, so every renderer received the correction at once. CNA's own spritefont-support.md still describes the combined flip as unrepresentable; the header and the tests supersede it.
Each glyph's local offset is its pen position plus its cropping offset minus origin, so origin is measured in unscaled text-layout space; the offset is then scaled by scale, rotated by rotation about the draw position and added to position, and the glyph is drawn at its glyph-bounds size times scale, with the same rotation and flip and the call's layerDepth. The resulting destination stays in floating point. CNA's source records why: text drawn at (64.5, 64.5) by a live XNA 4.0 build comes out filtered across the half pixel, which rounding each glyph to a whole pixel — CNA's earlier behaviour — could not reproduce (DrawStringKeepsItsFractionalPosition, DrawStringKeepsAFractionalScale). Whether the fraction survives to the screen then depends on the renderer family, exactly as for sprites (see sub-pixel destinations). An empty string, or a font whose atlas has zero width, draws nothing.
Evidence by renderer
The layout rules live in shared code, so the unit tests in SpriteFontTests.cpp and the recording-renderer tests in SpriteBatchTests.cpp establish them independently of any family. Pixel tests with hand-built one- and two-glyph fonts (fixtures whose expected pixels can be derived by hand, not real font assets) are registered at this snapshot for:
| Family | Registered tests |
|---|---|
| EasyGL | EasyGL_SpriteFont_Properties, _SingleGlyph, _MultiGlyphSpacing, _Newline, _DefaultChar, _EffectsFlip, _EffectsRotationScale |
SDL_RENDERER | SDL_Renderer_SpriteFont_SingleGlyph, _MultiGlyphSpacing, _Newline, _DefaultChar, _Effects, plus a sample-text scene |
VULKAN | Vulkan_SpriteFont_Properties, _SingleGlyph, _MultiGlyphSpacing, _Newline, _DefaultCharacterFallback, _EffectsFlip, _EffectsRotationScale |
SOFTWARE | six Software_SpriteFont_*Parity tests (single glyph, spacing, newline, default character, flip, rotation and scale) |
OPENGL4, DIRECTX11, DIRECTX12 | the same EasyGL sources through the OpenGL4 parity corpus and the Direct3D parity inventory (SpriteFont_*) |
FREEDIRECT | FreeDirect_SpriteFont |
None of these was executed for this page, and a registration is evidence only for the family that registers it; the other families reuse the shared layout code without a dedicated text pixel test. CNA's spritefont-support.md, which lists only SDL_RENDERER and EasyGL as pixel-verified and Vulkan as not yet exercised, predates the Vulkan and parity registrations. The examples on this page were syntax-checked with g++ -std=c++23 -fsyntax-only -DCNA_RENDERER_VULKAN against the TARGET headers and a sibling sharp-runtime checkout (not pinned by TARGET); the advance snippet is an annotated paraphrase of the loop, not a compilable excerpt.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Architecture
- Graphics architecture
- Internals
- Renderer backends internals
- Tests and validation
- Test architecture