viewport_lib/resources/font.rs
1//! Font atlas and single-line text layout for overlay rendering.
2//!
3//! This module is the text back-end for [`LabelItem`], [`ScalarBarItem`], and
4//! [`RulerItem`]. It uses [`fontdue`] for glyph rasterization and packs glyphs
5//! into a single GPU texture atlas on demand.
6//!
7//! Public surface: [`FontHandle`] (opaque font identifier) and
8//! [`super::ViewportGpuResources::upload_font`]. Everything else is `pub(crate)`.
9
10use std::collections::HashMap;
11
12/// Default font embedded in the library binary (Inter Regular, SIL OFL 1.1).
13const DEFAULT_FONT_BYTES: &[u8] = include_bytes!("../fonts/Inter-Regular.ttf");
14
15// ---------------------------------------------------------------------------
16// FontHandle : public opaque identifier
17// ---------------------------------------------------------------------------
18
19/// Opaque handle to a font uploaded via
20/// [`ViewportGpuResources::upload_font`](super::ViewportGpuResources::upload_font).
21///
22/// Pass `None` (or omit the field) on overlay items to use the built-in default
23/// font. Pass `Some(handle)` to use a user-supplied TTF font.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub struct FontHandle(pub(crate) usize);
26
27// ---------------------------------------------------------------------------
28// GlyphKey / GlyphEntry : atlas bookkeeping
29// ---------------------------------------------------------------------------
30
31/// Unique key for a rasterized glyph in the atlas.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33struct GlyphKey {
34 font_index: usize,
35 glyph_index: u16,
36 /// Font size in tenths of a pixel (e.g. 140 = 14.0 px).
37 /// Quantised to avoid unbounded atlas growth from fractional sizes.
38 size_tenths: u32,
39}
40
41/// Location and metrics of a single rasterized glyph in the atlas texture.
42#[derive(Debug, Clone, Copy)]
43struct GlyphEntry {
44 /// Top-left pixel coordinate in the atlas.
45 x: u32,
46 y: u32,
47 /// Rasterized bitmap dimensions.
48 width: u32,
49 height: u32,
50 /// Offset from the pen position to the top-left of the bitmap.
51 offset_x: f32,
52 offset_y: f32,
53}
54
55// ---------------------------------------------------------------------------
56// GlyphQuad / TextLayout : internal layout output
57// ---------------------------------------------------------------------------
58
59/// A positioned, textured quad for one glyph, ready for vertex generation.
60#[derive(Debug, Clone, Copy)]
61pub(crate) struct GlyphQuad {
62 /// Screen-space top-left corner (pixels from top-left of viewport).
63 pub pos: [f32; 2],
64 /// Screen-space size [w, h] in pixels.
65 pub size: [f32; 2],
66 /// UV top-left in the atlas (0..1).
67 pub uv_min: [f32; 2],
68 /// UV bottom-right in the atlas (0..1).
69 pub uv_max: [f32; 2],
70}
71
72/// Layout result for a single-line text string.
73#[derive(Debug, Clone)]
74pub(crate) struct TextLayout {
75 /// One quad per visible glyph (whitespace characters are skipped).
76 pub quads: Vec<GlyphQuad>,
77 /// Total advance width of the laid-out string in pixels.
78 pub total_width: f32,
79 /// Line height in pixels (ascent - descent + line gap at the requested size).
80 pub height: f32,
81}
82
83// ---------------------------------------------------------------------------
84// GlyphAtlas
85// ---------------------------------------------------------------------------
86
87/// A dynamically-growing glyph atlas backed by a single `Rgba8Unorm` texture.
88///
89/// Owned by [`ViewportGpuResources`]; never exposed in the public API.
90pub(crate) struct GlyphAtlas {
91 /// Parsed fontdue fonts. Index 0 is always the built-in default.
92 fonts: Vec<fontdue::Font>,
93
94 /// Cached rasterized glyphs.
95 entries: HashMap<GlyphKey, GlyphEntry>,
96
97 /// CPU-side atlas pixel data (single-channel alpha, packed row-major).
98 /// Stored as RGBA for direct GPU upload: R=G=B=255, A=coverage.
99 pixels: Vec<[u8; 4]>,
100
101 /// Current atlas dimensions (always square, power of two).
102 size: u32,
103
104 /// Simple row-based packer state.
105 cursor_x: u32,
106 cursor_y: u32,
107 row_height: u32,
108
109 /// GPU texture (recreated when the atlas grows).
110 pub texture: wgpu::Texture,
111 /// View into the atlas texture.
112 pub view: wgpu::TextureView,
113
114 /// Set to `true` whenever new glyphs have been rasterized since the last
115 /// GPU upload. Cleared by [`GlyphAtlas::upload_if_dirty`].
116 dirty: bool,
117}
118
119impl GlyphAtlas {
120 /// Initial atlas size in pixels (width = height).
121 const INITIAL_SIZE: u32 = 512;
122
123 /// Create a new atlas with the built-in default font pre-loaded.
124 pub fn new(device: &wgpu::Device) -> Self {
125 let default_font = fontdue::Font::from_bytes(
126 DEFAULT_FONT_BYTES,
127 fontdue::FontSettings::default(),
128 )
129 .expect("built-in default font must parse");
130
131 let size = Self::INITIAL_SIZE;
132 let pixel_count = (size * size) as usize;
133 let pixels = vec![[255, 255, 255, 0]; pixel_count];
134
135 let (texture, view) = Self::create_texture(device, size);
136
137 Self {
138 fonts: vec![default_font],
139 entries: HashMap::new(),
140 pixels,
141 size,
142 cursor_x: 0,
143 cursor_y: 0,
144 row_height: 0,
145 texture,
146 view,
147 dirty: false,
148 }
149 }
150
151 /// Register a user-supplied TTF font. Returns a [`FontHandle`] that can be
152 /// passed to overlay items.
153 pub fn upload_font(&mut self, ttf_bytes: &[u8]) -> Result<FontHandle, FontError> {
154 let font = fontdue::Font::from_bytes(ttf_bytes, fontdue::FontSettings::default())
155 .map_err(|e| FontError::ParseFailed(e.to_string()))?;
156 let index = self.fonts.len();
157 self.fonts.push(font);
158 Ok(FontHandle(index))
159 }
160
161 /// Lay out a single-line string and return positioned glyph quads.
162 ///
163 /// Glyphs that are not yet in the atlas are rasterized and packed on the
164 /// fly. Call [`upload_if_dirty`] after all layout calls for the frame to
165 /// push new glyphs to the GPU.
166 pub fn layout_text(
167 &mut self,
168 text: &str,
169 font_size: f32,
170 font: Option<FontHandle>,
171 device: &wgpu::Device,
172 ) -> TextLayout {
173 let font_index = font.map_or(0, |h| h.0);
174 let size_tenths = (font_size * 10.0).round() as u32;
175 let px = font_size;
176
177 let metrics = self.fonts[font_index].horizontal_line_metrics(px);
178 let height = metrics
179 .map(|m| m.ascent - m.descent + m.line_gap)
180 .unwrap_or(px * 1.2);
181
182 let mut quads = Vec::new();
183 let mut pen_x: f32 = 0.0;
184
185 let mut prev_glyph: Option<u16> = None;
186 for ch in text.chars() {
187 let glyph_index = self.fonts[font_index].lookup_glyph_index(ch);
188
189 // Kerning.
190 if let Some(prev) = prev_glyph {
191 if let Some(kern) = self.fonts[font_index]
192 .horizontal_kern_indexed(prev, glyph_index, px)
193 {
194 pen_x += kern;
195 }
196 }
197 prev_glyph = Some(glyph_index);
198
199 // Get metrics for advance, even for whitespace.
200 let m = self.fonts[font_index].metrics_indexed(glyph_index, px);
201
202 // Only emit a quad for glyphs with visible bitmap area.
203 if m.width > 0 && m.height > 0 {
204 let entry = self.ensure_glyph(device, font_index, glyph_index, size_tenths, px);
205 let atlas_size = self.size as f32;
206
207 quads.push(GlyphQuad {
208 pos: [
209 pen_x + entry.offset_x,
210 entry.offset_y,
211 ],
212 size: [entry.width as f32, entry.height as f32],
213 uv_min: [
214 entry.x as f32 / atlas_size,
215 entry.y as f32 / atlas_size,
216 ],
217 uv_max: [
218 (entry.x + entry.width) as f32 / atlas_size,
219 (entry.y + entry.height) as f32 / atlas_size,
220 ],
221 });
222 }
223
224 pen_x += m.advance_width;
225 }
226
227 TextLayout {
228 quads,
229 total_width: pen_x,
230 height,
231 }
232 }
233
234 /// Upload new glyph data to the GPU if any glyphs were rasterized since
235 /// the last upload.
236 pub fn upload_if_dirty(&mut self, queue: &wgpu::Queue) {
237 if !self.dirty {
238 return;
239 }
240 let flat: Vec<u8> = self.pixels.iter().flat_map(|p| p.iter().copied()).collect();
241 queue.write_texture(
242 wgpu::TexelCopyTextureInfo {
243 texture: &self.texture,
244 mip_level: 0,
245 origin: wgpu::Origin3d::ZERO,
246 aspect: wgpu::TextureAspect::All,
247 },
248 &flat,
249 wgpu::TexelCopyBufferLayout {
250 offset: 0,
251 bytes_per_row: Some(self.size * 4),
252 rows_per_image: Some(self.size),
253 },
254 wgpu::Extent3d {
255 width: self.size,
256 height: self.size,
257 depth_or_array_layers: 1,
258 },
259 );
260 self.dirty = false;
261 }
262
263 // ------------------------------------------------------------------
264 // Private helpers
265 // ------------------------------------------------------------------
266
267 /// Ensure a glyph is in the atlas, rasterizing and packing it if needed.
268 /// Returns the atlas entry.
269 fn ensure_glyph(
270 &mut self,
271 device: &wgpu::Device,
272 font_index: usize,
273 glyph_index: u16,
274 size_tenths: u32,
275 px: f32,
276 ) -> GlyphEntry {
277 let key = GlyphKey {
278 font_index,
279 glyph_index,
280 size_tenths,
281 };
282
283 if let Some(&entry) = self.entries.get(&key) {
284 return entry;
285 }
286
287 // Rasterize.
288 let (metrics, bitmap) = self.fonts[font_index].rasterize_indexed(glyph_index, px);
289 let w = metrics.width as u32;
290 let h = metrics.height as u32;
291
292 if w == 0 || h == 0 {
293 // Whitespace glyph: insert a zero-area entry.
294 let entry = GlyphEntry {
295 x: 0,
296 y: 0,
297 width: 0,
298 height: 0,
299 offset_x: metrics.xmin as f32,
300 offset_y: -(metrics.ymin as f32 + h as f32),
301 };
302 self.entries.insert(key, entry);
303 return entry;
304 }
305
306 // Pack into the atlas (simple row packer with 1px padding).
307 let pad = 1;
308 if self.cursor_x + w + pad > self.size {
309 // Move to next row.
310 self.cursor_y += self.row_height + pad;
311 self.cursor_x = 0;
312 self.row_height = 0;
313 }
314 if self.cursor_y + h + pad > self.size {
315 // Atlas is full : grow.
316 self.grow(device);
317 }
318
319 let x = self.cursor_x;
320 let y = self.cursor_y;
321
322 // Blit coverage into the RGBA pixel buffer.
323 for row in 0..h {
324 for col in 0..w {
325 let src = (row * w + col) as usize;
326 let dst = ((y + row) * self.size + (x + col)) as usize;
327 self.pixels[dst] = [255, 255, 255, bitmap[src]];
328 }
329 }
330 self.dirty = true;
331
332 self.cursor_x = x + w + pad;
333 self.row_height = self.row_height.max(h);
334
335 let entry = GlyphEntry {
336 x,
337 y,
338 width: w,
339 height: h,
340 offset_x: metrics.xmin as f32,
341 offset_y: -(metrics.ymin as f32 + h as f32),
342 };
343 self.entries.insert(key, entry);
344 entry
345 }
346
347 /// Double the atlas size, copying existing pixel data into the new buffer
348 /// and recreating the GPU texture.
349 fn grow(&mut self, device: &wgpu::Device) {
350 let old_size = self.size;
351 let new_size = old_size * 2;
352 tracing::info!("Growing glyph atlas from {}x{} to {}x{}", old_size, old_size, new_size, new_size);
353
354 let mut new_pixels = vec![[255, 255, 255, 0u8]; (new_size * new_size) as usize];
355 for row in 0..old_size {
356 let src_start = (row * old_size) as usize;
357 let dst_start = (row * new_size) as usize;
358 new_pixels[dst_start..dst_start + old_size as usize]
359 .copy_from_slice(&self.pixels[src_start..src_start + old_size as usize]);
360 }
361
362 self.pixels = new_pixels;
363 self.size = new_size;
364
365 let (texture, view) = Self::create_texture(device, new_size);
366 self.texture = texture;
367 self.view = view;
368 self.dirty = true; // Full re-upload needed.
369 }
370
371 fn create_texture(device: &wgpu::Device, size: u32) -> (wgpu::Texture, wgpu::TextureView) {
372 let texture = device.create_texture(&wgpu::TextureDescriptor {
373 label: Some("glyph_atlas"),
374 size: wgpu::Extent3d {
375 width: size,
376 height: size,
377 depth_or_array_layers: 1,
378 },
379 mip_level_count: 1,
380 sample_count: 1,
381 dimension: wgpu::TextureDimension::D2,
382 format: wgpu::TextureFormat::Rgba8Unorm,
383 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
384 view_formats: &[],
385 });
386 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
387 (texture, view)
388 }
389}
390
391// ---------------------------------------------------------------------------
392// FontError
393// ---------------------------------------------------------------------------
394
395/// Error returned by [`super::ViewportGpuResources::upload_font`].
396#[derive(Debug, Clone, thiserror::Error)]
397pub enum FontError {
398 /// The TTF data could not be parsed.
399 #[error("font parsing failed: {0}")]
400 ParseFailed(String),
401}
402
403// ---------------------------------------------------------------------------
404// ViewportGpuResources integration
405// ---------------------------------------------------------------------------
406
407impl super::ViewportGpuResources {
408 /// Upload a user-supplied TTF font for use with overlay items.
409 ///
410 /// Returns an opaque [`FontHandle`] that can be passed to [`LabelItem`],
411 /// [`ScalarBarItem`], or [`RulerItem`] via their `font` field. Pass
412 /// `None` on those items to use the built-in default font instead.
413 ///
414 /// The font bytes must be a valid TrueType (`.ttf`) file.
415 pub fn upload_font(&mut self, ttf_bytes: &[u8]) -> Result<FontHandle, FontError> {
416 self.glyph_atlas.upload_font(ttf_bytes)
417 }
418}