text rendering
This commit is contained in:
264
src/render/font_atlas.rs
Normal file
264
src/render/font_atlas.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user