LoginSignup
16
13

More than 3 years have passed since last update.

RustでstaticなEntity Component System

Last updated at Posted at 2020-12-10

Rustでゲームプログラ厶を組む場合、Entity Component System(以下ECS)による設計が有力な候補となります。

RustのECSライブラリ/フレームワークはいくつかありますが、抽象化の恩恵を得るために、内部はダイナミックな機構が使われていることが多いようです。

しかし、せっかくゼロコスト抽象化を謳っているRustなので、スタティックなECSを作りたい!と思い立って、作り始めました。

そして、だいたい形になったので、今回はその紹介をさせていただきます。

できたもの

先に、できたものの見た目を紹介します。
例えばこんなコンポーネントを作ったとして、


struct Input(f32,f32);  //アナログコントローラ入力 
struct AI{ state: i32 }; //敵のAI とりあえず例として、何かのステートを持つ
struct Velocity(f32,f32); //速度 x,y
struct Position(f32,f32); //位置 x,y

下記の様に、ワールドを定義、生成します。

world!(
    //型名
    World {
        //コンポーネント型を列挙
        Input,
        AI,
        Velocity,
        Position,
    }
}

//ワールドを生成
let world = World::default();

エンティティーの追加


//プレイヤーキャラ(エンティティ)の作成
add_entity!(
    world;
    Input::default(),
    Velocity::default(),
    Position::default(),
);

//敵(エンティティ)の作成
add_entity!(
    world;
    AI::default(),
    Velocity::default(),
    Position::default(),
);

システムの定義と呼び出し


//ゲームループ
while !game_end() {

    //Inputを参照してVelocityを計算するシステム
    system!(
        world,

        //ラムダ式風の書式で、
        //|エンティティIDを受け取る変数, 更新対象の変数:型, 参照する変数:型, 参照する変数:型...|
        |entity_id, vel:&Velocity, input:&Input| {
            //inputの5倍を速度とする
            let mut new_vel = vel.clone();
            new_vel.0 = input.0 * 5f32;
            new_vel.1 = input.1 * 5f32;

            //計算後の値を返す
            new_vel
        }
    );

    //AIからVelocityを計算するシステム
    system!(
        world,
        |entity_id, vel:&Velocity, ai: &AI| {
            let mut new_vel = vel.clone();
            //aiの情報を使って、new_velを決める
            // ... 何かのロジック ...
            //計算後の値を返す
            new_vel
        }
    );

    //VelocityからPositionを計算するシステム
    system!(
        world,
        |entity_id, pos:&Position, vel:&Velocity| {
            //位置に速度を加算
            let mut new_pos = pos.clone();
            new_pos.0 += vel.0;
            new_pos.1 += vel.1;

            //計算後の値を返す
            new_pos
        }
    );
} 

こんな感じになりました。
上記の例は、システムが「同じエンティティーIDを持つコンポーネントだけを参照する場合」ですが、もっと複雑なロジックを書くこともできます。

中身の説明

では中身の説明に入っていきます。
まず試しに、素直な実装でECSを組んでみると、こんな感じになると思います。


//コンポーネント1種類を管理するコンテナを作って
struct ComponentContainer<T> {
    components: Vec<T>,
    //管理情報など
}

//ワールドの定義
struct World {
    inputs: ComponentContainer<Input>,
    ais: ComponentContainer<AI>,
    velocities: ComponentContainer<Velocity>,
    positions: ComponentContainer<Position>,
}

ここでまず、構造体のフィールド名と、コンポーネント型を1対1で対応させることになります。

    positions: ComponentContainer<Position>,
    ^^^ここと                      ^^^ここが

同じになるので、わざわざ書くのが面倒な気持ちになります。
特にコンポーネントの種類が増えてくると、煩わしいですね。

かといってタプルを使って

type World = (
    ComponentContainer<Input>, 
    ComponentContainer<AI>,
    ComponentContainer<Velocity>,
    ComponentContainer<Position>,
);

としても、使うときに、えーとVelocityは何番目だったっけ・・・てなりますね。

なんとか、型名だけを覚えていれば使えるようにできないものでしょうか?

