Skip to main content

game_toolkit_gfx/
text.rs

1use std::collections::{BTreeSet, HashMap};
2
3use crate::target::Targets;
4use glyphon::{
5    Attrs, Buffer, Cache, Color, ColorMode, FontSystem, Metrics, Resolution, Shaping, SwashCache,
6    TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
7};
8
9#[derive(Clone)]
10struct Queued {
11    text: String,
12    left: f32,
13    top: f32,
14    size_px: f32,
15    color: [u8; 4],
16    /// `None` draws above every layer (`Painter::text`), `Some` sorts with the 2D layers.
17    layer: Option<i16>,
18}
19
20/// Horizontal alignment of text relative to its anchor point, for
21/// [`crate::TextDraw::align`].
22#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
23pub enum Align {
24    /// The anchor is the text's left edge (default).
25    #[default]
26    Left,
27    /// The anchor is the text's horizontal center.
28    Center,
29    /// The anchor is the text's right edge.
30    Right,
31}
32
33impl Align {
34    /// How far left of the anchor the text starts, as a fraction of its width.
35    pub(crate) fn factor(self) -> f32 {
36        match self {
37            Self::Left => 0.0,
38            Self::Center => 0.5,
39            Self::Right => 1.0,
40        }
41    }
42}
43
44/// A shaped `cosmic_text::Buffer` kept across frames, tagged with the frame it was last
45/// drawn on so stale entries can be evicted.
46struct CachedBuffer {
47    buffer: Buffer,
48    last_used: u64,
49}
50
51/// Drop cached buffers untouched for this many frames (~4s at 60fps). Bounds memory when
52/// text changes every frame (e.g. a live counter) without thrashing steady-state HUD text.
53const EVICT_AFTER_FRAMES: u64 = 240;
54
55pub(crate) struct TextSystem {
56    font_system: FontSystem,
57    swash_cache: SwashCache,
58    #[allow(dead_code)]
59    cache: Cache,
60    atlas: TextAtlas,
61    viewport: Viewport,
62    /// One renderer per text layer (`None` = above all layers): glyphon draws everything a
63    /// renderer prepared in one call, so each layer needs its own vertex buffer.
64    renderers: HashMap<Option<i16>, TextRenderer>,
65    /// Layers prepared this frame, i.e. the renderers with something to draw.
66    prepared: BTreeSet<Option<i16>>,
67    sample_count: u32,
68    depth_format: Option<wgpu::TextureFormat>,
69    queued: Vec<Queued>,
70    /// Shaped buffers cached across frames, keyed by quantized size then text so a cache hit
71    /// needs no allocation. Nested maps let the inner lookup borrow `&str` directly.
72    buffers: HashMap<u32, HashMap<String, CachedBuffer>>,
73    frame: u64,
74}
75
76/// Quantize a pixel size into a stable integer cache key (hundredths of a pixel).
77fn size_key(size_px: f32) -> u32 {
78    (size_px.max(0.0) * 100.0).round() as u32
79}
80
81/// Bundled fallback font for platforms without system fonts (the web).
82#[cfg(target_arch = "wasm32")]
83const DEFAULT_FONT: &[u8] = include_bytes!("../fonts/DejaVuSans.ttf");
84
85/// Native: system fonts via fontconfig/CoreText/DirectWrite. Web: there are no system fonts, so
86/// the bundled DejaVu Sans becomes every generic family.
87fn new_font_system() -> FontSystem {
88    #[cfg(not(target_arch = "wasm32"))]
89    {
90        FontSystem::new()
91    }
92    #[cfg(target_arch = "wasm32")]
93    {
94        let mut fs = FontSystem::new_with_fonts([glyphon::fontdb::Source::Binary(
95            std::sync::Arc::new(DEFAULT_FONT),
96        )]);
97        let db = fs.db_mut();
98        db.set_sans_serif_family("DejaVu Sans");
99        db.set_serif_family("DejaVu Sans");
100        db.set_monospace_family("DejaVu Sans");
101        fs
102    }
103}
104
105impl TextSystem {
106    #[allow(clippy::too_many_arguments)]
107    pub fn new(
108        device: &wgpu::Device,
109        queue: &wgpu::Queue,
110        surface_format: wgpu::TextureFormat,
111        width: u32,
112        height: u32,
113        sample_count: u32,
114        depth_format: Option<wgpu::TextureFormat>,
115    ) -> Self {
116        let font_system = new_font_system();
117        let swash_cache = SwashCache::new();
118        let cache = Cache::new(device);
119        let atlas =
120            TextAtlas::with_color_mode(device, queue, &cache, surface_format, ColorMode::Accurate);
121        let mut viewport = Viewport::new(device, &cache);
122        viewport.update(queue, Resolution { width, height });
123        Self {
124            font_system,
125            swash_cache,
126            cache,
127            atlas,
128            viewport,
129            renderers: HashMap::new(),
130            prepared: BTreeSet::new(),
131            sample_count,
132            depth_format,
133            queued: Vec::new(),
134            buffers: HashMap::new(),
135            frame: 0,
136        }
137    }
138
139    /// Register an extra font (TTF/OTF bytes). Text keeps using the default sans-serif family;
140    /// the new face is used as a fallback for glyphs the default lacks.
141    pub fn load_font_bytes(&mut self, bytes: Vec<u8>) {
142        self.font_system.db_mut().load_font_data(bytes);
143    }
144
145    pub fn resize(&mut self, queue: &wgpu::Queue, width: u32, height: u32) {
146        self.viewport.update(queue, Resolution { width, height });
147    }
148
149    pub fn queue(
150        &mut self,
151        text: &str,
152        pos: [f32; 2],
153        size_px: f32,
154        color: [f32; 4],
155        layer: Option<i16>,
156    ) {
157        let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0) as u8;
158        self.queued.push(Queued {
159            text: text.to_string(),
160            left: pos[0],
161            top: pos[1],
162            size_px,
163            color: [c(color[0]), c(color[1]), c(color[2]), c(color[3])],
164            layer,
165        });
166    }
167
168    /// Size `[width, height]` of `text` laid out at `size_px`: the widest line, and the lines'
169    /// total height. Shapes through the same cache as drawing, so measuring a string and then
170    /// drawing it shapes it once.
171    pub fn measure(&mut self, text: &str, size_px: f32) -> [f32; 2] {
172        let buffer = self.shaped(text, size_px);
173        let (mut width, mut height) = (0.0f32, 0.0f32);
174        for run in buffer.layout_runs() {
175            width = width.max(run.line_w);
176            height = height.max(run.line_top + run.line_height);
177        }
178        [width, height]
179    }
180
181    /// The cached shaped buffer for `(text, size_px)`, shaping it on a miss.
182    fn shaped(&mut self, text: &str, size_px: f32) -> &Buffer {
183        let frame = self.frame;
184        let inner = self.buffers.entry(size_key(size_px)).or_default();
185        if !inner.contains_key(text) {
186            let metrics = Metrics::new(size_px, size_px * 1.2);
187            let mut buffer = Buffer::new(&mut self.font_system, metrics);
188            buffer.set_text(text, &Attrs::new(), Shaping::Advanced, None);
189            buffer.shape_until_scroll(&mut self.font_system, false);
190            inner.insert(
191                text.to_owned(),
192                CachedBuffer {
193                    buffer,
194                    last_used: frame,
195                },
196            );
197        }
198        let cached = inner.get_mut(text).expect("inserted above");
199        cached.last_used = frame;
200        &cached.buffer
201    }
202
203    /// Record every 2D layer that has queued text, for interleaving with the other batchers.
204    pub fn collect_layers(&self, out: &mut BTreeSet<i16>) {
205        out.extend(self.queued.iter().filter_map(|q| q.layer));
206    }
207
208    /// Shape and upload all queued text, one glyphon renderer per layer. Must run before any
209    /// [`TextSystem::draw_layer`]: like the other batchers' instance buffers, each renderer's
210    /// vertex buffer is written once per frame, outside the encoder's command stream.
211    pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
212        self.prepared.clear();
213        if self.queued.is_empty() {
214            return;
215        }
216        self.frame += 1;
217
218        // Build (or refresh) the cached buffer for every queued string. Shaping happens once
219        // per distinct (size, text); a repeated string in steady state allocates nothing.
220        let queued = std::mem::take(&mut self.queued);
221        for q in &queued {
222            self.shaped(&q.text, q.size_px);
223        }
224
225        let layers: BTreeSet<Option<i16>> = queued.iter().map(|q| q.layer).collect();
226        for layer in layers {
227            let areas = queued
228                .iter()
229                .filter(|q| q.layer == layer)
230                .map(|q| TextArea {
231                    buffer: &self.buffers[&size_key(q.size_px)][&q.text].buffer,
232                    left: q.left,
233                    top: q.top,
234                    scale: 1.0,
235                    bounds: TextBounds::default(),
236                    default_color: Color::rgba(q.color[0], q.color[1], q.color[2], q.color[3]),
237                    custom_glyphs: &[],
238                });
239            let renderer = self.renderers.entry(layer).or_insert_with(|| {
240                TextRenderer::new(
241                    &mut self.atlas,
242                    device,
243                    crate::target::multisample(self.sample_count),
244                    self.depth_format.map(crate::target::no_write_depth),
245                )
246            });
247            match renderer.prepare(
248                device,
249                queue,
250                &mut self.font_system,
251                &mut self.atlas,
252                &self.viewport,
253                areas,
254                &mut self.swash_cache,
255            ) {
256                Ok(()) => {
257                    self.prepared.insert(layer);
258                }
259                Err(e) => log::warn!("glyphon prepare failed: {e:?}"),
260            }
261        }
262        // Hand the (now empty) Vec back to keep its allocation for the next frame.
263        self.queued = queued;
264        self.queued.clear();
265    }
266
267    /// Draw the text prepared for `layer` (`None`: the unlayered text above everything) in
268    /// its own pass. No-op if that layer has no text this frame.
269    pub fn draw_layer(
270        &self,
271        layer: Option<i16>,
272        encoder: &mut wgpu::CommandEncoder,
273        targets: &Targets<'_>,
274    ) {
275        if !self.prepared.contains(&layer) {
276            return;
277        }
278        let Some(renderer) = self.renderers.get(&layer) else {
279            return;
280        };
281        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
282            label: Some("text.pass"),
283            color_attachments: &[Some(targets.color_attachment(wgpu::LoadOp::Load))],
284            depth_stencil_attachment: targets.depth_attachment(wgpu::LoadOp::Load),
285            occlusion_query_set: None,
286            timestamp_writes: None,
287            multiview_mask: None,
288        });
289        if let Err(e) = renderer.render(&self.atlas, &self.viewport, &mut pass) {
290            log::warn!("glyphon render failed: {e:?}");
291        }
292    }
293
294    /// End-of-frame housekeeping, after every [`TextSystem::draw_layer`]: evict stale shaped
295    /// buffers and free atlas space held by glyphs no longer drawn.
296    pub fn finish(&mut self) {
297        if self.prepared.is_empty() {
298            return;
299        }
300        self.prepared.clear();
301        self.evict_stale();
302        self.atlas.trim();
303    }
304
305    /// Drop buffers not drawn within [`EVICT_AFTER_FRAMES`] so the cache stays bounded.
306    fn evict_stale(&mut self) {
307        let frame = self.frame;
308        self.buffers.retain(|_, inner| {
309            inner.retain(|_, c| c.last_used + EVICT_AFTER_FRAMES >= frame);
310            !inner.is_empty()
311        });
312    }
313}