avenger_wgpu/marks/
text.rs

1use crate::canvas::CanvasDimensions;
2use crate::error::AvengerWgpuError;
3use crate::marks::multi::{MultiVertex, TEXT_TEXTURE_CODE};
4use avenger::marks::path::PathTransform;
5use avenger::marks::text::{FontStyleSpec, FontWeightSpec, TextAlignSpec, TextBaselineSpec};
6use etagere::euclid::{Angle, Point2D, Vector2D};
7use image::DynamicImage;
8use std::collections::HashMap;
9use std::hash::Hash;
10use std::sync::Arc;
11use wgpu::Extent3d;
12
13pub trait TextAtlasBuilderTrait {
14    fn register_text(
15        &mut self,
16        text: TextInstance,
17        dimensions: CanvasDimensions,
18    ) -> Result<Vec<TextAtlasRegistration>, AvengerWgpuError>;
19
20    fn build(&self) -> (Extent3d, Vec<DynamicImage>);
21}
22
23#[derive(Clone)]
24pub struct NullTextAtlasBuilder;
25
26impl TextAtlasBuilderTrait for NullTextAtlasBuilder {
27    fn register_text(
28        &mut self,
29        _text: TextInstance,
30        _dimensions: CanvasDimensions,
31    ) -> Result<Vec<TextAtlasRegistration>, AvengerWgpuError> {
32        Err(AvengerWgpuError::TextNotEnabled(
33            "Text support is not enabled".to_string(),
34        ))
35    }
36
37    fn build(&self) -> (Extent3d, Vec<DynamicImage>) {
38        (
39            Extent3d {
40                width: 1,
41                height: 1,
42                depth_or_array_layers: 1,
43            },
44            vec![DynamicImage::ImageRgba8(image::RgbaImage::new(1, 1))],
45        )
46    }
47}
48
49#[derive(Clone)]
50pub struct TextAtlasBuilder<CacheKey: Hash + Eq + Clone> {
51    rasterizer: Arc<dyn TextRasterizer<CacheKey = CacheKey>>,
52    extent: Extent3d,
53    next_atlas: image::RgbaImage,
54    next_cache: HashMap<CacheKey, GlyphBBoxAndAtlasCoords>,
55    atlases: Vec<DynamicImage>,
56    initialized: bool,
57    allocator: etagere::AtlasAllocator,
58}
59
60impl<CacheKey: Hash + Eq + Clone> TextAtlasBuilder<CacheKey> {
61    pub fn new(rasterizer: Arc<dyn TextRasterizer<CacheKey = CacheKey>>) -> Self {
62        Self {
63            rasterizer,
64            extent: Extent3d {
65                width: 1,
66                height: 1,
67                depth_or_array_layers: 1,
68            },
69            next_atlas: image::RgbaImage::new(1, 1),
70            next_cache: Default::default(),
71            atlases: vec![],
72            initialized: false,
73            allocator: etagere::AtlasAllocator::new(etagere::Size::new(1, 1)),
74        }
75    }
76}
77
78impl<CacheKey: Hash + Eq + Clone> TextAtlasBuilderTrait for TextAtlasBuilder<CacheKey> {
79    fn register_text(
80        &mut self,
81        text: TextInstance,
82        dimensions: CanvasDimensions,
83    ) -> Result<Vec<TextAtlasRegistration>, AvengerWgpuError> {
84        if !self.initialized {
85            let limits = wgpu::Limits::downlevel_webgl2_defaults();
86
87            // Update extent
88            self.extent = Extent3d {
89                width: limits.max_texture_dimension_1d.min(256),
90                height: limits.max_texture_dimension_2d.min(256),
91                depth_or_array_layers: 1,
92            };
93
94            // Create backing image
95            self.next_atlas = image::RgbaImage::new(self.extent.width, self.extent.height);
96
97            // Create allocator
98            self.allocator = etagere::AtlasAllocator::new(etagere::Size::new(
99                self.extent.width as i32,
100                self.extent.height as i32,
101            ));
102
103            // Set initialized
104            self.initialized = true;
105        }
106
107        // Extract values we need from text instance before passing to buffer constructor
108        let align = *text.align;
109        let baseline = *text.baseline;
110        let position = text.position;
111        let angle = text.angle;
112
113        let buffer = self.rasterizer.rasterize(
114            dimensions,
115            &TextRasterizationConfig::from(text),
116            &self.next_cache,
117        )?;
118
119        let buffer_left = match align {
120            TextAlignSpec::Left => position[0],
121            TextAlignSpec::Center => position[0] - buffer.buffer_width / 2.0,
122            TextAlignSpec::Right => position[0] - buffer.buffer_width,
123        };
124
125        let buffer_top = match baseline {
126            TextBaselineSpec::Alphabetic => position[1] - buffer.buffer_line_y,
127            TextBaselineSpec::Top => position[1],
128            TextBaselineSpec::Middle => position[1] - buffer.buffer_height * 0.5,
129            TextBaselineSpec::Bottom => position[1] - buffer.buffer_height,
130            TextBaselineSpec::LineTop => todo!(),
131            TextBaselineSpec::LineBottom => todo!(),
132        };
133
134        // Build rotation_transform
135        let rotation_transform = if angle != 0.0 {
136            PathTransform::translation(-position[0], -position[1])
137                .then_rotate(Angle::degrees(angle))
138                .then_translate(Vector2D::new(position[0], position[1]))
139        } else {
140            PathTransform::identity()
141        };
142
143        let mut registrations: Vec<TextAtlasRegistration> = Vec::new();
144        let mut verts: Vec<MultiVertex> = Vec::new();
145        let mut indices: Vec<u32> = Vec::new();
146
147        for (glyph_image, phys_position) in &buffer.glyphs {
148            let glyph_bbox_and_atlas_coords =
149                if let Some(glyph_position) = self.next_cache.get(&glyph_image.cache_key) {
150                    // Glyph has already been written to atlas
151                    glyph_position
152                } else {
153                    // Allocate space in active atlas image, leaving space for 1 pixel empty border
154                    let allocation = if let Some(allocation) =
155                        self.allocator.allocate(etagere::Size::new(
156                            (glyph_image.bbox.width + 2) as i32,
157                            (glyph_image.bbox.height + 2) as i32,
158                        )) {
159                        // Successfully allocated space in the active atlas
160                        allocation
161                    } else {
162                        // No more room in active atlas
163
164                        // Commit current registration
165                        let mut full_verts = Vec::new();
166                        let mut full_inds = Vec::new();
167                        std::mem::swap(&mut full_verts, &mut verts);
168                        std::mem::swap(&mut full_inds, &mut indices);
169
170                        registrations.push(TextAtlasRegistration {
171                            atlas_index: self.atlases.len(),
172                            verts: full_verts,
173                            indices: full_inds,
174                        });
175
176                        // Store atlas image and create fresh image
177                        let mut full_atlas =
178                            image::RgbaImage::new(self.extent.width, self.extent.height);
179                        std::mem::swap(&mut full_atlas, &mut self.next_atlas);
180                        self.atlases
181                            .push(image::DynamicImage::ImageRgba8(full_atlas));
182
183                        // Clear cache, since this reflects the current atlas
184                        self.next_cache.clear();
185
186                        // Create fresh allocator
187                        self.allocator = etagere::AtlasAllocator::new(etagere::Size::new(
188                            self.extent.width as i32,
189                            self.extent.height as i32,
190                        ));
191
192                        // Try allocation again
193                        if let Some(allocation) = self.allocator.allocate(etagere::Size::new(
194                            (glyph_image.bbox.width + 2) as i32,
195                            (glyph_image.bbox.height + 2) as i32,
196                        )) {
197                            allocation
198                        } else {
199                            return Err(AvengerWgpuError::ImageAllocationError(
200                                "Failed to allocate space for glyph".to_string(),
201                            ));
202                        }
203                    };
204
205                    // Write image to allocated portion of final texture image
206                    // Use one pixel offset to avoid aliasing artifacts in linear interpolation
207                    let p0 = allocation.rectangle.min;
208                    let atlas_x0 = p0.x + 1;
209                    let atlas_x1 = atlas_x0 + glyph_image.bbox.width as i32;
210                    let atlas_y0 = p0.y + 1;
211                    let atlas_y1 = atlas_y0 + glyph_image.bbox.height as i32;
212
213                    let Some(img) = glyph_image.image.as_ref() else {
214                        return Err(AvengerWgpuError::TextError(
215                            "Expected glyph image to be available on first use".to_string(),
216                        ));
217                    };
218
219                    for (src_x, dest_x) in (atlas_x0..atlas_x1).enumerate() {
220                        for (src_y, dest_y) in (atlas_y0..atlas_y1).enumerate() {
221                            self.next_atlas.put_pixel(
222                                dest_x as u32,
223                                dest_y as u32,
224                                *img.get_pixel(src_x as u32, src_y as u32),
225                            );
226                        }
227                    }
228
229                    self.next_cache.insert(
230                        glyph_image.cache_key.clone(),
231                        GlyphBBoxAndAtlasCoords {
232                            bbox: glyph_image.bbox,
233                            tex_coords: TextAtlasCoords {
234                                x0: (atlas_x0 as f32) / self.extent.width as f32,
235                                y0: (atlas_y0 as f32) / self.extent.height as f32,
236                                x1: (atlas_x1 as f32) / self.extent.width as f32,
237                                y1: (atlas_y1 as f32) / self.extent.height as f32,
238                            },
239                        },
240                    );
241                    self.next_cache.get(&glyph_image.cache_key).unwrap()
242                };
243
244            // Create verts for rectangle around glyph
245            let bbox = &glyph_bbox_and_atlas_coords.bbox;
246            let x0 = (phys_position.x + bbox.left as f32) / dimensions.scale + buffer_left;
247            let y0 = (buffer.buffer_line_y).round()
248                + (phys_position.y - bbox.top as f32) / dimensions.scale
249                + buffer_top;
250            let x1 = x0 + bbox.width as f32 / dimensions.scale;
251            let y1 = y0 + bbox.height as f32 / dimensions.scale;
252
253            let top_left = rotation_transform
254                .transform_point(Point2D::new(x0, y0))
255                .to_array();
256            let bottom_left = rotation_transform
257                .transform_point(Point2D::new(x0, y1))
258                .to_array();
259            let bottom_right = rotation_transform
260                .transform_point(Point2D::new(x1, y1))
261                .to_array();
262            let top_right = rotation_transform
263                .transform_point(Point2D::new(x1, y0))
264                .to_array();
265
266            let tex_coords = glyph_bbox_and_atlas_coords.tex_coords;
267            let tex_x0 = tex_coords.x0;
268            let tex_y0 = tex_coords.y0;
269            let tex_x1 = tex_coords.x1;
270            let tex_y1 = tex_coords.y1;
271
272            let offset = verts.len() as u32;
273
274            verts.push(MultiVertex {
275                position: top_left,
276                color: [TEXT_TEXTURE_CODE, tex_x0, tex_y0, 0.0],
277                top_left,
278                bottom_right,
279            });
280            verts.push(MultiVertex {
281                position: bottom_left,
282                color: [TEXT_TEXTURE_CODE, tex_x0, tex_y1, 0.0],
283                top_left,
284                bottom_right,
285            });
286            verts.push(MultiVertex {
287                position: bottom_right,
288                color: [TEXT_TEXTURE_CODE, tex_x1, tex_y1, 0.0],
289                top_left,
290                bottom_right,
291            });
292            verts.push(MultiVertex {
293                position: top_right,
294                color: [TEXT_TEXTURE_CODE, tex_x1, tex_y0, 0.0],
295                top_left,
296                bottom_right,
297            });
298
299            indices.extend([
300                offset,
301                offset + 1,
302                offset + 2,
303                offset,
304                offset + 2,
305                offset + 3,
306            ])
307        }
308
309        // Add final registration
310        registrations.push(TextAtlasRegistration {
311            atlas_index: self.atlases.len(),
312            verts,
313            indices,
314        });
315
316        Ok(registrations)
317    }
318
319    fn build(&self) -> (Extent3d, Vec<DynamicImage>) {
320        let mut images = self.atlases.clone();
321        images.push(image::DynamicImage::ImageRgba8(self.next_atlas.clone()));
322        (self.extent, images)
323    }
324}
325
326#[derive(Clone)]
327pub struct TextAtlasRegistration {
328    pub atlas_index: usize,
329    pub verts: Vec<MultiVertex>,
330    pub indices: Vec<u32>,
331}
332
333#[derive(Clone, Debug)]
334pub struct TextInstance<'a> {
335    pub position: [f32; 2],
336    pub text: &'a String,
337    pub color: &'a [f32; 4],
338    pub align: &'a TextAlignSpec,
339    pub angle: f32,
340    pub baseline: &'a TextBaselineSpec,
341    pub font: &'a String,
342    pub font_size: f32,
343    pub font_weight: &'a FontWeightSpec,
344    pub font_style: &'a FontStyleSpec,
345    pub limit: f32,
346}
347
348// Position of glyph in text buffer
349#[derive(Debug, Clone)]
350pub struct PhysicalGlyphPosition {
351    pub x: f32,
352    pub y: f32,
353}
354
355// Position of glyph in text atlas
356#[derive(Copy, Clone)]
357pub struct TextAtlasCoords {
358    pub x0: f32,
359    pub y0: f32,
360    pub x1: f32,
361    pub y1: f32,
362}
363
364// Glyph bounding box relative to glyph origin
365#[derive(Clone, Copy)]
366pub struct GlyphBBox {
367    pub top: i32,
368    pub left: i32,
369    pub width: u32,
370    pub height: u32,
371}
372
373#[derive(Clone)]
374pub struct GlyphImage<CacheKey: Hash + Eq + Clone> {
375    pub cache_key: CacheKey,
376    // None if image for same CacheKey was already included
377    pub image: Option<image::RgbaImage>,
378    pub bbox: GlyphBBox,
379}
380
381impl<CacheKey: Hash + Eq + Clone> GlyphImage<CacheKey> {
382    pub fn without_image(&self) -> Self {
383        Self {
384            cache_key: self.cache_key.clone(),
385            image: None,
386            bbox: self.bbox,
387        }
388    }
389}
390
391#[derive(Clone)]
392pub struct GlyphBBoxAndAtlasCoords {
393    pub bbox: GlyphBBox,
394    pub tex_coords: TextAtlasCoords,
395}
396
397#[derive(Debug, Clone)]
398pub struct TextRasterizationConfig<'a> {
399    pub text: &'a String,
400    pub color: &'a [f32; 4],
401    pub font: &'a String,
402    pub font_size: f32,
403    pub font_weight: &'a FontWeightSpec,
404    pub font_style: &'a FontStyleSpec,
405    pub limit: f32,
406}
407
408impl<'a> From<TextInstance<'a>> for TextRasterizationConfig<'a> {
409    fn from(value: TextInstance<'a>) -> Self {
410        Self {
411            text: value.text,
412            color: value.color,
413            font: value.font,
414            font_size: value.font_size,
415            font_weight: value.font_weight,
416            font_style: value.font_style,
417            limit: value.limit,
418        }
419    }
420}
421
422#[derive(Clone)]
423pub struct TextRasterizationBuffer<CacheKey: Hash + Eq + Clone> {
424    pub glyphs: Vec<(GlyphImage<CacheKey>, PhysicalGlyphPosition)>,
425    pub buffer_width: f32,
426    pub buffer_height: f32,
427    pub buffer_line_y: f32,
428}
429
430pub trait TextRasterizer {
431    type CacheKey: Hash + Eq + Clone;
432    fn rasterize(
433        &self,
434        dimensions: CanvasDimensions,
435        config: &TextRasterizationConfig,
436        cached_glyphs: &HashMap<Self::CacheKey, GlyphBBoxAndAtlasCoords>,
437    ) -> Result<TextRasterizationBuffer<Self::CacheKey>, AvengerWgpuError>;
438}