Small CPU-side MSDF text atlas builder shared by Varinomics plotting and editor components.
The library builds a static C++ target:
vnm_msdf_text::vnm_msdf_textIt uses FreeType and msdfgen. When configured as the top-level project, CMake
fetches those dependencies when compatible targets are not already available.
Parent projects should set VNM_MSDF_TEXT_FETCH_DEPS explicitly.
An opt-in Qt QRhi text component builds alongside it when a consumer asks for it, adding the shared GPU text substrate described under QRhi text component:
vnm_msdf_text::rhiThe package also exports dependency-light LCD/MSDF support targets:
vnm_msdf_text::lcd_contract
vnm_msdf_text::lcd_shader_referenceUse find_package(vnm_msdf_text CONFIG COMPONENTS lcd_contract) or
lcd_shader_reference when a consumer only needs the resolved LCD enum, mapping
helpers, or shader drift-reference constants. Request COMPONENTS atlas for the
full atlas-builder target and its FreeType/msdfgen dependencies. A no-component
package lookup succeeds with the dependency-light LCD targets when the installed
package does not export the atlas target. See
docs/lcd_msdf_commonization_contract.md for the LCD contract.
Versioned package lookup requires the exact current project version.
The source code is licensed under the BSD 2-Clause License, and this repository
redistributes no third-party asset. The font the tests and the source-consumer
gate bake comes from vnm_fonts, which
ships it byte-verbatim under the Ubuntu Font Licence 1.0 and carries that
licence's text and notice; see THIRD_PARTY_NOTICES.md.
build_font_atlas builds a row-major linear RGBA8 MTSDF atlas from font bytes
and a requested set of Unicode scalar values. Requested codepoints are validated,
sorted, and deduplicated before glyph generation.
The baked bitmap is generated at msdf_bake_pixel_height(draw_pixel_height, options), which is the requested draw_pixel_height clamped up to
ceil(options_t::min_atlas_font_size). Glyph geometry, kerning, and font metrics
are stored in scale-independent font units (glyph_t::bounds_*_units,
glyph_t::advance_units, kerning_units, font_metrics_units), not in output
pixels. A single baked atlas therefore serves a range of draw pixel heights: two
requested heights that share a bake bucket produce a byte-identical bitmap and
identical glyph UVs, while their draw-size geometry differs.
Convert baked data to a specific draw pixel height with the scaling helpers:
scaled_glyph(atlas, glyph, draw_pixel_height)returns ascaled_glyph_twith the output-pixeladvance_x, baseline-relativeplane_*rectangle, and UVs. Non-visible glyphs (for example U+0020) scale to a degenerate zero-area plane while still carryingadvance_x.px_range_for_pixel_heightis the shader distance range in output pixels, includingsharpness_bias.scaled_font_metricsreturns ascender, descender, line height, and em size in output pixels.
The layout and measurement entry points (measure_text_advance_px,
for_each_positioned_glyph, measure_text_bounds_px, append_text_quads) each
take a draw_pixel_height and apply this scaling internally.
Build results use Build_status:
SUCCESS: every valid requested codepoint was emitted.PARTIAL_SUCCESS: the atlas contains at least one emitted glyph, and the diagnostic vectors describe skipped codepoints.FAILURE: no usable atlas was produced. The returned atlas is default-constructed and must not be rendered.
Diagnostics are split into invalid Unicode scalar values, missing font coverage, glyph load failures, glyphs too large for the atlas, and glyphs skipped after the atlas ran out of space.
options_t::missing_glyph_policy controls missing requested codepoints:
SKIP: report missing coverage and omit those codepoints from the atlas.USE_REPLACEMENT_CHARACTER: when the font contains U+FFFD, alias missing codepoints to that replacement glyph while still reporting the original missing codepoints.FAIL_BUILD: fail the build when any valid requested codepoint is missing.
atlas_t::font_metrics_units exposes ascender, descender, line height, and em
size in font units; scaled_font_metrics(atlas, draw_pixel_height) returns the
same metrics in output pixels. A baseline-to-descender-bottom offset for a draw
height is -scaled_font_metrics(atlas, draw_pixel_height).descender.
atlas_t::zero_advance_units is the font-unit advance of glyph 0 when
available; it is a reference advance, not proof that the font is monospace.
default_codepoints() is a UI-oriented scalar set covering printable ASCII,
selected Latin, Greek, currency, and UI symbol codepoints. It includes U+FFFD so
callers can request a replacement glyph for invalid UTF-8 fallback. The bundled
font is a test fixture and license-noticed convenience asset; it does not cover
every codepoint in this default set.
append_text_quads treats x, y as the baseline origin in output pixels. The
layout convention is screen-style Y-down coordinates. Glyph plane coordinates
are relative to the baseline; for normal visible glyphs, plane_bottom is the
smaller Y value and plane_top is the larger Y value after converting the
font's Y-up outline space.
Atlas data is row-major RGBA8. Row 0 is the first row in memory, and the UV t
coordinate increases downward to match that layout. Upload the texture as
linear data, not sRGB.
options_t::atlas_px_range is the baked distance range in atlas pixels, retained
on the atlas as atlas_t::atlas_px_range. The output-pixel distance range
intended for shader reconstruction is produced per draw height by
px_range_for_pixel_height(atlas, draw_pixel_height), which applies the draw
scaling and sharpness_bias. options_t::atlas_gutter_px controls empty pixels
between packed glyph bitmaps. Shaders should still clamp sampling to the glyph UV
rectangle when using linear filtering.
A minimal OpenGL-style renderer setup looks like:
// Linear RGBA8 upload; do not use an sRGB internal format for distance data.
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, atlas.atlas_size, atlas.atlas_size,
0, GL_RGBA, GL_UNSIGNED_BYTE, atlas.rgba.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
const float shader_px_range =
vnm::msdf_text::px_range_for_pixel_height(atlas, draw_pixel_height);
set_uniform("u_px_range", shader_px_range);
// atlas.atlas_px_range is the baked atlas-space range; shader_px_range is the
// draw-size output-pixel range used for coverage reconstruction below.// Inputs from text_vertex_t: v_uv = (s, t).
// v_uv_bounds = (s_min, t_min, s_max, t_max).
float median3(vec3 v) {
return max(min(v.r, v.g), min(max(v.r, v.g), v.b));
}
vec2 uv = clamp(v_uv, v_uv_bounds.xy, v_uv_bounds.zw);
vec4 mtsdf = texture(u_atlas, uv);
float signed_distance = median3(mtsdf.rgb) - 0.5;
float coverage = clamp(signed_distance * u_px_range + 0.5, 0.0, 1.0);
out_color = vec4(text_color.rgb, text_color.a * coverage);Text layout is single-line, left-to-right codepoint layout with optional
kerning. It does not perform HarfBuzz shaping, bidirectional reordering,
ligature substitution, grapheme-cluster handling, or combining-mark placement.
measure_text_advance_px returns pen advance, not visual bounds. Invalid UTF-8
decodes to U+FFFD; missing glyphs are skipped during layout unless the atlas
contains a glyph entry for that decoded codepoint.
For custom renderers, for_each_positioned_glyph exposes the same decoded
layout stream used by measure_text_advance_px, measure_text_bounds_px, and
append_text_quads. append_text_quads can throw std::length_error if indexed
output would exceed uint32_t capacity; vector growth may throw allocation
exceptions.
vnm_msdf_text::rhi is the shared GPU substrate that draws the atlas above with
Qt's QRhi. It is a compositional layer over the CPU API: it owns device-local
texture, buffer, sampler, pipeline, and binding resources, the per-frame upload
and recording, and the status of both. Where text goes, what it says, how it is
coloured, and when it is worth drawing stay with the consumer.
The component is off by default. Configure with -DVNM_MSDF_TEXT_BUILD_RHI=ON
to build it; Qt is located only when that option is on, so a CPU-only or
editor-style consumer neither finds nor links Qt through this project. A
consumer that requires GPU text can read the VNM_MSDF_TEXT_HAS_RHI cache
entry and fail its own configure instead of linking a build without it. The
component requires C++20, Qt 6.7 or newer, and Qt Shader Tools at build time
only.
It is a source-tree target, consumed through add_subdirectory or
FetchContent and linked as vnm_msdf_text::rhi. It is not part of the
installed package: the installed tree contains neither an RHI target nor the
vnm_msdf_text/rhi/ headers, and find_package(vnm_msdf_text COMPONENTS rhi)
is rejected as an unsupported component. Installing headers a package cannot
satisfy would be a contract the package does not have.
Public headers live under vnm_msdf_text/rhi/ and everything is in namespace
vnm::msdf_text::rhi. The public headers only forward-declare the QRhi types
they take pointers to, so Qt's private QRhi headers stay inside the
implementation.
build_font_snapshot(font_bytes, draw_pixel_height, codepoints, options) builds
an immutable Font_snapshot from caller-supplied bytes. The component never
reads a file or resolves an asset: font acquisition belongs to the consumer.
The result carries a status and, on success, a shared_ptr to the snapshot:
INVALID_ARGUMENTfor empty bytes or a non-positive draw pixel height.FONT_BUILD_FAILEDwhen no usable atlas could be produced; no snapshot.OKotherwise. A partially built atlas is anOKresult with a usable snapshot whosebuild_result().statusisPARTIAL_SUCCESSand whose diagnostic vectors name the codepoints that were not emitted.
A snapshot exposes the CPU data it was built from rather than restating it:
atlas(), draw_pixel_height(), and the full build_result(). Measurement,
bounds, positioned glyphs, and quad emission are the existing free functions in
msdf_text.h applied to those two values, so there is one set of metrics.
identity() is a digest of every input that determines the bake: the font
bytes, the draw pixel height, the atlas options, and the requested codepoints.
Two snapshots with equal identity measure identically and emit identical quads,
so a consumer can key cached CPU measurements or a presentation key on it and
keep them across a rebuild.
revision() distinguishes snapshot instances that share an identity, so a
consumer can tell that state it derived from a snapshot came from an older one.
Revisions are unique and increasing among the snapshots built by one loaded copy
of this static library; two modules that each link it count independently, so a
revision compares snapshots from one producer and does not identify a snapshot
across a module boundary. Compare content with identity(), and decide whether
a device resource built from a snapshot can be reused from the retained snapshot
object, which is what Text_renderer does.
A Text_batch holds quads and the identity of the font they were laid out
against. append_run(font, text, baseline_x, baseline_y) emits them through
append_text_quads, so a batch always agrees with the measurement helpers;
append_quads(font, vertices, indices) accepts geometry a consumer produced
itself and rebases the indices. A batch holds geometry from one font only, and a
renderer rejects a batch whose font is not its own. Because a batch needs
nothing but an immutable snapshot, CPU preparation can build one away from the
render thread and hand it over when it is complete.
A draw_state_t carries the column-major transform from the batch's own
coordinates to clip space, a straight (non-premultiplied) RGBA colour, an
optional scissor rectangle, and the optional draw capabilities below.
pixel_ortho_transform(frame) builds the transform for text laid out in
top-left-origin framebuffer pixels, including the backend's clip-space
correction.
clip_rect_t is in framebuffer pixels with x, y at the bottom-left corner.
QRhi takes OpenGL-style scissor coordinates on every backend and itself flips
them for the backends whose native origin is the top-left, so a caller uses one
convention everywhere - and it is the opposite of the Y-down pixel space the
text itself is laid out in. The real-backend gate pins that origin rather than
asserting it: it clips the lower and the upper half of a target the text crosses
and checks which half survives. Negative x or y and partially out-of-bounds
rectangles are clamped by QRhi, and a zero width or height clips the draw away.
A negative width or height is not a rectangle QRhi can express -
it drops such a scissor and leaves the previous one in force - so queue()
rejects an enabled clip with one instead of recording a draw under someone
else's clip.
draw_capabilities.h adds four optional capabilities. Each is a presence-tagged
record with a version of its own, and each is absent by default. A draw state
that carries none of them takes the base path described above: the same
geometry, uniform block, shader, and pipeline a build without that header uses.
A draw state that carries any of them is a styled draw and takes a second
path, created only once a frame queues one.
| Capability | Record | What it does |
|---|---|---|
| Per-glyph frame rectangles | glyph_frames_t on Text_batch |
Records each quad's rectangle so the fragment stage can measure in output pixels |
| LCD subpixel order | lcd_style_t on draw_state_t |
Filters coverage per channel and composes it against an opaque background |
| Outer glow | glow_style_t on draw_state_t |
Ramps coverage outwards from the glyph outline |
| True-SDF alpha masking | sdf_mask_t on draw_state_t |
Takes the smaller of the multi-channel and true-SDF coverages |
The provider supplies rendering semantics only. Which text gets a subpixel order, a glow, or a mask, what background it sits on, and when any of that is worth doing stay with the consumer.
A styled draw is laid out in framebuffer pixels under
pixel_ortho_transform(frame), and its batch carries frame rectangles:
enable_glyph_frames() on an empty batch, after which append_run() derives
each quad's rectangle itself and append_quads() takes one rectangle per
vertex. Caller-supplied frames must be finite, positive, identical across each
four-vertex quad, and equal that quad's axis-aligned bound. prepare() rejects
a styled transform other than pixel_ortho_transform(frame) before it changes
resources or enqueues updates. The fragment stage reconstructs where a fragment
sits inside its glyph from that rectangle, which is what lets a subpixel filter
step by a third of an output pixel.
lcd_style_t::order is the existing vnm_msdf_text::lcd_contract resolution and
the filter is the one vnm_msdf_text::lcd_shader_reference describes: three
five-tap windows a third of a pixel apart, along X for RGB and BGR and along
Y for VRGB and VBGR, in reverse channel order for BGR and VBGR. A
display-specific order composes each channel's coverage against
background_color and writes the straight colour that reproduces that mix once
the pipeline has blended it, so it is only the intended image where the
destination really is that background. queue() therefore rejects a
display-specific order unless the draw colour and the background are both opaque
within lcd::shader_reference::k_lcd_opaque_alpha_cutoff and no glow is present.
NONE is a valid order meaning no subpixel filtering: it composes exactly as the
base path does, and none of those conditions applies to it.
glow_style_t ramps coverage outwards from the glyph outline over radius_px
output pixels and is drawn under the glyphs of that same draw, so a later
glyph's glow never darkens an earlier glyph's body - which is why a glowing draw
records two draw calls. Draw states themselves stay in the order they were
queued. It is drawn on the glyph quads, so its reach is the padding
options_t::atlas_px_range leaves around each outline at the draw size; a
consumer that wants a wide glow bakes a snapshot with a wider range. The glow is
centred, so an offset drop shadow is a second displaced run.
sdf_mask_t takes the smaller of the multi-channel coverage and the true signed
distance in the MTSDF alpha channel, which keeps the sharp corners of the median
while dropping what it reconstructs where its channels disagree.
Absence is not a mode. A frame that queues no styled draw builds no styled
pipeline and allocates no styled buffers, which diagnostics() reports through
styled_pipeline_builds and the styled_*_buffer_bytes counters. A record
naming a version this build does not implement is refused at that record's own
boundary with CAPABILITY_UNSUPPORTED, and a record whose values or combination
cannot describe a drawable result with INVALID_ARGUMENT; either way the call
changes nothing, so the frame's other draws - base text included - record
unchanged. Nothing is ever drawn without a capability that was asked for.
Text_renderer belongs to one renderer, one window, and one QRhi device, and
lives on the thread that drives that device's frames. There is no process-global
or cross-device QRhi object: two renderers own two independent resource sets
even when they draw the same snapshot. QRhi requires its resources to be
destroyed before the device, so the owner calls release_resources() on the
render thread while the device is still alive.
A frame is:
begin_frame().queue(batch, state)once per draw state. Batches accumulate into one vertex and one index buffer per active base or styled path. Each ordinary draw state produces one draw call, while a glow-enabled styled state produces two, so draw count follows draw states rather than glyphs.prepare(frame)before the host opens its render pass, because the atlas, geometry, and uniform uploads go into the frame's resource-update batch.record(frame)inside the pass the host opened with that batch.
Every step returns a text_result_t. record() reports NOT_PREPARED when
text was queued but preparation did not run or did not succeed, so a frame can
never present as text-complete when its text was dropped; diagnostics()
reports what the call actually issued. A queue() that fails - a rejected clip,
a batch from another font, a geometry limit, or OUT_OF_MEMORY - leaves the
frame exactly as it was rather than half-queued. The renderer sets a scissor for
every draw and leaves the viewport as the host set it.
The frame's first batch with geometry fixes the snapshot the whole frame uses.
Every later batch must be laid out against that same snapshot, and the atlas
prepare() offers and the smoothing data record() draws with come from it, so
a frame can never combine one snapshot's geometry with another's atlas. A
set_font() during a frame that already holds text therefore applies to the
next frame that queues its first batch, not to the one in flight.
The resource set is rebuilt when the QRhi changes, the pipeline when the
render-pass descriptor or sample count changes, and the atlas is uploaded again
when the renderer draws a different snapshot, even if the queued text did not
change. Growing the vertex and index buffers rebuilds neither the shader
bindings nor the pipeline, because neither buffer is named by the binding set.
diagnostics() exposes those causes as counters.
prepare() enqueues its uploads; it does not perform them. QRhi runs the
commands in a QRhiResourceUpdateBatch only when the host submits the batch
through beginPass(), endPass(), or QRhiCommandBuffer::resourceUpdate(),
and a host may release or abandon a batch instead, in which case nothing runs.
So an enqueued atlas upload stays outstanding until a frame carrying it reaches
record(), which the host runs inside the pass it opened with that batch; a
frame cancelled, abandoned, or failed before then leaves the upload outstanding
and the next prepare() enqueues it again. atlas_upload_enqueues counts those
offers, not executed uploads. Nothing in this component observes GPU execution,
presentation, or display, and it never claims to.
The component bakes its own .qsb artifacts covering SPIR-V, OpenGL ES 3.0,
desktop GL 3.3 and 4.1, HLSL 5.0, and Metal 1.2 and 2.1, for both the base and
the styled stages. The base fragment stage reconstructs coverage from the MTSDF
median as shown above, clamps sampling inside each glyph's UV rectangle, and
writes premultiplied colour; its pipeline blends One against
OneMinusSrcAlpha.
The styled fragment stage adds the optional capabilities. It writes straight
colour, because an LCD draw's one alpha stands for three channel coverages and
cannot premultiply them, so its pipeline blends SrcAlpha against
OneMinusSrcAlpha - the same source over composite by another route. Its LCD
decode thresholds, filter weights, tap windows, and subpixel step are the ones
vnm_msdf_text::lcd_shader_reference describes, and
vnm_msdf_text_rhi_styled_shader_reference_tests reads the shader source to
check they still are. The opacity and glow conditions that reference also states
are enforced where the draw is queued rather than in the shader, so a caller that
cannot have them is told rather than quietly given grayscale.
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build buildTo require system or pre-provided FreeType/msdfgen targets without network fetching:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DVNM_MSDF_TEXT_FETCH_DEPS=OFF
cmake --build buildFor offline builds with fetched dependencies already checked out, pass
FETCHCONTENT_SOURCE_DIR_FREETYPE and FETCHCONTENT_SOURCE_DIR_MSDFGEN, or set
VNM_MSDF_TEXT_DEP_OVERRIDES_FILE to a CMake file that defines those cache
entries.
When this project is configured as the top-level CMake project, tests are built by default:
cmake --build build --target vnm_msdf_text_tests
ctest --test-dir build --output-on-failureSet -DVNM_MSDF_TEXT_BUILD_TESTS=OFF to skip the test executable.
The tests bake their atlases from a font that lives in
vnm_fonts, so a test build resolves
that repository. A tree that has already added vnm_fonts has published the
verbatim files in VNM_FONTS_DIRECTORY, and that is used as it stands;
otherwise a checkout beside this one is used when it exists and CMake fetches
master when it does not, and VNM_MSDF_TEXT_VNM_FONTS_SOURCE_DIR overrides
that pair.
Only the file contract is used. vnm_fonts configures without Qt and publishes
VNM_FONTS_DIRECTORY either way, so adding it puts no Qt in front of this
build. Its vnm::fonts library marks a family name on the way into
QFontDatabase, which is what two files declaring one family need so they
cannot merge into a single entry. An atlas is baked from the file's bytes and
enters no font database, so nothing here can collide with a font the user has
installed and vnm::fonts is deliberately not linked.
A build configured with -DVNM_MSDF_TEXT_BUILD_RHI=ON registers further tests.
vnm_msdf_text_rhi_tests covers the snapshot, batch, frame, and optional
draw-capability contracts, drives the Null QRhi backend, which proves resource
lifecycle and command recording, and checks that every compiled shader artifact
carries all seven backend profiles.
vnm_msdf_text_rhi_styled_shader_reference_tests checks the styled shader
source against the shared LCD reference; it needs neither Qt nor a device.
vnm_msdf_text_rhi_real_backend_tests renders text with a real backend and
inspects the rasterized result, including the subpixel orders, the glow, the
mask, and their combinations; it needs a working graphics device and is never a
substitute for the Null suite. Pass --backend, --image, and --window to
select the backend, save the rendered images, and additionally record frames
against a shown window. The registered Windows gate uses D3D11 by default, so
its raster proof is D3D11 evidence rather than a claim that an OpenGL backend
was exercised.
tests/source_consumer gates the way the component is actually consumed: it is
a project of its own that adds this repository with add_subdirectory, checks
VNM_MSDF_TEXT_HAS_RHI, includes only public headers, and links only
vnm_msdf_text::rhi. Configure, build, and run it like any other consumer:
cmake -S tests/source_consumer -B build-source-consumer -DVNM_MSDF_TEXT_FETCH_DEPS=ON
cmake --build build-source-consumer
./build-source-consumer/vnm_msdf_text_rhi_source_consumerIt is not registered with CTest. Configuring it detects a compiler and building it invokes one, and a build a test run starts is a build nothing scheduled.
The vnm_msdf_text_package_smoke test installs the package and inspects it:
component and exact-version resolution, dependency-light degradation, and the
headers and targets the installed tree must not carry. It configures its
consumers as LANGUAGES NONE projects, so it detects no compiler and builds
nothing. tests/package_consumer covers the other half - that the installed
headers and imported targets really compile, link, and run - and is a project
of its own for the same reason tests/source_consumer is. Point it at the
prefix the smoke test installed and at the version this project declares, once
as the package stands and once as a dependency-light consumer sees it:
ctest --test-dir build -R vnm_msdf_text_package_smoke --output-on-failure
cmake -S tests/package_consumer -B build-package-consumer \
-DVNM_MSDF_TEXT_PACKAGE_PREFIX=$PWD/build/package_smoke/install \
-DVNM_MSDF_TEXT_PACKAGE_VERSION=0.2.0
cmake --build build-package-consumer
ctest --test-dir build-package-consumer --output-on-failure
cmake -S tests/package_consumer -B build-package-consumer-nodeps \
-DVNM_MSDF_TEXT_PACKAGE_PREFIX=$PWD/build/package_smoke/install \
-DVNM_MSDF_TEXT_PACKAGE_VERSION=0.2.0 \
-DCMAKE_DISABLE_FIND_PACKAGE_Freetype=TRUE \
-DCMAKE_DISABLE_FIND_PACKAGE_msdfgen=TRUE
cmake --build build-package-consumer-nodeps
ctest --test-dir build-package-consumer-nodeps --output-on-failure