From 2c104d7936193bb19eb52b3e4d47657ffb18a9d7 Mon Sep 17 00:00:00 2001 From: Tengu712 Date: Thu, 1 Oct 2026 01:53:23 +0900 Subject: render text --- Cargo.lock | 1 + Cargo.toml | 1 + src/graphics.rs | 93 ++++++++++++++++++-- src/graphics/command.rs | 115 +++++++++++++++++++++++++ src/graphics/font.rs | 224 ++++++++++++++++++++++++++++++------------------ src/main.rs | 15 +++- src/shader.wgsl | 22 +++-- 7 files changed, 375 insertions(+), 96 deletions(-) create mode 100644 src/graphics/command.rs diff --git a/Cargo.lock b/Cargo.lock index fca1180..85911d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -326,6 +326,7 @@ dependencies = [ "glam", "lru", "pollster", + "ttf-parser", "wgpu", "winit", ] diff --git a/Cargo.toml b/Cargo.toml index ae775ed..b9f5b9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,5 +9,6 @@ etagere = "0.3.0" glam = "0.33.10" lru = "0.18.5" pollster = "1.0.1" +ttf-parser = "0.25.1" winit = "0.30.13" wgpu = { version = "30.0.0", default-features = false, features = [ "dx12", "metal", "parking_lot", "std", "vulkan", "wgsl" ] } diff --git a/src/graphics.rs b/src/graphics.rs index 2b96a7a..bab9f05 100644 --- a/src/graphics.rs +++ b/src/graphics.rs @@ -1,12 +1,27 @@ -use std::{borrow::Cow, sync::Arc}; +use glam::{Vec2, Vec4}; +use std::{borrow::Cow, mem, slice, sync::Arc}; use wgpu::*; use winit::{dpi::PhysicalSize, event_loop::OwnedDisplayHandle, window::Window}; +pub mod command; mod font; mod texture; +use command::RenderCommand; use font::CharAtlas; +pub const INSTANCE_MAX_COUNT: u64 = 128; + +#[repr(C)] +struct InstanceData { + // 中央原点 左→右[-1, 1] 下→上[-1, 1] + position: Vec2, + // 拡大率 (width, height): [0, 1]^2 + scale: Vec2, + // (u, v, width, height): [0, 1]^4 + uv: Vec4, +} + pub enum AcquireSurfaceTextureResult { Ok(SurfaceTexture), ShouldSkip, @@ -22,6 +37,8 @@ pub struct Renderer { present_mode: PresentMode, pipeline: RenderPipeline, + instances: Buffer, + trans_bind_group: BindGroup, char_atlas: CharAtlas, fixed_bind_group: BindGroup, } @@ -61,6 +78,19 @@ impl Renderer { source: ShaderSource::Wgsl(Cow::Borrowed(include_str!("shader.wgsl"))), }); + let trans_bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor { + label: None, + entries: &[BindGroupLayoutEntry { + binding: 0, + visibility: ShaderStages::VERTEX, + ty: BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); let fixed_bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor { label: None, entries: &[ @@ -84,7 +114,10 @@ impl Renderer { }); let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { label: None, - bind_group_layouts: &[Some(&fixed_bind_group_layout)], + bind_group_layouts: &[ + Some(&trans_bind_group_layout), + Some(&fixed_bind_group_layout), + ], immediate_size: 0, }); let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor { @@ -117,6 +150,26 @@ impl Renderer { cache: None, }); + // NOTE: インスタンスバッファは固定長で使いまわすので、ここで1個だけ作成すれば良い。 + let instances = device.create_buffer(&BufferDescriptor { + label: None, + size: mem::size_of::() as u64 * INSTANCE_MAX_COUNT, + usage: BufferUsages::STORAGE | BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let trans_bind_group = device.create_bind_group(&BindGroupDescriptor { + label: None, + layout: &trans_bind_group_layout, + entries: &[BindGroupEntry { + binding: 0, + resource: BindingResource::Buffer(BufferBinding { + buffer: &instances, + offset: 0, + size: None, + }), + }], + }); + // NOTE: 本ゲームではlinearなサンプラしか用意しないので、ここで1個だけ作成すれば良い。 let sampler = device.create_sampler(&SamplerDescriptor { label: None, @@ -153,6 +206,8 @@ impl Renderer { view_format, present_mode, pipeline, + instances, + trans_bind_group, char_atlas, fixed_bind_group, }; @@ -185,7 +240,12 @@ impl Renderer { } } - pub fn render(&mut self, window: &Window, current_texture: SurfaceTexture) { + pub fn render( + &mut self, + window: &Window, + current_texture: SurfaceTexture, + commands: &[RenderCommand], + ) { let surface_texture_view = current_texture.texture.create_view(&TextureViewDescriptor { format: Some(self.view_format), ..Default::default() @@ -210,9 +270,32 @@ impl Renderer { }); render_pass.set_pipeline(&self.pipeline); - render_pass.set_bind_group(0, &self.fixed_bind_group, &[]); + render_pass.set_bind_group(0, &self.trans_bind_group, &[]); + render_pass.set_bind_group(1, &self.fixed_bind_group, &[]); - // TODO: 描画 + let size = window.inner_size().to_logical::(window.scale_factor()); + let mut instances = Vec::new(); + for command in commands { + command.build( + size.width, + size.height, + window.scale_factor() as f32, + &self.queue, + &mut self.char_atlas, + &mut instances, + ); + // TODO: 今はfont_texしか使わないので1ドローコールで済むが、 + // bind groupを変える必要があればドローコールを切る。 + } + instances.truncate(INSTANCE_MAX_COUNT as usize); + let bytes = unsafe { + slice::from_raw_parts( + instances.as_ptr() as *const u8, + mem::size_of_val(instances.as_slice()), + ) + }; + self.queue.write_buffer(&self.instances, 0, bytes); + render_pass.draw(0..4, 0..instances.len() as u32); drop(render_pass); diff --git a/src/graphics/command.rs b/src/graphics/command.rs new file mode 100644 index 0000000..54b6744 --- /dev/null +++ b/src/graphics/command.rs @@ -0,0 +1,115 @@ +use super::{ + InstanceData, + font::{self, CharAtlas}, +}; +use glam::Vec2; +use wgpu::Queue; + +pub enum HorizontalAlign { + Left, + Center, + Right, +} + +pub enum VerticalAlign { + Top, + Center, + Bottom, +} + +pub struct Text { + pub text: String, + pub origin: Vec2, + pub scale: u32, + pub h_align: HorizontalAlign, + pub v_align: VerticalAlign, +} + +pub enum RenderCommand { + Text(Text), +} + +impl RenderCommand { + /// 描画コマンドからインスタンス情報に変換するメソッド + /// + /// * screen_width - 画面幅 + /// * screen_height - 画面高 + /// * scale_factor - 画面拡大率 + /// * queue - queue + /// * char_atlas - 文字テクスチャアトラス + /// * buf - インスタンスバッファ + pub fn build( + &self, + screen_width: f32, + screen_height: f32, + scale_factor: f32, + queue: &Queue, + char_atlas: &mut CharAtlas, + buf: &mut Vec, + ) { + match self { + RenderCommand::Text(text) => build_text( + text, + screen_width, + screen_height, + scale_factor, + queue, + char_atlas, + buf, + ), + } + } +} + +fn build_text( + text: &Text, + screen_width: f32, + screen_height: f32, + scale_factor: f32, + queue: &Queue, + char_atlas: &mut CharAtlas, + buf: &mut Vec, +) { + let scale = text.scale as f32; + let line_height = font::line_height(scale); + let mut baseline = text.origin.y + font::ascent(scale); + + for line in text.text.split('\n') { + // NOTE: ピクセル境界に合わせるため、整数にする。 + // 最終的にRust側でNDCに変換するが、fragmentation時に整数に直る。 + let y = (baseline * scale_factor).round() / scale_factor; + let mut ox = text.origin.x; + let mut prev = None; + + for c in line.chars() { + let ci = char_atlas.acquire(queue, c, scale); + + if let Some(prev) = prev { + ox += font::kern(scale, prev, ci.glyph_id); + } + prev = Some(ci.glyph_id); + + if ci.alloc_id.is_none() { + ox += font::h_advance(scale, ci.glyph_id); + continue; + } + + // NOTE: ピクセル境界に合わせるため、整数にする。 + // 最終的にRust側でNDCに変換するが、fragmentation時に整数に直る。 + let x = (ox * scale_factor).round() / scale_factor; + let center_x = x + ci.x_offset + ci.width / 2.0; + let center_y = y + ci.y_offset + ci.height / 2.0; + buf.push(InstanceData { + position: Vec2::new( + center_x / screen_width * 2.0 - 1.0, + 1.0 - center_y / screen_height * 2.0, + ), + scale: Vec2::new(ci.width / screen_width, ci.height / screen_height), + uv: ci.uv, + }); + ox += font::h_advance(scale, ci.glyph_id); + } + + baseline += line_height; + } +} diff --git a/src/graphics/font.rs b/src/graphics/font.rs index d05682d..78a28a3 100644 --- a/src/graphics/font.rs +++ b/src/graphics/font.rs @@ -4,34 +4,73 @@ use etagere::{AllocId, AtlasAllocator}; use glam::Vec4; use lru::LruCache; use std::sync::LazyLock; -use wgpu::*; - -static FONT: LazyLock> = LazyLock::new(|| { - FontRef::try_from_slice(include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/KazukiReiwa-Regular.ttf" - ))) - .expect("fatal: failed to load font") +use ttf_parser::{ + Face, Tag, + gpos::{PairAdjustment, PositioningSubtable}, +}; +use wgpu::{Device, Queue, TextureFormat, TextureUsages}; + +static FONT_DATA: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/KazukiReiwa-Regular.ttf" +)); + +static FONT: LazyLock> = + LazyLock::new(|| FontRef::try_from_slice(FONT_DATA).expect("fatal: failed to load font")); + +static KERN: LazyLock>> = LazyLock::new(|| { + let face = Face::parse(FONT_DATA, 0).expect("fatal: failed to load font"); + let gpos = face.tables().gpos.expect("fatal: failed to load GPOS"); + gpos.features + .into_iter() + .find(|v| v.tag == Tag::from_bytes(b"kern")) + .expect("fatal: failed to load kern") + .lookup_indices + .into_iter() + .filter_map(|v| gpos.lookups.get(v)) + .flat_map(|v| v.subtables.into_iter::()) + .filter_map(|v| match v { + PositioningSubtable::Pair(v) => Some(v), + _ => None, + }) + .collect() }); -/// スケールされたフォントのトップ-ベースライン間を取得するメソッド -pub fn ascent(scale: u32) -> f32 { - FONT.as_scaled(scale as f32).ascent() +pub fn kern(scale: f32, left: GlyphId, right: GlyphId) -> f32 { + let left = ttf_parser::GlyphId(left.0); + let right = ttf_parser::GlyphId(right.0); + KERN.iter() + .find_map(|v| match v { + PairAdjustment::Format1 { coverage, sets } => sets.get(coverage.get(left)?)?.get(right), + PairAdjustment::Format2 { + coverage, + classes, + matrix, + } => { + coverage.get(left)?; + matrix.get((classes.0.get(left), classes.1.get(right))) + } + }) + .map_or(0.0, |(v, _)| v.x_advance as f32 * factor(scale)) +} + +pub fn ascent(scale: f32) -> f32 { + FONT.ascent_unscaled() * factor(scale) } -/// スケールされたフォントの行間を取得するメソッド -pub fn line_gap(scale: u32) -> f32 { - FONT.as_scaled(scale as f32).line_gap() +pub fn line_height(scale: f32) -> f32 { + (FONT.height_unscaled() + FONT.line_gap_unscaled()) * factor(scale) } -/// スケールされたフォントのカーニング量を取得するメソッド -pub fn kern(scale: u32, first: char, second: char) -> f32 { - let first = FONT.glyph_id(first); - let second = FONT.glyph_id(second); - FONT.as_scaled(scale as f32).kern(first, second) +pub fn h_advance(scale: f32, id: GlyphId) -> f32 { + FONT.h_advance_unscaled(id) * factor(scale) } -const WIDTH: u32 = 128; +fn factor(scale: f32) -> f32 { + scale / FONT.units_per_em().unwrap() +} + +const WIDTH: u32 = 512; const HEIGHT: u32 = WIDTH; // NOTE: macOSではswizzleをゼロコストで使えないのでRGBAにする。 @@ -41,44 +80,53 @@ const FORMAT: TextureFormat = if cfg!(target_os = "macos") { TextureFormat::R8Unorm }; +/// ラスタライズ可能な最大文字サイズ (論理解像度) +const MAX_CHAR_SIZE: f32 = 64.0; /// 1文字テクスチャの1辺におけるマージン /// -/// NOTE: 境界で隣の文字のピクセルを含まないように、テクスチャ上で四辺に2pxのマージンを取る。 +/// NOTE: 境界で隣の文字のピクセルを含まないように、 +/// テクスチャ上で四辺に2pxのマージンを取る。 /// 文字情報(UV)はこのマージンを除いた領域を指定する。 const MARGIN: usize = 2; /// 1文字テクスチャの1方向におけるマージン const MARGIN_AXIS: usize = MARGIN * 2; -#[derive(Clone, Copy)] -pub enum CharInfo { - Rasterizable { - alloc_id: AllocId, - width: f32, - height: f32, - x_offset: f32, - y_offset: f32, - advance: f32, - uv: Vec4, - }, - Unrasterizable { - advance: f32, - }, +#[derive(Default)] +pub struct CharInfo { + pub glyph_id: GlyphId, + pub alloc_id: Option, + pub width: f32, + pub height: f32, + pub x_offset: f32, + pub y_offset: f32, + pub uv: Vec4, } pub struct CharAtlas { /// ウィンドウの倍率 /// - /// 物理解像度と論理解像度の差を考慮するため、この倍率に従ってテクスチャアトラス全体をスケールする。 + /// 物理解像度と論理解像度の差を考慮するため、 + /// この倍率に従ってテクスチャアトラス全体をスケールする。 /// /// NOTE: 実行中に変化しうるが、致命的ではないので初期化時の倍率に従うことにする。 scale_factor: f32, + /// テクスチャの物理幅 (uv計算用) width: f32, + /// テクスチャの物理高 (uv計算用) height: f32, + + /// 文字テクスチャアトラス + /// + /// etagereが賢く文字テクスチャを配置してくれる。 atlas: AtlasAllocator, /// 文字テクスチャアトラスに登録されている文字とそのID /// - /// NOTE: 文字テクスチャアトラスが満ちている場合に最も古く使われた文字を消すためにLRUキャッシュを用いている。 + /// NOTE: 文字テクスチャアトラスが満ちている場合に + // 最も古く使われた文字を消すために、LRUキャッシュを用いている。 registereds: LruCache<(char, u32), CharInfo>, + /// アロケーション回避目的のラスタライズ作業用バッファ + scratch: Vec, + pub texture: TextureWrapper, } @@ -87,12 +135,16 @@ impl CharAtlas { let width = WIDTH as f32 * scale_factor; let height = HEIGHT as f32 * scale_factor; + let max_side = (MAX_CHAR_SIZE * scale_factor).ceil() as usize + MARGIN_AXIS; + let channel_count = FORMAT.block_copy_size(None).unwrap() as usize; + Self { scale_factor, width, height, atlas: AtlasAllocator::new(etagere::size2(width as i32, height as i32)), registereds: LruCache::unbounded(), + scratch: vec![0x00; channel_count * max_side * max_side], texture: TextureWrapper::new( device, width as u32, @@ -107,29 +159,39 @@ impl CharAtlas { /// /// * queue - queue /// * c - 対象文字 - /// * scale - 論理解像度上の倍率 (雑に1文字の最大高と思って良い) + /// * scale - 論理解像度上の文字サイズ /// /// 対象文字がテクスチャアトラス上に存在しない場合はラスタライズ・登録を行う。 /// また、同時にLRUキャッシュの更新を行う。 /// + /// 対象文字がフォントにない場合(豆腐)やグリフが存在しない場合(空白等)、 + /// ラスタライズ・登録を行わず、`glyph_id`のみ有効で・無害な結果を返す。 + /// そのため、`CharInfo.alloc_id`が`None`である場合、 + /// その`glyph_id`で水平方向のadvanceを取得すること。 + /// /// WARN: アトラス上の全ての文字が同時に使用される場合、 /// 一部文字はテクスチャが更新されるため、意図しない結果を得る。 - pub fn acquire(&mut self, queue: &Queue, c: char, scale: u32) -> &CharInfo { - if !self.registereds.contains(&(c, scale)) { + pub fn acquire(&mut self, queue: &Queue, c: char, scale: f32) -> &CharInfo { + let key = (c, scale.to_bits()); + if !self.registereds.contains(&key) { self.register_character(queue, c, scale); } - self.registereds.get(&(c, scale)).unwrap() + self.registereds.get(&key).unwrap() } - fn register_character(&mut self, queue: &Queue, c: char, scale: u32) { + fn register_character(&mut self, queue: &Queue, c: char, scale: f32) { + let id = FONT.glyph_id(c); + // ラスタライズ - let rasterized = match RasterizedChar::new(&FONT, c, scale as f32 * self.scale_factor) { - Ok(n) => n, - Err(advance) => { - self.registereds - .push((c, scale), CharInfo::Unrasterizable { advance }); - return; - } + let Some(rasterized) = + RasterizedChar::new(id, scale * self.scale_factor, &mut self.scratch) + else { + let info = CharInfo { + glyph_id: id, + ..Default::default() + }; + self.registereds.push((c, scale.to_bits()), info); + return; }; // アロケート @@ -142,7 +204,7 @@ impl CharAtlas { .registereds .pop_lru() .expect("internal error: no character exists but character atlas is full"); - if let CharInfo::Rasterizable { alloc_id, .. } = least_used.1 { + if let Some(alloc_id) = least_used.1.alloc_id { self.atlas.deallocate(alloc_id); } } @@ -151,7 +213,7 @@ impl CharAtlas { // アップロード self.texture.write_partial( queue, - &rasterized.data, + &self.scratch, // NOTE: 非負が保証されているため、`u32`にキャストして良い。 allocated.rectangle.min.x as u32, // NOTE: 非負が保証されているため、`u32`にキャストして良い。 @@ -163,13 +225,13 @@ impl CharAtlas { // 登録 let width = rasterized.width as f32 - MARGIN_AXIS as f32; let height = rasterized.height as f32 - MARGIN_AXIS as f32; - let info = CharInfo::Rasterizable { - alloc_id: allocated.id, + let info = CharInfo { + glyph_id: id, + alloc_id: Some(allocated.id), width: width / self.scale_factor, height: height / self.scale_factor, x_offset: rasterized.x_offset / self.scale_factor, y_offset: rasterized.y_offset / self.scale_factor, - advance: rasterized.advance / self.scale_factor, uv: Vec4::new( (allocated.rectangle.min.x as f32 + MARGIN as f32) / self.width, (allocated.rectangle.min.y as f32 + MARGIN as f32) / self.height, @@ -177,62 +239,54 @@ impl CharAtlas { height / self.height, ), }; - self.registereds.push((c, scale), info); + self.registereds.push((c, scale.to_bits()), info); } } struct RasterizedChar { - data: Vec, width: u32, height: u32, x_offset: f32, y_offset: f32, - advance: f32, } impl RasterizedChar { /// 文字をラスタライズするコンストラクタ /// - /// * font - フォント - /// * c - 対象文字 - /// * scale - 論理解像度上の倍率 (雑に1文字の最大高と思って良い) + /// * id - 対象グリフ + /// * scale - 物理解像度上の文字サイズ + /// * data - アロケーション回避用の作業用バッファ /// - /// 物理解像度と論理解像度の違いを考慮しない。 - /// ラスタライザブルでない文字に対してはadvanceと共に`Err`を返す。 - fn new(font: &FontRef<'static>, c: char, scale: f32) -> Result { - let font = font.as_scaled(scale); - let glyph = font.scaled_glyph(c); - let advance = font.h_advance(glyph.id); - - let Some(outlined_glyph) = font.outline_glyph(glyph) else { - return Err(advance); - }; + /// ラスタライズ不可能なグリフに対しては`None`を返す。 + fn new(id: GlyphId, scale: f32, data: &mut [u8]) -> Option { + let scale = PxScale::from(factor(scale) * FONT.height_unscaled()); + let outlined = FONT.outline_glyph(id.with_scale(scale))?; + let bounds = outlined.px_bounds(); - let width = outlined_glyph.px_bounds().width().ceil() as usize + MARGIN_AXIS; - let height = outlined_glyph.px_bounds().height().ceil() as usize + MARGIN_AXIS; + let width = bounds.width() as usize + MARGIN_AXIS; + let height = bounds.height() as usize + MARGIN_AXIS; let channel_count = FORMAT.block_copy_size(None).unwrap() as usize; - let mut data = vec![0x00; channel_count * width * height]; - outlined_glyph.draw(|x, y, c| { - let x = x as usize + MARGIN; - let y = y as usize + MARGIN; - let i = channel_count * width * y + channel_count * x; + // 0埋め + // + // NOTE: 描画ではマージンが初期化されないため。 + // マージンだけ0埋めするとコードが煩雑になるため。 + data[..channel_count * width * height].fill(0x00); + + outlined.draw(|x, y, c| { + let i = channel_count * (width * (y as usize + MARGIN) + x as usize + MARGIN); data[i] = (c * 255.0) as u8; #[cfg(target_os = "macos")] - for j in 1..4 { - data[i + j] = 255; - } + data[i + 1..i + 4].fill(255); }); - Ok(Self { - data, + Some(Self { width: width as u32, height: height as u32, - x_offset: outlined_glyph.px_bounds().min.x, - y_offset: outlined_glyph.px_bounds().min.y, - advance, + x_offset: bounds.min.x, + y_offset: bounds.min.y, }) } } diff --git a/src/main.rs b/src/main.rs index 879e010..7a8df1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -115,7 +115,20 @@ impl ApplicationHandler for App { AcquireSurfaceTextureResult::Lost => panic!("fatal: device or surface lost."), }; - renderer.render(window, current_texture); + // DEBUG: + renderer.render( + window, + current_texture, + &[graphics::command::RenderCommand::Text( + graphics::command::Text { + text: "君は呪われている!".to_string(), + origin: glam::Vec2::new(240.0, 120.0), + scale: 48, + v_align: graphics::command::VerticalAlign::Top, + h_align: graphics::command::HorizontalAlign::Left, + }, + )], + ); if !renderer.is_presentation_mode_fifo() { sync_60fps(start); diff --git a/src/shader.wgsl b/src/shader.wgsl index c154cc4..e0b34f4 100644 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -1,18 +1,30 @@ -@group(0) @binding(0) var tex_sampler: sampler; -@group(0) @binding(1) var font_tex: texture_2d; +struct Instance { + position: vec2, + scale: vec2, + uv: vec4, +} struct VertexOutput { @builtin(position) position: vec4, @location(0) uv: vec2, } +@group(0) @binding(0) var instances: array; + +@group(1) @binding(0) var tex_sampler: sampler; +@group(1) @binding(1) var font_tex: texture_2d; + @vertex -fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> VertexOutput { +fn vs_main( + @builtin(vertex_index) in_vertex_index: u32, + @builtin(instance_index) in_instance_index: u32, +) -> VertexOutput { let uv = vec2(f32((in_vertex_index << 1) & 2), f32(in_vertex_index & 2)) / 2.0; + let instance = instances[in_instance_index]; var out: VertexOutput; - out.position = vec4(uv.x - 0.5, uv.y - 0.5, 0.0, 1.0); - out.uv = uv; + out.position = vec4(instance.position + (uv - 0.5) * instance.scale * 2.0, 0.0, 1.0); + out.uv = instance.uv.xy + vec2(uv.x, 1.0 - uv.y) * instance.uv.zw; return out; } -- cgit v1.3.1