Open25

inkfs 🦑 devlog 3 (セーブ・ロードなど)

toyboot4etoyboot4e

ホットリロード 案 2: ホスト + セーブ/ロード

動的ライブラリをリロードすると、新しくグローバル変数が生成されます 。本ゲームではグラフィクスリソースのカウンタにグローバル変数を使っているため、テクスチャのインデクスがずれたりしました。

案 2 では、セーブ/ロード機能を作ってホットリロードを試みます。ウィンドウを表示するホストクレートが、ゲームクレートを動的に読み込む形です。ゲームクレートが新しくコンパイルされたら、ゲームを保存してゲームクレートをリロードします。セーブデータを読み込めば、新しいプログラムでゲームを続行できる見通しです。

案 2 は、失敗してもセーブロード機能によって開発効率を改善できます。案 2 は市民の幸福に繋がります。

toyboot4etoyboot4e

(Rust) トラブルシューティング

開発再開!? wgpu 0.13 では WGSL の構文が新しくなって良かったです。

cargo-edit 使用時のエラー

cargo addcargo upgrade でエラーが出ます:

$ cargo upgrade --workspace
    Updating 'https://github.com/rust-lang/crates.io-index' index
Error: failed to acquire username/password from local configuration

Caused by:
    failed to acquire username/password from local configuration
~~~~

.gitconfig (~/.config/git/config ) から以下の行を削除すると動作するようになりました。

# [url "git@github.com:"]
#     insteadOf = https://github.com/
#     insteadOf = git://github.com/

なぜ追加したのか忘れました……

依存クレートが yank された場合の対処法

cargo upgrade 実行時に謎のエラーが出ました。エラーが出た tiled 周りを調べてみると……

バージョン 説明 備考
0.10.0 以降 Cargo が選択するバージョン
0.9.5 Cargo に無視されたバージョン ヤンクされていた
0.9.4 Cargo が選択しないバージョン 使いたいバージョン

……ヤンクされた バージョンを使おうとしていたようです。

この場合、 Cargo.tomltiled0.9.4 にして、 ~/.cargo/registry/cache 以下の tiled 0.9.5 を削除すれば、正常にバージョンが解決 (0.9.4) されるようになりました。

toyboot4etoyboot4e

Z 軸ソートの (再) 実装

wgpu / ECS 移行時に吹き飛んだ機能の 1 つです。

スクリーンショット

ソート実装前は、テキストに影が被さっていました:

ソート実装後は、テキストが影よりも手前に来ます:

ソート方法

描画の流れは以下の通りです:

  1. Queue フェーズ
    GPU リソース (頂点バッファなど) の更新コマンドを作成します。また、ドローコール (の引数) を作成します。

  2. ソート
    ドローコールをソートします。

  3. Render フェーズ
    更新されたリソースを用いてドローコールを発行します。

Queue フェーズでは Sprite, NineSliceSprite などの異種データを全て頂点データに変換してドローコールを作成します。ドローコールは共通種類のデータですから、これをソートすれば異なる種類の描画対象をソートできたことになります。

wgpu を使っていると、必然的に Queue / Render フェーズが分かれることになります [1] 。これにより、ドローコールのソートが自然な発想 [2] になったり、新しい制約 [3] が生まれたりします。 wgpu が『正しい』設計をもたらすのかは分かりませんが、他の Rust の制約と同様に、期待できるヒューリスティクスを与えてくれると感じています。

関連の issue

脚注
  1. wgpu::RenderPass<'a>Drop trait を実装しているため、借用の条件が厳しくなります (drop check) 。そのため伝統的な sprite batcher のように『テクスチャの種類が切り替わったら即ドローコールを発行』とは なりづらい です。バッファ更新の Queue フェーズで RenderPass に触れず、 Render フェーズで 1 度だけ RenderPass を作って一気にドローコールを発行します。 ↩︎

  2. よくある従来の発想としては、 IRenderable をソートします。 ↩︎

  3. Queue フェーズ/ Render フェーズが分かれると、 uniform 変数が実質的に定数になります。複数種類の unifrom 変数が必要な場合は、 UniformVec<T> のようなものを用意するか、 1 種類の uniform で済むように頂点データを修正したりします (Bevy Engine の場合) 。 ↩︎

toyboot4etoyboot4e

マップレイヤーの Z 軸

進捗

屋根よりも手前にキャラが来ています:

Z 軸ソートを適用すると:

API

マップ描画では、描画レイヤーを const generics で渡しています。今回はユーザ引数で Z 軸も設定するように変更しました。

