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#[derive(Debug, Clone, lumen_macros::Delegate)]
53pub struct TextParams {
54 #[meta(multiline, recommended_rows = 4)]
56 pub content: String,
57 #[meta(format = "font_family")]
59 pub font_family: String,
60 #[meta(min = 1, step = 1)]
62 pub font_size: f64,
63 #[meta(min = 100, max = 900, step = 100)]
65 pub font_weight: i64,
66 #[meta(kind = "enum", enum_type = TextFontStyle)]
68 pub font_style: TextFontStyle,
69 #[meta(min = 0, step = 1)]
71 pub max_width: f64,
72 #[meta()]
74 pub position: (f64, f64),
75 #[meta(role = "color")]
77 pub color: Paint,
78 #[meta(kind = "enum", enum_type = TextAlignmentHorizontal)]
80 pub alignment_horizontal: TextAlignmentHorizontal,
81 #[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#[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}