Skip to main content

lumen_engine/node/source/
text.rs

1use std::{
2    collections::HashMap,
3    sync::atomic::{AtomicU64, Ordering},
4    sync::{Mutex, OnceLock},
5};
6
7#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
8use std::time::Instant;
9
10use crate::error::{LumenError, RenderError};
11use crate::gpu::{BoundFrame, CompiledOutput, FrameBindContext, GpuCompileNode, GpuCompiledNode};
12use crate::node::{NodeId, NodeParamEvalContext, NodeParams, PortRef, vector::paint::Paint};
13
14#[derive(
15    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, lumen_macros::NodeEnum, lumen_macros::Delegate,
16)]
17#[repr(i64)]
18#[delegate(kind = "enum")]
19pub enum TextFontStyle {
20    #[default]
21    Normal = 0,
22    Italic = 1,
23    Oblique = 2,
24}
25
26#[derive(
27    Debug, Clone, Copy, PartialEq, Eq, Default, lumen_macros::NodeEnum, lumen_macros::Delegate,
28)]
29#[repr(i64)]
30#[delegate(kind = "enum")]
31pub enum TextAlignmentHorizontal {
32    #[default]
33    Left = 0,
34    Center = 1,
35    Right = 2,
36    Justify = 3,
37}
38
39#[derive(
40    Debug, Clone, Copy, PartialEq, Eq, Default, lumen_macros::NodeEnum, lumen_macros::Delegate,
41)]
42#[repr(i64)]
43#[delegate(kind = "enum")]
44pub enum TextAlignmentVertical {
45    #[default]
46    Top = 0,
47    Middle = 1,
48    Bottom = 2,
49}
50
51/// Produces a text raster source.
52#[derive(Debug, Clone, lumen_macros::Delegate)]
53pub struct TextParams {
54    /// Text content to render.
55    #[meta(multiline, recommended_rows = 4)]
56    pub content: String,
57    /// Font family name.
58    #[meta(format = "font_family")]
59    pub font_family: String,
60    /// Font size in pixels.
61    #[meta(min = 1, step = 1)]
62    pub font_size: f64,
63    /// Font weight.
64    #[meta(min = 100, max = 900, step = 100)]
65    pub font_weight: i64,
66    /// Font style.
67    #[meta(kind = "enum", enum_type = TextFontStyle)]
68    pub font_style: TextFontStyle,
69    /// Maximum line width in pixels. Use 0 for automatic width.
70    #[meta(min = 0, step = 1)]
71    pub max_width: f64,
72    /// Text origin in pixels.
73    #[meta()]
74    pub position: (f64, f64),
75    /// Text color.
76    #[meta(role = "color")]
77    pub color: Paint,
78    /// Horizontal text alignment.
79    #[meta(kind = "enum", enum_type = TextAlignmentHorizontal)]
80    pub alignment_horizontal: TextAlignmentHorizontal,
81    /// Vertical text alignment.
82    #[meta(kind = "enum", enum_type = TextAlignmentVertical)]
83    pub alignment_vertical: TextAlignmentVertical,
84}
85
86impl Default for TextParams {
87    fn default() -> Self {
88        Self {
89            content: String::new(),
90            font_family: lumen_text::DEFAULT_FONT_FAMILY.to_string(),
91            font_size: 16.0,
92            font_weight: 400,
93            font_style: TextFontStyle::Normal,
94            max_width: 0.0,
95            position: (0.0, 0.0),
96            color: Paint::solid([255, 255, 255, 255]),
97            alignment_horizontal: TextAlignmentHorizontal::Left,
98            alignment_vertical: TextAlignmentVertical::Top,
99        }
100    }
101}
102
103/// Produces a text raster source.
104#[derive(Debug, Clone, lumen_macros::Node)]
105#[node(kind = "text", name = "Text", category = "source")]
106pub struct Text {
107    pub id: NodeId,
108    #[params]
109    pub params: TextParamsDelegate,
110}
111
112impl Default for Text {
113    fn default() -> Self {
114        Self {
115            id: NodeId::new(0),
116            params: TextParamsDelegate::default(),
117        }
118    }
119}
120
121impl GpuCompileNode for Text {
122    fn compile_gpu(
123        &self,
124        ctx: &mut crate::gpu::CompileContext<'_>,
125        port: &PortRef,
126    ) -> crate::Result<CompiledOutput> {
127        crate::node::vector::renderer::VectorRenderer::new(ctx).compile_text(self, port)
128    }
129}
130
131#[derive(Debug, Clone)]
132pub(crate) struct CompiledText {
133    pub(crate) node_id: NodeId,
134    pub(crate) params: TextParamsDelegate,
135    pub(crate) atlas_texture: lumen_gpu::TextureId,
136    pub(crate) globals_buffer: lumen_gpu::BufferId,
137    pub(crate) instances_buffer: lumen_gpu::BufferId,
138    pub(crate) atlas_size: lumen_gpu::Size,
139    pub(crate) max_glyphs: usize,
140    pub(crate) size: lumen_gpu::Size,
141}
142
143impl GpuCompiledNode for CompiledText {
144    fn node_id(&self) -> NodeId {
145        self.node_id
146    }
147
148    fn bind(&self, ctx: &FrameBindContext<'_>, bound: &mut BoundFrame) -> crate::Result<()> {
149        let trace_started = crate::log_level_enabled(tracing::Level::TRACE).then(trace_now_ms);
150        let evaluated = self.params.eval(&NodeParamEvalContext {
151            node_id: self.node_id,
152            expr: &ctx.expr_context(self.node_id, "params"),
153        })?;
154        let content = evaluated.content;
155        let font_family = evaluated.font_family;
156        let color = evaluated.color.to_gpu([255, 255, 255, 255]).colors[0];
157        let (position_x, position_y) = evaluated.position;
158        let font_size = evaluated.font_size as f32;
159        let max_width = evaluated.max_width as f32;
160        let alignment_horizontal = evaluated.alignment_horizontal;
161        let alignment_vertical = evaluated.alignment_vertical;
162        let font_weight = evaluated.font_weight;
163        let font_style = evaluated.font_style;
164
165        let mut request = lumen_text::TextLayoutRequest::new(content.clone());
166        request.font_family = font_family.clone();
167        request.font_size = font_size;
168        request.font_weight = font_weight.clamp(1, 1000) as u16;
169        request.font_style = match font_style {
170            TextFontStyle::Italic => lumen_text::TextFontStyle::Italic,
171            TextFontStyle::Oblique => lumen_text::TextFontStyle::Oblique,
172            TextFontStyle::Normal => lumen_text::TextFontStyle::Normal,
173        };
174        request.max_width = (max_width > 0.0).then_some(max_width);
175        request.origin = [0.0, 0.0];
176        let color_f32 = color;
177        request.color = [1.0; 4];
178        request.align = match alignment_horizontal {
179            TextAlignmentHorizontal::Center => lumen_text::TextAlign::Center,
180            TextAlignmentHorizontal::Right => lumen_text::TextAlign::Right,
181            TextAlignmentHorizontal::Justify => lumen_text::TextAlign::Justified,
182            TextAlignmentHorizontal::Left => lumen_text::TextAlign::Left,
183        };
184
185        let mut text_system = text_system()?;
186        load_font_family(&mut text_system, ctx, &font_family)?;
187
188        let atlas_key = TextAtlasCacheKey {
189            content: content.clone(),
190            font_family: font_family.clone(),
191            font_size_bits: font_size.to_bits(),
192            font_weight: request.font_weight,
193            font_style,
194            max_width_bits: max_width.to_bits(),
195            alignment_horizontal,
196            atlas_width: self.atlas_size.width,
197            atlas_height: self.atlas_size.height,
198            max_glyphs: self.max_glyphs,
199        };
200        let frame_key = TextFrameCacheKey {
201            atlas_key: atlas_key.clone(),
202            position_x_bits: (position_x as f32).to_bits(),
203            position_y_bits: (position_y as f32).to_bits(),
204            color: color.map(|component| (component.clamp(0.0, 1.0) * 255.0).round() as u8),
205            alignment_vertical,
206            output_width: self.size.width,
207            output_height: self.size.height,
208        };
209        if text_cache()?
210            .get(&self.node_id.0)
211            .is_some_and(|cached| cached.frame_key.as_ref() == Some(&frame_key))
212        {
213            return Ok(());
214        }
215
216        let atlas_config = lumen_text::AtlasConfig {
217            width: self.atlas_size.width,
218            height: self.atlas_size.height,
219            px_range: 1,
220        };
221        let mut cache = text_cache()?;
222        let cached = cache.entry(self.node_id.0).or_default();
223        let atlas_changed = cached.atlas_key.as_ref() != Some(&atlas_key);
224        let mut layout_ms = 0.0;
225        let mut atlas_ms = 0.0;
226        let mut upload_ms = 0.0;
227        if atlas_changed {
228            let layout_started = trace_started.map(|_| trace_now_ms());
229            let layout = text_system.layout(&request);
230            if let Some(started) = layout_started {
231                layout_ms = trace_now_ms() - started;
232            }
233            let atlas_started = trace_started.map(|_| trace_now_ms());
234            let atlas = text_system.render_alpha_atlas(&layout, atlas_config, self.max_glyphs);
235            if let Some(started) = atlas_started {
236                atlas_ms = trace_now_ms() - started;
237            }
238            cached.atlas_key = Some(atlas_key.clone());
239            cached.base_instances = atlas.instances;
240            cached.glyph_count = atlas.glyph_count;
241            cached.measurement_height = layout.measurement.height;
242            let used_size = atlas.atlas.used_size();
243            let upload_height = used_size[1].max(1);
244            let upload_len = self.atlas_size.width as usize * upload_height as usize * 4;
245            let upload_pixels = atlas.pixels[..upload_len.min(atlas.pixels.len())].to_vec();
246            let upload_started = trace_started.map(|_| trace_now_ms());
247            bound.write_texture_rgba8_region(
248                self.atlas_texture,
249                upload_pixels,
250                [0, 0, 0],
251                lumen_gpu::Size::new(self.atlas_size.width, upload_height),
252                self.atlas_size.width * 4,
253                upload_height,
254            );
255            if let Some(started) = upload_started {
256                upload_ms = trace_now_ms() - started;
257            }
258        }
259
260        let y_offset = match alignment_vertical {
261            TextAlignmentVertical::Top => position_y as f32,
262            TextAlignmentVertical::Middle => position_y as f32 - cached.measurement_height * 0.5,
263            TextAlignmentVertical::Bottom => position_y as f32 - cached.measurement_height,
264        };
265        let instances = positioned_text_instances(
266            &cached.base_instances,
267            position_x as f32,
268            y_offset,
269            color_f32,
270        );
271        let globals = lumen_text::GpuTextGlobals {
272            target_size: [self.size.width as f32, self.size.height as f32],
273            px_range: atlas_config.px_range as f32,
274            glyph_count: cached.glyph_count as u32,
275        };
276
277        bound.write_buffer(self.globals_buffer, 0, bytemuck::bytes_of(&globals));
278        if !instances.is_empty() {
279            bound.write_buffer(self.instances_buffer, 0, bytemuck::cast_slice(&instances));
280        }
281        cached.frame_key = Some(frame_key);
282        if let Some(started) = trace_started {
283            trace_text_bind(
284                self.node_id,
285                atlas_changed,
286                cached.glyph_count,
287                layout_ms,
288                atlas_ms,
289                upload_ms,
290                trace_now_ms() - started,
291            );
292        }
293        Ok(())
294    }
295}
296
297#[derive(Debug, Clone, Default)]
298struct CachedText {
299    atlas_key: Option<TextAtlasCacheKey>,
300    frame_key: Option<TextFrameCacheKey>,
301    base_instances: Vec<lumen_text::GpuGlyphInstance>,
302    glyph_count: usize,
303    measurement_height: f32,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq)]
307struct TextAtlasCacheKey {
308    content: String,
309    font_family: String,
310    font_size_bits: u32,
311    font_weight: u16,
312    font_style: TextFontStyle,
313    max_width_bits: u32,
314    alignment_horizontal: TextAlignmentHorizontal,
315    atlas_width: u32,
316    atlas_height: u32,
317    max_glyphs: usize,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
321struct TextFrameCacheKey {
322    atlas_key: TextAtlasCacheKey,
323    position_x_bits: u32,
324    position_y_bits: u32,
325    color: [u8; 4],
326    alignment_vertical: TextAlignmentVertical,
327    output_width: u32,
328    output_height: u32,
329}
330
331fn positioned_text_instances(
332    base_instances: &[lumen_text::GpuGlyphInstance],
333    x_offset: f32,
334    y_offset: f32,
335    color: [f32; 4],
336) -> Vec<lumen_text::GpuGlyphInstance> {
337    base_instances
338        .iter()
339        .map(|instance| {
340            let mut instance = *instance;
341            instance.rect[0] += x_offset;
342            instance.rect[1] += y_offset;
343            instance.color = color;
344            instance
345        })
346        .collect()
347}
348
349fn text_system() -> crate::Result<std::sync::MutexGuard<'static, lumen_text::TextSystem>> {
350    static TEXT_SYSTEM: OnceLock<Mutex<lumen_text::TextSystem>> = OnceLock::new();
351    TEXT_SYSTEM
352        .get_or_init(|| Mutex::new(lumen_text::TextSystem::new()))
353        .lock()
354        .map_err(|_| {
355            LumenError::Render(RenderError::Gpu {
356                details: "text system lock was poisoned".to_string(),
357            })
358        })
359}
360
361fn load_font_family(
362    text_system: &mut lumen_text::TextSystem,
363    ctx: &FrameBindContext<'_>,
364    font_family: &str,
365) -> crate::Result<()> {
366    if font_family.is_empty() {
367        return Ok(());
368    }
369    let Some(store) = ctx.media() else {
370        return Ok(());
371    };
372
373    let Some(resolver) = store.get_font_resolver(font_family) else {
374        return Ok(());
375    };
376    let resolver_id = resolver.id().to_string();
377    if loaded_fonts()?
378        .get(font_family)
379        .is_some_and(|loaded_id| loaded_id == &resolver_id)
380    {
381        return Ok(());
382    }
383    for data in resolver.data().map_err(LumenError::Media)? {
384        text_system.load_font_data(data);
385    }
386    loaded_fonts()?.insert(font_family.to_string(), resolver_id);
387    text_cache()?.clear();
388    Ok(())
389}
390
391fn loaded_fonts() -> crate::Result<std::sync::MutexGuard<'static, HashMap<String, String>>> {
392    static LOADED_FONTS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
393    LOADED_FONTS
394        .get_or_init(|| Mutex::new(HashMap::new()))
395        .lock()
396        .map_err(|_| {
397            LumenError::Render(RenderError::Gpu {
398                details: "loaded fonts lock was poisoned".to_string(),
399            })
400        })
401}
402
403fn text_cache() -> crate::Result<std::sync::MutexGuard<'static, HashMap<u64, CachedText>>> {
404    static TEXT_CACHE: OnceLock<Mutex<HashMap<u64, CachedText>>> = OnceLock::new();
405    TEXT_CACHE
406        .get_or_init(|| Mutex::new(HashMap::new()))
407        .lock()
408        .map_err(|_| {
409            LumenError::Render(RenderError::Gpu {
410                details: "text cache lock was poisoned".to_string(),
411            })
412        })
413}
414
415pub(crate) fn clear_text_cache_for(node_id: NodeId) {
416    if let Ok(mut cache) = text_cache() {
417        cache.remove(&node_id.0);
418    }
419    if crate::log_level_enabled(tracing::Level::TRACE) {
420        tracing::trace!(
421            target: "lumen_text",
422            node_id = node_id.0,
423            "text cache clear"
424        );
425    }
426}
427
428fn trace_text_bind(
429    node_id: NodeId,
430    atlas_changed: bool,
431    glyph_count: usize,
432    layout_ms: f64,
433    atlas_ms: f64,
434    upload_ms: f64,
435    total_ms: f64,
436) {
437    let should_log = atlas_changed || total_ms >= 1.0;
438    if !should_log {
439        return;
440    }
441    static BIND_COUNT: AtomicU64 = AtomicU64::new(0);
442    static ATLAS_MISS_COUNT: AtomicU64 = AtomicU64::new(0);
443    let bind_count = BIND_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
444    let atlas_miss_count = if atlas_changed {
445        ATLAS_MISS_COUNT.fetch_add(1, Ordering::Relaxed) + 1
446    } else {
447        ATLAS_MISS_COUNT.load(Ordering::Relaxed)
448    };
449    tracing::trace!(
450        target: "lumen_text",
451        bind = bind_count,
452        atlas_misses = atlas_miss_count,
453        node_id = node_id.0,
454        atlas_miss = atlas_changed,
455        glyphs = glyph_count,
456        layout_ms,
457        atlas_ms,
458        atlas_upload_ms = upload_ms,
459        bind_ms = total_ms,
460        "text bind"
461    );
462}
463
464#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
465fn trace_now_ms() -> f64 {
466    static START: OnceLock<Instant> = OnceLock::new();
467    START.get_or_init(Instant::now).elapsed().as_secs_f64() * 1000.0
468}
469
470#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
471fn trace_now_ms() -> f64 {
472    js_sys::Date::now()
473}