inkfs 🦑 devlog 2 (→ テキスト表示, シーングラフやコルーチンなど)
inkfs 🦑 devlog 1 に引き続き 2D フレームワークを wgpu
に移行するスクラップです。
前回のあらすじ
- 🔺 を描く
- Batcher を作る (ただし Render / Queue フェーズを分離する)
- 影を描く
以下常態です。
テキスト表示 (MSDF)
レイアウト計算
TTF ファイルから引っ張ってきたデータを元に、各文字に対応する四辺形の配置を決める。
今回は人気の fontdue の layout
モジュールを使ってみようと思う。
追記: もしくは msdf-atlas-gen
の JSON 出力のパラメータを利用する (参考: MSDF font rendering in WebGL 。 MSDF を使うなら、 TTF からレイアウト情報だけ切り出しておけば、実行時に TTF は要らないみたい)
文字画像データ
レイアウトした四辺形に対応する画像データを用意する
a. 1 文字ずつフォントテクスチャに焼く
小さな文字も綺麗に描ける反面、回転でぼやける、などのデメリットがある。
fontdue の raster
モジュールを使う
b. MSDF
シェーダで補完して文字を描く方法 (の 1 つ) 。
もう 1 年以上前、記事 Unityで「まとも」なテキスト描画を行いたい - Qiita へのリンクをもらった。
もう21世紀も1/5を過ぎたわけですから、テキスト描画にSDFフォントを使わないとかあり得ないわけです。
いい話だ……。今回は MSDF を使ってみようと思う。
まずは MSDF のフォントアトラスを作る。
- msdfgen: 1 文字の MSDF 画像を作る
- msdf-atlas-gen: 複数文字の MSDF 画像を作る
msdf-atlas-gen をダウンロード & コンパイルした:
$ ghq get https://github.com/Chlumsky/msdf-atlas-gen
$ cd ~/ghq/github.com/Chlumsky/msdf-atlas-gen
$ mkdir build && cd build
$ cmake ../ && make
$ # cp bin/msdf-atlas-gen /path/to/a/directory/in/your/PATH
msdf-atlas-gen
は MSDF 画像や JSON (文字 → サブテクスチャ) を出力してくれる。変換する文字コードは -charset <path>
引数で指定する。この フォーマットファイル にはコメントや改行が無いようだけれど、僕としては以下のように書きたい:
# 日本語文字コード: https://stackoverflow.com/a/53807563
# ASCII
[0x0000, 0x007E]
# Hiragana
[0x3041,0x3096]
# Katakana
[0x30A0,0x30FF]
# Kanji
[0x3400,0x4DB5]
[0x4E00,0x9FCB]
[0xF900,0xFA6A]
# Japanese Symbols and Punctuation
[0x3000,0x303F]
そこでバッシュスクリプトを用意した:
#!/usr/bin/env bash -euE
size=16
px_range=2
ttf="$1"
charset="$2"
tmp="$(mktemp)"
cat "$charset" | grep -v '#' | grep -v '^$' | tr '\n' ',' > "$tmp"
dir="$(dirname "$ttf")"
name="$(basename "$ttf" .ttf)"
msdf-atlas-gen \
-size "$size" \
-pxrange "$px_range" \
-charset "$tmp" \
-font "$ttf" \
-json "$dir/$name-meta.json" \
-imageout "$dir/$name-texture.png"
また出力された JSON ファイルを Rust で読み込むための型を用意した:
msdf_serde.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Msdf {
pub atlas: MsdfAtlas,
pub metrics: Metrics,
pub glyphs: Vec<Glyph>,
// pub kerning: Vec<Kerning>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MsdfAtlas {
pub distance_range: u32,
pub size: f32,
pub width: u32,
pub height: u32,
pub y_origin: YOrigin,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum YOrigin {
Top,
Bottom,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Metrics {
pub em_size: u32,
pub line_height: f32,
pub ascender: f32,
pub descender: f32,
pub underline_y: f32,
pub underline_thickness: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Glyph {
pub unicode: u32,
pub advance: f32,
pub plane_bounds: Option<Bounds>,
pub atlas_bounds: Option<Bounds>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Bounds {
pub left: f32,
pub right: f32,
pub bottom: f32,
pub top: f32,
}
TODO:
Option
を消す? (存在しない文字コードはスキップする?)
これで msfg-atlas-gen
の出力を Rust で使う用意ができた。次は ImGUI にも同封されている M+ FONTS を MSDF で表示したい。
fontdue のフォント読み込みで 20 秒かかる?! リリースビルドでも同様に。 Issue を出しておいた けれど、暗雲立ち込める感じかも。最悪は別クレートに移ろう。 単に --release
の設定ミスだったみたい。申し訳ない……
画像のみならずフォントの読み込みも debug ビルドでも最適化すべきだとわかった:
[profile.dev.package."image"]
opt-level = 3
[profile.dev.package."ttf-parser"]
opt-level = 3
MSDFA というのもあるそうで (比較画像) 。 Unity 拡張いいな……
よく見ると本家 msdfgen にも MTSDF というのがあって、同じものかも。 → 同じもので、元ネタは MSDFA の方だった 。
縺ァ UV 縺倶ス輔°繧偵★繧峨☆縺ィ髱「逋ス縺昴≧
msdf-atlas-gen
の atlasBounds
は、規格化した上で Y 軸を反転する (1.0 - top / h
と 1.0 - bottom / h
を使う) 必要があるみたい。 OpenGL の UV 軸が前提なんだろうな:
aAあア亜
後は補完が上手くいっていないのを何とかしたい
何が悪いんだろう
補完をステップ関数にしてみた。シェーダじゃなくて MSDF テクスチャが良くないのだろうか。
基画像を 32z32 にすると、ステップ関数で色を決めた時に比較的滑らかに出る:
OpenSiv3D の MSDF フォントシェーダ をパクってなお綺麗に文字が出ないので、やっぱりシェーダの問題じゃなさそう?
なんか細いフォントを扱うのは厳しいらしい。
- Parameters to reproduce first figure · Issue #1 · Chlumsky/msdfgen
- Issues with various fonts when generating small MSDFs (< 24x24) · Issue #9 · Chlumsky/msdfgen
16x16 の arial black (pxrange=2) がヨレてしまった。なぜか僕の環境は精度が悪そう:
フォントサイズを上げると綺麗に描ける……はずがちょっと波打っている orz:
仮に精度が上がったとしても、思っていた以上に大きな画像が必要になる見込みなので、 MSDF は止めようかなと思う。
MSDF は置いておこう……。
影の修正案を考える
不可視のセルに光が滲んでいっているのが良くない:
接続に応じてサブセルを描画する
1 つのセルを 3x3 のブロック (サブセルの集まり) として描画する。
右に行くほど ブラー回数が多い (0, 1, 2, 3 回):
少し影の形が良くなった気がする 。まだ惜しい。
もっとセルに影が食い込む形にすれば、不可視のセルが完全に見えなくなるはず。
やや影を優位にしたものの、十分にぼかすと光が漏れ出てしまう:
影の形としては今までの中で一番良い 全体的な形は丸みを帯びて良くなかった。
失敗: 描くピクセルを必ず暗くするブラー
影から光のセルへ、一方的に滲むようにしたい。ガウシアンブラーのフラグメントシェーダを書き換えてみた:
let color = textureSample(shadow_texture, shadow_sampler, uv);
var a: f32; // 周囲のセルのアルファ値を合成していく
// ~~~~
- return vec4<f32>(color.rgb, a);
+ return vec4<f32>(color.rgb, max(a, color.a));
縁だけやや高級になった (ping png 回数 3. もっと多い方がいい):
やっぱり適当にブラーをかけるのではなくて、数式で影を描くべきだろうか。
Rect packer
fyrox_core::rectpack をポートした。これは新しい四角形を挿入する度に、未使用の四角形から対象の四角形よりも大きいものを見つけ、分割するというもの。 vek の Aabr
(axis-aligned bounding box) を使うと split_at_x
というメソッドがあって便利だった。
TODO: リサイズ対応
リサイズするときは新しい RectPacker
を作ればいいかな? プラットフォームによっては最大テクスチャサイズの制限もあるはずなので 1024x1024 決め打ちでもいい気はする?
ひとまずフォントアトラスを 1024x1024 で作った。ピクセルデータを送信するための BufferUsages::COPY_DST
を忘れずに。
フォントアトラスの動的更新
Queue::write_texture
で更新する。 fontdue
が返してくれるのは単色の画像 (チャンネル数 1) であることに気をつける。 a と A を焼いてみた:
フォントアトラス
a が A で上書きされている。書き込み先 (フォントアトラス) のオフセット修正後、文字を描けるようになった:
まだ単色画像の文字を RGBA 画像に変換していないため真っ赤
アトラス更新のコード:
// フォントアトラスの四辺形に文字 (glyph) を焼く
let dst_aabr = /* ~~ */
let glyph_size = wgpu::Extent3d {
width: dst_aabr.size().w as u32,
height: dst_aabr.size().h as u32,
depth_or_array_layers: 1,
};
let n_channels = 1;
// グリフのピクセルデータを説明する型
let data_layout = wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: std::num::NonZeroU32::new(n_channels as u32 * glyph_size.width),
rows_per_image: None,
};
// 書き込み先 (フォントアトラス) のオフセットを設定する
let mut target = self.img.texture.as_image_copy();
target.origin.x = dst_aabr.min.x as u32;
target.origin.y = dst_aabr.min.y as u32;
queue.write_texture(target, &glyph.pixels, data_layout, glyph_size);
縦書き表示の検討中
縦書きでキャプションを出せると、映画みたいでカッコいい。 fontdue
をフォークして、簡易縦書きもできるようにした:
- レイアウト計算にバグあり (まだ謎)
- TTF フォントの GSUB (グリフ置換) テーブルをみると、縦書きの文字に変換できる (
…
→︙
など) 。 ちょっとやってみた けれど、フォントの中に一部記号の縦書きグリフが無いことも多くて、手動で縦書きに見せかける対応が必要になりそう。
wgsl-analyzer に気づかなかった。 VSCode の拡張しかヒットしないのは何でだろう。
あああ〜〜 いい!
ありがとう LSP.. MS はクールな企業です。
Emacs Lisp (leaf.el
+ straight.el
):
(leaf wgsl-mode
:doc "$ cargo install --git https://github.com/wgsl-analyzer/wgsl-analyzer wgsl_analyzer"
:ensure nil
:straight (wgsl-mode :type git :host github :repo "KeenS/wgsl-mode.el")
:hook (wgsl-mode-hook . lsp-deferred)
:hook (wgsl-mode-hook . lsp-ui-mode)
:config
(add-to-list 'lsp-language-id-configuration '(wgsl-mode . "wgsl"))
(with-eval-after-load 'lsp-mode
(lsp-register-client
(make-lsp-client :new-connection
(lsp-stdio-connection "~/.cargo/bin/wgsl_analyzer")
:major-modes
'(wgsl-mode)
:server-id 'wgsl))))
UI (シーングラフ)
inkfs::ui
はシーングラフ (主にスプライトのシステム) を指す。普通 UI と言えば HUD のことだけれど、短い名前が良いと思うので UI で通す!
- Local / global transform + dirty flags
-
HieMut
(階層構造の操作) -
Render
-
Sprite
-
Text
- MarkupText?
-
NineSlice
-
-
Animation
-
Frame 切り替え (
Sprite
の UV 書き換え) - Tweens
- Keyframe / timeline / delay?
-
Frame 切り替え (
Transform
ECS ベースで、親子関係を辿って変換行列を更新する。
追記: 修正済み
型
/// (Internal) Resource for implicit root node
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RootNode {
entity: Entity,
}
/// Component with child/parent relationship
#[derive(Component, Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct Node {
pub as_parent: AsParent,
pub as_child: Option<AsChild>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct AsParent {
pub(crate) n_children: u32,
pub(crate) first_child: Option<Entity>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AsChild {
pub(crate) parent: Entity,
pub(crate) next: Option<Entity>,
pub(crate) prev: Option<Entity>,
}
System
こんな感じかな。 EnTT の『グループ』相当の機能を使う余地が無くて悲しい:
pub fn update_transform_system(
root: Res<RootNode>,
node: Comp<Node>,
(local, mut global): (Comp<LocalTransform2d>, CompMut<GlobalTransform2d>),
) {
let is_dirty = false;
let root_mat = Mat3::identity();
node.child_entities(root.entity()).for_each(|entity| {
self::update_transform_rec(is_dirty, entity, root_mat, &node, (&local, &mut global));
});
}
macro_rules! unwrap {
($x:expr) => {
match $x {
Some(x) => x,
None => return,
}
};
}
/// DFS (depth-first search)
fn update_transform_rec(
mut is_dirty: bool,
entity: Entity,
mut mat: Mat3<f32>,
node: &Comp<Node>,
(local, global): (&Comp<LocalTransform2d>, &mut CompMut<GlobalTransform2d>),
) {
{
let local = unwrap!(local.get(entity));
let global = unwrap!(global.get_mut(entity));
is_dirty |= local.is_dirty;
if is_dirty {
mat = mat * local.mat;
global.mat = mat;
}
}
node.child_entities(entity).for_each(|entity| {
update_transform_rec(is_dirty, entity, mat, node, (local, global));
});
}
回文『ぼくはかわくぼ』を transform でクルクル回してネタにしたい。先じてテキスト描画のコード構成をマシする。
Immediate-mode なテキスト描画
ECS World
から複数のリソースを借りてきて、その上に API を作るのを定石にしたい。
今回、複数のリソースを借りる手続きの実装を #[derive(GatBorrwWorld)]
で自動化した:
#[derive(GatBorrowWorld, Debug)]
pub struct TextRender<'w> {
fonts: ResMut<'w, text::FontArena>,
baker: ResMut<'w, text::RasterGlyphBaker>,
buf: ResMut<'w, RasterFontRenderBuffer>,
}
試しに文字を描く:
fn test_raster_render(
mut draw: Draw,
mut fonts: ResMut<res::GameFonts>,
mut text_render: TextRender,
) {
// extracted text
let h = 24;
let text = "ぼくはかわくぼ";
let offset = Vec2::new(100.0, 400.0);
let font_ix = fonts.mp1;
text_render.draw_sized(&mut draw, text, font_ix, h, offset);
}
一応記念に:
Sprite
component を追加中。開発中に意味の分からない景色が生まれることはよくある:
- Sprite に UV を追加:
Transform も既に動いているはず。
- ノードの親子関係を操作する API:
/// API for handling hierarchy of entities under the [`RootNode`](tfm::RootNode)
#[derive(GatBorrowWorld, Debug)]
pub struct HieMut<'w> {
root: Res<'w, tfm::RootNode>,
node: CompMut<'w, tfm::Node>,
}
impl<'w> HieMut<'w> {
pub fn add_child(&mut self, parent: Entity, child: Entity) {
self.node.add_child(parent, child);
}
pub fn print(&self) {
tfm_system::print_ui_rec(0, self.root.entity(), &self.node);
}
}
こんな形で使える:
let mut hie = world.borrow::<ui::HieMut>();
hie.add_child(root, ent);
hie.add_child(ent, ent2);
- Tranform もいけてそう:
-
Sprite
でキャラ表示 -
Sprite
アニメーション -
Sprite
にorigin
フィールド追加
よくある跳ねる移動
補完を間違えてポップな移動感になった。修正予定。
向き変更を tween で:
シーングラフのノードとして Text
表示:
文字の中心とキャラの中心を揃えるためには、文字列のサイズを把握する必要がある。そこで、仮にレイアウトの計算結果をキャッシュすることにした:
/// (Component) Retained-mode rendering text
#[derive(Component, Debug, Clone, PartialEq)]
pub struct Text {
pub s: String,
pub style: RasterGlyphStyle,
pub origin: Vec2<f32>,
}
/// (Component) Rasterized [`Text`]
#[derive(Component, Debug, Clone, PartialEq)]
pub struct RasterizedText {
pub is_dirty: bool,
pub tex_quads: TexQuads,
/// The whole size after rasterization
pub size: Extent2<f32>,
}
キャッシュに文字列全体のサイズが入っており、 Text
の origin
フィールドを使ってアラインメントを変えることができる。試しに中央寄せにすると:
TODO: テキスト表現
良い形が頭の中に無い。必要になった時に随時作りたい。
任意の変換
1 枚の Sprite
とは異なり、 Text
は複数の文字で構成されている。任意の変換を適用するには、文字毎に Transform
を持たなければならない。
ひとまず平行移動だけ考えることにした。拡大や回転をかけるとおかしなことになるはず。
文字影
透明度 0 なら同じ文字をズラして表示する。
透明度を考えるならシェーダが必要そう。文字専用のパイプラインや batcher を用意すべきか。。
文字毎にエフェクトをかける
パラララララっと 1 文字ずつエフェクトをかけながら表示したい。
簡易マークアップ言語とリッチテキスト
fontdue
が新しくなっていた。
- cargo-outdated で更新が必要か調べられる
-
cargo update --aggressive
で全パッケージを更新できる (たしか)
ついでに Rust をバージョンアップした:
$ rustup update
ただしバージョンアップの度に target
を削除しないと、ゴミが残って平気で 200 GB 埋まる。。
bash で雑に削除した:
#!/usr/bin/env bash -euE
IFS=$'\n\t'
clean() {
echo "+ $1"
cargo clean --manifest-path "$1"
}
for toml in $(fd -a Cargo.toml) ; do
clean "$toml" &
done
wait
FPS が下がっていた。調べてみると、影メッシュの計算が重かったみたい。
-
黒いセルは明治的に描かない (clear color で描く)
-
プロファイリング + ターミナルまたはゲーム内表示
→ puffin で計測・表示できた
-
プロファイリング + ターミナルまたはゲーム内表示
最後にカットシーンを書けるようにする! 今回は cosync
で……
-
会話再生
- NineSlice で会話ウィンドウ表示
-
Entity
の削除をスケジュールする (ツリーの親子関係も更新する) - ウィンドウのアニメーション
- 文字送り
- kira で SE 再生
- cosync でカットシーンを書く
-
cosync
で書いたカットシーンをdylib
クレートに置き、ホットリロード可能にする
突発的バグ
……というわけで、とりあえずナインスライス:
原点位置の計算を追加、レイアウトも調整:
UI ノードを持つ Entity
は ui::despawn_subtree
で削除する
バグ取ったぞ〜! UI にアニメ追加:
影がウィンドウの上に来ておる。。 Z 軸ソートをしよう
AAA ゲームをプレイしたい気分だ。。
しかしゲーム機が無い! お金も無いんだ〜〜
メッセージの連続表示
APEX も UI 消去にアニメーションは無いということで、このまま手抜きしていこうと思います。
cosync
関数を割り当てる
キャラ毎に HashMap<Entity, cosync関数>
を作りたい。 Cosync 関数を保存したり、 cosync 関数から 'static な Future を作るには?:
-
Box<dyn Fn(..) -> Pin<Box<dyn Future(..)>>
はクローンできない?- ユーザ定義型のトレイトオブジェクトなら dyn-clone が使える
- 関数は
dyn Fn + DynClone
ではないはず - この方向性は難しそう
-
fn
の形なら保存できたしクローンできた: https://users.rust-lang.org/t/how-to-store-async-function-pointer/38343/4
#[apply(script!)]
pub async fn example1(mut world: CosyncInput<World>) { /* ~~ */ }
#[apply(script!)]
pub async fn example2(mut world: CosyncInput<World>) { /* ~~ */ }
type F = fn(CosyncInput<World>) -> Pin<Box<dyn Future<Output = ()> + Send>>;
let mut xs: Vec<F> = Vec::new();
xs.push(example1);
xs.push(example2);
-
apply
は macro_rules_attribute から来ていて、宣言型マクロを属性マクロとして使うことができる。 -
#[apply(script!)]
はasync
関数の戻値をBox::pin
に詰める (戻値の型も変える)
#[macro_export]
macro_rules! script {(
$( #[$attr:meta] )*
$pub:vis
async
fn $fname:ident ( $($args:tt)* ) $(-> $Ret:ty)?
{
$($body:tt)*
}
) => (
$( #[$attr] )*
#[allow(unused_parens)]
$pub
fn $fname ( $($args)* ) -> ::std::pin::Pin<::std::boxed::Box<
dyn 'static + Send + ::std::future::Future<Output = ($($Ret)?)>
>>
{
Box::pin(async move { $($body)* })
}
)}
dylib 対応の検討
dylib からできること・できないことの区別を付けたい。
dylib は独立したグローバル変数を持つため、静的リンクされたライブラリが持つグローバル変数に依存できない。迂闊に触れると null だったり、 UB にすらなるかもしれない。たとえば OpenGL のコンテクストがグローバル変数の場合、 graphics API を叩く (叩く関数を呼び出す) と実行時エラーになる。代わりに dylib からはデータを返し、静的リンクされたライブラリから graphics API を叩くなどの工夫が必要になるはず。
調べていこう:
-
自分のコード
- Type object ストレージがグローバル変数だった。
-
グラフィクス?
-
wgpu
(OpenGL) -
wgpu
(Metal)
-
-
オーディオ? (未実装
kira
(roddio
)?)
そもそもリロード前に dylib 関連の全データを drop する必要がある気がする (component ストレージとか) 。静的リンクされるライブラリに大半のデータを置かないといけない。大変そうだぁ〜〜
リロードは置いておき、 dylib
をロードしてみた。普通に動く! 普通に……
動いてない……。若干コードを変えてみると、こんな形に:
なんか テクスチャ (もしくはアセットハンドル) がズレているぞ!! なんで〜〜
メモ: そういえばグローバルな atomic 変数を wgpu リソースの wrapper への ID 生成に使っていた。リロードはしてないけれど、影響出たかも?
やはり static 変数があるとホットリロードがむずかしそう。。
文字も出るようになったし、ここで閉じます〜