use std::sync::Arc;
use glyphon::{
Cache, Color as GlyphonColor, ColorMode, FontSystem, Resolution, SwashCache, TextArea,
TextAtlas, TextBounds, TextRenderer, Viewport,
};
use wgpu::util::DeviceExt;
use wgpu::{
BindGroup, Buffer as WgpuBuffer, Device, Extent3d, MultisampleState, Queue, RenderPass,
Texture, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages,
};
use super::constants::{TEXT_BUFFER_MARGIN_MULTIPLIER, TEXT_SUPERSAMPLE, TEXT_TEXTURE_PADDING};
use super::textured_quad::{QuadDraw, TexturedQuadPipeline};
use super::textured_vertex::{QuadClip, TexturedVertex};
use super::types::TextEntry;
use crate::widgets::font::FontWeight;
use crate::widgets::{Rect, TextAlign};
const TEXT_MARGIN: f32 = TEXT_BUFFER_MARGIN_MULTIPLIER;
pub(super) fn shaping_buffer(rect: Rect, effective_scale: f32, align: TextAlign) -> (f32, f32) {
let (width, height) = texture_room(rect, effective_scale);
match align {
TextAlign::Start => (width, height),
_ => (rect.width * effective_scale, height),
}
}
fn texture_room(rect: Rect, effective_scale: f32) -> (f32, f32) {
(
rect.width * effective_scale * TEXT_MARGIN,
rect.height * effective_scale * TEXT_MARGIN,
)
}
pub struct PreparedTextQuad {
cached: std::rc::Rc<CachedTextTexture>,
vertex_buffer: WgpuBuffer,
}
impl QuadDraw for PreparedTextQuad {
fn bind_group(&self) -> &BindGroup {
&self.cached.bind_group
}
fn vertex_buffer(&self) -> &WgpuBuffer {
&self.vertex_buffer
}
}
struct CachedTextTexture {
#[allow(dead_code)] texture: Texture,
bind_group: BindGroup,
last_used: std::cell::Cell<u64>,
}
#[derive(PartialEq, Eq, Hash)]
struct TextCacheKey {
text: String,
font_size_bits: u32,
weight: u16,
family: crate::widgets::FontFamily,
align: TextAlign,
color: [u8; 4],
tex_width: u32,
tex_height: u32,
fit: Option<super::text_measurer::LineFitKey>,
}
pub struct TextQuadRenderer {
text_cache: rustc_hash::FxHashMap<TextCacheKey, std::rc::Rc<CachedTextTexture>>,
frame_gen: u64,
font_system: FontSystem,
swash_cache: SwashCache,
#[allow(dead_code)] cache: Cache,
atlas: TextAtlas,
text_renderer: TextRenderer,
viewport: Viewport,
quad: TexturedQuadPipeline,
format: TextureFormat,
}
impl TextQuadRenderer {
pub fn new(device: &Device, queue: &Queue, format: TextureFormat) -> Self {
let mut font_system = FontSystem::new();
for data in crate::get_registered_fonts() {
font_system
.db_mut()
.load_font_source(glyphon::fontdb::Source::Binary(data));
}
let swash_cache = SwashCache::new();
let cache = Cache::new(device);
let mut atlas = TextAtlas::with_color_mode(device, queue, &cache, format, ColorMode::Web);
let text_renderer =
TextRenderer::new(&mut atlas, device, MultisampleState::default(), None);
let viewport = Viewport::new(device, &cache);
Self {
quad: TexturedQuadPipeline::new(device, format, "TextQuad"),
font_system,
text_cache: rustc_hash::FxHashMap::default(),
frame_gen: 0,
swash_cache,
cache,
atlas,
text_renderer,
viewport,
format,
}
}
pub fn set_screen_size(&mut self, width: f32, height: f32) {
self.quad.set_screen_size(width, height);
}
pub fn prepare(
&mut self,
device: &Arc<Device>,
queue: &Arc<Queue>,
entries: &[TextEntry],
indices: &[usize],
scale_factor: f32,
out: &mut Vec<PreparedTextQuad>,
) {
self.frame_gen += 1;
out.extend(indices.iter().map(|&idx| {
let entry = &entries[idx];
self.render_text_to_quad(device, queue, entry, scale_factor)
}));
if self.text_cache.len() > 512 {
let current = self.frame_gen;
self.text_cache.retain(|_, c| c.last_used.get() == current);
}
}
fn render_text_to_quad(
&mut self,
device: &Arc<Device>,
queue: &Arc<Queue>,
entry: &TextEntry,
scale_factor: f32,
) -> PreparedTextQuad {
let effective_scale = scale_factor * TEXT_SUPERSAMPLE;
let scaled_font_size = entry.font_size * effective_scale;
let (room_width, room_height) = texture_room(entry.rect, effective_scale);
let padding = TEXT_TEXTURE_PADDING * effective_scale;
let tex_width = ((room_width + padding * 2.0).ceil() as u32).max(1);
let tex_height = ((room_height + padding * 2.0).ceil() as u32).max(1);
let weight = if entry.font_weight == FontWeight::default() {
FontWeight::NORMAL
} else {
entry.font_weight
};
let cache_key = TextCacheKey {
text: entry.text.clone(),
font_size_bits: scaled_font_size.to_bits(),
weight: weight.0,
family: entry.font_family,
align: entry.align,
color: [
(entry.color.r * 255.0) as u8,
(entry.color.g * 255.0) as u8,
(entry.color.b * 255.0) as u8,
(entry.color.a * 255.0) as u8,
],
tex_width,
tex_height,
fit: entry.fit.map(|fit| fit.key()),
};
if let Some(cached) = self.text_cache.get(&cache_key) {
cached.last_used.set(self.frame_gen);
let cached = cached.clone();
return self.build_quad(
device,
entry,
cached,
tex_width,
tex_height,
padding,
scale_factor,
);
}
let (buffer_width, buffer_height) =
shaping_buffer(entry.rect, effective_scale, entry.align);
let buffer = super::text::shape(
&mut self.font_system,
&entry.text,
scaled_font_size,
entry.font_family,
entry.font_weight,
entry.align,
(Some(buffer_width), Some(buffer_height)),
entry.fit,
effective_scale,
);
let texture = device.create_texture(&TextureDescriptor {
label: Some("Text Texture"),
size: Extent3d {
width: tex_width,
height: tex_height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: self.format,
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
self.viewport.update(
queue,
Resolution {
width: tex_width,
height: tex_height,
},
);
let text_area = TextArea {
buffer: &buffer,
left: padding,
top: padding,
scale: 1.0,
bounds: TextBounds {
left: 0,
top: 0,
right: tex_width as i32,
bottom: tex_height as i32,
},
default_color: GlyphonColor::rgba(
(entry.color.r * 255.0) as u8,
(entry.color.g * 255.0) as u8,
(entry.color.b * 255.0) as u8,
(entry.color.a * 255.0) as u8,
),
custom_glyphs: &[],
};
if let Err(e) = self.text_renderer.prepare(
device,
queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
vec![text_area],
&mut self.swash_cache,
) {
log::error!("Text texture prepare failed: {:?}", e);
}
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Text Texture Encoder"),
});
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Text Texture Render Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
self.text_renderer
.render(&self.atlas, &self.viewport, &mut render_pass)
.expect("Failed to render text to texture");
}
queue.submit(std::iter::once(encoder.finish()));
let bind_group = self
.quad
.bind_texture(device, &view, "Text Texture Bind Group");
let cached = std::rc::Rc::new(CachedTextTexture {
texture,
bind_group,
last_used: std::cell::Cell::new(self.frame_gen),
});
self.text_cache.insert(cache_key, cached.clone());
self.build_quad(
device,
entry,
cached,
tex_width,
tex_height,
padding,
scale_factor,
)
}
#[allow(clippy::too_many_arguments)]
fn build_quad(
&self,
device: &Arc<Device>,
entry: &TextEntry,
cached: std::rc::Rc<CachedTextTexture>,
tex_width: u32,
tex_height: u32,
padding: f32,
scale_factor: f32,
) -> PreparedTextQuad {
let total_scale = TEXT_SUPERSAMPLE;
let display_width = tex_width as f32 / total_scale;
let display_height = tex_height as f32 / total_scale;
let display_padding = padding / total_scale;
let local_left = entry.rect.x - display_padding / scale_factor;
let local_top = entry.rect.y - display_padding / scale_factor;
let local_right = local_left + display_width / scale_factor;
let local_bottom = local_top + display_height / scale_factor;
let local_corners = [
(local_left, local_top), (local_right, local_top), (local_left, local_bottom), (local_right, local_bottom), ];
let screen_corners: [(f32, f32); 4] = local_corners.map(|(x, y)| {
let (sx, sy) = entry.transform.transform_point(x, y);
(sx * scale_factor, sy * scale_factor)
});
let clip = entry
.clip
.as_ref()
.map_or(QuadClip::NONE, |clip| QuadClip::shape(clip, scale_factor));
let uvs = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
let vertices: [TexturedVertex; 4] = std::array::from_fn(|i| {
let (x, y) = screen_corners[i];
TexturedVertex::corner(self.quad.to_ndc(x, y), uvs[i], (x, y), &clip, entry.opacity)
});
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("TextQuad Vertex Buffer"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
PreparedTextQuad {
cached,
vertex_buffer,
}
}
pub fn render<'a>(&'a self, render_pass: &mut RenderPass<'a>, quads: &'a [PreparedTextQuad]) {
self.quad.draw(render_pass, quads);
}
}
#[cfg(test)]
mod the_texture_cache_gives_back_what_a_frame_stopped_asking_for {
use super::TextQuadRenderer;
use crate::renderer::GpuContext;
use crate::renderer::text::test_entry;
use crate::transform::Transform;
use crate::widgets::Rect;
const BOUND: usize = 512;
#[test]
fn a_frame_that_overflows_it_keeps_only_its_own() {
let Some(gpu) = crate::or_skip(GpuContext::try_new()) else {
return;
};
let mut renderer =
TextQuadRenderer::new(&gpu.device, &gpu.queue, wgpu::TextureFormat::Rgba8UnormSrgb);
renderer.set_screen_size(200.0, 100.0);
let box_ = Rect::new(0.0, 0.0, 6.0, 6.0);
let labelled = |text: String| {
let mut entry = test_entry(box_, Transform::scale(2.0));
entry.text = text;
entry
};
let crowd: Vec<_> = (0..=BOUND).map(|i| labelled(i.to_string())).collect();
let every = (0..crowd.len()).collect::<Vec<_>>();
let mut quads = Vec::new();
renderer.prepare(&gpu.device, &gpu.queue, &crowd, &every, 1.0, &mut quads);
assert_eq!(
renderer.text_cache.len(),
BOUND + 1,
"one frame past the bound, and every texture in it is this frame's"
);
quads.clear();
let after = [labelled("a label the frame before never asked for".into())];
renderer.prepare(&gpu.device, &gpu.queue, &after, &[0], 1.0, &mut quads);
assert_eq!(
renderer.text_cache.len(),
1,
"the frame before rasterized {BOUND} textures nobody wants now"
);
}
}
#[cfg(test)]
mod shaping_buffer_tests {
use super::{TEXT_MARGIN, shaping_buffer};
use crate::widgets::{Rect, TextAlign};
#[test]
fn the_margin_is_a_factor_on_the_box_and_the_scale_multiplies_both() {
let rect = Rect::new(0.0, 0.0, 100.0, 40.0);
assert_eq!(
shaping_buffer(rect, 1.0, TextAlign::Start),
(100.0 * TEXT_MARGIN, 40.0 * TEXT_MARGIN)
);
assert_eq!(
shaping_buffer(rect, 4.0, TextAlign::Start),
(400.0 * TEXT_MARGIN, 160.0 * TEXT_MARGIN)
);
assert!(shaping_buffer(Rect::new(0.0, 0.0, 10.0, 4.0), 1.0, TextAlign::Start).0 < 20.0);
}
}