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) 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}