Files
snow_trail/src/render/font_atlas.rs
2026-03-29 10:47:10 +02:00

292 lines
8.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use ab_glyph::{Font, FontVec, PxScale, ScaleFont};
use glam::Vec3;
use crate::paths;
use super::text_pipeline::TextVertex;
const ATLAS_COLS: u32 = 16;
const ATLAS_ROWS: u32 = 8;
const FIRST_CHAR: u32 = 32;
const LAST_CHAR: u32 = 126;
const NUM_GLYPHS: u32 = LAST_CHAR - FIRST_CHAR + 1;
const FONT_PX: f32 = 124.0;
struct GlyphMetrics
{
uv_min: [f32; 2],
uv_max: [f32; 2],
}
pub struct FontAtlas
{
pub texture_view: wgpu::TextureView,
pub sampler: wgpu::Sampler,
glyphs: Vec<GlyphMetrics>,
pub cell_w: f32,
pub cell_h: f32,
}
impl FontAtlas
{
pub fn load(device: &wgpu::Device, queue: &wgpu::Queue) -> Self
{
let path = paths::fonts::departure_mono();
let data = std::fs::read(&path).unwrap_or_else(|_| panic!("Failed to read font: {path}"));
let font = FontVec::try_from_vec(data).expect("Failed to parse font");
let scale = PxScale::from(FONT_PX);
let scaled = font.as_scaled(scale);
let ascent = scaled.ascent();
let descent = scaled.descent();
let cell_h = (ascent - descent).ceil() as u32;
let cell_w = scaled.h_advance(font.glyph_id(' ')).ceil() as u32;
let atlas_w = ATLAS_COLS * cell_w;
let atlas_h = ATLAS_ROWS * cell_h;
let mut pixels = vec![0u8; (atlas_w * atlas_h) as usize];
let mut glyphs = Vec::with_capacity(NUM_GLYPHS as usize);
for idx in 0..NUM_GLYPHS
{
let ch = char::from_u32(FIRST_CHAR + idx).unwrap_or(' ');
let col = idx % ATLAS_COLS;
let row = idx / ATLAS_COLS;
let cell_x = col * cell_w;
let cell_y = row * cell_h;
glyphs.push(GlyphMetrics {
uv_min: [
cell_x as f32 / atlas_w as f32,
cell_y as f32 / atlas_h as f32,
],
uv_max: [
(cell_x + cell_w) as f32 / atlas_w as f32,
(cell_y + cell_h) as f32 / atlas_h as f32,
],
});
let glyph_id = font.glyph_id(ch);
let glyph = glyph_id.with_scale_and_position(
scale,
ab_glyph::point(cell_x as f32, cell_y as f32 + ascent),
);
if let Some(outlined) = font.outline_glyph(glyph)
{
let bounds = outlined.px_bounds();
outlined.draw(|x, y, coverage| {
let abs_x = bounds.min.x.floor() as i32 + x as i32;
let abs_y = bounds.min.y.floor() as i32 + y as i32;
if abs_x >= 0 && abs_y >= 0
{
let ax = abs_x as u32;
let ay = abs_y as u32;
if ax < atlas_w && ay < atlas_h
{
let i = (ay * atlas_w + ax) as usize;
let v = (coverage * 255.0 + 0.5) as u8;
pixels[i] = pixels[i].max(v);
}
}
});
}
}
let extent = wgpu::Extent3d {
width: atlas_w,
height: atlas_h,
depth_or_array_layers: 1,
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Font Atlas"),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(atlas_w),
rows_per_image: Some(atlas_h),
},
extent,
);
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Font Atlas Sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
texture_view,
sampler,
glyphs,
cell_w: cell_w as f32,
cell_h: cell_h as f32,
}
}
pub fn aspect(&self) -> f32
{
self.cell_w / self.cell_h
}
/// Build billboard-space text quads for a single dialog bubble.
///
/// `anchor` world-space centre of the bubble body
/// `right`/`up` billboard orientation vectors
/// `inner_half_w` half-width of the text area in world units
/// `inner_top_y` y-offset (up-axis) of the top edge of the text area
/// `char_world_h` world-space height of one character cell
/// `line_spacing` extra vertical gap between lines in world units
/// Returns `(n_lines, max_chars_in_any_line)` for the given text wrapped at `inner_half_w`.
pub fn measure_text(&self, text: &str, char_world_h: f32, inner_half_w: f32) -> (usize, usize)
{
let char_w = char_world_h * self.aspect();
let chars_per_line = ((inner_half_w * 2.0) / char_w).floor() as usize;
if chars_per_line == 0
{
return (1, 0);
}
let lines = word_wrap(text, chars_per_line);
let n_lines = lines.len().max(1);
let max_chars = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
(n_lines, max_chars)
}
pub fn build_bubble_text(
&self,
text: &str,
anchor: Vec3,
right: Vec3,
up: Vec3,
inner_half_w: f32,
inner_top_y: f32,
char_world_h: f32,
line_spacing: f32,
) -> Vec<TextVertex>
{
let char_w = char_world_h * self.aspect();
let chars_per_line = ((inner_half_w * 2.0) / char_w).floor() as usize;
if chars_per_line == 0
{
return Vec::new();
}
let lines = word_wrap(text, chars_per_line);
let half_char_h = char_world_h * 0.5;
let half_char_w = char_w * 0.5;
let text_left = -inner_half_w;
let mut verts = Vec::new();
for (row, line) in lines.iter().enumerate()
{
let y = inner_top_y - half_char_h - row as f32 * (char_world_h + line_spacing);
if y < -inner_top_y + half_char_h
{
break;
}
for (col, ch) in line.chars().enumerate()
{
if ch == ' '
{
continue;
}
let code = ch as u32;
if code < FIRST_CHAR || code > LAST_CHAR
{
continue;
}
let g = &self.glyphs[(code - FIRST_CHAR) as usize];
let x = text_left + half_char_w + col as f32 * char_w;
let centre = anchor + right * x + up * y;
let tl = centre - right * half_char_w + up * half_char_h;
let tr = centre + right * half_char_w + up * half_char_h;
let br = centre + right * half_char_w - up * half_char_h;
let bl = centre - right * half_char_w - up * half_char_h;
verts.extend_from_slice(&[
TextVertex {
position: tl.to_array(),
uv: g.uv_min,
},
TextVertex {
position: tr.to_array(),
uv: [g.uv_max[0], g.uv_min[1]],
},
TextVertex {
position: br.to_array(),
uv: g.uv_max,
},
TextVertex {
position: bl.to_array(),
uv: [g.uv_min[0], g.uv_max[1]],
},
]);
}
}
verts
}
}
fn word_wrap(text: &str, chars_per_line: usize) -> Vec<String>
{
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in text.split_whitespace()
{
if current.is_empty()
{
current.push_str(word);
}
else if current.len() + 1 + word.len() <= chars_per_line
{
current.push(' ');
current.push_str(word);
}
else
{
lines.push(current.clone());
current = word.to_string();
}
}
if !current.is_empty()
{
lines.push(current);
}
lines
}