1use 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
47pub 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 #[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 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 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 #[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 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 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 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}