use std::sync::Arc;
use glyphon::{Color as GlyphonColor, Resolution, TextArea, TextBounds};
use wgpu::{
BindGroup, Device, Extent3d, Queue, RenderPass, Texture, TextureDescriptor, TextureDimension,
TextureFormat, TextureUsages,
};
use super::constants::{TEXT_BUFFER_MARGIN_MULTIPLIER, TEXT_SUPERSAMPLE, TEXT_TEXTURE_PADDING};
use super::text_mask::Shaper;
use super::textured_quad::{QuadDraw, TexturedQuadPipeline};
use super::textured_vertex::{NO_TINT, QuadClip, TexturedVertex};
use super::types::TextEntry;
use crate::render_stats::Pipeline;
use crate::widgets::font::FontWeight;
use crate::widgets::{Rect, TextAlign};
const TEXT_MARGIN: f32 = TEXT_BUFFER_MARGIN_MULTIPLIER;
pub(super) fn buffer_height(rect: Rect, effective_scale: f32) -> f32 {
texture_room(rect, effective_scale).1
}
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>,
quad: u32,
}
impl QuadDraw for PreparedTextQuad {
fn bind_group(&self) -> &std::rc::Rc<BindGroup> {
&self.cached.bind_group
}
fn quad(&self) -> u32 {
self.quad
}
}
struct CachedTextTexture {
#[allow(dead_code)] texture: Texture,
bind_group: std::rc::Rc<BindGroup>,
last_used: std::cell::Cell<u64>,
}
#[derive(PartialEq, Eq, Hash)]
struct TextCacheKey {
text: std::rc::Rc<str>,
font_size_bits: u32,
weight: u16,
family: crate::widgets::FontFamily,
line_height: (u8, u32),
letter_spacing_bits: u32,
align: TextAlign,
color: [u8; 4],
tex_width: u32,
tex_height: u32,
box_width_bits: u32,
fit: Option<super::text_measurer::LineFitKey>,
}
pub struct TextQuadRenderer {
text_cache: rustc_hash::FxHashMap<TextCacheKey, std::rc::Rc<CachedTextTexture>>,
frame_gen: u64,
shaper: Option<Shaper>,
quad: TexturedQuadPipeline,
format: TextureFormat,
}
impl TextQuadRenderer {
pub fn new(
device: &Device,
format: TextureFormat,
clip_records: &wgpu::BindGroupLayout,
) -> Self {
Self {
quad: TexturedQuadPipeline::new(device, format, clip_records, "TextQuad"),
text_cache: rustc_hash::FxHashMap::default(),
frame_gen: 0,
shaper: None,
format,
}
}
pub fn begin_frame(&mut self) {
self.quad.begin_frame();
}
pub fn upload(&mut self, device: &Device, queue: &Queue) {
self.quad.upload(device, queue);
}
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,
line_height: entry.line_height.key(),
letter_spacing_bits: entry.letter_spacing.to_bits(),
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,
box_width_bits: entry.rect.width.to_bits(),
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(entry, cached, tex_width, tex_height, padding, scale_factor);
}
let format = self.format;
let shaper = self
.shaper
.get_or_insert_with(|| Shaper::new(device, queue, format));
let box_width = entry.rect.width * effective_scale;
let buffer = super::text::shape(
&mut shaper.font_system,
&entry.text,
scaled_font_size,
entry.font_family,
entry.font_weight,
entry.line_height,
entry.letter_spacing,
entry.align,
(
Some(box_width),
Some(buffer_height(entry.rect, effective_scale)),
),
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,
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
shaper.viewport.update(
queue,
Resolution {
width: tex_width,
height: tex_height,
},
);
let text_area = TextArea {
buffer: &buffer,
left: padding + super::text::paint_offset(&buffer, box_width, entry.align),
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: &[],
};
shaper.draw(device, queue, text_area, &view, "Text Texture");
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(entry, cached, tex_width, tex_height, padding, scale_factor)
}
#[allow(clippy::too_many_arguments)]
fn build_quad(
&mut self,
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::resolved(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,
NO_TINT,
)
});
PreparedTextQuad {
quad: self.quad.push(vertices),
cached,
}
}
pub fn render<'a>(
&'a self,
render_pass: &mut RenderPass<'a>,
quads: &'a [PreparedTextQuad],
clip_records: &'a wgpu::BindGroup,
) {
self.quad
.draw(render_pass, quads, clip_records, Pipeline::TextQuads);
}
}
#[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,
wgpu::TextureFormat::Rgba8UnormSrgb,
super::super::clip_records::ClipRecords::new(&gpu.device).layout(),
);
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.into();
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 the_glyph_atlas_holds_a_text_not_a_history {
use super::{Shaper, TextQuadRenderer};
use crate::renderer::GpuContext;
use crate::renderer::text::the_glyph_atlas_holds_a_frame_not_a_history::{
holds_one_page, page,
};
use crate::transform::Transform;
fn atlas_after(gpu: &GpuContext, frames: &[String]) -> isize {
let before = gpu.texture_bytes();
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let mut renderer = TextQuadRenderer::new(
&gpu.device,
format,
super::super::clip_records::ClipRecords::new(&gpu.device).layout(),
);
let mut shaper = Shaper::new(&gpu.device, &gpu.queue, format);
shaper.font_system = crate::renderer::text::vendored_font_system();
renderer.shaper = Some(shaper);
renderer.set_screen_size(1200.0, 60.0);
let mut quads = Vec::new();
for text in frames {
renderer.begin_frame();
quads.clear();
let texts = [page(text, Transform::rotate_degrees(10.0))];
renderer.prepare(&gpu.device, &gpu.queue, &texts, &[0], 1.0, &mut quads);
renderer.upload(&gpu.device, &gpu.queue);
}
quads.clear();
renderer.text_cache.clear();
gpu.texture_bytes() - before
}
#[test]
fn a_turned_text_that_changes_every_frame_needs_no_more_atlas_than_one_frame() {
holds_one_page(atlas_after);
}
}
#[cfg(test)]
mod nothing_turned_nothing_built {
use super::TextQuadRenderer;
use crate::renderer::GpuContext;
#[test]
fn a_renderer_that_draws_no_turned_text_holds_no_atlas() {
let Some(gpu) = crate::or_skip(GpuContext::try_new()) else {
return;
};
let before = gpu.texture_bytes();
let mut renderer = TextQuadRenderer::new(
&gpu.device,
wgpu::TextureFormat::Rgba8UnormSrgb,
super::super::clip_records::ClipRecords::new(&gpu.device).layout(),
);
let mut quads = Vec::new();
for _ in 0..3 {
renderer.begin_frame();
renderer.prepare(&gpu.device, &gpu.queue, &[], &[], 1.0, &mut quads);
renderer.upload(&gpu.device, &gpu.queue);
}
assert_eq!(
gpu.texture_bytes() - before,
0,
"three frames without a turned text, and an atlas was made for one"
);
}
}
#[cfg(test)]
mod buffer_height_tests {
use super::{TEXT_MARGIN, buffer_height};
use crate::widgets::Rect;
#[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!(buffer_height(rect, 1.0), 40.0 * TEXT_MARGIN);
assert_eq!(buffer_height(rect, 4.0), 160.0 * TEXT_MARGIN);
assert!(buffer_height(Rect::new(0.0, 0.0, 10.0, 4.0), 1.0) < 10.0);
}
}