1use std::collections::HashMap;
24
25use ab_glyph::{Font, FontArc, FontVec, Glyph, PxScale, ScaleFont, point};
26use bytemuck::{Pod, Zeroable};
27
28use crate::pipeline_helpers::load_wgsl;
29
30pub const TEXT_ATLAS_SIZE: u32 = 1024;
35
36#[repr(C)]
38#[derive(Debug, Clone, Copy, Default)]
39pub struct TextQuadVertex {
40 pub pos_clip: [f32; 2],
44 pub uv: [f32; 2],
46 pub rgba: [f32; 4],
49}
50
51unsafe impl Pod for TextQuadVertex {}
52unsafe impl Zeroable for TextQuadVertex {}
53
54#[derive(Debug, Clone)]
58pub struct TextFrame {
59 pub text: String,
60 pub font: u32,
63 pub size_px: f32,
66 pub x: f32,
68 pub y: f32,
69 pub rgba: [f32; 4],
71}
72
73#[derive(Debug, Clone, Copy)]
75struct GlyphSlot {
76 uv_min: [f32; 2],
78 uv_max: [f32; 2],
79 px_w: u32,
81 px_h: u32,
82 bearing_x: f32,
87 bearing_y: f32,
88 advance: f32,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
94struct GlyphKey {
95 font: u32,
96 size_q: u16,
101 glyph_id: u16,
102}
103
104pub struct TextAtlas {
106 texture: wgpu::Texture,
107 view: wgpu::TextureView,
108 sampler: wgpu::Sampler,
109 width: u32,
110 height: u32,
111 cpu_pixels: Vec<u8>,
116 cursor_x: u32,
118 cursor_y: u32,
120 row_h: u32,
123 glyphs: HashMap<GlyphKey, GlyphSlot>,
124 fonts: Vec<FontArc>,
125 dirty: bool,
128}
129
130impl TextAtlas {
131 pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
132 let width = TEXT_ATLAS_SIZE;
133 let height = TEXT_ATLAS_SIZE;
134 let texture = device.create_texture(&wgpu::TextureDescriptor {
135 label: Some("Text Atlas"),
136 size: wgpu::Extent3d {
137 width,
138 height,
139 depth_or_array_layers: 1,
140 },
141 mip_level_count: 1,
142 sample_count: 1,
143 dimension: wgpu::TextureDimension::D2,
144 format: wgpu::TextureFormat::Rgba8Unorm,
145 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
146 view_formats: &[],
147 });
148 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
149 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
150 label: Some("Text Atlas Sampler"),
151 address_mode_u: wgpu::AddressMode::ClampToEdge,
152 address_mode_v: wgpu::AddressMode::ClampToEdge,
153 mag_filter: wgpu::FilterMode::Linear,
154 min_filter: wgpu::FilterMode::Linear,
155 ..Default::default()
156 });
157 let cpu_pixels = vec![0u8; (width * height * 4) as usize];
158 queue.write_texture(
162 wgpu::TexelCopyTextureInfo {
163 texture: &texture,
164 mip_level: 0,
165 origin: wgpu::Origin3d::ZERO,
166 aspect: wgpu::TextureAspect::All,
167 },
168 &cpu_pixels,
169 wgpu::TexelCopyBufferLayout {
170 offset: 0,
171 bytes_per_row: Some(width * 4),
172 rows_per_image: Some(height),
173 },
174 wgpu::Extent3d {
175 width,
176 height,
177 depth_or_array_layers: 1,
178 },
179 );
180
181 let fonts = bundled_fonts();
182 Self {
183 texture,
184 view,
185 sampler,
186 width,
187 height,
188 cpu_pixels,
189 cursor_x: 1,
190 cursor_y: 1,
191 row_h: 0,
192 glyphs: HashMap::new(),
193 fonts,
194 dirty: false,
195 }
196 }
197
198 fn ensure_glyph(
202 &mut self,
203 font: u32,
204 size_px: f32,
205 glyph_id: u16,
206 ch: char,
207 ) -> Option<GlyphSlot> {
208 let font_idx = (font as usize).min(self.fonts.len().saturating_sub(1));
209 let font_arc = self.fonts.get(font_idx)?.clone();
210 let size_q = (size_px * 2.0).round().clamp(8.0, 4096.0) as u16;
211 let key = GlyphKey {
212 font: font_idx as u32,
213 size_q,
214 glyph_id,
215 };
216 if let Some(slot) = self.glyphs.get(&key) {
217 return Some(*slot);
218 }
219
220 let scale = PxScale::from(size_px.max(8.0));
221 let scaled = font_arc.as_scaled(scale);
222 let glyph: Glyph = font_arc
223 .glyph_id(ch)
224 .with_scale_and_position(scale, point(0.0, 0.0));
225 let advance = scaled.h_advance(glyph.id);
226 let v_metrics = scaled.ascent();
227 let Some(outlined) = font_arc.outline_glyph(glyph) else {
228 let slot = GlyphSlot {
231 uv_min: [0.0, 0.0],
232 uv_max: [0.0, 0.0],
233 px_w: 0,
234 px_h: 0,
235 bearing_x: 0.0,
236 bearing_y: v_metrics,
237 advance,
238 };
239 self.glyphs.insert(key, slot);
240 return Some(slot);
241 };
242
243 let bbox = outlined.px_bounds();
244 let w = bbox.width().ceil() as u32;
245 let h = bbox.height().ceil() as u32;
246 if w == 0 || h == 0 {
247 let slot = GlyphSlot {
248 uv_min: [0.0, 0.0],
249 uv_max: [0.0, 0.0],
250 px_w: 0,
251 px_h: 0,
252 bearing_x: 0.0,
253 bearing_y: v_metrics,
254 advance,
255 };
256 self.glyphs.insert(key, slot);
257 return Some(slot);
258 }
259
260 if self.cursor_x + w + 1 > self.width {
262 self.cursor_x = 1;
263 self.cursor_y += self.row_h + 1;
264 self.row_h = 0;
265 }
266 if self.cursor_y + h + 1 > self.height {
267 log::warn!("text atlas exhausted; new glyph dropped");
268 let slot = GlyphSlot {
270 uv_min: [0.0, 0.0],
271 uv_max: [0.0, 0.0],
272 px_w: 0,
273 px_h: 0,
274 bearing_x: 0.0,
275 bearing_y: v_metrics,
276 advance,
277 };
278 self.glyphs.insert(key, slot);
279 return Some(slot);
280 }
281
282 let dst_x = self.cursor_x;
283 let dst_y = self.cursor_y;
284 outlined.draw(|gx, gy, cov| {
285 let px = dst_x + gx;
286 let py = dst_y + gy;
287 if px < self.width && py < self.height {
288 let i = ((py * self.width + px) * 4) as usize;
289 let a = (cov.clamp(0.0, 1.0) * 255.0) as u8;
290 self.cpu_pixels[i] = a.max(self.cpu_pixels[i]);
291 }
292 });
293 self.cursor_x += w + 1;
294 self.row_h = self.row_h.max(h);
295 self.dirty = true;
296
297 let inv_w = 1.0 / self.width as f32;
298 let inv_h = 1.0 / self.height as f32;
299 let slot = GlyphSlot {
300 uv_min: [dst_x as f32 * inv_w, dst_y as f32 * inv_h],
301 uv_max: [(dst_x + w) as f32 * inv_w, (dst_y + h) as f32 * inv_h],
302 px_w: w,
303 px_h: h,
304 bearing_x: bbox.min.x,
305 bearing_y: bbox.min.y + v_metrics,
306 advance,
307 };
308 self.glyphs.insert(key, slot);
309 Some(slot)
310 }
311
312 fn flush(&mut self, queue: &wgpu::Queue) {
315 if !self.dirty {
316 return;
317 }
318 queue.write_texture(
319 wgpu::TexelCopyTextureInfo {
320 texture: &self.texture,
321 mip_level: 0,
322 origin: wgpu::Origin3d::ZERO,
323 aspect: wgpu::TextureAspect::All,
324 },
325 &self.cpu_pixels,
326 wgpu::TexelCopyBufferLayout {
327 offset: 0,
328 bytes_per_row: Some(self.width * 4),
329 rows_per_image: Some(self.height),
330 },
331 wgpu::Extent3d {
332 width: self.width,
333 height: self.height,
334 depth_or_array_layers: 1,
335 },
336 );
337 self.dirty = false;
338 }
339
340 pub fn glyph_count(&self) -> usize {
342 self.glyphs.len()
343 }
344}
345
346pub struct TextPipeline {
348 pipeline: wgpu::RenderPipeline,
349 bgl: wgpu::BindGroupLayout,
350 vertex_buffer: wgpu::Buffer,
351 vertex_capacity: u64,
352 atlas: TextAtlas,
353}
354
355impl TextPipeline {
356 pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
357 let shader = load_wgsl(device, "Text Shader", include_str!("../shaders/text.wgsl"));
358
359 let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
360 label: Some("Text BGL"),
361 entries: &[
362 wgpu::BindGroupLayoutEntry {
363 binding: 0,
364 visibility: wgpu::ShaderStages::FRAGMENT,
365 ty: wgpu::BindingType::Texture {
366 sample_type: wgpu::TextureSampleType::Float { filterable: true },
367 view_dimension: wgpu::TextureViewDimension::D2,
368 multisampled: false,
369 },
370 count: None,
371 },
372 wgpu::BindGroupLayoutEntry {
373 binding: 1,
374 visibility: wgpu::ShaderStages::FRAGMENT,
375 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
376 count: None,
377 },
378 ],
379 });
380
381 let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
382 label: Some("Text Layout"),
383 bind_group_layouts: &[Some(&bgl)],
384 immediate_size: 0,
385 });
386
387 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
388 label: Some("Text Pipeline"),
389 layout: Some(&layout),
390 vertex: wgpu::VertexState {
391 module: &shader,
392 entry_point: Some("vs_main"),
393 buffers: &[wgpu::VertexBufferLayout {
394 array_stride: std::mem::size_of::<TextQuadVertex>() as u64,
395 step_mode: wgpu::VertexStepMode::Vertex,
396 attributes: &[
397 wgpu::VertexAttribute {
398 format: wgpu::VertexFormat::Float32x2,
399 offset: 0,
400 shader_location: 0,
401 },
402 wgpu::VertexAttribute {
403 format: wgpu::VertexFormat::Float32x2,
404 offset: 8,
405 shader_location: 1,
406 },
407 wgpu::VertexAttribute {
408 format: wgpu::VertexFormat::Float32x4,
409 offset: 16,
410 shader_location: 2,
411 },
412 ],
413 }],
414 compilation_options: Default::default(),
415 },
416 fragment: Some(wgpu::FragmentState {
417 module: &shader,
418 entry_point: Some("fs_main"),
419 targets: &[Some(wgpu::ColorTargetState {
420 format,
421 blend: Some(wgpu::BlendState {
422 color: wgpu::BlendComponent {
426 src_factor: wgpu::BlendFactor::One,
427 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
428 operation: wgpu::BlendOperation::Add,
429 },
430 alpha: wgpu::BlendComponent {
431 src_factor: wgpu::BlendFactor::One,
432 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
433 operation: wgpu::BlendOperation::Add,
434 },
435 }),
436 write_mask: wgpu::ColorWrites::ALL,
437 })],
438 compilation_options: Default::default(),
439 }),
440 primitive: wgpu::PrimitiveState {
441 topology: wgpu::PrimitiveTopology::TriangleList,
442 ..Default::default()
443 },
444 depth_stencil: None,
445 multisample: wgpu::MultisampleState::default(),
446 multiview_mask: None,
447 cache: None,
448 });
449
450 let vertex_capacity = 256 * 6 * std::mem::size_of::<TextQuadVertex>() as u64;
453 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
454 label: Some("Text Vertex Buffer"),
455 size: vertex_capacity,
456 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
457 mapped_at_creation: false,
458 });
459
460 let atlas = TextAtlas::new(device, queue);
461
462 Self {
463 pipeline,
464 bgl,
465 vertex_buffer,
466 vertex_capacity,
467 atlas,
468 }
469 }
470
471 pub fn atlas(&self) -> &TextAtlas {
473 &self.atlas
474 }
475
476 #[allow(clippy::too_many_arguments)]
479 pub fn record(
480 &mut self,
481 encoder: &mut wgpu::CommandEncoder,
482 queue: &wgpu::Queue,
483 device: &wgpu::Device,
484 target: &wgpu::TextureView,
485 frames: &[TextFrame],
486 render_w: u32,
487 render_h: u32,
488 ) {
489 if frames.is_empty() {
490 return;
491 }
492 let mut verts: Vec<TextQuadVertex> = Vec::with_capacity(frames.len() * 32 * 6);
493 for f in frames {
494 self.append_text(&mut verts, f, render_w, render_h);
495 }
496 if verts.is_empty() {
497 return;
498 }
499 self.atlas.flush(queue);
500
501 let needed = (verts.len() * std::mem::size_of::<TextQuadVertex>()) as u64;
503 if needed > self.vertex_capacity {
504 let mut new_cap = self.vertex_capacity.max(1);
505 while new_cap < needed {
506 new_cap *= 2;
507 }
508 self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
509 label: Some("Text Vertex Buffer"),
510 size: new_cap,
511 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
512 mapped_at_creation: false,
513 });
514 self.vertex_capacity = new_cap;
515 }
516 queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&verts));
517
518 let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
519 label: Some("Text BG"),
520 layout: &self.bgl,
521 entries: &[
522 wgpu::BindGroupEntry {
523 binding: 0,
524 resource: wgpu::BindingResource::TextureView(&self.atlas.view),
525 },
526 wgpu::BindGroupEntry {
527 binding: 1,
528 resource: wgpu::BindingResource::Sampler(&self.atlas.sampler),
529 },
530 ],
531 });
532
533 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
534 label: Some("Text Pass"),
535 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
536 view: target,
537 resolve_target: None,
538 ops: wgpu::Operations {
539 load: wgpu::LoadOp::Load,
540 store: wgpu::StoreOp::Store,
541 },
542 depth_slice: None,
543 })],
544 depth_stencil_attachment: None,
545 timestamp_writes: None,
546 occlusion_query_set: None,
547 multiview_mask: None,
548 });
549 pass.set_pipeline(&self.pipeline);
550 pass.set_bind_group(0, Some(&bg), &[]);
551 pass.set_vertex_buffer(0, self.vertex_buffer.slice(..needed));
552 pass.draw(0..verts.len() as u32, 0..1);
553 }
554
555 fn append_text(
559 &mut self,
560 verts: &mut Vec<TextQuadVertex>,
561 frame: &TextFrame,
562 render_w: u32,
563 render_h: u32,
564 ) {
565 if frame.text.is_empty() || render_w == 0 || render_h == 0 {
566 return;
567 }
568 let size_px = frame.size_px.max(8.0);
569
570 let lines: Vec<&str> = frame.text.split('\n').collect();
572 let mut line_widths: Vec<f32> = Vec::with_capacity(lines.len());
573 for line in &lines {
574 let mut w = 0.0_f32;
575 for ch in line.chars() {
576 let gid = self
577 .atlas
578 .fonts
579 .get(frame.font.min(self.atlas.fonts.len() as u32 - 1) as usize)
580 .map(|f| f.glyph_id(ch).0)
581 .unwrap_or(0);
582 if let Some(slot) = self.atlas.ensure_glyph(frame.font, size_px, gid, ch) {
583 w += slot.advance;
584 }
585 }
586 line_widths.push(w);
587 }
588 let line_h = size_px * 1.25;
589 let total_h = line_h * lines.len() as f32;
590
591 let anchor_px_x = frame.x * render_w as f32;
593 let anchor_px_y = frame.y * render_h as f32;
594 let mut pen_y = anchor_px_y - total_h * 0.5 + size_px;
595
596 for (line, line_w) in lines.iter().zip(line_widths.iter()) {
597 let mut pen_x = anchor_px_x - line_w * 0.5;
598 for ch in line.chars() {
599 let gid = self
600 .atlas
601 .fonts
602 .get(frame.font.min(self.atlas.fonts.len() as u32 - 1) as usize)
603 .map(|f| f.glyph_id(ch).0)
604 .unwrap_or(0);
605 let Some(slot) = self.atlas.ensure_glyph(frame.font, size_px, gid, ch) else {
606 continue;
607 };
608 if slot.px_w > 0 && slot.px_h > 0 {
609 let x0_px = pen_x + slot.bearing_x;
610 let y0_px = pen_y + slot.bearing_y;
611 let x1_px = x0_px + slot.px_w as f32;
612 let y1_px = y0_px + slot.px_h as f32;
613 let x0 = (x0_px / render_w as f32) * 2.0 - 1.0;
614 let x1 = (x1_px / render_w as f32) * 2.0 - 1.0;
615 let y0 = 1.0 - (y0_px / render_h as f32) * 2.0;
616 let y1 = 1.0 - (y1_px / render_h as f32) * 2.0;
617 let [u0, v0] = slot.uv_min;
618 let [u1, v1] = slot.uv_max;
619 let rgba = frame.rgba;
620 let tl = TextQuadVertex {
621 pos_clip: [x0, y0],
622 uv: [u0, v0],
623 rgba,
624 };
625 let tr = TextQuadVertex {
626 pos_clip: [x1, y0],
627 uv: [u1, v0],
628 rgba,
629 };
630 let bl = TextQuadVertex {
631 pos_clip: [x0, y1],
632 uv: [u0, v1],
633 rgba,
634 };
635 let br = TextQuadVertex {
636 pos_clip: [x1, y1],
637 uv: [u1, v1],
638 rgba,
639 };
640 verts.extend_from_slice(&[tl, tr, bl, tr, br, bl]);
641 }
642 pen_x += slot.advance;
643 }
644 pen_y += line_h;
645 }
646 }
647}
648
649fn bundled_fonts() -> Vec<FontArc> {
653 use epaint_default_fonts::{HACK_REGULAR, UBUNTU_LIGHT};
654 let mut out = Vec::new();
655 match FontVec::try_from_vec(UBUNTU_LIGHT.to_vec()) {
656 Ok(f) => out.push(FontArc::from(f)),
657 Err(e) => log::warn!("default font (Ubuntu-Light) load failed: {e}"),
658 }
659 match FontVec::try_from_vec(HACK_REGULAR.to_vec()) {
660 Ok(f) => out.push(FontArc::from(f)),
661 Err(e) => log::warn!("mono font (Hack-Regular) load failed: {e}"),
662 }
663 out
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669
670 #[test]
671 fn bundled_fonts_load_two_faces() {
672 let fonts = bundled_fonts();
673 assert_eq!(fonts.len(), 2, "expected Ubuntu-Light + Hack-Regular");
674 }
675}