Skip to content

glyphon (Rust, wgpu) ​

A 1 775-line wgpu middleware that turns cosmic-text layout into one instanced draw call over two GPU glyph atlases, the "rasterize on the CPU, sample on the GPU" counterpoint to vello's per-frame compute rasterization.

FieldValue
LanguageRust (edition 2021) + one WGSL shader
LicenseMIT OR Apache-2.0 OR Zlib (Cargo.toml)
RepositorygrovesNL/glyphon
DocumentationREADME.md, rustdoc on docs.rs/glyphon, examples/
Categoryrasterizer (GPU atlas renderer)
Layer(s) coveredraster (atlas + composite only; shape/layout/raster delegated to cosmic-text)
Version at pin0.12.0, on cosmic-text 0.19, wgpu 30, etagere 0.3
Pinned revision49dc8f7bafa8091f4d71521fd62ee6f647b556f5 (2026-07-09)

Overview ​

What it solves ​

Drawing text inside an existing wgpu frame without a dedicated render pass or a font stack of its own. All font work is cosmic-text's: glyphon re-exports FontSystem, Buffer, SwashCache, CacheKey, SubpixelBin and the rest verbatim (lib.rs, lines 24–31). What glyphon adds is the GPU half: a texture atlas with LRU eviction and doubling growth, an instance buffer, and a render pipeline. The crate is 1 647 lines of Rust plus 128 lines of WGSL in src/ (2 773 .rs lines counting examples and benches).

Design philosophy ​

The README states the three-stage split and the integration contract:

  • shaping/calculating layout/rasterizing glyphs (with cosmic-text)
  • packing the glyphs into texture atlas (with etagere)
  • sampling from the texture atlas to render text (with wgpu)

To avoid extra render passes, rendering uses existing render passes (following the middleware pattern described in wgpu's Encapsulating Graphics Work wiki page).

— README.md

The middleware pattern means a prepare phase that owns every mutation (rasterize, upload, write vertices) and a render phase that only borrows a RenderPass and records commands, so the caller's pass ordering is untouched.

How it works ​

Four public objects, each with a distinct lifetime:

  • Cache — Arc<Inner> holding the shader module, sampler, bind-group layouts and a Mutex<Vec<…>> of render pipelines keyed by (TextureFormat, MultisampleState, Option<DepthStencilState>) (cache.rs, lines 22–43). One per device, cloned freely.
  • TextAtlas — two InnerAtlases (mask_atlas: R8Unorm; color_atlas: Rgba8UnormSrgb or Rgba8Unorm by ColorMode) plus the bind group over both (text_atlas.rs, lines 285–293).
  • Viewport — a uniform buffer with the screen resolution; written only when it changes (viewport.rs).
  • TextRenderer — a vertex buffer, a pipeline and a Vec<GlyphToRender> rebuilt on every prepare.

TextRenderer::prepare(device, queue, &mut FontSystem, &mut TextAtlas, &Viewport, text_areas, &mut SwashCache) walks each TextArea's Buffer::layout_runs(), skipping runs outside bounds vertically, and for each LayoutGlyph calls glyph.physical((left, top), scale) to get a cosmic_text::CacheKey with the integer pixel position and the subpixel bins (text_render.rs, lines 247–313). A cache hit stamps last_used = generation; a miss calls SwashCache::get_image_uncached — glyphon deliberately bypasses SwashCache's CPU image map, since the atlas is the cache — allocates a rectangle, and queue.write_textures the bitmap (lines 463–545). Each glyph becomes a 28-byte instance:

rust
pub(crate) struct GlyphToRender {
    pos: [i32; 2],
    dim: [u16; 2],
    uv: [u16; 2],
    color: u32,
    content_type_with_srgb: [u16; 2],
    depth: f32,
}

— lib.rs, lines 58–67

Clipping to TextBounds is done on the CPU by shrinking dim and shifting uv (lines 585–613), so the shader needs no scissor. render is five lines:

rust
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &atlas.bind_group, &[]);
pass.set_bind_group(1, &viewport.bind_group, &[]);
pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
pass.draw(0..4, 0..self.glyph_vertices.len() as u32);

— text_render.rs, lines 359–363

That is the one-draw-call property: a triangle strip of four vertices, instanced once per glyph (VertexStepMode::Instance, cache.rs, line 65), expanded in shader.wgsl from vertex_index bits. It holds per TextRenderer, across any number of TextAreas, mask and color glyphs mixed.

Analysis spine ​

1. Layering and ownership ​

glyphon is a fifth layer on top of cosmic-text's four (font database, Font, Buffer, SwashCache): the GPU glyph cache. Ownership is split by lifetime — Cache per device (Arc, shareable across threads behind its Mutex), TextAtlas per surface format, TextRenderer per draw site, Viewport per render target. Every mutable input arrives as &mut, so prepare is single-threaded by construction, and the FontSystem borrow ties it to the thread that owns fonts. Errors are two enums: PrepareError::AtlasFull when eviction and growth both fail, and RenderError (error.rs). The frame contract is explicit in the example: prepare → render → submit → atlas.trim() (hello-world.rs, lines 186, 259), where trim just bumps the generation counter that marks glyphs as in use.

2. Face loading and table access ​

Not applicable. glyphon never sees a font file; faces come from cosmic-text's FontSystem and its fontdb database.

3. Shaping ​

Not applicable: shaping and line layout happen in cosmic-text's Buffer before prepare. glyphon consumes only positioned LayoutGlyphs.

