Skip to main content

wisp/text/
flexible_renderer.rs

1//! Flexible text **renderer** — glyphon rasterization (M-TEXT.3 / AUT-77).
2//!
3//! Pairs with [`FlexibleTextEngine`](super::FlexibleTextEngine). The
4//! engine produces [`FlexibleTextLayout`] shaped buffers; this
5//! renderer hands those buffers to `glyphon` to rasterize into a
6//! wgpu render target.
7//!
8//! # Lifecycle
9//!
10//! ```text
11//! let engine   = FlexibleTextEngine::new();
12//! let renderer = FlexibleTextRenderer::new(&app, format,
13//!                                          engine.font_system_handle());
14//! renderer.set_resolution(width_px, height_px);
15//! let layout   = engine.layout_concrete(&text);
16//! renderer.draw(&target_view, &[(&layout, position_ndc, color)]);
17//! ```
18//!
19//! The engine and renderer share the same `FontSystem` through an
20//! `Arc<Mutex<…>>` so layout-time metrics match the glyphs glyphon
21//! eventually rasterizes.
22//!
23//! # Why this is a sibling of `Renderer`, not a method on it
24//!
25//! `glyphon::TextRenderer` keeps its own GPU pipeline + atlas separate
26//! from wisp's existing batching pipelines. Putting it on
27//! `crate::render::Renderer` would force every renderer instance to
28//! pay the glyphon initialization cost; instead we keep
29//! `FlexibleTextRenderer` as an opt-in resource owned by whichever
30//! caller needs it (the editor for caption rendering, the export path
31//! for burn-in text, M-TEXT.5's RT cache for filter/mask
32//! composition).
33
34use std::sync::{Arc, Mutex};
35
36use cosmic_text::FontSystem;
37use glam::Vec2;
38use glyphon::{
39    Cache, Color as GlyphonColor, Resolution, SwashCache, TextArea, TextAtlas, TextBounds,
40    TextRenderer as GlyphonTextRenderer, Viewport,
41};
42
43use super::flexible::{FlexibleTextLayout, REFERENCE_PX};
44use crate::application::Application;
45use crate::color::Color;
46
47/// Wgpu glyph rasterizer wrapping glyphon's pipeline.
48///
49/// Owns the glyphon-side GPU resources (atlas, viewport, renderer,
50/// swash cache) plus a shared handle to the font system used by the
51/// paired [`FlexibleTextEngine`](super::FlexibleTextEngine).
52pub struct FlexibleTextRenderer {
53    font_system: Arc<Mutex<FontSystem>>,
54    swash_cache: SwashCache,
55    atlas: TextAtlas,
56    viewport: Viewport,
57    text_renderer: GlyphonTextRenderer,
58    device: wgpu::Device,
59    queue: wgpu::Queue,
60    resolution: Resolution,
61}
62
63impl std::fmt::Debug for FlexibleTextRenderer {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.debug_struct("FlexibleTextRenderer")
66            .field(
67                "resolution",
68                &(self.resolution.width, self.resolution.height),
69            )
70            .finish_non_exhaustive()
71    }
72}
73
74impl FlexibleTextRenderer {
75    /// Build the renderer for a target color format.
76    ///
77    /// `format` MUST match the format of every `TextureView` later
78    /// passed to [`Self::draw`]. Multisample is fixed at 1× and depth
79    /// stencil is unused — glyphon writes directly to the color
80    /// attachment.
81    #[must_use]
82    pub fn new(
83        app: &Application,
84        format: wgpu::TextureFormat,
85        font_system: Arc<Mutex<FontSystem>>,
86    ) -> Self {
87        let device = app.device().clone();
88        let queue = app.queue().clone();
89        let cache = Cache::new(&device);
90        let mut atlas = TextAtlas::new(&device, &queue, &cache, format);
91        let viewport = Viewport::new(&device, &cache);
92        let text_renderer =
93            GlyphonTextRenderer::new(&mut atlas, &device, wgpu::MultisampleState::default(), None);
94        Self {
95            font_system,
96            swash_cache: SwashCache::new(),
97            atlas,
98            viewport,
99            text_renderer,
100            device,
101            queue,
102            resolution: Resolution {
103                width: 1,
104                height: 1,
105            },
106        }
107    }
108
109    /// Update the rasterization resolution. Call whenever the target
110    /// view's dimensions change.
111    pub fn set_resolution(&mut self, width_px: u32, height_px: u32) {
112        self.resolution = Resolution {
113            width: width_px,
114            height: height_px,
115        };
116        self.viewport.update(&self.queue, self.resolution);
117    }
118
119    /// Stage `layouts` and emit draw calls into a fresh render pass on
120    /// `target_view`. `clear` chooses whether to clear the target
121    /// before drawing — set `false` when compositing on top of
122    /// existing content.
123    ///
124    /// Each entry is `(layout, position_ndc, color)`:
125    /// - `position_ndc` is the top-left of the layout box in NDC
126    ///   (`[-1, +1]`). It's converted to glyphon pixel space using
127    ///   the current resolution.
128    /// - `color` provides the default per-glyph color.
129    pub fn draw(
130        &mut self,
131        target_view: &wgpu::TextureView,
132        layouts: &[(&FlexibleTextLayout, Vec2, Color)],
133        clear: bool,
134    ) {
135        let resolution = self.resolution;
136        let mut areas: Vec<TextArea<'_>> = Vec::with_capacity(layouts.len());
137        for (layout, pos_ndc, color) in layouts {
138            // NDC -> pixel: ndc.x in [-1, +1] -> px in [0, width].
139            // y in NDC has +y up; glyphon expects +y down origin at top-left.
140            #[expect(
141                clippy::cast_precision_loss,
142                reason = "resolution dimensions are < 2^23 in practice"
143            )]
144            let width_px = resolution.width as f32;
145            #[expect(
146                clippy::cast_precision_loss,
147                reason = "resolution dimensions are < 2^23 in practice"
148            )]
149            let height_px = resolution.height as f32;
150            let left_px = (pos_ndc.x * 0.5 + 0.5) * width_px;
151            let top_px = (0.5 - pos_ndc.y * 0.5) * height_px;
152            // Scale the layout (which was shaped at REFERENCE_PX basis)
153            // to the current resolution.
154            let scale = height_px / REFERENCE_PX;
155            areas.push(TextArea {
156                buffer: &layout.buffer,
157                left: left_px,
158                top: top_px,
159                scale,
160                bounds: TextBounds {
161                    left: 0,
162                    top: 0,
163                    #[expect(
164                        clippy::cast_possible_wrap,
165                        reason = "resolution dims < i32::MAX in practice"
166                    )]
167                    right: resolution.width as i32,
168                    #[expect(
169                        clippy::cast_possible_wrap,
170                        reason = "resolution dims < i32::MAX in practice"
171                    )]
172                    bottom: resolution.height as i32,
173                },
174                default_color: wisp_color_to_glyphon(*color),
175                custom_glyphs: &[],
176            });
177        }
178
179        {
180            let mut fs = self
181                .font_system
182                .lock()
183                .expect("FlexibleTextRenderer font_system poisoned");
184            self.text_renderer
185                .prepare(
186                    &self.device,
187                    &self.queue,
188                    &mut fs,
189                    &mut self.atlas,
190                    &self.viewport,
191                    areas,
192                    &mut self.swash_cache,
193                )
194                .expect("glyphon prepare");
195        }
196
197        let mut encoder = self
198            .device
199            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
200                label: Some("wisp::FlexibleTextRenderer encoder"),
201            });
202        {
203            let load = if clear {
204                wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
205            } else {
206                wgpu::LoadOp::Load
207            };
208            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
209                label: Some("wisp::FlexibleTextRenderer pass"),
210                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
211                    view: target_view,
212                    resolve_target: None,
213                    ops: wgpu::Operations {
214                        load,
215                        store: wgpu::StoreOp::Store,
216                    },
217                })],
218                depth_stencil_attachment: None,
219                timestamp_writes: None,
220                occlusion_query_set: None,
221            });
222            self.text_renderer
223                .render(&self.atlas, &self.viewport, &mut pass)
224                .expect("glyphon render");
225        }
226        self.queue.submit(std::iter::once(encoder.finish()));
227    }
228
229    /// Trim atlas LRU entries that haven't been referenced since the
230    /// last call. Call between frames to keep the atlas bounded.
231    pub fn trim_atlas(&mut self) {
232        self.atlas.trim();
233    }
234}
235
236#[expect(
237    clippy::cast_possible_truncation,
238    clippy::cast_sign_loss,
239    reason = "Color components are clamped to [0, 1] then * 255 — fits in u8"
240)]
241fn wisp_color_to_glyphon(color: Color) -> GlyphonColor {
242    let red = (color.r.clamp(0.0, 1.0) * 255.0).round() as u8;
243    let green = (color.g.clamp(0.0, 1.0) * 255.0).round() as u8;
244    let blue = (color.b.clamp(0.0, 1.0) * 255.0).round() as u8;
245    let alpha = (color.a.clamp(0.0, 1.0) * 255.0).round() as u8;
246    GlyphonColor::rgba(red, green, blue, alpha)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::application::{AppConfig, Application};
253    use crate::text::{FlexibleTextEngine, WispText};
254    use crate::texture::render_texture::RenderTexture;
255
256    fn boot() -> Application {
257        pollster::block_on(Application::new(AppConfig::default())).expect("init")
258    }
259
260    #[test]
261    fn renderer_constructs_against_default_app() {
262        let app = boot();
263        let engine = FlexibleTextEngine::new();
264        let _r = FlexibleTextRenderer::new(
265            &app,
266            wgpu::TextureFormat::Rgba8Unorm,
267            engine.font_system_handle(),
268        );
269    }
270
271    #[test]
272    fn empty_draw_does_not_panic() {
273        let app = boot();
274        let engine = FlexibleTextEngine::new();
275        let mut r = FlexibleTextRenderer::new(
276            &app,
277            wgpu::TextureFormat::Rgba8Unorm,
278            engine.font_system_handle(),
279        );
280        r.set_resolution(64, 64);
281        let rt = RenderTexture::with_format(&app, 64, 64, wgpu::TextureFormat::Rgba8Unorm);
282        r.draw(rt.view(), &[], true);
283    }
284
285    #[test]
286    fn draw_hello_paints_some_non_zero_pixels() {
287        let app = boot();
288        let engine = FlexibleTextEngine::new();
289        let mut r = FlexibleTextRenderer::new(
290            &app,
291            wgpu::TextureFormat::Rgba8Unorm,
292            engine.font_system_handle(),
293        );
294        let rt = RenderTexture::with_format(&app, 256, 64, wgpu::TextureFormat::Rgba8Unorm);
295        r.set_resolution(rt.width(), rt.height());
296        let layout = engine.layout_concrete(&WispText::new("Hello"));
297        r.draw(
298            rt.view(),
299            &[(&layout, Vec2::new(-0.5, 0.5), Color::WHITE)],
300            true,
301        );
302        // Best-effort: read back, count non-zero alpha pixels. With
303        // system fonts loaded we should see >0 pixels of glyph
304        // coverage. If the host has no system fonts at all (extremely
305        // unusual on a dev box / CI runner with `fontconfig`), this
306        // assertion is the surface that will catch it.
307        let bytes = rt.read_pixels(&app);
308        let non_zero_alpha = bytes.chunks_exact(4).filter(|p| p[3] > 0).count();
309        assert!(
310            non_zero_alpha > 0,
311            "expected glyphon to paint some non-zero alpha pixels"
312        );
313    }
314}