-    render::map::<1, 100>,
+    |w: &mut World| w.run_arg_ex(render::map::<1, 99>, crate::z::TILES_UNDER),
+    |w: &mut World| w.run_arg_ex(render::map::<100, 1000>, crate::z::TILES_OVER),
toyboot4etoyboot4e

セーブロード法を考える

World には動的にデータを追加しますから、単純な derive では済みません。

ポイントは以下の 3 つです:

  1. 動的 ser/de の方法 (serde + erased_serdeReflect)
  2. TypeId の代わりのキー値 (Uuidcore::any::type_name)
  3. Resource の ser/de に対応しているか

specs の場合

用例が見つかりました (Roguelike Tutorial)。実装は調べていません。

legion 場合

  1. 動的 ser/de には erased_serde を使っています。
  2. TypeId の代わりに任意のキーを使えます:
let mut registry = Registry::<String>::default();
registry.register::<Position>("position".to_string());
registry.register::<f32>("f32".to_string());
  1. Resource には (たぶん) 非対応です。

Bevy の場合

セーブロードは DynamicScene を経由します。

  1. 動的 ser/de には Reflect を使っています。
  2. TypeId の代わりに any::type_name を使っています。将来的には変更がありそうです。
  3. Resource には非対応です。

なお &dyn Reflect を ser/de の対象にすることもできそうです (Serializable, Deserializable を経由します) 。

toyboot4etoyboot4e

本ゲームでは legion のやり方を踏襲したいと思います。

  1. 動的 ser/de には erased_serde を使います
  2. TypeId の代わりに any::type_name を使います (一旦)
  3. Resource の ser/de 用の型を toecs のビルトインにします
    legionRegistry に近いですが、 component ではなく Resource 用の Registry です。

serde やホットリロードを考えると、 Rust は 100% ゲーム開発向きの言語というわけでもないなと思います。

toyboot4etoyboot4e

シリアライズ

ひとまず簡単な方を実装しました。出力はだいぶん汚いです:

World の RON 出力
(
  res: {
    "it::serde_test::Pos": (
      x: 100,
      y: 100,
    ),
  },
  ents: (
    sparse: [
      ToDense((
        raw: (0),
        gen: (
          raw: 1,
        ),
      )),
      ToDense((
        raw: (1),
        gen: (
          raw: 1,
        ),
      )),
    ],
    dense: [
      (
        raw: (0),
        gen: (
          raw: 1,
        ),
      ),
      (
        raw: (1),
        gen: (
          raw: 1,
        ),
      ),
    ],
    first_free: None,
    n_free: 0,
    n_reserved: 0,
  ),
  comp: {
    "it::serde_test::Pos": (
      x: 100,
      y: 100,
    ),
  },
)

一部の構造体を tuple struct で書き直すか検討中です。

Feature

serde0 とか use-serde という名前のフィーチャーフラッグを作って conditional compilation をします:

#[cfg(feature = "use-serde")]
use serde::{Serialize, Deserialize};

cfg_attr を使えば derive directve や属性も optional にできます:

#[derive(Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "use-serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "use-serde", serde(transparent))]
#[repr(transparent)]
pub struct Entity(pub(crate) SparseIndex);

Serialize の実装

World のデータ配置は以下のようになっています:

world
├── entities
├── resources
│   ├── resource_a  <-- trait object として持たれる
│   ├── ....
│   └── resource_x
└── coomponent_pools
    ├── component_pool_a  <-- trait object として持たれる
    ├── ....
    └── component_pool_x

world.resourcesworld.component_pools は、子要素に以下の関数でアクセスします:

// 本当は `&dyn erased_serde::Serialize を返したいけれど、
// ライフタイムの問題でクロージャを取る形:
type FetchFn = fn(&World, &mut dyn FnMut(&dyn erased_serde::Serialize));

すると、それぞれ以下の形で serde::Serialize を実装できます:

let mut map = serializer.serialize_map();

for ty in self.children().map(|c| c.type_id) {
    // `TypeId` → `StableTypeId` → `FetchFn` 
    let stable_id = registry.to_stable(ty).unwrap();
    let fetch_fn = registry.fetch_fns.get(stable_id).unwrap();

    (fetch_fn)(world, &mut |serialize| {
        // `&dyn erased_serde::Serialize` を `serde::Serialize` として使う:
        map.serialize_entry(&stable_id, serialize).unwrap();
    });
}

map.end()

なお Registry には型情報が入っています。 API は未定……

impl Registry {
    /// Registers a [`Serialize`] resource type
    pub fn register_res<T: Resource + serde::Serialize + 'static>(&mut self) {
        let ty = self.register_::<T>();

