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#[derive(Debug, Clone, lumen_macros::Node)]
74#[node(kind = "text", name = "Text", category = "source")]
75pub struct Text {
76 pub id: NodeId,
77 #[property(kind = "string", multiline, recommended_rows = 4)]
79 pub content: NodeProperty,
80 #[property(kind = "string", format = "font_family")]
82 pub font_family: NodeProperty,
83 #[property(kind = "float", min = 1, step = 1)]
85 pub font_size: NodeProperty,
86 #[property(kind = "int", min = 100, max = 900, step = 100)]
88 pub font_weight: NodeProperty,
89 #[property(kind = "enum", enum_type = TextFontStyle)]
91 pub font_style: NodeProperty,
92 #[property(kind = "float", min = 0, step = 1)]
94 pub max_width: NodeProperty,
95 #[property(kind = "vec2")]
97 pub position: NodeProperty,
98 #[property(kind = "color")]
100 pub color: NodeProperty,
101 #[property(kind = "enum", enum_type = TextAlignmentHorizontal)]
103 pub alignment_horizontal: NodeProperty,
104 #[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}