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) paint_buffer: lumen_gpu::BufferId,
139    pub(crate) atlas_size: lumen_gpu::Size,
140    pub(crate) max_glyphs: usize,
141    pub(crate) size: lumen_gpu::Size,
142}
143
144impl GpuCompiledNode for CompiledText {
145    fn node_id(&self) -> NodeId {
146        self.node_id
147    }
148
149    fn bind(&self, ctx: &FrameBindContext<'_>, bound: &mut BoundFrame) -> crate::Result<()> {
150        let trace_started = crate::log_level_enabled(tracing::Level::TRACE).then(trace_now_ms);
151        let evaluated = self.params.eval(&NodeParamEvalContext {
152            node_id: self.node_id,
153            expr: &ctx.expr_context(self.node_id, "params"),
154        })?;
155        let content = evaluated.content;
156        let font_family = evaluated.font_family;
157        let paint = evaluated.color.to_gpu([255, 255, 255, 255]);
158        let (position_x, position_y) = evaluated.position;
159        let font_size = evaluated.font_size as f32;
160        let max_width = evaluated.max_width as f32;
161        let alignment_horizontal = evaluated.alignment_horizontal;
162        let alignment_vertical = evaluated.alignment_vertical;
163        let font_weight = evaluated.font_weight;
164        let font_style = evaluated.font_style;
165
166        let mut request = lumen_text::TextLayoutRequest::new(content.clone());
167        request.font_family = font_family.clone();
168        request.font_size = font_size;
169        request.font_weight = font_weight.clamp(1, 1000) as u16;
170        request.font_style = match font_style {
171            TextFontStyle::Italic => lumen_text::TextFontStyle::Italic,
172            TextFontStyle::Oblique => lumen_text::TextFontStyle::Oblique,
173            TextFontStyle::Normal => lumen_text::TextFontStyle::Normal,
174        };
175        request.max_width = (max_width > 0.0).then_some(max_width);
176        request.origin = [0.0, 0.0];
177        let color_f32 = [1.0; 4];
178        request.color = [1.0; 4];
179        request.align = match alignment_horizontal {
180            TextAlignmentHorizontal::Center => lumen_text::TextAlign::Center,
181            TextAlignmentHorizontal::Right => lumen_text::TextAlign::Right,
182            TextAlignmentHorizontal::Justify => lumen_text::TextAlign::Justified,
183            TextAlignmentHorizontal::Left => lumen_text::TextAlign::Left,
184        };
185
186        let mut text_system = text_system()?;
187        load_font_family(&mut text_system, ctx, &font_family)?;
188
189        let atlas_key = TextAtlasCacheKey {
190            content: content.clone(),
191            font_family: font_family.clone(),
192            font_size_bits: font_size.to_bits(),
193            font_weight: request.font_weight,
194            font_style,
195            max_width_bits: max_width.to_bits(),
196            alignment_horizontal,
197            atlas_width: self.atlas_size.width,
198            atlas_height: self.atlas_size.height,
199            max_glyphs: self.max_glyphs,
200        };
201        let frame_key = TextFrameCacheKey {
202            atlas_key: atlas_key.clone(),
203            position_x_bits: (position_x as f32).to_bits(),
204            position_y_bits: (position_y as f32).to_bits(),
205            paint: evaluated.color.clone(),
206            alignment_vertical,
207            output_width: self.size.width,
208            output_height: self.size.height,
209        };
210        if text_cache()?
211            .get(&self.node_id.0)
212            .is_some_and(|cached| cached.frame_key.as_ref() == Some(&frame_key))
213        {
214            return Ok(());
215        }
216
217        let atlas_config = lumen_text::AtlasConfig {
218            width: self.atlas_size.width,
219            height: self.atlas_size.height,
220            px_range: 1,
221        };
222        let mut cache = text_cache()?;
223        let cached = cache.entry(self.node_id.0).or_default();
224        let atlas_changed = cached.atlas_key.as_ref() != Some(&atlas_key);
225        let mut layout_ms = 0.0;
226        let mut atlas_ms = 0.0;
227        let mut upload_ms = 0.0;
228        if atlas_changed {
229            let layout_started = trace_started.map(|_| trace_now_ms());
230            let layout = text_system.layout(&request);
231            if let Some(started) = layout_started {
232                layout_ms = trace_now_ms() - started;
233            }
234            let atlas_started = trace_started.map(|_| trace_now_ms());
235            let atlas = text_system.render_alpha_atlas(&layout, atlas_config, self.max_glyphs);
236            if let Some(started) = atlas_started {
237                atlas_ms = trace_now_ms() - started;
238            }
239            cached.atlas_key = Some(atlas_key.clone());
240            cached.base_instances = atlas.instances;
241            cached.glyph_count = atlas.glyph_count;
242            cached.measurement_height = layout.measurement.height;
243            let used_size = atlas.atlas.used_size();
244            let upload_height = used_size[1].max(1);
245            let upload_len = self.atlas_size.width as usize * upload_height as usize * 4;
246            let upload_pixels = atlas.pixels[..upload_len.min(atlas.pixels.len())].to_vec();
247            let upload_started = trace_started.map(|_| trace_now_ms());
248            bound.write_texture_rgba8_region(
249                self.atlas_texture,
250                upload_pixels,
251                [0, 0, 0],
252                lumen_gpu::Size::new(self.atlas_size.width, upload_height),
253                self.atlas_size.width * 4,
254                upload_height,
255            );
256            if let Some(started) = upload_started {
257                upload_ms = trace_now_ms() - started;
258            }
259        }
260
261        let y_offset = match alignment_vertical {
262            TextAlignmentVertical::Top => position_y as f32,
263            TextAlignmentVertical::Middle => position_y as f32 - cached.measurement_height * 0.5,
264            TextAlignmentVertical::Bottom => position_y as f32 - cached.measurement_height,
265        };
266        let instances = positioned_text_instances(
267            &cached.base_instances,
268            position_x as f32,
269            y_offset,
270            color_f32,
271        );
272        let globals = lumen_text::GpuTextGlobals {
273            target_size: [self.size.width as f32, self.size.height as f32],
274            px_range: atlas_config.px_range as f32,
275            glyph_count: cached.glyph_count as u32,
276        };
277
278        bound.write_buffer(self.globals_buffer, 0, bytemuck::bytes_of(&globals));
279        bound.write_buffer(self.paint_buffer, 0, bytemuck::bytes_of(&paint));
280        if !instances.is_empty() {
281            bound.write_buffer(self.instances_buffer, 0, bytemuck::cast_slice(&instances));
282        }
283        cached.frame_key = Some(frame_key);
284        if let Some(started) = trace_started {
285            trace_text_bind(
286                self.node_id,
287                atlas_changed,
288                cached.glyph_count,
289                layout_ms,
290                atlas_ms,
291                upload_ms,
292                trace_now_ms() - started,
293            );
294        }
295        Ok(())
296    }
297}
298
299#[derive(Debug, Clone, Default)]
300struct CachedText {
301    atlas_key: Option<TextAtlasCacheKey>,
302    frame_key: Option<TextFrameCacheKey>,
303    base_instances: Vec<lumen_text::GpuGlyphInstance>,
304    glyph_count: usize,
305    measurement_height: f32,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
309struct TextAtlasCacheKey {
310    content: String,
311    font_family: String,
312    font_size_bits: u32,
313    font_weight: u16,
314    font_style: TextFontStyle,
315    max_width_bits: u32,
316    alignment_horizontal: TextAlignmentHorizontal,
317    atlas_width: u32,
318    atlas_height: u32,
319    max_glyphs: usize,
320}
321
322#[derive(Debug, Clone, PartialEq)]
323struct TextFrameCacheKey {
324    atlas_key: TextAtlasCacheKey,
325    position_x_bits: u32,
326    position_y_bits: u32,
327    paint: Paint,
328    alignment_vertical: TextAlignmentVertical,
329    output_width: u32,
330    output_height: u32,
331}
332
333fn positioned_text_instances(
334    base_instances: &[lumen_text::GpuGlyphInstance],
335    x_offset: f32,
336    y_offset: f32,
337    color: [f32; 4],
338) -> Vec<lumen_text::GpuGlyphInstance> {
339    base_instances
340        .iter()
341        .map(|instance| {
342            let mut instance = *instance;
343            instance.rect[0] += x_offset;
344            instance.rect[1] += y_offset;
345            instance.color = color;
346            instance
347        })
348        .collect()
349}
350
351fn text_system() -> crate::Result<std::sync::MutexGuard<'static, lumen_text::TextSystem>> {
352    static TEXT_SYSTEM: OnceLock<Mutex<lumen_text::TextSystem>> = OnceLock::new();
353    TEXT_SYSTEM
354        .get_or_init(|| Mutex::new(lumen_text::TextSystem::new()))
355        .lock()
356        .map_err(|_| {
357            LumenError::Render(RenderError::Gpu {
358                details: "text system lock was poisoned".to_string(),
359            })
360        })
361}
362
363fn load_font_family(
364    text_system: &mut lumen_text::TextSystem,
365    ctx: &FrameBindContext<'_>,
366    font_family: &str,
367) -> crate::Result<()> {
368    if font_family.is_empty() {
369        return Ok(());
370    }
371    let Some(store) = ctx.media() else {
372        return Ok(());
373    };
374
375    let Some(resolver) = store.get_font_resolver(font_family) else {
376        return Ok(());
377    };
378    let resolver_id = resolver.id().to_string();
379    if loaded_fonts()?
380        .get(font_family)
381        .is_some_and(|loaded_id| loaded_id == &resolver_id)
382    {
383        return Ok(());
384    }
385    for data in resolver.data().map_err(LumenError::Media)? {
386        text_system.load_font_data(data);
387    }
388    loaded_fonts()?.insert(font_family.to_string(), resolver_id);
389    text_cache()?.clear();
390    Ok(())
391}
392
393fn loaded_fonts() -> crate::Result<std::sync::MutexGuard<'static, HashMap<String, String>>> {
394    static LOADED_FONTS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
395    LOADED_FONTS
396        .get_or_init(|| Mutex::new(HashMap::new()))
397        .lock()
398        .map_err(|_| {
399            LumenError::Render(RenderError::Gpu {
400                details: "loaded fonts lock was poisoned".to_string(),
401            })
402        })
403}
404
405fn text_cache() -> crate::Result<std::sync::MutexGuard<'static, HashMap<u64, CachedText>>> {
406    static TEXT_CACHE: OnceLock<Mutex<HashMap<u64, CachedText>>> = OnceLock::new();
407    TEXT_CACHE
408        .get_or_init(|| Mutex::new(HashMap::new()))
409        .lock()
410        .map_err(|_| {
411            LumenError::Render(RenderError::Gpu {
412                details: "text cache lock was poisoned".to_string(),
413            })
414        })
415}
416
417pub(crate) fn clear_text_cache_for(node_id: NodeId) {
418    if let Ok(mut cache) = text_cache() {
419        cache.remove(&node_id.0);
420    }
421    if crate::log_level_enabled(tracing::Level::TRACE) {
422        tracing::trace!(
423            target: "lumen_text",
424            node_id = node_id.0,
425            "text cache clear"
426        );
427    }
428}
429
430fn trace_text_bind(
431    node_id: NodeId,
432    atlas_changed: bool,
433    glyph_count: usize,
434    layout_ms: f64,
435    atlas_ms: f64,
436    upload_ms: f64,
437    total_ms: f64,
438) {
439    let should_log = atlas_changed || total_ms >= 1.0;
440    if !should_log {
441        return;
442    }
443    static BIND_COUNT: AtomicU64 = AtomicU64::new(0);
444    static ATLAS_MISS_COUNT: AtomicU64 = AtomicU64::new(0);
445    let bind_count = BIND_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
446    let atlas_miss_count = if atlas_changed {
447        ATLAS_MISS_COUNT.fetch_add(1, Ordering::Relaxed) + 1
448    } else {
449        ATLAS_MISS_COUNT.load(Ordering::Relaxed)
450    };
451    tracing::trace!(
452        target: "lumen_text",
453        bind = bind_count,
454        atlas_misses = atlas_miss_count,
455        node_id = node_id.0,
456        atlas_miss = atlas_changed,
457        glyphs = glyph_count,
458        layout_ms,
459        atlas_ms,
460        atlas_upload_ms = upload_ms,
461        bind_ms = total_ms,
462        "text bind"
463    );
464}
465
466#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
467fn trace_now_ms() -> f64 {
468    static START: OnceLock<Instant> = OnceLock::new();
469    START.get_or_init(Instant::now).elapsed().as_secs_f64() * 1000.0
470}
471
472#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
473fn trace_now_ms() -> f64 {
474    js_sys::Date::now()
475}