        self.serialize_fetch.insert(ty, |world, closure| {
            let res = match world.try_res::<T>() {
                Ok(res) => res,
                _ => return,
            };

            (closure)(&*res);
        });
    }

    /// Registers a [`Serialize`] component type
    pub fn register<T: Component + serde::Serialize + 'static>(&mut self) {
        /* ~~ */
    }
}
toyboot4etoyboot4e

Scene について

今は World のシリアライズを考えています。全状態のスナップショットを取れる安心感がありますが、難点としては

  1. 後方互換性の維持が難しい
  2. セーブファイルが人には読みづらい

スナップショットを取る代わりに、ゲーム状態の復元に必要な情報を保存することも考えられます。たとえば Bevy ではデータ入力のフォーマットとして scene (*.scn.ron) を使っており、 Component は記録しますが、 Entity の世代番号などは保存しません (動的に決まります) 。この scene をセーブ機能として使うこともできるようです。

Scene のフォーマット

Bevy の scene 定義ファイルは、 Entity の直下に Component があるという形で書きます。少なくともセーブファイルの可読性は大いに良くなりそうです:

#[derive(Default, TypeUuid)]
#[uuid = "749479b1-fb8c-4ff8-a775-623aa76014f5"]
pub struct DynamicScene {
    pub entities: Vec<DynamicEntity>,
}

pub struct DynamicEntity {
    pub entity: u32,
    pub components: Vec<Box<dyn Reflect>>,
}

Bevy の scene には、今後アセットやシステムの有効/無効なども設定できるようになりそうです。他の開発で忙しいかもですが……

https://github.com/bevyengine/bevy/issues/255

inkfs 🦑 でも scene を作っていくのか

Scene では Entity をフィールドに持つ Component の記述が難しいと思います。データ入力のフォーマットとして scene は良いアイデアだと思いますが、セーブロードのフォーマットとしてはスナップショットかそれに近い形式を採用したいです。

toyboot4etoyboot4e

Deserialize の実装を考える

serde の良い点はデータモデルを介して data structure / data format を接続してくれる所です。 MapAccess::next_value_seed などを使えるように、動的な deserialize も単なる関数ではなく Deserialize の実装にしたいです。

serde 周りを見てみました

  • DeserializeDeserializeSeedtrait object にすることはできない
  • erased_serde にも Deserialize は無い
  • typetag は避けたい (できれば ctor に依存したくない)

Target を抽象した具体的な型を DeserializeSeed にする

(TypeId, Box<dyn Any>) を返す deserialize 関数を考えます:

type ErasedDeserializeFn =
    fn(&mut dyn erased_serde::Deserializer) -> Result<(TypeId, Box<dyn Any>), erased_serde::Error>;

インスタンスはこういうやつです (ライフタイムを誤魔化して書きつつ):

|d| {
    x = erased_serde::deserialize::<T>(d)?;
    Ok(Box::new(x))
}

これをフィールドに入れて、 DeserializeSeed を実装できます:

struct ErasedDeserialize {
    ty: TypeId,
    f: ErasedDeserializeFn,
}

impl<'a, 'de> serde::de::DeserializeSeed<'de> for &'a ErasedDeserialize {
    type Value = (TypeId, Box<dyn Any>);

    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let mut deserializer = Box::new(<dyn erased_serde::Deserializer>::erase(deserializer));

        let x = (self.f)(&mut *deserializer)
            .map_err(|e| serde::de::Error::custom(format!("{:?}", e)))?;

        Ok((self.ty, Box::new(x)))
    }
}

あまり冴えた方法とは思えませんが、これを使えば Word 全体の deserialize ができますかね……?

toyboot4etoyboot4e

ErasedDeserialzie 改修

返値の型は Box<dyn ErasedComponentPool>Box<dyn Resource> だと分かりました:

type ErasedDeserializeFn<T> =
    fn(&mut dyn erased_serde::Deserializer) -> Result<T, erased_serde::Error>;

struct ErasedDeserialize<T> {
    f: ErasedDeserializeFn<T>,
}

macro_rules! impl_erased_deserialize {
    ( $( $ty:ty; )+ ) => {
        $(
            impl<'a, 'de> serde::de::DeserializeSeed<'de> for &'a ErasedDeserialize<$ty> {
                type Value = $ty;

                fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
                where
                    D: serde::Deserializer<'de>,
                {
                    let mut deserializer = Box::new(<dyn erased_serde::Deserializer>::erase(deserializer));

                    match (self.f)(&mut *deserializer) {
                        Ok(x) => Ok(x),
                        Err(e) => Err(serde::de::Error::custom(format!("{}", e))),
                    }
                }
            }
        )+
    }
}

