292 lines
8.9 KiB
Rust
292 lines
8.9 KiB
Rust
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
|
||
}
|