4. Variation and instances ​

Not applicable. Whatever variation cosmic-text applies is folded into its CacheKey (font id, glyph id, size bits, weight, flags), which glyphon uses verbatim as its atlas key, so distinct instances get distinct atlas slots without glyphon knowing about axes.

5. Rasterization and outlines ​

No outline access (RQ6). glyphon receives bitmaps only.

Atlas strategy (RQ2). Each InnerAtlas starts at 256×256 (INITIAL_SIZE, text_atlas.rs, line 33), packed by etagere's BucketedAtlasAllocator. On allocation failure try_allocate pops the LRU entry until it reaches one touched this generation — then the atlas is full for this frame (lines 73–106). Only then does grow double the side up to max_texture_dimension_2d, create a new texture and re-rasterize every cached glyph from scratch via get_image_uncached, because the old texture's contents are not copied (lines 112–217). Growth is therefore a full re-render of the working set, bounded by the device's texture limit.

Subpixel positioning. The key carries cosmic-text's SubpixelBin, four bins per axis (glyph_cache.rs, lines 65–70), so one glyph at one size occupies up to 16 atlas slots. Custom glyphs can opt out with snap_to_physical_pixel (custom_glyph.rs).

Sampling, gamma and LCD. The sampler is FilterMode::Nearest everywhere (cache.rs, lines 50–52): quads land on integer pixels and the fractional offset lives in the bin, so no bilinear blur. The mask path multiplies color.a by the R8 coverage; ColorMode::Accurate converts the vertex color sRGB→linear in the shader, Web does not (shader.wgsl). There is no gamma or contrast adjustment of coverage itself. LCD output is unsupported: SwashContent::SubpixelMask is mapped to Mask with the comment "Not implemented yet, but don't panic if this happens." (text_render.rs, lines 292–295).

Color glyphs. Whatever swash renders as SwashContent::Color (COLR, bitmap strikes) goes to the RGBA atlas. CustomGlyph plus a rasterize_custom_glyph callback lets the caller inject SVG icons or images into the same atlases (the custom-glyphs example rasterizes SVGs with resvg).

6. Metrics and measurement ​

Not applicable beyond placement: glyph left/top bearings come from the swash image placement, and line positions from LayoutRun::line_y. All units are physical pixels after TextArea::scale.

7. Discovery, matching and fallback ​

Not applicable; delegated wholly to cosmic-text (see cosmic-text §7).

What it teaches sparkles:font ​

  • Separate the GPU glyph cache from the font stack. glyphon proves the atlas needs exactly one input from the font layer: a hashable (face, glyph, size, subpixel bins, flags) key plus a "rasterize this key" function. sparkles:font should export that key type and keep atlas policy in sparkles:raylib-text / sparkles:ui-raylib.
  • Two atlases by content type (R8 coverage, RGBA color) with a per-instance flag in one pipeline is the minimal colour-emoji design; FontSet today has neither a colour atlas nor a content-type flag.
  • Generation-stamped LRU eviction before growth is cheap and correct: a glyph touched this frame is never evicted, so AtlasFull means the frame genuinely does not fit.
  • Growth by re-rasterization is the simple answer when the CPU rasterizer is cheap and deterministic; a texture-to-texture copy would avoid it and is the obvious improvement.
  • Subpixel bins plus nearest sampling beats bilinear oversampling for crisp text: quarter-pixel horizontal bins cost a 16× worst-case slot multiplier, which a terminal grid (integer cell origins) can drop to 1×.
  • Instanced quads with CPU clipping is the one-draw-call recipe a terminal renderer can copy directly: 28 bytes per glyph, no index buffer, no scissor state.

Strengths ​

  • Tiny: 1 647 lines of Rust, one WGSL file; easy to read whole.
  • One instanced draw per renderer, inside the caller's render pass.
  • Mask and color atlases with LRU eviction and doubling growth.
  • Custom glyph hook shares the atlas with text.
  • Correct sRGB handling selectable per atlas (ColorMode).

Weaknesses ​

  • No LCD/subpixel AA; SubpixelMask silently degrades to grayscale.
  • Growth re-rasterizes the whole cached set; capped at the device texture limit, then AtlasFull.
  • Hard-wired to cosmic-text types and wgpu; no outline or SDF path, so large or transformed text is re-rasterized per size.
  • No coverage gamma or stem darkening; quality is whatever swash produces.
  • prepare needs &mut FontSystem, so text preparation cannot run off the font-owning thread.

Key design decisions and trade-offs ​

DecisionRationaleTrade-off
Middleware: prepare mutates, render recordsFits into an existing render passCaller must sequence prepare/render/trim each frame
Atlas key = cosmic_text::CacheKeyZero translation; variations and size come freeCouples glyphon to cosmic-text versions
Separate R8 mask and RGBA color atlases4× less memory for monochrome textTwo textures, two growth paths
LRU eviction gated by frame generationNever evicts a glyph in use this frameWorking set must fit one texture
Grow ×2 and re-rasterize everythingNo GPU copy, simple codeGrowth frames stall on CPU rasterization
Subpixel bins + nearest samplingCrisp glyphs at fractional positionsUp to 16 slots per glyph per size
CPU clipping into instance dim/uvOne pipeline, no scissorCPU work per glyph per frame
Bypass SwashCache image mapAvoid a second (unbounded) CPU copyRe-rasterize on growth instead

Sources ​

All paths verified at the pinned revision with git cat-file -e.