text rendering

This commit is contained in:
Jonas H
2026-03-28 13:23:27 +01:00
parent dcd40ae443
commit e558b682e2
8 changed files with 696 additions and 45 deletions

264
src/render/font_atlas.rs Normal file
View File

@@ -0,0 +1,264 @@
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 = 16.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::Nearest,
min_filter: wgpu::FilterMode::Nearest,
..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
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
}