Skip to main content

wisp/text/
stroke.rs

1//! Stroked / outlined text rendering (M-TEXT.7 / AUT-81).
2//!
3//! Text that has to stay readable over a busy screen recording needs
4//! a high-contrast outline. The standard CSS-style technique — stamp
5//! the rendered text texture multiple times in the stroke color at
6//! small offsets around a center, then stamp the fill color on top —
7//! works without a shader change.
8//!
9//! Output of [`stroked_text_sprites`] is a `Vec<Sprite>` ready to be
10//! attached to a [`crate::Container`]. The container's transform
11//! handles position / scale; offsets are emitted in **local** NDC so
12//! the container scale doesn't break stroke geometry.
13
14use std::sync::Arc;
15
16use glam::Vec2;
17
18use crate::color::Color;
19use crate::scene::Sprite;
20use crate::texture::render_texture::RenderTexture;
21
22/// Builder for a stroked-text composition.
23#[derive(Debug, Clone)]
24pub struct StrokedTextLayer {
25    /// Color the glyph interior fills with.
26    pub fill: Color,
27    /// Color the outline ring is drawn in.
28    pub stroke: Color,
29    /// Outline radius in **local NDC** units (the container's
30    /// untransformed NDC). `0.0` skips the stroke and emits just the
31    /// fill sprite.
32    pub stroke_width_ndc: f32,
33}
34
35impl Default for StrokedTextLayer {
36    fn default() -> Self {
37        Self {
38            fill: Color::WHITE,
39            stroke: Color::BLACK,
40            stroke_width_ndc: 0.0,
41        }
42    }
43}
44
45/// Eight unit-vectors at 45° spacing — the offsets used to stamp the
46/// stroke ring. More directions = smoother edges but more sprite
47/// nodes per stroke.
48#[expect(
49    clippy::approx_constant,
50    reason = "0.7071 is √2/2 in 4-digit literals — explicit for readability"
51)]
52const STROKE_OFFSETS: [(f32, f32); 8] = [
53    (1.0, 0.0),
54    (0.7071, 0.7071),
55    (0.0, 1.0),
56    (-0.7071, 0.7071),
57    (-1.0, 0.0),
58    (-0.7071, -0.7071),
59    (0.0, -1.0),
60    (0.7071, -0.7071),
61];
62
63/// Produce a list of sprites that, when attached to a container,
64/// render the given text-texture with `fill` interior and `stroke`
65/// outline at the configured `stroke_width_ndc`.
66///
67/// Sprites are emitted with `tint` set; the caller positions them via
68/// the container's transform. The `+y` flip used by every text-texture
69/// consumer (`scale.y = -1`) is **not** applied here — apply it on
70/// the parent container or override per-sprite if your render-texture
71/// already arrives oriented `+y`-up.
72///
73/// Order: stroke sprites first (background), fill sprite last. The
74/// renderer draws in scene-tree order, so the fill sits on top.
75///
76/// Skipping the stroke entirely (`stroke_width_ndc == 0.0`) returns a
77/// single-sprite vector.
78#[must_use]
79pub fn stroked_text_sprites(text_rt: &Arc<RenderTexture>, layer: &StrokedTextLayer) -> Vec<Sprite> {
80    let tex = text_rt.as_texture();
81    let mut sprites = Vec::with_capacity(if layer.stroke_width_ndc > 0.0 { 9 } else { 1 });
82
83    if layer.stroke_width_ndc > 0.0 {
84        for (dx, dy) in STROKE_OFFSETS {
85            let mut s = Sprite::from_texture(tex.clone()).with_tint(layer.stroke);
86            s.container.transform.position =
87                Vec2::new(dx * layer.stroke_width_ndc, dy * layer.stroke_width_ndc);
88            sprites.push(s);
89        }
90    }
91
92    let fill_sprite = Sprite::from_texture(tex).with_tint(layer.fill);
93    sprites.push(fill_sprite);
94
95    sprites
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn dummy_rt(width: u32, height: u32) -> Arc<RenderTexture> {
103        // Test only needs an `Arc<RenderTexture>` shape — construct
104        // one via the real GPU path under pollster.
105        use crate::application::{AppConfig, Application};
106        use pollster::block_on;
107        let app = block_on(Application::new(AppConfig::default())).expect("Application::new");
108        Arc::new(RenderTexture::new(&app, width, height))
109    }
110
111    #[test]
112    fn zero_stroke_emits_one_sprite() {
113        let rt = dummy_rt(64, 32);
114        let sprites = stroked_text_sprites(
115            &rt,
116            &StrokedTextLayer {
117                stroke_width_ndc: 0.0,
118                ..StrokedTextLayer::default()
119            },
120        );
121        assert_eq!(sprites.len(), 1);
122        assert_eq!(sprites[0].tint, Color::WHITE);
123    }
124
125    #[test]
126    fn positive_stroke_emits_eight_plus_one() {
127        let rt = dummy_rt(64, 32);
128        let sprites = stroked_text_sprites(
129            &rt,
130            &StrokedTextLayer {
131                fill: Color::WHITE,
132                stroke: Color::BLACK,
133                stroke_width_ndc: 0.01,
134            },
135        );
136        assert_eq!(sprites.len(), 9);
137        // First 8 are stroke sprites; last is fill.
138        for stroke_sprite in &sprites[..8] {
139            assert_eq!(stroke_sprite.tint, Color::BLACK);
140        }
141        assert_eq!(sprites[8].tint, Color::WHITE);
142    }
143
144    #[test]
145    fn stroke_sprites_offset_at_configured_radius() {
146        let rt = dummy_rt(64, 32);
147        let r = 0.04_f32;
148        let sprites = stroked_text_sprites(
149            &rt,
150            &StrokedTextLayer {
151                fill: Color::WHITE,
152                stroke: Color::BLACK,
153                stroke_width_ndc: r,
154            },
155        );
156        // Every stroke sprite's position lies on the radius-r ring (within fp tolerance).
157        for s in &sprites[..8] {
158            let p = s.container.transform.position;
159            let dist = (p.x * p.x + p.y * p.y).sqrt();
160            assert!(
161                (dist - r).abs() < 1e-4,
162                "stroke sprite at {p:?} is at radius {dist}, expected {r}",
163            );
164        }
165        // Fill sprite is centered.
166        let fill_pos = sprites[8].container.transform.position;
167        assert!(fill_pos.length() < 1e-6);
168    }
169
170    #[test]
171    fn stroke_width_scales_linearly() {
172        let rt = dummy_rt(64, 32);
173        for &r in &[0.001_f32, 0.01, 0.05, 0.1] {
174            let sprites = stroked_text_sprites(
175                &rt,
176                &StrokedTextLayer {
177                    fill: Color::WHITE,
178                    stroke: Color::BLACK,
179                    stroke_width_ndc: r,
180                },
181            );
182            let p = sprites[0].container.transform.position;
183            let dist = (p.x * p.x + p.y * p.y).sqrt();
184            assert!((dist - r).abs() < 1e-4);
185        }
186    }
187}