Advent Calendarということで、この1年かけてちまちまRustでゲームを作ってきたことに関して考えたことを書いていきます。Rustをゲーム開発に使って感じた、Rustの長所と短所を自分なりにまとめていきます。具体的にゲームはこうして作るものだ、といった技術的な話ではありませんが、よろしければお付き合いください。
何を作ったか
GitHubの次のページにて公開しています。
作成したのは、2Dドット絵のローグライクゲームです。描画や入出力機能はSDLに依存しますが、他はほぼ全てRustで作成したものです。未完成品ではありますが、基本的な探索や戦闘、アイテム、会話、店なんかは実装してあります。さびた(rusted)遺跡(ruin)を探索するゲームということで、Rusted Ruinsと命名しています。
なぜRustで作ったか
プログラミングができてゲームも好きなら、自分でやりたいゲームを好きな言語で書いてみたいものです。目的と手段が逆転している気もしますが…。
ただ、この記事の目的の一つは、ゲーム(に限らずさまざまなソフトウェア)を書くのに、Rustという選択肢があることをお伝えすることです。Rustを使いたいからRustを使った、ではそこで話が終わってしまいますから、以下でRustを使うことの利点を述べていきたいと思います。
Rustを使う利点
トレイトを中心とした強力な型システム
Rustの中心的な機能を1つあげるとすれば、おそらくトレイトになるでしょう。traitを日本語に直訳すると「特徴・特性」ですが、Rustのトレイトは型の特性を記述する役割を果たします。
例として、標準ライブラリのWrite
トレイトを取り上げます。Write
トレイトが実装された型は、バイナリデータの書き込み先として機能します。その宣言は以下のようになります。
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
/// 途中省略
}
write
という関数はbuf
引数で渡されたバイナリデータを受け取り、そのデータをどこかへ出力します。出力先はどこかというと、それはこのWrite
を実装している型により異なります。std::fs::File
ならファイルですし、Vec<u8>
ならヒープ上に書き込まれます。その他、標準出力だったりネットワーク上のソケットだったりと、データを受け取って書き出す、という動作をする型の多くは、このWrite
を実装することになります。
このような動作によって共通のインターフェイスを定義する機能は、他の言語にもよく見られるものですが、Rustのトレイトはコンパイル時、静的に実装が決定されるため、動的ディスパッチによるオーバーヘッドが無く、クラス以外の型に実装することもでき、間違ったトレイトの使い方をした場合、親切なエラーメッセージを出してくれます。動的ディスパッチを使いたい場合でも、トレイトオブジェクトを用いれば実現できます。
このWrite
のような標準ライブラリにあるトレイトは、コミュニティが提供するライブラリでも広く使われており、ライブラリ同士を組み合わせて使うことが簡単になっています。例えば、ライブラリ独自のデータをどこかに書き出す関数write_my_data()
は、次のような宣言にするのが良いでしょう。
fn write_my_data<W: Write>(data: MyData, w: &mut W);
これでwrite_my_data()
の作者は書き出し先がどこだろうと気にする必要はありませんし、またWrite
を実装した新しい型を実装すれば、また別の書き出し先を指定できるようになります。また、Write
が宣言に入っていることにより、この関数がやりたいことがぱっと見るだけで理解しやすくなります。トレイトを適切に使えば、作成したライブラリも自然と再利用しやすい形になっていくのは、Rustの型システムが上手く設計されているおかげでしょう。
よく使われるトレイトは他にもあり、その中にはトレイトの型引数や関連型など様々な応用例があります。このあたりは、標準ライブラリを中心にRustを触っていけば自然に覚えられると思います。
所有権、参照、ライフタイム
Rustを学び始めた人にとって、所有権やライフタイムは、学習難度の高い概念と言われています。実際、この概念が理解できていないと、コンパイラのエラーメッセージも理解できず、なぜ自分のプログラムがコンパイルできないのかと頭を抱えることになります。
しかしこれらの機能はプログラマを困らせるためではなく、メモリやリソースの管理を楽で確実にするために存在します。習熟すればするほど、コンパイラに怒られることは少なくなり、むしろエラーメッセージが助けになります。
Rustの特徴は、所有権システムにより、メモリ管理の妥当性がコンパイル時に確認できることです。CやC++のような言語では、メモリ安全はプログラマが確保する必要があります。メモリ関連で間違ったコードを書いてしまうと、どこがバグの原因なのかわからず、再現性に乏しい場合も多々あるため、デバッグに長期間かかることもあり得ます。最悪、バグに気づくこと無く脆弱性を生み出すことにもなります。それに比べれば、コンパイル時のエラーメッセージに対処するなんて簡単です。
また、Rustにはガーベジコレクタを必要としません。ガーベジコレクタのコストを気にせずにメモリ管理ができるのは大きな利点です。
また、前述の型システムと所有権により、プログラマが"正しい"コードを書くための負担がかなり小さくなります。コンパイラによく怒られる代わりに、コンパイラが出すエラーを潰せば、そのコードの正しさはある程度コンパイラが保証してくれるということです。少なくとも、unsafe
なコードを書かなければメモリ安全性は保証されます。さらに、適切な型付けを行うことで、ロジックの面でもコンパイラがミスを指摘してくれるようになります。
低水準なコードも書ける
Rustは、Cと同じくらい低水準のコードを書くことができます。またRustの型・トレイト・所有権などの抽象化・メモリ安全のための機能のほとんどはコンパイル時に静的に解析されるものであり、実行時にはゼロコストとなります。現代的な言語機能と低水準プログラミングが両立できる、というのはRustの大きな特徴でしょう。また、速度面でもCと同レベルで、最適化もしやすいです。
どの程度ハードウェアの性能を引き出したいか、というのはプログラムの用途によって変わってくるでしょうが、CやC++と同等の速度を達成でき、高度な言語機能の恩恵を受けたい、という場合にはRustは有力な選択肢になると思います。
serdeの便利さ
serdeはRustのコア機能というわけではありませんが、実用的なプログラムを書く上では強力な武器になります。Rustの多くのデータ構造が、#[derive(Serialize,Deserialize)]
を付けるだけでJSONやTOML、その他いろいろなフォーマットと相互変換できるようになる、というのはかなり便利です。
Rusted Ruinsでもserdeは多用しており、各設定ファイルやアセットの読み込み、セーブファイルの読み書きをserdeに依存しています。もしserdeがなければ、これらの実装はずっと困難なものになっていたでしょう。
整備されたドキュメント
Rustのクレートのドキュメントは、cargo doc
コマンドを使えばソースコード上のドキュメントコメントを基に一発で作成されます。また、crates.ioにアップロードされたクレートは、docs.rsからドキュメントを見られるようになります。このため、外部クレートを使いたいとき、ドキュメントが無かったり古かったりすることがありません。もちろん、英語は読めなければなりませんし、関数などの定義は載っていてもそれに説明文がつけられていないことも多々ありますが、それでも統一したデザインのHTMLドキュメントが用意されるのは便利です。
Rustの欠点、もしくはまだ足りないと思うこと
細かく別れたライブラリ
Rustの標準ライブラリは最小限の機能を提供するだけで、標準ライブラリに無い機能は別のクレートへの依存関係をCargo.toml
に書いてインポートします。Rustでは、ひとつひとつのクレートはなるべく小さくすることが好まれるようで、簡単なプログラムでもそれなりの数のクレートに依存します。この設計自体は悪いものでは無いのですが、サードパーティのクレートの数は膨大で、目的にあったものを探すのは一苦労です。また、クレートの中から、利用者が多く、ちゃんとメンテナンスされているものを見極めなければなりません。もう少し探しやすくなれば良いのですが。
学習難度
一般的にRustの学習難度は高いと言われています。特に、所有権やライフタイムあたりが鬼門になります。しかし、一度壁を乗り越えてしまえば、わけのわからないエラーを吐く敵だったコンパイラが親切な味方に思えるようになります。筆者の主観ですが、ある程度Rustで書けるようになれば、C++よりも簡単に思えてくるでしょう。
Rustでのゲーム開発について
これまでにRustそのものの利点(欠点)を述べてきましたが、現状のRustでのゲーム開発について考えてみます。
Rustそのものはその速度と現代的な言語機能により、十分ゲーム開発に使える言語だと言えます。しかしながら、実際にゲームを作るなら、何らかのゲーム開発用のライブラリ、フレームワーク、ゲームエンジンに頼る必要があります。
現代において大規模なゲームを作成するなら、既存の高機能なゲームエンジンを使うことが多いです。しかしながら、そういったゲームエンジンではRustを利用できませんので、Rustコミュニティの提供するライブラリを使うことになります。
Awesome Rustを見るとゲームエンジンを名乗るRustプロジェクトがいくつか存在します。2018年12月現在、Piston、Amethyst、ggez等いろいろなプロジェクトが活発に開発されており、これらから選んでゲーム開発に使うことも1つの手です。
筆者のプロジェクトではSDLのRust向けバインディングrust-sdl2を用いています。これを用いる利点は以下の通りです。
- SDLは広く用いられているライブラリで、安定している
- 2Dの描画なら基本機能である程度こなせる。OpenGLを呼び出したりすることも可能
- IMEに対応しているため、日本語入力ができなくもない
もっともSDLは最低限の機能しか提供しないので、UIやその他ゲームに必要な要素は地道に自前で構築してやる必要があります。逆に言えば、SDLのような基本機能からゲームを組んでいくような人には、すでにRustは十分実用の域に達していると言えます。もっとリッチな機能を提供することを目指しているプロジェクトとしてPistonやAmethystがあり、これらの開発が進めばよりRustがゲーム開発へ利用しやすくなるでしょう。すでにこれらのゲームエンジンを用いたゲームも出はじめています。
まとめ
- Rustのトレイトや所有権などの言語機能は、はじめは難しいかもしれないが、慣れれば助けになる。
- メモリ安全性などはコンパイル時に静的に検証されるため、速度面でも優秀。この特徴はゲーム開発にも適する。
- まだリッチなRust用ゲーム開発用環境は現れていないが、低レイヤから開発するならRustはすでに実用的。