Closed47

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> 引数で指定する。この フォーマットファイル にはコメントや改行が無いようだけれど、僕としては以下のように書きたい:

charset-jp.txt
# 日本語文字コード: 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]

そこでバッシュスクリプトを用意した:

to_msdf
#!/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 ビルドでも最適化すべきだとわかった:

.cargo/config.toml
[profile.dev.package."image"]
opt-level = 3

[profile.dev.package."ttf-parser"]
opt-level = 3

縺ァ UV 縺倶ス輔°繧偵★繧峨☆縺ィ髱「逋ス縺昴≧

msdf-atlas-gen の atlasBounds は、規格化した上で Y 軸を反転する (1.0 - top / h と 1.0 - bottom / h を使う) 必要があるみたい。 OpenGL の UV 軸が前提なんだろうな:


aAあア亜

後は補完が上手くいっていないのを何とかしたい

何が悪いんだろう

補完をステップ関数にしてみた。シェーダじゃなくて MSDF テクスチャが良くないのだろうか。

基画像を 32z32 にすると、ステップ関数で色を決めた時に比較的滑らかに出る:

なんか細いフォントを扱うのは厳しいらしい。

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. もっと多い方がいい):

やっぱり適当にブラーをかけるのではなくて、数式で影を描くべきだろうか。

フォントテクスチャ

MSDF が行き詰まっているので、フォントアトラスに 1 文字ずつ焼いていく伝統的な方法を使おうと思う。

  • fyrox_ui の ttf.rs は fontdue を使ってフォントを読み込んでいる。
  • bevy_text は ab_glyph を使っている。
  • bevy_fontdue は存在しない。残念。

fyrox_ui を参考に動的にフォントアトラスを作って、 fontdue::layout と組み合わせて文字描画しよう。

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))))

いずれ SDF

vger がカッコいい。 Rust 版は vger-rs と rui が WIP みたい。 SDF の便利さは 9 スライスに通ずるものがあるはずで、しかも 9 スライスよりも変形が自由! 全面的に……ではなく要所で使いたい。

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?

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 で計測・表示できた

puffin でプロファイリングを取るはずが、なぜか No profiling data になってしまう。

こういうのが多いんだよな。。やれば進むわけじゃない。ハマり過ぎてどうにもならなくなった時にエタると思う。

あああ〜〜 puffin と puffin-imgui をフォークしたのに、 puffin にはパッチをあてていなかった……。無事表示できた:

タイルマップ表示が 1ms 未満なのを考えると、影メッシュの計算 (CPU サイド) が明らかに重過ぎる。サブセルに分けて描画してになくても遅い。。何だろう

最後にカットシーンを書けるようにする! 今回は 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 変数があるとホットリロードがむずかしそう。。

文字も出るようになったし、ここで閉じます〜

このスクラップは1ヶ月前にクローズされました
ログインするとコメントできます