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