またすぐそういうコードを書く〜!

toyboot4etoyboot4e

バグ修正 & component の deserialize

serialize
(
  comp: {
    "it::serde_test::Pos": (
      set: (
        to_dense: (
          data: [
            Some((
              raw: (0),
              gen: (
                raw: 1,
              ),
            )),
            Some((
              raw: (1),
              gen: (
                raw: 1,
              ),
            )),
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
          ],
        ),
        to_sparse: [
          (
            raw: (0),
            gen: (
              raw: 1,
            ),
          ),
          (
            raw: (1),
            gen: (
              raw: 1,
            ),
          ),
        ],
        data: [
          (
            x: 10,
            y: 10,
          ),
          (
            x: 11,
            y: 11,
          ),
        ],
      ),
    ),
  },
)

Deserialize 後の inspect:

WorldDisplay { res: [], ents: EntityPool { sparse: [], dense: [], first_free: None, n_free: 0, n_reserved: 0 }, comp: {TypeId { t: 3017681224247174301 }: [Pos { x: 10, y: 10 }, Pos { x: 11, y: 11 }]} }

たぶん正常に復元できているかな……? でも sparse set の内部データは見れません。より詳細なインスペクタや deep equal (?) が必要です。もうバイトとして比較するのでも良いです。

toyboot4etoyboot4e

World にデータ追加する形の deserialize も必要な気がしてきた

serde のユースケース

  1. 疑似的なホットリロード
  2. 本格的なセーブ・ロード

今は前者のみサポートできたら良いです。

deserialize のオプション

trait が 2 種類あります。

  • deserialize::<World>(desrializer)
    新しい World を作ります。

  • world.as_deserialize().deserialize_seed(deserialzier)
    既存の World にデータを読み込みます。

グラフィクスのコンテクストなどは既存の World が持っています。後者のように、既存の World に対してデータを読み込む必要がありそうです。

より具体的な使い方を考えてみると、

1. Resource だけ合成する

  • 既存 World のリソースを抜いておく
  • Deserialzie して World を作る
  • World のリソースを今 World に挿入する

楽そうです。ホットリロードには十分そうなので、まずはこれから。 ああ〜〜〜〜

2. 中間データ形式を作って deserialize_seed する

  • World から別の World へのデータ移行に使える
    • Entity の interning などをやってくれる
  • ひいては DeserializeSeed の実装にも使える

将来的にはこれが欲しいです。ここまでやるなら、結局 Bevy の DynamicScene に近づけても良いかもしれません。

toyboot4etoyboot4e

ser/de 実装完了

  • Serialize: World → RON
  • Desrialize: RON → World

ホットリロードができるかは今後次第です。ただ思っていたよりも難しそうではあります。 TypeId も変わりますからね。

toyboot4etoyboot4e

hecs の ser/de

作者の Relith 氏は飛び抜けて頭の良い人物として知られています。ここでも影を追うことに。

ポイント一覧

ユーザ定義の ser/de context を差し込むことができます。

  1. serialize / deserialize の割り当て方
    Context が割り当てる。動的でも静的でも良い (サンプルは静的) 。

  2. TypeId の代わりの ID
    Context が ser/de する。動的でも静的でも良い (サンプルは静的) 。

  3. Resource について
    hecs::World には Resource という概念が無い (entity/component のみ)

2 種類の実装

serialize::column

Legion の ser/de に近い。 World を archetype 列として ser/de する。

serialize::row

