Skip to content

Instantly share code, notes, and snippets.

@caspark
Created February 22, 2026 22:08
Show Gist options
  • Select an option

  • Save caspark/b88108696d0e7678b2e6768da32f1be2 to your computer and use it in GitHub Desktop.

Select an option

Save caspark/b88108696d0e7678b2e6768da32f1be2 to your computer and use it in GitHub Desktop.
Cosmic-text rendering in macroquad - glyph atlas, buffer caching, word wrap. Dual-licensed: Unlicense OR WTFPL.
//! Demonstrates how to use cosmic-text for text shaping and layout, with a custom glyph atlas
//! and renderer built on top of macroquad's texture drawing.
//!
//! Needs the following dependencies:
//! cosmic-text = "0.17.1"
//! guillotiere = "0.6.2"
//!
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use cosmic_text::fontdb;
use cosmic_text::{Attrs, Buffer, CacheKey, Metrics, Shaping, SwashCache, SwashContent};
use guillotiere::{AllocId, AtlasAllocator, size2};
use macroquad::prelude::*;
/// A region of text to render, referencing a shaped cosmic-text Buffer.
#[derive(Clone, Copy)]
struct TextArea<'a> {
buffer: &'a Buffer,
left: f32,
top: f32,
/// Screen-space clipping rectangle: (min_x, min_y, max_x, max_y).
clip: (f32, f32, f32, f32),
default_color: Color,
}
/// Errors that can occur during text preparation.
#[derive(Debug)]
enum PrepareError {
/// The glyph atlas is full and needs to grow before retrying.
AtlasFull,
}
/// A cached glyph in the atlas.
struct CachedGlyph {
alloc_id: AllocId,
/// Position in the atlas texture (pixels).
atlas_x: u16,
atlas_y: u16,
/// Pixel dimensions of the rasterized glyph.
width: u16,
height: u16,
/// Offset from the glyph origin to the top-left of the rasterized image.
offset_x: i32,
offset_y: i32,
}
/// A texture atlas for caching rasterized glyphs.
struct TextAtlas {
allocator: AtlasAllocator,
texture: Texture2D,
size: u32,
glyph_cache: HashMap<CacheKey, CachedGlyph>,
retired_textures: Vec<Texture2D>,
used_this_frame: HashSet<CacheKey>,
}
impl TextAtlas {
fn new(initial_size: u32) -> Self {
let allocator = AtlasAllocator::new(size2(initial_size as i32, initial_size as i32));
let blank = Image::gen_image_color(
initial_size as u16,
initial_size as u16,
Color::new(0.0, 0.0, 0.0, 0.0),
);
let texture = Texture2D::from_image(&blank);
texture.set_filter(FilterMode::Nearest);
Self {
allocator,
texture,
size: initial_size,
glyph_cache: HashMap::new(),
retired_textures: Vec::new(),
used_this_frame: HashSet::new(),
}
}
/// Look up a cached glyph, or rasterize and insert it.
///
/// `rasterize_fn` returns `Option<(grayscale_bitmap, width, height, offset_x, offset_y)>`.
/// Returns `Ok(None)` for empty/invisible glyphs, `Err(())` if the atlas is full.
fn get_or_insert<F>(
&mut self,
key: CacheKey,
rasterize_fn: F,
) -> Result<Option<&CachedGlyph>, ()>
where
F: FnOnce(CacheKey) -> Option<(Vec<u8>, u32, u32, i32, i32)>,
{
self.used_this_frame.insert(key);
if self.glyph_cache.contains_key(&key) {
return Ok(self.glyph_cache.get(&key));
}
let Some((bitmap, width, height, offset_x, offset_y)) = rasterize_fn(key) else {
return Ok(None);
};
if width == 0 || height == 0 {
return Ok(None);
}
// Try to allocate space (1px padding on each side)
let alloc = self
.allocator
.allocate(size2(width as i32 + 2, height as i32 + 2));
let Some(alloc) = alloc else {
return Err(());
};
let atlas_x = (alloc.rectangle.min.x + 1) as u16;
let atlas_y = (alloc.rectangle.min.y + 1) as u16;
// Expand grayscale bitmap to RGBA (white with varying alpha)
let mut rgba = vec![0u8; (width * height * 4) as usize];
for i in 0..(width * height) as usize {
let a = bitmap[i];
rgba[i * 4] = 255;
rgba[i * 4 + 1] = 255;
rgba[i * 4 + 2] = 255;
rgba[i * 4 + 3] = a;
}
let image = Image {
bytes: rgba,
width: width as u16,
height: height as u16,
};
self.texture.update_part(
&image,
atlas_x as i32,
atlas_y as i32,
width as i32,
height as i32,
);
let cached = CachedGlyph {
alloc_id: alloc.id,
atlas_x,
atlas_y,
width: width as u16,
height: height as u16,
offset_x,
offset_y,
};
self.glyph_cache.insert(key, cached);
Ok(self.glyph_cache.get(&key))
}
/// Grow the atlas: retire old texture, double size, clear glyph cache.
fn grow(&mut self) {
let new_size = self.size * 2;
let blank = Image::gen_image_color(
new_size as u16,
new_size as u16,
Color::new(0.0, 0.0, 0.0, 0.0),
);
let new_tex = Texture2D::from_image(&blank);
new_tex.set_filter(FilterMode::Nearest);
let old_texture = std::mem::replace(&mut self.texture, new_tex);
self.retired_textures.push(old_texture);
self.size = new_size;
self.allocator = AtlasAllocator::new(size2(self.size as i32, self.size as i32));
self.glyph_cache.clear();
}
/// Trim: drop retired textures and evict glyphs not used this frame.
fn trim(&mut self) {
self.retired_textures.clear();
let used = &self.used_this_frame;
let allocator = &mut self.allocator;
self.glyph_cache.retain(|key, glyph| {
if used.contains(key) {
true
} else {
allocator.deallocate(glyph.alloc_id);
false
}
});
self.used_this_frame.clear();
}
fn texture(&self) -> &Texture2D {
&self.texture
}
}
/// A glyph ready to be rendered.
struct GlyphToRender {
x: f32,
y: f32,
width: f32,
height: f32,
atlas_x: f32,
atlas_y: f32,
color: Color,
}
/// Rasterizes glyphs via SwashCache, renders from TextAtlas
struct TextRenderer {
swash_cache: SwashCache,
prepared: Vec<GlyphToRender>,
}
impl TextRenderer {
fn new() -> Self {
Self {
swash_cache: SwashCache::new(),
prepared: Vec::new(),
}
}
/// Prepare text areas for rendering: rasterize glyphs, pack into atlas, collect draw commands.
///
/// Returns `Err(PrepareError::AtlasFull)` if the atlas runs out of space - caller should
/// call `atlas.grow()` and retry.
fn prepare(
&mut self,
font_system: &mut cosmic_text::FontSystem,
atlas: &mut TextAtlas,
text_areas: &[TextArea<'_>],
) -> Result<(), PrepareError> {
self.prepared.clear();
for area in text_areas {
let default_color = area.default_color;
for run in area.buffer.layout_runs() {
let line_y = run.line_y;
for glyph in run.glyphs.iter() {
let physical = glyph.physical((area.left, area.top + line_y), 1.0);
let cache_key = physical.cache_key;
let swash_cache = &mut self.swash_cache;
let cached = atlas
.get_or_insert(cache_key, |key| {
let image = swash_cache.get_image(font_system, key);
let image = image.as_ref()?;
let p = &image.placement;
let alpha_data = match image.content {
SwashContent::Mask => image.data.clone(),
SwashContent::Color | SwashContent::SubpixelMask => {
// Extract alpha channel from RGBA
image.data.iter().skip(3).step_by(4).copied().collect()
}
};
Some((alpha_data, p.width, p.height, p.left, p.top))
})
.map_err(|()| PrepareError::AtlasFull)?;
let Some(cached) = cached else {
continue;
};
let screen_x = physical.x as f32 + cached.offset_x as f32;
let screen_y = physical.y as f32 - cached.offset_y as f32;
let w = cached.width as f32;
let h = cached.height as f32;
// Clip against bounds
let (min_x, min_y, max_x, max_y) = area.clip;
if screen_x + w < min_x
|| screen_x > max_x
|| screen_y + h < min_y
|| screen_y > max_y
{
continue;
}
let color = if let Some(c) = glyph.color_opt {
Color::new(
c.r() as f32 / 255.0,
c.g() as f32 / 255.0,
c.b() as f32 / 255.0,
c.a() as f32 / 255.0,
)
} else {
default_color
};
self.prepared.push(GlyphToRender {
x: screen_x,
y: screen_y,
width: w,
height: h,
atlas_x: cached.atlas_x as f32,
atlas_y: cached.atlas_y as f32,
color,
});
}
}
}
Ok(())
}
/// Render all prepared glyphs via draw_texture_ex.
fn render(&mut self, atlas: &TextAtlas) {
let texture = atlas.texture();
for glyph in self.prepared.drain(..) {
draw_texture_ex(
texture,
glyph.x,
glyph.y,
glyph.color,
DrawTextureParams {
source: Some(Rect::new(
glyph.atlas_x,
glyph.atlas_y,
glyph.width,
glyph.height,
)),
dest_size: Some(Vec2::new(glyph.width, glyph.height)),
..Default::default()
},
);
}
}
}
/// Horizontal text alignment within the wrap width.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
enum TextAlign {
#[default]
Left,
Center,
}
impl TextAlign {
fn to_cosmic(self) -> Option<cosmic_text::Align> {
match self {
TextAlign::Left => None,
TextAlign::Center => Some(cosmic_text::Align::Center),
}
}
}
/// Cache key for a shaped cosmic-text Buffer.
///
/// Cache key for a shaped cosmic-text Buffer.
///
/// Float fields stored as bits via `f32::to_bits()` so they're Hash + Eq.
/// NaN has multiple bit representations, so we assert non-NaN at construction.
#[derive(Hash, Eq, PartialEq)]
struct BufferCacheKey {
text: String,
font_size_bits: u32,
max_width_bits: Option<u32>,
align: TextAlign,
}
impl BufferCacheKey {
fn new(text: String, font_size: f32, max_width: Option<f32>, align: TextAlign) -> Self {
debug_assert!(!font_size.is_nan(), "font_size must not be NaN");
debug_assert!(
!max_width.is_some_and(|w| w.is_nan()),
"max_width must not be NaN"
);
Self {
text,
font_size_bits: font_size.to_bits(),
max_width_bits: max_width.map(f32::to_bits),
align,
}
}
}
/// A cached shaped Buffer with its measured dimensions.
struct CachedBuffer {
buffer: Buffer,
size: Vec2,
last_used: u64,
}
/// How many frames a buffer can go unused before eviction.
const EVICT_AFTER_FRAMES: u64 = 3;
/// Owns all text rendering state: font system, atlas, renderer, and a buffer cache.
///
/// All text measurement and drawing goes through this struct. Shaped cosmic-text Buffers are
/// cached by (text, font_size, max_width, align) and evicted when unused for several frames.
struct TextSystem {
font_system: cosmic_text::FontSystem,
atlas: TextAtlas,
renderer: TextRenderer,
cache: HashMap<BufferCacheKey, CachedBuffer>,
frame: u64,
font_family_name: String,
}
impl TextSystem {
fn new(font_data: Vec<u8>) -> Self {
let db = fontdb::Database::new();
let mut font_system =
cosmic_text::FontSystem::new_with_locale_and_db("en-US".to_string(), db);
// Load font and read back the family name
let ids = font_system
.db_mut()
.load_font_source(fontdb::Source::Binary(Arc::new(font_data)));
let font_family_name = ids
.first()
.and_then(|&id| font_system.db().face(id))
.and_then(|face| face.families.first())
.map(|(name, _)| name.clone())
.expect("Failed to load font");
// Warm up with a dummy buffer
let _warmup = Buffer::new(&mut font_system, Metrics::new(16.0, 20.0));
Self {
font_system,
atlas: TextAtlas::new(512),
renderer: TextRenderer::new(),
cache: HashMap::new(),
frame: 0,
font_family_name,
}
}
/// Measure text dimensions (no wrapping).
fn measure(&mut self, text: &str, font_size: f32) -> Vec2 {
self.ensure_cached(text, font_size, None, TextAlign::Left)
}
/// Draw text at position. Returns measured dimensions.
fn draw(&mut self, text: &str, pos: Vec2, font_size: f32, color: Color) -> Vec2 {
let size = self.ensure_cached(text, font_size, None, TextAlign::Left);
self.draw_cached_buffer(text, font_size, None, TextAlign::Left, pos, color);
size
}
/// Draw text with word wrapping. Returns measured dimensions.
fn draw_wrapped(
&mut self,
text: &str,
pos: Vec2,
font_size: f32,
max_width: f32,
align: TextAlign,
color: Color,
) -> Vec2 {
let size = self.ensure_cached(text, font_size, Some(max_width), align);
self.draw_cached_buffer(text, font_size, Some(max_width), align, pos, color);
size
}
/// Trim atlas and evict stale buffer cache entries. Call once per frame after rendering.
fn end_frame(&mut self) {
self.atlas.trim();
let frame = self.frame;
self.cache
.retain(|_, entry| frame - entry.last_used < EVICT_AFTER_FRAMES);
self.frame += 1;
}
fn ensure_cached(
&mut self,
text: &str,
font_size: f32,
max_width: Option<f32>,
align: TextAlign,
) -> Vec2 {
let key = BufferCacheKey::new(text.to_string(), font_size, max_width, align);
if let Some(entry) = self.cache.get_mut(&key) {
entry.last_used = self.frame;
return entry.size;
}
let line_height = font_size * 1.2;
let metrics = Metrics::new(font_size, line_height);
let cosmic_family = cosmic_text::Family::Name(&self.font_family_name);
let attrs = Attrs::new().family(cosmic_family);
let mut buffer = Buffer::new(&mut self.font_system, metrics);
buffer.set_size(&mut self.font_system, max_width, None);
buffer.set_text(
&mut self.font_system,
text,
&attrs,
Shaping::Advanced,
align.to_cosmic(),
);
// Measure
let mut width: f32 = 0.0;
let mut height: f32 = 0.0;
for run in buffer.layout_runs() {
width = width.max(run.line_w);
height = run.line_top + run.line_height;
}
let size = Vec2::new(width, height);
self.cache.insert(
key,
CachedBuffer {
buffer,
size,
last_used: self.frame,
},
);
size
}
fn draw_cached_buffer(
&mut self,
text: &str,
font_size: f32,
max_width: Option<f32>,
align: TextAlign,
pos: Vec2,
color: Color,
) {
let key = BufferCacheKey::new(text.to_string(), font_size, max_width, align);
let entry = self
.cache
.get(&key)
.expect("draw_cached_buffer called without ensure_cached");
// Full screen clip rect
let clip = (0.0, 0.0, screen_width(), screen_height());
let text_area = TextArea {
buffer: &entry.buffer,
left: pos.x,
top: pos.y,
clip,
default_color: color,
};
loop {
match self
.renderer
.prepare(&mut self.font_system, &mut self.atlas, &[text_area])
{
Ok(()) => break,
Err(PrepareError::AtlasFull) => self.atlas.grow(),
}
}
self.renderer.render(&self.atlas);
}
}
#[macroquad::main("Cosmic Text")]
async fn main() {
let font_data = load_file("./examples/DancingScriptRegular.ttf")
.await
.expect("Failed to load font file");
let mut text = TextSystem::new(font_data);
loop {
clear_background(DARKGRAY);
// Simple text
text.draw("Hello, cosmic-text!", vec2(40.0, 40.0), 48.0, WHITE);
// Smaller text
text.draw("Rendered with a glyph atlas", vec2(40.0, 100.0), 24.0, YELLOW);
// Wrapped text with left alignment
text.draw_wrapped(
"This is a longer paragraph that demonstrates word wrapping. \
The text will automatically break at word boundaries when it \
exceeds the maximum width.",
vec2(40.0, 160.0),
20.0,
300.0,
TextAlign::Left,
SKYBLUE,
);
// Wrapped text with center alignment
text.draw_wrapped(
"Center-aligned wrapped text also works correctly with cosmic-text layout.",
vec2(400.0, 160.0),
20.0,
250.0,
TextAlign::Center,
LIME,
);
// Measure then draw
let measurement = text.measure("Measured text", 32.0);
let msg = format!("This text is {:.0}x{:.0}px", measurement.x, measurement.y);
text.draw(&msg, vec2(40.0, 340.0), 24.0, ORANGE);
// FPS
let fps = format!("FPS: {}", get_fps());
text.draw(&fps, vec2(40.0, 400.0), 16.0, GREEN);
next_frame().await;
// Trim atlas and such
text.end_frame();
}
}
This example code is dual-licensed under your choice of either license:
1. The Unlicense (https://spdx.org/licenses/Unlicense.html)
2. WTFPL (https://spdx.org/licenses/WTFPL.html)
Pick whichever you prefer.
===============================================================================
THE UNLICENSE
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute
this software, either in source code form or as a compiled binary, for any
purpose, commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this
software dedicate any and all copyright interest in the software to the public
domain. We make this dedication for the benefit of the public at large and to
the detriment of our heirs and successors. We intend this dedication to be an
overt act of relinquishment in perpetuity of all present and future rights to
this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
===============================================================================
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Everyone is permitted to copy and distribute verbatim or modified copies of
this license document, and changing it is allowed as long as the name is
changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment