inkfs 🦑 devlog 3 (セーブ・ロードなど)
前回までの inkfs 🦑
inkfs 🦑 (inkfish) は自分専用の 2D フレームワークです。
スクリーンショット再掲
左から読んでも右から読んでも "ぼくはかわくぼ"
向き変更を tween で
メッセージの連続表示 消去アニメーション無し
(APEX がそうらしいですね: https://www.youtube.com/watch?v=uTNLr4AEmZ8 )
ホットリロード 案 2: ホスト + セーブ/ロード
動的ライブラリをリロードすると、新しくグローバル変数が生成されます 。本ゲームではグラフィクスリソースのカウンタにグローバル変数を使っているため、テクスチャのインデクスがずれたりしました。
案 2 では、セーブ/ロード機能を作ってホットリロードを試みます。ウィンドウを表示するホストクレートが、ゲームクレートを動的に読み込む形です。ゲームクレートが新しくコンパイルされたら、ゲームを保存してゲームクレートをリロードします。セーブデータを読み込めば、新しいプログラムでゲームを続行できる見通しです。
案 2 は、失敗してもセーブロード機能によって開発効率を改善できます。案 2 は市民の幸福に繋がります。
(Rust) トラブルシューティング
開発再開!? wgpu 0.13
では WGSL の構文が新しくなって良かったです。
cargo-edit
使用時のエラー
cargo add
や cargo 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.toml
の tiled
を 0.9.4
にして、 ~/.cargo/registry/cache
以下の tiled 0.9.5
を削除すれば、正常にバージョンが解決 (0.9.4
) されるようになりました。
Z 軸ソートの (再) 実装
wgpu
/ ECS 移行時に吹き飛んだ機能の 1 つです。
スクリーンショット
ソート実装前は、テキストに影が被さっていました:
ソート実装後は、テキストが影よりも手前に来ます:
ソート方法
描画の流れは以下の通りです:
-
Queue フェーズ
GPU リソース (頂点バッファなど) の更新コマンドを作成します。また、ドローコール (の引数) を作成します。 -
ソート
ドローコールをソートします。 -
Render フェーズ
更新されたリソースを用いてドローコールを発行します。
Queue フェーズでは Sprite
, NineSliceSprite
などの異種データを全て頂点データに変換してドローコールを作成します。ドローコールは共通種類のデータですから、これをソートすれば異なる種類の描画対象をソートできたことになります。
wgpu
を使っていると、必然的に Queue / Render フェーズが分かれることになります [1] 。これにより、ドローコールのソートが自然な発想 [2] になったり、新しい制約 [3] が生まれたりします。 wgpu
が『正しい』設計をもたらすのかは分かりませんが、他の Rust の制約と同様に、期待できるヒューリスティクスを与えてくれると感じています。
-
wgpu::RenderPass<'a>
はDrop
trait を実装しているため、借用の条件が厳しくなります (drop check) 。そのため伝統的な sprite batcher のように『テクスチャの種類が切り替わったら即ドローコールを発行』とは なりづらい です。バッファ更新の Queue フェーズでRenderPass
に触れず、 Render フェーズで 1 度だけRenderPass
を作って一気にドローコールを発行します。 ↩︎ -
よくある従来の発想としては、
IRenderable
をソートします。 ↩︎ -
Queue フェーズ/ Render フェーズが分かれると、 uniform 変数が実質的に定数になります。複数種類の unifrom 変数が必要な場合は、
UniformVec<T>
のようなものを用意するか、 1 種類の uniform で済むように頂点データを修正したりします (Bevy Engine の場合) 。 ↩︎
マップレイヤーの 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),
セーブロード法を考える
World
には動的にデータを追加しますから、単純な derive
では済みません。
ポイントは以下の 3 つです:
- 動的 ser/de の方法 (
serde
+ erased_serde やReflect
) -
TypeId
の代わりのキー値 (Uuid
やcore::any::type_name
) -
Resource
の ser/de に対応しているか
specs
の場合
用例が見つかりました (Roguelike Tutorial)。実装は調べていません。
legion
場合
- 動的 ser/de には erased_serde を使っています。
-
TypeId
の代わりに任意のキーを使えます:
let mut registry = Registry::<String>::default();
registry.register::<Position>("position".to_string());
registry.register::<f32>("f32".to_string());
-
Resource
には (たぶん) 非対応です。
Bevy の場合
セーブロードは DynamicScene
を経由します。
- 動的 ser/de には Reflect を使っています。
-
TypeId
の代わりにany::type_name
を使っています。将来的には変更がありそうです。 -
Resource
には非対応です。
なお &dyn Reflect
を ser/de の対象にすることもできそうです (Serializable
, Deserializable
を経由します) 。
本ゲームでは legion
のやり方を踏襲したいと思います。
- 動的 ser/de には
erased_serde
を使います -
TypeId
の代わりにany::type_name
を使います (一旦) -
Resource
の ser/de 用の型をtoecs
のビルトインにします
legion
のRegistry
に近いですが、 component ではなくResource
用のRegistry
です。
serde
やホットリロードを考えると、 Rust は 100% ゲーム開発向きの言語というわけでもないなと思います。
シリアライズ
ひとまず簡単な方を実装しました。出力はだいぶん汚いです:
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.resources
や world.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) {
/* ~~ */
}
}
Scene について
今は World
のシリアライズを考えています。全状態のスナップショットを取れる安心感がありますが、難点としては
- 後方互換性の維持が難しい
- セーブファイルが人には読みづらい
スナップショットを取る代わりに、ゲーム状態の復元に必要な情報を保存することも考えられます。たとえば 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 には、今後アセットやシステムの有効/無効なども設定できるようになりそうです。他の開発で忙しいかもですが……
inkfs 🦑 でも scene を作っていくのか
Scene では Entity
をフィールドに持つ Component
の記述が難しいと思います。データ入力のフォーマットとして scene は良いアイデアだと思いますが、セーブロードのフォーマットとしてはスナップショットかそれに近い形式を採用したいです。
Deserialize
の実装を考える
serde
の良い点はデータモデルを介して data structure / data format を接続してくれる所です。 MapAccess::next_value_seed
などを使えるように、動的な deserialize も単なる関数ではなく Deserialize
の実装にしたいです。
serde
周りを見てみました
-
Deserialize
もDeserializeSeed
も trait 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 ができますかね……?
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))),
}
}
}
)+
}
}
またすぐそういうコードを書く〜!
バグ修正 & 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 (?) が必要です。もうバイトとして比較するのでも良いです。
World
にデータ追加する形の deserialize も必要な気がしてきた
serde
のユースケース
- 疑似的なホットリロード
- 本格的なセーブ・ロード
今は前者のみサポートできたら良いです。
deserialize
のオプション
trait
が 2 種類あります。
-
deserialize::<World>(desrializer)
新しいWorld
を作ります。 -
world.as_deserialize().deserialize_seed(deserialzier)
既存のWorld
にデータを読み込みます。
グラフィクスのコンテクストなどは既存の World
が持っています。後者のように、既存の World
に対してデータを読み込む必要がありそうです。
案
より具体的な使い方を考えてみると、
1. Resource だけ合成する
- 既存
World
のリソースを抜いておく - Deserialzie して
World
を作る - 前
World
のリソースを今World
に挿入する
楽そうです。ホットリロードには十分そうなので、まずはこれから。 ああ〜〜〜〜
deserialize_seed
する
2. 中間データ形式を作って -
World
から別のWorld
へのデータ移行に使える-
Entity
の interning などをやってくれる
-
- ひいては
DeserializeSeed
の実装にも使える
将来的にはこれが欲しいです。ここまでやるなら、結局 Bevy の DynamicScene
に近づけても良いかもしれません。
ser/de 実装完了
- Serialize: World → RON
- Desrialize: RON → World
ホットリロードができるかは今後次第です。ただ思っていたよりも難しそうではあります。 TypeId
も変わりますからね。
doc_cfg
にょーん
hecs の ser/de
作者の Relith 氏は飛び抜けて頭の良い人物として知られています。ここでも影を追うことに。
ポイント一覧
ユーザ定義の ser/de context を差し込むことができます。
-
serialize / deserialize の割り当て方
Context が割り当てる。動的でも静的でも良い (サンプルは静的) 。 -
TypeId
の代わりの ID
Context が ser/de する。動的でも静的でも良い (サンプルは静的) 。 -
Resource について
hecs::World
にはResource
という概念が無い (entity/component のみ)
2 種類の実装
serialize::column
Legion の ser/de に近い。 World
を archetype 列として ser/de する。
serialize::row
Bevy の DynamicScene
に近い。 ``Worldを
Entity` をキーにしたマップとして保存する。
クイックロードを考える
ホットリロードへの道は遠い。まずはゲームウィンドウを閉じずにセーブ・ロードしてみます。
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):
}
}
}
みたいなことが必要かもしれません。
フック案 2
セーブ・ロード前後でシステムを実行すればいいですね。
内部状態の ser/de はしっかり動いていたので、セーブロードは全然できそうな気がします。
TODO: オーディオ
kira であっさり実装できる予定です。当方アセットの非同期ロードは考えておりませぬゆえ……
オーディオクレートの評判としては:
- cpal: ローレベルでクロスプラットフォーム
- rodio: ハイレベルだけどいまいち
- oddio: カスタマイズする人向け
- kira: 既存 API で満足する人向け
TODO: マークアップ
パーサ
手書きします。
文字影
シェーダで書けます。 Bevy に習って
- 描画アイテム毎に DrawFunction 割り当て
- TrackedRenderPass でパイプライン切り替えのコスト削減
後は UniformVec からの要素選択法が気になります。
レイアウト
スパンで区切った文字をシーングラフに入れる予定です。
sokol_gl の layer_id の実装を見て来た
最近 sokol_gl.h (sokol_gfx.h の上に作られた 2D 描画するフレームワーク、いわゆる batcher) にレイヤ機能が追加されました。 sgl_draw の中では、 CPU 側のコマンド列をイテレートして、 layer_id がマッチするものだけを描画します。とても普通ですね。
ところで計算量について考えると、 sgl_draw は
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
-
Semigroup
やMonoid
も実装する必要がある (mappend
とmempty
)
- リソースはユニークな component として表現される:
- WIP
algebric effects, extensible-effects, Idris2 の人
エタっている間に RenderPass から lifetime が消えた……?!
👍👍
エタりつつ情報収集
wgpu22
僕が使ってるときは 11 とかだったような
cubecl
Rust-GPU とは違うアプローチだとか