diff options
| -rw-r--r-- | .gitattributes | 1 | ||||
| -rw-r--r-- | Cargo.lock | 37 | ||||
| -rw-r--r-- | Cargo.toml | 6 | ||||
| -rw-r--r-- | assets/KazukiReiwa-Regular-License.txt | 94 | ||||
| -rw-r--r-- | assets/KazukiReiwa-Regular.ttf | bin | 0 -> 22847604 bytes | |||
| -rw-r--r-- | src/graphics.rs | 61 | ||||
| -rw-r--r-- | src/graphics/font.rs | 238 | ||||
| -rw-r--r-- | src/graphics/texture.rs | 10 | ||||
| -rw-r--r-- | src/main.rs | 2 |
9 files changed, 415 insertions, 34 deletions
diff --git a/.gitattributes b/.gitattributes index fcadb2c..ec5f70c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text eol=lf +*.ttf binary @@ -321,6 +321,10 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" name = "cursed" version = "0.1.0" dependencies = [ + "ab_glyph", + "etagere", + "glam", + "lru", "pollster", "wgpu", "winit", @@ -395,6 +399,24 @@ dependencies = [ ] [[package]] +name = "etagere" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecb5c40d5683a270e077688f53a99eac51c1cb1ae5ba63b55590370cb3536bf" +dependencies = [ + "euclid", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] name = "find-msvc-tools" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -491,6 +513,12 @@ dependencies = [ ] [[package]] +name = "glam" +version = "0.33.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928452f9c953e142b2f0973e4bd5f34445fcaa8069556ea97a3c9d34d15a4cf8" + +[[package]] name = "gpu-allocator" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -699,6 +727,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] -winit = "0.30.13" +ab_glyph = "0.2.32" +etagere = "0.3.0" +glam = "0.33.10" +lru = "0.18.5" pollster = "1.0.1" +winit = "0.30.13" wgpu = { version = "30.0.0", default-features = false, features = [ "dx12", "metal", "parking_lot", "std", "vulkan", "wgsl" ] } diff --git a/assets/KazukiReiwa-Regular-License.txt b/assets/KazukiReiwa-Regular-License.txt new file mode 100644 index 0000000..0e8008b --- /dev/null +++ b/assets/KazukiReiwa-Regular-License.txt @@ -0,0 +1,94 @@ +Copyright (c) 2022 Kazuki Kimura (https://github.com/KazukiKimuraJP/KazukiReiwa) +with Reserved Font Names "KazukiReiwa". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/KazukiReiwa-Regular.ttf b/assets/KazukiReiwa-Regular.ttf Binary files differnew file mode 100644 index 0000000..b801e21 --- /dev/null +++ b/assets/KazukiReiwa-Regular.ttf diff --git a/src/graphics.rs b/src/graphics.rs index bbe07df..2b96a7a 100644 --- a/src/graphics.rs +++ b/src/graphics.rs @@ -2,9 +2,10 @@ use std::{borrow::Cow, sync::Arc}; use wgpu::*; use winit::{dpi::PhysicalSize, event_loop::OwnedDisplayHandle, window::Window}; +mod font; mod texture; -use texture::TextureWrapper; +use font::CharAtlas; pub enum AcquireSurfaceTextureResult { Ok(SurfaceTexture), @@ -21,7 +22,7 @@ pub struct Renderer { present_mode: PresentMode, pipeline: RenderPipeline, - font_tex: TextureWrapper, + char_atlas: CharAtlas, fixed_bind_group: BindGroup, } @@ -31,10 +32,10 @@ impl Renderer { window: Arc<Window>, ) -> Result<Self, String> { let size = window.inner_size(); + let scale_factor = window.scale_factor() as f32; let instance = Instance::new(InstanceDescriptor::new_with_display_handle(display)); let surface = instance.create_surface(window).map_err(|e| e.to_string())?; - let adapter = instance .request_adapter(&RequestAdapterOptions { compatible_surface: Some(&surface), @@ -53,20 +54,7 @@ impl Renderer { } let (surface_format, view_format) = decide_surface_and_view_format(&adapter, &surface_cap); - - // REVIEW: 高負荷なので素直にFIFOにして垂直同期で回す方が良いかもしれない。 - let present_mode = if surface_cap.present_modes.contains(&PresentMode::Mailbox) { - // 垂直同期を待つフレーム上書きモード - PresentMode::Mailbox - } else if surface_cap.present_modes.contains(&PresentMode::Immediate) { - // 垂直同期を待たないフレーム上書きモード - PresentMode::Immediate - } else { - // 垂直同期を待つフレーム保守モード - // NOTE: 殆どすべての環境でサポートされていることを期待して良い。 - // https://wgpu.rs/doc/wgpu/enum.PresentMode.html - PresentMode::Fifo - }; + let present_mode = decide_presentation_mode(&surface_cap); let shader = device.create_shader_module(ShaderModuleDescriptor { label: None, @@ -112,7 +100,11 @@ impl Renderer { module: &shader, entry_point: Some("fs_main"), compilation_options: Default::default(), - targets: &[Some(view_format.into())], + targets: &[Some(ColorTargetState { + format: view_format, + blend: Some(BlendState::ALPHA_BLENDING), + write_mask: ColorWrites::ALL, + })], }), primitive: PrimitiveState { topology: PrimitiveTopology::TriangleStrip, @@ -137,13 +129,7 @@ impl Renderer { ..Default::default() }); // NOTE: 文字テクスチャアトラスは使い回すので、ここで1個だけ作成すれば良い。 - let font_tex = TextureWrapper::new( - &device, - 128, - 128, - TextureFormat::R8Unorm, - TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, - ); + let char_atlas = CharAtlas::new(scale_factor, &device); let fixed_bind_group = device.create_bind_group(&BindGroupDescriptor { label: None, layout: &fixed_bind_group_layout, @@ -154,7 +140,7 @@ impl Renderer { }, BindGroupEntry { binding: 1, - resource: BindingResource::TextureView(&font_tex.view), + resource: BindingResource::TextureView(&char_atlas.texture.view), }, ], }); @@ -167,7 +153,7 @@ impl Renderer { view_format, present_mode, pipeline, - font_tex, + char_atlas, fixed_bind_group, }; @@ -199,7 +185,7 @@ impl Renderer { } } - pub fn render(&self, window: &Window, current_texture: SurfaceTexture) { + pub fn render(&mut self, window: &Window, current_texture: SurfaceTexture) { let surface_texture_view = current_texture.texture.create_view(&TextureViewDescriptor { format: Some(self.view_format), ..Default::default() @@ -289,6 +275,7 @@ fn decide_surface_and_view_format( // sRGBがサポートされていない・かつテクスチャとビューのフォーマットが一致しなくて良いなら // テクスチャを`formats[0]`・ビューをsRGB + // // NOTE: ビューのフォーマットに従って色変換が行われるので、 // テクスチャの実際のフォーマットが違うだけでsRGBと同様の結果になる。 if can_srgb_view { @@ -296,9 +283,27 @@ fn decide_surface_and_view_format( } // sRGBがサポートされていない・かつテクスチャとビューのフォーマットが一致する必要があるなら // テクスチャもビューも`formats[0]` + // // NOTE: 色変換が行われないので中間色が暗くなる。 // シェーダで自力補正してやれば解決するが、まあ余力があったらね。 else { (format, format) } } + +fn decide_presentation_mode(cap: &SurfaceCapabilities) -> PresentMode { + // REVIEW: 高負荷なので素直にFIFOにして垂直同期で回す方が良いかもしれない。 + if cap.present_modes.contains(&PresentMode::Mailbox) { + // 垂直同期を待つフレーム上書きモード + PresentMode::Mailbox + } else if cap.present_modes.contains(&PresentMode::Immediate) { + // 垂直同期を待たないフレーム上書きモード + PresentMode::Immediate + } else { + // 垂直同期を待つフレーム保守モード + // + // NOTE: 殆どすべての環境でサポートされていることを期待して良い。 + // https://wgpu.rs/doc/wgpu/enum.PresentMode.html + PresentMode::Fifo + } +} diff --git a/src/graphics/font.rs b/src/graphics/font.rs new file mode 100644 index 0000000..d05682d --- /dev/null +++ b/src/graphics/font.rs @@ -0,0 +1,238 @@ +use super::texture::TextureWrapper; +use ab_glyph::*; +use etagere::{AllocId, AtlasAllocator}; +use glam::Vec4; +use lru::LruCache; +use std::sync::LazyLock; +use wgpu::*; + +static FONT: LazyLock<FontRef<'static>> = LazyLock::new(|| { + FontRef::try_from_slice(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/KazukiReiwa-Regular.ttf" + ))) + .expect("fatal: failed to load font") +}); + +/// スケールされたフォントのトップ-ベースライン間を取得するメソッド +pub fn ascent(scale: u32) -> f32 { + FONT.as_scaled(scale as f32).ascent() +} + +/// スケールされたフォントの行間を取得するメソッド +pub fn line_gap(scale: u32) -> f32 { + FONT.as_scaled(scale as f32).line_gap() +} + +/// スケールされたフォントのカーニング量を取得するメソッド +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) +} + +const WIDTH: u32 = 128; +const HEIGHT: u32 = WIDTH; + +// NOTE: macOSではswizzleをゼロコストで使えないのでRGBAにする。 +const FORMAT: TextureFormat = if cfg!(target_os = "macos") { + TextureFormat::Rgba8Unorm +} else { + TextureFormat::R8Unorm +}; + +/// 1文字テクスチャの1辺におけるマージン +/// +/// 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, + }, +} + +pub struct CharAtlas { + /// ウィンドウの倍率 + /// + /// 物理解像度と論理解像度の差を考慮するため、この倍率に従ってテクスチャアトラス全体をスケールする。 + /// + /// NOTE: 実行中に変化しうるが、致命的ではないので初期化時の倍率に従うことにする。 + scale_factor: f32, + width: f32, + height: f32, + atlas: AtlasAllocator, + /// 文字テクスチャアトラスに登録されている文字とそのID + /// + /// NOTE: 文字テクスチャアトラスが満ちている場合に最も古く使われた文字を消すためにLRUキャッシュを用いている。 + registereds: LruCache<(char, u32), CharInfo>, + pub texture: TextureWrapper, +} + +impl CharAtlas { + pub fn new(scale_factor: f32, device: &Device) -> Self { + let width = WIDTH as f32 * scale_factor; + let height = HEIGHT as f32 * scale_factor; + + Self { + scale_factor, + width, + height, + atlas: AtlasAllocator::new(etagere::size2(width as i32, height as i32)), + registereds: LruCache::unbounded(), + texture: TextureWrapper::new( + device, + width as u32, + height as u32, + FORMAT, + TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, + ), + } + } + + /// 文字情報を取得するメソッド + /// + /// * queue - queue + /// * c - 対象文字 + /// * scale - 論理解像度上の倍率 (雑に1文字の最大高と思って良い) + /// + /// 対象文字がテクスチャアトラス上に存在しない場合はラスタライズ・登録を行う。 + /// また、同時にLRUキャッシュの更新を行う。 + /// + /// WARN: アトラス上の全ての文字が同時に使用される場合、 + /// 一部文字はテクスチャが更新されるため、意図しない結果を得る。 + pub fn acquire(&mut self, queue: &Queue, c: char, scale: u32) -> &CharInfo { + if !self.registereds.contains(&(c, scale)) { + self.register_character(queue, c, scale); + } + self.registereds.get(&(c, scale)).unwrap() + } + + fn register_character(&mut self, queue: &Queue, c: char, scale: u32) { + // ラスタライズ + 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 size = etagere::size2(rasterized.width as i32, rasterized.height as i32); + let allocated = loop { + if let Some(alloc) = self.atlas.allocate(size) { + break alloc; + } else { + let least_used = self + .registereds + .pop_lru() + .expect("internal error: no character exists but character atlas is full"); + if let CharInfo::Rasterizable { alloc_id, .. } = least_used.1 { + self.atlas.deallocate(alloc_id); + } + } + }; + + // アップロード + self.texture.write_partial( + queue, + &rasterized.data, + // NOTE: 非負が保証されているため、`u32`にキャストして良い。 + allocated.rectangle.min.x as u32, + // NOTE: 非負が保証されているため、`u32`にキャストして良い。 + allocated.rectangle.min.y as u32, + rasterized.width, + rasterized.height, + ); + + // 登録 + 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, + 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, + width / self.width, + height / self.height, + ), + }; + self.registereds.push((c, scale), info); + } +} + +struct RasterizedChar { + data: Vec<u8>, + width: u32, + height: u32, + x_offset: f32, + y_offset: f32, + advance: f32, +} + +impl RasterizedChar { + /// 文字をラスタライズするコンストラクタ + /// + /// * font - フォント + /// * c - 対象文字 + /// * scale - 論理解像度上の倍率 (雑に1文字の最大高と思って良い) + /// + /// 物理解像度と論理解像度の違いを考慮しない。 + /// ラスタライザブルでない文字に対してはadvanceと共に`Err`を返す。 + fn new(font: &FontRef<'static>, c: char, scale: f32) -> Result<Self, f32> { + 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); + }; + + 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 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; + + data[i] = (c * 255.0) as u8; + + #[cfg(target_os = "macos")] + for j in 1..4 { + data[i + j] = 255; + } + }); + + Ok(Self { + data, + 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, + }) + } +} diff --git a/src/graphics/texture.rs b/src/graphics/texture.rs index 6273b6e..95b9727 100644 --- a/src/graphics/texture.rs +++ b/src/graphics/texture.rs @@ -51,14 +51,15 @@ impl TextureWrapper { /// /// `data`がテクスチャサイズと一致しない場合は、クラッシュしないように不足分が白色で埋められる。 pub fn write(&self, queue: &Queue, data: &[u8]) { - self.write_partial(queue, data, Origin3d::ZERO, self.width, self.height); + self.write_partial(queue, data, 0, 0, self.width, self.height); } /// テクスチャを部分的に更新するメソッド /// /// * queue - queue /// * data - 更新データ - /// * origin - 更新領域の開始座標 + /// * x - 更新領域の開始X座標 + /// * y - 更新領域の開始Y座標 /// * width - 更新領域の幅 /// * height - 更新領域の高さ /// @@ -67,7 +68,8 @@ impl TextureWrapper { &self, queue: &Queue, data: &[u8], - origin: Origin3d, + x: u32, + y: u32, width: u32, height: u32, ) { @@ -89,7 +91,7 @@ impl TextureWrapper { TexelCopyTextureInfo { texture: &self.texture, mip_level: 0, - origin, + origin: Origin3d { x, y, z: 0 }, aspect: TextureAspect::All, }, data, diff --git a/src/main.rs b/src/main.rs index d78aee7..879e010 100644 --- a/src/main.rs +++ b/src/main.rs @@ -96,7 +96,7 @@ impl ApplicationHandler for App { fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { let start = Instant::now(); - let (Some(window), Some(renderer)) = (&self.window, &self.renderer) else { + let (Some(window), Some(renderer)) = (&self.window, &mut self.renderer) else { // NOTE: `window`が`Some`になる`resumed`メソッドで`ControlFlow::Poll`を指定しているため、 // ここで`ControlFlow::WaitUntil`を指定しても問題ない。 event_loop.set_control_flow(ControlFlow::WaitUntil(start + Duration::from_millis(16))); |
