Skip to main content

astrelis_text/
sdf.rs

1//! Signed Distance Field (SDF) text rendering.
2//!
3//! SDF rendering stores distance information in textures instead of grayscale values,
4//! enabling sharp text rendering at any scale without artifacts.
5//!
6//! # Algorithm
7//!
8//! For each pixel in the SDF texture, we store the distance to the nearest edge:
9//! - Inside the glyph: positive distance (0.5 to 1.0)
10//! - Outside the glyph: negative distance (0.0 to 0.5)
11//! - Exactly on the edge: 0.5
12//!
13//! # Benefits
14//!
15//! - Resolution-independent scaling
16//! - Better outline and shadow effects
17//! - Reduced texture memory (can use lower resolution)
18//! - Smooth anti-aliasing at any scale
19//!
20//! # Example
21//!
22//! ```ignore
23//! use astrelis_text::*;
24//!
25//! let mut renderer = FontRenderer::new(context, font_system);
26//! renderer.set_render_mode(TextRenderMode::SDF { spread: 4.0 });
27//!
28//! // Text will now render using SDF
29//! let buffer = renderer.prepare(&text);
30//! renderer.draw_text(&buffer, position);
31//! ```
32
33use cosmic_text::SwashImage;
34
35/// Text rendering mode.
36#[derive(Debug, Clone, Copy, PartialEq, Default)]
37pub enum TextRenderMode {
38    /// Standard grayscale bitmap rendering.
39    #[default]
40    Bitmap,
41    /// Signed Distance Field rendering.
42    SDF {
43        /// Distance field spread in pixels.
44        /// Higher values = smoother at larger scales, but more texture memory.
45        /// Typical values: 2.0 to 8.0
46        spread: f32,
47    },
48}
49
50impl TextRenderMode {
51    /// Check if this is SDF mode.
52    pub fn is_sdf(&self) -> bool {
53        matches!(self, Self::SDF { .. })
54    }
55
56    /// Get the SDF spread value, or 0.0 for bitmap mode.
57    pub fn spread(&self) -> f32 {
58        match self {
59            Self::SDF { spread } => *spread,
60            Self::Bitmap => 0.0,
61        }
62    }
63}
64
65/// Generate a signed distance field from a grayscale bitmap.
66///
67/// This uses a brute-force algorithm that's simple but slow for large textures.
68/// For production use, consider using a more efficient algorithm like:
69/// - Dead reckoning (8SSEDT)
70/// - Jump flooding algorithm (JFA)
71///
72/// # Arguments
73///
74/// * `source` - Source grayscale image (0-255)
75/// * `spread` - Distance field spread in pixels
76///
77/// # Returns
78///
79/// SDF image where:
80/// - 0 = far outside (distance > spread)
81/// - 127 = exactly on edge
82/// - 255 = far inside (distance > spread)
83pub fn generate_sdf(source: &SwashImage, spread: f32) -> Vec<u8> {
84    let width = source.placement.width as usize;
85    let height = source.placement.height as usize;
86
87    if width == 0 || height == 0 {
88        return Vec::new();
89    }
90
91    let mut output = vec![0u8; width * height];
92    let threshold = 128u8;
93
94    // For each output pixel, find distance to nearest edge
95    for y in 0..height {
96        for x in 0..width {
97            let idx = y * width + x;
98            let value = source.data[idx];
99
100            // Determine if we're inside or outside the glyph
101            let inside = value >= threshold;
102
103            // Find minimum distance to an edge pixel
104            let mut min_dist = spread;
105
106            // Search within the spread radius
107            let search_radius = (spread.ceil() as i32) + 1;
108
109            for dy in -search_radius..=search_radius {
110                for dx in -search_radius..=search_radius {
111                    let nx = x as i32 + dx;
112                    let ny = y as i32 + dy;
113
114                    // Skip out of bounds
115                    if nx < 0 || ny < 0 || nx >= width as i32 || ny >= height as i32 {
116                        continue;
117                    }
118
119                    let nidx = ny as usize * width + nx as usize;
120                    let neighbor_value = source.data[nidx];
121                    let neighbor_inside = neighbor_value >= threshold;
122
123                    // Found an edge
124                    if inside != neighbor_inside {
125                        let dist = ((dx * dx + dy * dy) as f32).sqrt();
126                        min_dist = min_dist.min(dist);
127                    }
128                }
129            }
130
131            // Normalize distance to [0, 1]
132            let normalized = (min_dist / spread).clamp(0.0, 1.0);
133
134            // Map to [0, 255]
135            // Inside: 0.5 to 1.0 -> 127 to 255
136            // Outside: 0.0 to 0.5 -> 0 to 127
137            let sdf_value = if inside {
138                127.0 + normalized * 128.0
139            } else {
140                127.0 - normalized * 127.0
141            };
142
143            output[idx] = sdf_value.clamp(0.0, 255.0) as u8;
144        }
145    }
146
147    output
148}
149
150/// Generate a signed distance field with bilinear interpolation for smoother results.
151///
152/// This is a higher quality but slower version of `generate_sdf`.
153pub fn generate_sdf_smooth(source: &SwashImage, spread: f32) -> Vec<u8> {
154    let width = source.placement.width as usize;
155    let height = source.placement.height as usize;
156
157    if width == 0 || height == 0 {
158        return Vec::new();
159    }
160
161    let mut output = vec![0u8; width * height];
162
163    // For each output pixel
164    for y in 0..height {
165        for x in 0..width {
166            let idx = y * width + x;
167
168            // Sample the source with bilinear filtering
169            let source_value = bilinear_sample(source, x as f32, y as f32);
170            let threshold = 0.5f32;
171            let inside = source_value >= threshold;
172
173            // Find minimum distance to edge
174            let mut min_dist = spread;
175            let search_radius = (spread.ceil() as i32) + 1;
176
177            for dy in -search_radius..=search_radius {
178                for dx in -search_radius..=search_radius {
179                    let nx = x as i32 + dx;
180                    let ny = y as i32 + dy;
181
182                    if nx < 0 || ny < 0 || nx >= width as i32 || ny >= height as i32 {
183                        continue;
184                    }
185
186                    let neighbor_value = bilinear_sample(source, nx as f32, ny as f32);
187                    let neighbor_inside = neighbor_value >= threshold;
188
189                    if inside != neighbor_inside {
190                        let dist = ((dx * dx + dy * dy) as f32).sqrt();
191                        min_dist = min_dist.min(dist);
192                    }
193                }
194            }
195
196            let normalized = (min_dist / spread).clamp(0.0, 1.0);
197            let sdf_value = if inside {
198                127.0 + normalized * 128.0
199            } else {
200                127.0 - normalized * 127.0
201            };
202
203            output[idx] = sdf_value.clamp(0.0, 255.0) as u8;
204        }
205    }
206
207    output
208}
209
210/// Bilinear sampling helper for smooth SDF generation.
211fn bilinear_sample(image: &SwashImage, x: f32, y: f32) -> f32 {
212    let width = image.placement.width as usize;
213    let height = image.placement.height as usize;
214
215    let x0 = x.floor() as i32;
216    let y0 = y.floor() as i32;
217    let x1 = (x0 + 1).min(width as i32 - 1);
218    let y1 = (y0 + 1).min(height as i32 - 1);
219
220    let fx = x - x0 as f32;
221    let fy = y - y0 as f32;
222
223    // Sample corners
224    let sample = |ix: i32, iy: i32| -> f32 {
225        if ix < 0 || iy < 0 || ix >= width as i32 || iy >= height as i32 {
226            0.0
227        } else {
228            let idx = iy as usize * width + ix as usize;
229            image.data[idx] as f32 / 255.0
230        }
231    };
232
233    let v00 = sample(x0, y0);
234    let v10 = sample(x1, y0);
235    let v01 = sample(x0, y1);
236    let v11 = sample(x1, y1);
237
238    // Bilinear interpolation
239    let v0 = v00 * (1.0 - fx) + v10 * fx;
240    let v1 = v01 * (1.0 - fx) + v11 * fx;
241    v0 * (1.0 - fy) + v1 * fy
242}
243
244/// SDF rendering configuration.
245#[derive(Debug, Clone)]
246pub struct SdfConfig {
247    /// Render mode
248    pub mode: TextRenderMode,
249    /// Edge softness for anti-aliasing (0.0 to 1.0)
250    /// Lower = sharper, Higher = softer
251    pub edge_softness: f32,
252    /// Outline width (0.0 = no outline)
253    pub outline_width: f32,
254    /// Use smooth SDF generation (slower but higher quality)
255    pub smooth: bool,
256}
257
258impl Default for SdfConfig {
259    fn default() -> Self {
260        Self {
261            mode: TextRenderMode::Bitmap,
262            edge_softness: 0.05,
263            outline_width: 0.0,
264            smooth: false,
265        }
266    }
267}
268
269impl SdfConfig {
270    /// Create a new SDF config with default settings.
271    pub fn new() -> Self {
272        Self::default()
273    }
274
275    /// Enable SDF rendering with the specified spread.
276    pub fn with_sdf(mut self, spread: f32) -> Self {
277        self.mode = TextRenderMode::SDF { spread };
278        self
279    }
280
281    /// Set edge softness.
282    pub fn edge_softness(mut self, softness: f32) -> Self {
283        self.edge_softness = softness.clamp(0.0, 1.0);
284        self
285    }
286
287    /// Set outline width.
288    pub fn outline_width(mut self, width: f32) -> Self {
289        self.outline_width = width.max(0.0);
290        self
291    }
292
293    /// Enable smooth SDF generation.
294    pub fn smooth(mut self, enable: bool) -> Self {
295        self.smooth = enable;
296        self
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_render_mode_default() {
306        let mode = TextRenderMode::default();
307        assert!(!mode.is_sdf());
308        assert_eq!(mode.spread(), 0.0);
309    }
310
311    #[test]
312    fn test_render_mode_sdf() {
313        let mode = TextRenderMode::SDF { spread: 4.0 };
314        assert!(mode.is_sdf());
315        assert_eq!(mode.spread(), 4.0);
316    }
317
318    #[test]
319    fn test_render_mode_bitmap() {
320        let mode = TextRenderMode::Bitmap;
321        assert!(!mode.is_sdf());
322        assert_eq!(mode.spread(), 0.0);
323    }
324
325    #[test]
326    fn test_sdf_config_default() {
327        let config = SdfConfig::default();
328        assert!(!config.mode.is_sdf());
329        assert_eq!(config.edge_softness, 0.05);
330        assert_eq!(config.outline_width, 0.0);
331        assert!(!config.smooth);
332    }
333
334    #[test]
335    fn test_sdf_config_builder() {
336        let config = SdfConfig::new()
337            .with_sdf(6.0)
338            .edge_softness(0.1)
339            .outline_width(2.0)
340            .smooth(true);
341
342        assert!(config.mode.is_sdf());
343        assert_eq!(config.mode.spread(), 6.0);
344        assert_eq!(config.edge_softness, 0.1);
345        assert_eq!(config.outline_width, 2.0);
346        assert!(config.smooth);
347    }
348
349    #[test]
350    fn test_sdf_config_edge_softness_clamp() {
351        let config = SdfConfig::new().edge_softness(2.0); // Should clamp to 1.0
352
353        assert_eq!(config.edge_softness, 1.0);
354    }
355
356    #[test]
357    fn test_sdf_config_outline_width_clamp() {
358        let config = SdfConfig::new().outline_width(-5.0); // Should clamp to 0.0
359
360        assert_eq!(config.outline_width, 0.0);
361    }
362
363    // Note: Full SDF generation tests require proper SwashImage setup which is complex.
364    // The generate_sdf and generate_sdf_smooth functions are integration-tested
365    // in the renderer when actual glyphs are rasterized.
366}