Bevy の DynamicScene に近い。 ``WorldEntity` をキーにしたマップとして保存する。

toyboot4etoyboot4e

クイックロードを考える

ホットリロードへの道は遠い。まずはゲームウィンドウを閉じずにセーブ・ロードしてみます。

ser/de できない component

たとえばグラフィクス・リソースのカウンタを持っていると、直接 ser/de できません:

ゲームを再起動したら無効なカウンタになります。

#[derive(Debug, Clone)]
pub struct SubImage {
    pub tex_id: rgpu::Id<rgpu::Texture>,
    pub uv: Aabr<f32>,
}

この場合、 tex_id は画像ファイルのパスなどとして保存して、 deserialize 時に Id に変換する必要があります。 大変に手間。。

ser/de の中間データが必要

たとえば Component を拡張しておいて、

pub trait Component {
    fn on_deserialize(&self) ->  fn (Entity, &mut World) {}
}

Desrialize 時にフックを呼び出す:

#[derive(Debug, Deserialize, Serialize)]
pub struct RestoreActorView {
    pub img: AssetPath;
    pub uv: Aabr<f32>,
    pub dir: Dir8,
}

impl Compoennt for RestoreActor {
    fn on_deserialize(&self) ->  fn (Entity, &mut World) {
        |entity, world| {
            let img = world.comp::<Self>()[entity].img;

            let gpu_img: GpuImage = world.res_mut::<Assets>().load(img);
            let sub_img = SubImage::new(gpu_img.id, img.uv);

            world.insert(entity, SpriteDirAnim::new(sub_image, img.dir);

            // 他の component の挿入したり:
            // world.insert(entity, ViewBody::new( .. ));

            // Hook 用の component は削除しておく
            world.remove::<Self>(entity):
        }
    }
}

みたいなことが必要かもしれません。

toyboot4etoyboot4e

フック案 2

セーブ・ロード前後でシステムを実行すればいいですね。

内部状態の ser/de はしっかり動いていたので、セーブロードは全然できそうな気がします。

toyboot4etoyboot4e

TODO: オーディオ

kira であっさり実装できる予定です。当方アセットの非同期ロードは考えておりませぬゆえ……

オーディオクレートの評判としては:

  • cpal: ローレベルでクロスプラットフォーム
  • rodio: ハイレベルだけどいまいち
  • oddio: カスタマイズする人向け
  • kira: 既存 API で満足する人向け
toyboot4etoyboot4e

TODO: マークアップ

パーサ

手書きします。

文字影

シェーダで書けます。 Bevy に習って

  • 描画アイテム毎に DrawFunction 割り当て
  • TrackedRenderPass でパイプライン切り替えのコスト削減

後は UniformVec からの要素選択法が気になります。

レイアウト

スパンで区切った文字をシーングラフに入れる予定です。

toyboot4etoyboot4e

sokol_gl の layer_id の実装を見て来た

最近 sokol_gl.h (sokol_gfx.h の上に作られた 2D 描画するフレームワーク、いわゆる batcher) にレイヤ機能が追加されました。 sgl_draw の中では、 CPU 側のコマンド列をイテレートして、 layer_id がマッチするものだけを描画します。とても普通ですね。

ところで計算量について考えると、 sgl_draw は O(mn) になったと思うべきなのでしょうか。それともフィルタリングは無視できるほど軽いと見るべきなのか。無知は持病です。

https://github.com/floooh/sokol/blob/master/util/sokol_gl.h

toyboot4etoyboot4e

間話: aaa.sh

最近は Haskell をやっています。やがて Haskell で 2D ゲーム開発を堪能したり、結局コルーチンが書けてホットリロードできる自作言語 (toylisp) が必要という点に戻る予定ですが、ちょっと予習しておきましょう。 aaa.sh の記事を読んでいきます。

Of Boxes and Threads: Game development in Haskell

状態管理が難し過ぎるという話に見えます。感想としては

  • tick :: GameState -> GameState という制限はキツ過ぎる
    実際は、 tick を複数の subtick :: GameState -> [Command] に分け、細かく時間を進めていく必要がある気がします。たとえば 1 体のキャラが行動する度に時間を進めていくとか。それはほぼ手続き型プログラミングですが、変更の指令を返り値で示すところまで関数型だったら十分な気がします。 Rust でもそういう形でゲームを作っていました (変更イベントに対する handler の割り当てが大変ですが、拡張に対しては開くので) 。

  • FRP (functional reactive programming)
    GUI やゲーム界で一時期 FRP が流行りました。記事の中では、同じ内容を別の表現に変えただけで特に意味が分からなかった書かれていたと思います。一旦 FRP のことは忘れようと思いました。

HSRogue: A roguelike in Haskell

HSRogue は Apecs + SDL2 で作られたゲームです。 SDL2 のスプライト描画にはバッチ処理が働かず、多数の文字を表示すると処理落ちしたとか。界隈では SDL は『ウィンドウのシェル』のように使うべきと言われていますね。

Haskell への言及としては:

  • コンパイルできればクラッシュしない
  • GHCJS で web 版も作成できる (はず)
  • Pure / unpure なコードをが区別されるためエラーの検出が容易なコードになる

標的を共有する entity が出てきたのが凄い。

An Introduction to game development in Haskell using Apecs

  • SDL2 の IO 関数でゲームループを書く
  • System モナド
  • Apecs における component
    • リソースはユニークな component として表現される:
      instance Component Time where type Storage Time = Global Time
    • SemigroupMonoid も実装する必要がある (mappendmempty)
  • WIP

algebric effects, extensible-effects, Idris2 の人