concat_idents (https://doc.rust-lang.org/std/macro.concat_idents.html) を使えばいけそうな気もしますが、型名をidentityにつなげることは無理な様でした。

そこで、考案したのがこちら

macro_rules! recursive_tuple {
    ( $t: ty ) => {
        ($t, ())
    };
    ( $th:ty, $( $tt: ty ),+ $(,)? ) => {
        ($th, recursive_tuple!($($tt),+))
    };
}

これは、例えば

recursive_tuple!(i32, f32, u32)

と書くと

(i32, (f32, (u32, ())))

こんな風に展開されます。(再帰的なタプル)
これだと、例えば

type IntFloatUint = recursive_tuple!(i32, f32, u32);
let ifu = IntFloatUint::default();

としたとして、

//i32を参照するには
ifu.0
//f32を参照するには
ifu.1.0
//u32を参照するには
ifu.1.1.0

このように、再帰的に 1. を繰り返すことで、目的の型を持つデータを参照できます。
そして、型を指定して参照するようにしたい場合は、ジェネリックなtraitを用いて、下記のようにできます。

trait TypeRef<T> {
    fn type_ref(&self) -> &T;
}

impl TypeRef<i32> for IntFloatUint {
    fn type_ref(&self) -> &i32 {
        &self.0
    }
}
impl TypeRef<f32> for IntFloatUint {
    fn type_ref(&self) -> &f32 {
        &self.1.0
    }
}
impl TypeRef<u32> for IntFloatUint {
    fn type_ref(&self) -> &u32 {
        &self.1.1.0
    }
}

このimpl をマクロで定義できるようにしてみます。

macro_rules! impl_typeref {
    ( $torg:ty, $t: ty ) => {
        impl TypeRef<$t> for $torg {
            fn type_ref(&self) -> &$t {
                &self.0
            }
        }
    };
    ( $torg:ty, $th:ty, $( $tt: ty ),+ $(,)? ) => {
        impl TypeRef<$th> for $torg {
            fn type_ref(&self) -> &$th {
                &self.0
            }
        }
        $(
            impl TypeRef<$tt> for $torg {
                fn type_ref(&self) -> &$tt {
                    self.1.type_ref()
                }
            }
        )+
        impl_typeref!{ recursive_tuple!($($tt),+), $($tt),+ }
    };
}

説明します。

impl_typeref!(IntFloatUint, i32, f32, u32)

と書くと、以下のように再帰的に展開されます。

//展開 第一段階
impl TypeRef<i32> for IntFloatUint {
    fn type_ref(&self) -> &i32 {
        &self.0
    }
}
impl TypeRef<f32> for IntFloatUint {
    fn type_ref(&self) -> &f32 {
        self.1.type_ref()
    }
}
impl TypeRef<u32> for IntFloatUint {
    fn type_ref(&self) -> &u32 {
        self.1.type_ref()
    }
}
impl_typeref!( recursive_tuple!(f32, u32) ); // (f32, (u32, ()))に対して再帰
//展開第二段階
impl TypeRef<f32> for (f32, (u32, ())) {
    fn type_ref(&self) -> &f32 {
        &self.0
    }
}
impl TypeRef<u32> for (f32, (u32, ())) {
    fn type_ref(&self) -> &u32 {
        self.1.type_ref()
    }
}
impl_typeref!( recursive_tuple!(u32) ); // (u32, ())に対して再帰
//第三段階
impl TypeRef<u32> for (u32, ()) {
    fn type_ref(&self) -> &u32 {
        &self.0
    }
}

そうすると、下記のように型を指定してアクセスできるようになります。

let i = TypeRef::<i32>::type_ref(&ifu);

これもちょっと煩わしいので、マクロにします。

macro_rules! typeref {
    ($e:expr, $t:ty) => {
        TypeRef::<$t>::type_ref(&$e);
    };
}

そうすると

let i = typeref!(ifu, i32);

このように「型を指定するだけでフィールドを参照できるタプル」を作ることができます。
なお、型は同じものが2つ使われていないことが前提です。
もし使おうとすると、impl Traitのところで二重定義のエラーがでます。

これをECSのコンポーネントの管理に使うことで、最初に紹介した様な、スタティックにコンポーネントにアクセスするECSの仕組みができました。

ソースはこちらで公開しています(まだ開発中です)
https://github.com/mas-yo/static_ecs

これを使ってサンプルゲームも作っています。
https://github.com/mas-yo/rust-ecs-game

さいごに

私もまだまだ勉強中なので、それならこんなクレートを使えば便利だよ!とか、ここはこうしたらいいんじゃない?とかあったら、教えてください。

いやーRustって、本当に、面白いものですね。
ではまた、お会いしましょう♪

16
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
13