Rustでゲーム作ってるお話

  • 28
    いいね
  • 0
    コメント

Rustといえば高速実行高安全性、そして並行性を達成するということを目的とした言語ですが、それらにものすごくぴったりな用途が存在します。そう「ゲームプログラミング」です(安全性はゲームプログラミングに限った話ではないですが)。
というわけでRustでゲームエンジンとゲーム作ってますというお話をします。というより、それらの中で便利だと思うor裏技的に活用してるRustの機能を紹介していく感じになります。

ゲームエンジンの詳細については別のアドベントカレンダーの同日記事を参照していただくとして、とりあえず本題に入っていきます。

traitによる詳細な可視範囲の設定

ゲームを製作するにあたって、勉強とかもろもろを兼ねて新規に内製エンジン"Interlude"を並行して開発しているのですが(いずれcrates.ioにあがるとおもいます。たぶん)、そのなかでtraitを活用(駆使)してクレート内にのみ見える内部メソッドみたいなものを定義しています。
現在のRustでは全体に公開(pub)か同一モジュール外には非公開かの可視性しか指定できませんが、Interludeではtraitを使用してそれをクレート外にエクスポートしないことによりクレート外から内部メソッドにアクセスできないようにしています。これが正攻法かどうかはわからないのですが(もっと良い方法があったら教えてください...)。

lib.rs
mod a;
pub use self::a::Data;

mod internals { pub use super::a::*; }
a.rs
use super::internals::*;

// Data::getは外からみえるようにしたいけどData::newは外からみえて欲しくない(でも同一クレートの他modからは見えてほしい)
// -> DataInternalsというトレイトを定義してそれをクレート外にエクスポートしなければ中でしかみえなくなる
pub struct Data(u32);
impl Data { fn get(&self) -> u32 { self.0 } }
pub trait DataInternals : Sized { fn new(v: u32) -> Self; }
impl DataInternals for Data { fn new(v: u32) -> Self { Data(v) } }

enumと&mut selfを用いてオブジェクト死亡処理を効率よく行う

制作中のゲームのほうではenumでオブジェクトの状態を管理するようにして、アップデート処理で&mut selfを受け取るようにしています。こうすることでオブジェクトの状態を綺麗に管理することができ、「死亡中はこのメンバにさわってはいけない」みたいな不安全さも取り払うことができて、さらにインプレースでの処理になるので効率もよくいいことだらけです。また、ゲームのほうではアップデート処理を並列化するのにRayonを使用しているのですが、これとの相性も良いです。ただselfを直接書き換えるので見栄えとしてはそんなによろしくないというか。

例として、次のようにオブジェクトを定義するとします。

#[derive(Clone)]
enum GameObject { Free, Living(u32), Garbage(u32) }
impl GameObject
{
  fn update(&mut self, require_to_die: bool)
  {
    if let &mut GameObject::Living(bx) = self
    {
      if require_to_die { *self = GameObject::Garbage(bx); }
    }
  }
}

updateでrequire_to_dieがtrueの状態でわたってくると死亡状態になるようにします。オブジェクトの後始末に必要な情報も一緒に渡す必要があるのでu32を渡しています。
これを管理するほうですが、次のようにします。

let mut objects = [GameObject::Free; MAX_GO];
...
objects.par_iter_mut().for_each(|o| o.update(...));
for go in objects.iter_mut()
{
  match go
  {
    &mut GameObject::Garbage(bx) => { 後始末コード; *go = GameObject::Free; },
    _ => ()
  }
}

par_iter_mut().for_each(...)でループを回して、そのあとにもういちどforを回してGarbageを後始末しつつFreeに変換しています。
最終的に普通にforで回すのでupdateと一緒にしてしまってもいいんじゃないかと思わなくもないのですが、updateの中身が激重とかだと天と地ほどの差が出るんじゃないかと思います(期待)。ここに出てくる後始末コードというのはどう頑張ってもシングルスレッドでしか動かせないような処理(メモリ解放とか)をするコードで、これもupdate内でやってもいいのですがロックにかかる負荷を少しでも減らそうという考えです。

おわりに(雑感)

ゲーム制作においてGCは動作負荷を予測できなくて予期しないプチフリーズを引き起こしたりするからできるだけ切るようにする、というのはずっと言われてきたことではありますが、ぶっちゃけC#とか普通に使われてるし今ではもうそこまで気にしなくてもいいような気がRustはGCがなくその心配がないので、性能にかなり信頼を寄せてプログラムを書くことができたような気がします。ただ、GCがない分メモリ管理に重点を置く必要が出てくるのでそこだけ見れば普通に生産性は落ちます。ただ、その落ちた生産性を上回る(かもしれない)くらいの便利な機能(パターンマッチとか)が豊富なので普通にC++で開発するよりはかなり精神的に良いプログラミングができると思います。