LoginSignup
96
84

More than 1 year has passed since last update.

Godot + Rust + wasmによる3Dブラウザゲームの作り方またはRustはゲーム制作向き言語なのかの考察的な何か

Last updated at Posted at 2021-12-20

この記事は Rust Advent Calendar 2021 - Qiita 20日目の記事です!

ゲームエンジン Godot (ゴドー https://godotengine.org/ ) とRustを用いて3D1シューティングゲームを作成し、さらにソースコード等をwasm化することでWebGLゲームとして公開することができたので、その手法の解説記事となります!(環境構築がメインです)

ここから遊べます!!!!! ↓ スマホも対応! (注意:音が出ます)

  • 操作は十字キーとスペースを使います
  • 一度画面をクリックするとキーボードで操作できます

完成したシューティングゲームのデモ(Windows版)↓

AlienWarCaption.gif

アピールポイント

  • スマホのブラウザでも遊べる3Dゲーム! (UnityのWebGLも一応対応してるらしい...?)
  • ゲームロジックの殆どがRustで書かれている! (with gdnative クレート)

はじめに

筆者の背景を一応紹介します。筆者はプログラミングとゲーム制作が趣味の大学生ですが、Unity C#のNullReferenceExceptionやその他の言語のガベコレに辟易し、Rustをメイン言語としてゲームを作れないかとここ数年模索してきました。

昨年のアドベントカレンダー記事では、Rustで3DCGを行う手法を模索しました。このほか、nannouを試したりbevyについて調べたりなどもしてきました。しかしいくらRustが好きだからと言って制作のすべてをコードベースで行うのは大変です。要はRustでもUnityみたいな開発環境がほしいわけです。

そんな折、Rustで作った動的ライブラリを利用できるGodotというゲームエンジンがあることを知りました。

Godot EngineからRustを呼ぶ

このGodot、ゲームをWebGLエクスポートすることも可能とのこと!制作物はすぐに遊んでもらいたく、Webで簡単に公開できることにもこだわっていた自分としては大変好都合です。しかしRustが使えてかつWebGL化もすぐできるなんて美味しい話があるのかと思ったらそうでもなく、その場合ちょっと工夫が必要になります。

Support wasm32-unknown-emscripten target · Issue #647 · godot-rust/godot-rust

今回の記事では、このIssuesで紹介されているorion78frさんの手法を使った、Rustのソースコードをwasm化する開発環境の作成方法を前半で解説し、後半は今回作ったシューティングゲームの工夫した点などを語ります。

Godot & Rust & WebAssembly 開発環境構築編

Godot

いきなりGodotの存在だけ紹介されても困惑すると思うので、Godotについて説明します。本気で入門したい方はまずはRustのことは一旦忘れ、普通に公式のチュートリアルをやりましょう2!(Godotの環境構築に関しては割愛します、といっても本体のインストールはダウンロードしてzipを展開するだけなのでとても簡単です3。)

クリープを避けろというゲームなのですが、結構短時間で作れます。Godotの特徴を一通りつかめると思います。以下のような感じでしょうか

  • ゲームオブジェクトなどはすべてNodeという単位で管理され、Nodeはツリーを形成できる
  • ツリーのルートであるルートノードが1つ存在するシーンという単位でファイルとして保存される。シーンもまたノードとしたり、インスタンス化できる
  • 基本的には、GDScriptというPythonライクな言語でプログラミングを行う。イベントハンドラのようなものとしてシグナルという仕組みがある

オブジェクト指向で、コンポネント指向であるようです。Unityと似た部分も多く楽に開発ができそうです!以降、Rust向け環境構築の話題になります。GodotのためのプログラムをRustで書いて、Rustによるゲーム制作を実現しましょう!

環境構築に関してのTL;DR

環境構築というのは大変です。「そんな面倒で長ったらしい環境構築しなきゃ駄目なの??ていうかここうまくいかないんですけど!本当に環境構築できるの?」ってなるのが普通です(コメント大歓迎です!)。そこで少しでも手助けするべく、今回の記事のオマケとしてDockerfileを用意しました!使い方はGitHubのREADME.mdの方を参照してください。

  • Dockerfile: wasmへのコンパイル用に用意したDockerfileになります。
  • all_in_one_win/Dockerfile: wasmへのコンパイルに加え、Windows向けに.dllへもコンパイルできる環境です。VSCodeのRemote Containersとの併用を想定しています。

最初はwasmコンパイル用の環境のみ用意しようと思ったのですが、一応.dll有りの環境も後付で用意しました。そのためこのような配置になっています。

(環境構築は遠慮しておくよという方はシューティングゲーム制作編まで飛びましょう)

Godot & Rustの開発環境整備

おそらくチュートリアルを終えた後と思われますので、次はRustのgdnativeクレートを用いて、RustでGodotゲームをプログラミングできるように環境を整えましょう!

先程挙げたGodot EngineからRustを呼ぶの記事様がとてもわかりやすかったため、これに従って環境構築します。すべてlinux向けの場合はclangを入れるぐらいで難しいことは何もないのですが、Windows & WSL2環境の場合少しだけ設定が必要です4

依存関係導入

Rustは導入済みとします。(一応リンク: https://www.rust-lang.org/tools/install )

Rustの環境:
- cargo 1.57.0 (b2e52d7ca 2021-10-21)

まずは依存関係をインストールしていきます。本記事ではコピペ簡易化のためプロンプトの $ は省略します。

bash
sudo apt update
sudo apt install -y build-essential clang mingw-w64

clang と、linux環境でWindows向けにコンパイルするための mingw-w64 をインストールします。 build-essential はRustをインストールする過程で入れているとは思われますが、一応記載しました。

そしてWindows向けのコンパイルターゲットをrustupで入れておきます。

bash
rustup target add x86_64-pc-windows-gnu

設定とコンパイル

ここからは早速プロジェクトフォルダを作成していきましょう!

bash
cargo new --lib hello_godot
cd hello_godot

先に面倒事を済ませましょう。 cargo に関する設定を行うため .cargo/config.toml ファイルを追加します。

bash
mkdir .cargo
touch .cargo/config.toml

config.toml の中身を以下のようにします。Windows向けにコンパイルするための設定です。

.cargo/config.toml
[build]
target = "x86_64-pc-windows-gnu"

[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"

あとはいつもどおり Cargo.tomlsrc/lib.rs を書いていくだけとなります!

内容はgdnativeクレートの公式( godot-rust/godot-rust: Rust bindings for GDNative )から拝借しました。

Cargo.toml
[package]
name = "hello_godot"
version = "0.1.0"
edition = "2021"

[dependencies]
gdnative = "0.9.3"

[lib]
crate-type = ["cdylib"]
src/lib.rs
use gdnative::prelude::*;

#[derive(NativeClass)]
#[inherit(Node)]
pub struct HelloWorld;

#[methods]
impl HelloWorld {
    fn new(_owner: &Node) -> Self {
        HelloWorld
    }

    #[export]
    fn _ready(&self, _owner: &Node) {
        godot_print!("Hello, world.");
    }
}

fn init(handle: InitHandle) {
    handle.add_class::<HelloWorld>();
}

godot_init!(init);

入力が終わったらcargo build --release等でコンパイルしましょう。 target/x86_64-pc-windows-gnu/release/hello_godot.dll が目的のライブラリになります。

動作確認

Godotに hello_godot.dll を読み込ませて、正しく動作することを確認します。

Godotのプロジェクトフォルダに hello_godot.dll をコピーし、以下の手順でシーンを用意していきます。(チュートリアルは行ったものと仮定して大雑把に書きます)

  1. Godotのプロジェクトを用意し、Node 型のルートノードを含むシーンを用意します
  2. 動的ライブラリを導入するためにファイルシステムタブで右クリック→ 新規リソースGDNativeLibraryから .gdnlib ファイルを用意( .tres から変更。 .tres との違いはあまり無いみたい...?)。名前は好きなものでよいのでここではデフォルトの new_gdnativelibrary.gdnlib とします。
  3. 作成した動的ライブラリを対応するプラットフォームに指定します。指定後必ずインスペクタにある保存ボタンでnew_gdnativelibrary.gdnlibファイルを保存してください。 .dll なので Windows 64 に入れます。 save2.png
  4. ルートノードにスクリプトをアタッチします。言語を NativeScript 、クラス名を HelloWorld にしておきます。 Node.gdns のようなファイルが生成されます。 script_attach.png
  5. アタッチしたスクリプトのインスペクタにある NativeScriptLibrary に先程用意した new_gdnativelibrary.gdnlib を指定します。その後、 Node.gdns の方を保存します。(Ctrl + s で保存されます) inspector.png
  6. Node.tscn を保存し、メインシーンに指定します。

この状態で現在のシーンを実行し、コンソールに Hello, world. と出ていれば成功です!

WebAssembly化するための開発環境整備

ここからが本題ですが私もorion78frさんの手法を真似ただけですので詳細がよくわからない部分が結構あります。あと本題とは言いましたがかなりニッチなパートであることは理解しています。誰かの手助けになれば幸いという気持ちで書いています。

本筋としては、Rustツールチェインの内emscriptenを使ったwasm化が行えるwasm32-unknown-emscriptenを使用します。emscriptenを使用しないwasm32-unknown-unknownの方でのやり方はまだないようです。

依存関係追加

まずは依存関係を追加していきます。emscriptenを使用するためにemsdkを入れていきますが、emsdk関連の知識が要求されることはないです。(というか僕もわかってない)

apt , git clone , rustup コマンドを実行していきましょう。以下ホームディレクトリに移ってから行っています。

ホームディレクトリ以外でも構いませんがその場合は間違えないように適宜読み替えてください。

bash
cd ~
sudo apt install python3
git clone https://github.com/emscripten-core/emsdk.git
rustup target add wasm32-unknown-emscripten

git clone で取ってきた emsdk フォルダに移ります。最新のファイルが入るように git pull コマンドを打ち、 emsdk の必要なバージョンを取ってきます。

bash(@~)
cd emsdk
git pull
./emsdk install 2.0.17
./emsdk activate 2.0.17

emsdk のバージョンは 2.0.17 である必要があります。すべてのバージョンを試したわけではないですが、現在の最新バージョンでは条件が足りないのかコンパイルできませんでした。

activate を実行すると最後にこう書いてあります。

- Configure emsdk in your shell startup scripts by running:
    echo 'source "~/emsdk/emsdk_env.sh"' >> $HOME/.bash_profile

emsdk_env.sh は環境変数などをアクティブにするためのコマンドが含まれていますので、とりあえず .wasm をコンパイルする際には予め実行しておくようにしましょう。

bash
source "~/emsdk/emsdk_env.sh"

このコマンドを実行後に、emsdkフォルダの存在位置が $EMSDK 変数で参照できるようになります(本記事では $EMSDK == ~/emsdk )。そのため、以降本記事でも $EMSDK 変数を使用していきます。

ここからがちょっとというかかなり大変なところになります。頑張りましょう。

wasm-opt への細工

まずは、wasmファイルを最適化するためのwasm-optというアプリケーションに対して、 -all というオプションを渡すための細工をします。

オリジナルのものを別なファイルに保存しておき、コマンドの中身を書き換えます。(強引...)

bash(@$EMSDK)
mv upstream/bin/wasm-opt upstream/bin/wasm-opt-bak
upstream/bin/wasm-opt
#!/bin/bash

$EMSDK/upstream/bin/wasm-opt-bak $@ -all

ファイルに実行権限を与えます。

bash
chmod +x $EMSDK/upstream/bin/wasm-opt

リンカー emcc-test の作成

デフォルトのリンカー( emcc )ではなく、細工を施したリンカーを用意します。orion78frさんの手法そのままに emcc-test という名前にしています。場所はどこでもいいので個人的に好きな ~/bin/ 以下に置くこととします。(PATHを通してね)

~/bin/emcc-test
#!/bin/bash

arr=()

for f in "$@"; do
    if [[ "$f" == *.rlib ]]; then
        #echo Extracting $f

        ar --output "$(dirname $f)" -x $f

        ar -t $f | grep .o | while read o; do
            fo=$(dirname $f)/$o

            #echo File $fo
            arr+=("$fo")
        done
    else
        #echo Passing arg $f
        arr+=("$f")
    fi
done

emcc ${arr[@]}

ar コマンドはアーカイブを作成するコマンドらしい...? 正直 emcc を呼ぶ前に特殊なことをしているということしかわからない...まぁ動いてしまえばこっちのものです!

emcc-test にも実行権限を与えておきましょう。

bash
chmod +x ~/bin/emcc-test 

環境変数の用意

あともう少しです!コンパイル前に環境変数をexportしておきます!( config.toml に書く方法ではうまく行かなかったため直接exportしています。)

bash
export C_INCLUDE_PATH="$EMSDK/upstream/emscripten/cache/sysroot/include/"
export EMMAKEN_CFLAGS="-s SIDE_MODULE=1 -shared -Wl,--no-check-features -all"

C_INCLUDE_PATH はemsdkにあるライブラリを使いたいのですからわかるのですが、EMMAKEN_CFLAG の方はビルド用のおまじないみたいです()

./cargo/config.toml への追記

.dll のとき同様、コンパイル設定を書いていきます!コンパイルまであと少し!

以下のように追記してください

.cargo/config.toml
[build]
target = "x86_64-pc-windows-gnu"

[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"

+ [profile.release]
+ opt-level = "s"
+ overflow-checks = false
+ debug-assertions = false
+ lto = true
+ panic = "abort"

+ [target.wasm32-unknown-emscripten]
+ linker = "emcc-test"
+ rustflags = "-C link-args=-fPIC -C relocation-model=pic -v"

コピペ用
[build]
target = "x86_64-pc-windows-gnu"

[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"

[profile.release]
opt-level = "s"
overflow-checks = false
debug-assertions = false
lto = true
panic = "abort"

[target.wasm32-unknown-emscripten]
linker = "emcc-test"
rustflags = "-C link-args=-fPIC -C relocation-model=pic -v"

コンパイル

ここまで正確にできていれば、あとはコンパイルするのみです!

bash
cargo build --release --target=wasm32-unknown-emscripten

実行後、目的の target/wasm32-unknown-emscripten/release/hello_godot.wasm が生成されています。

動作確認

最後に動作確認しましょう! .wasm の方はHTMLにエクスポートして確かめます。

.dll のときと同様に、 new_gdnativelibrary.gdnlibhello_godot.wasm ファイルを指定します。今回は HTML5 -> wasm32 に指定しましょう

プロジェクトエクスポート からエクスポートを行います。プリセットに HTML5 を追加し、Export Typeに GDNative を指定した後、「プロジェクトのエクスポート」をクリックしてエクスポートします。エクスポート先では新たなディレクトリを作成してエクスポートすることを推奨します。また名前も index.html にしておくと楽です。

export_wasm2.png

その後例えば python3 -m http.server などのコマンドを使用してディレクトリをホストし、ブラウザを起動してアクセスしてみます。デベロッパーツールの出力に Hello, world. と表示されていれば無事にコンパイルが完了しています!

シューティングゲーム制作編

以降、小話を一つと考察を行って本記事を締めたいと思います。

オブジェクトプール

シューティングゲームってゲームの中では(僕が今回作ったようなレベルのものならば)簡単に作れそうな気がしませんか?UnityやGodotのように予め当たり判定用のコライダーのような仕組みがあるゲームエンジンならそのように思えるかもしれません。

しかし過去のプログラム経験が浅かった自分は一度挫折したことがあります。それはメモリ管理への意識が薄かったからでした。

初心者だった自分は敵に当たった弾や外に出ていった弾を free ( destroydel とも。)してしまっていたのです!!!毎フレーム無限にメモリ確保と解放を行っていたのでは、いくら高性能な言語でも遅くなってしまいます。ましてや、当時の自分は pygame でゲームを作っていたのですが、限界は弾を円形に飛ばしたところですぐに来てしまいました。

これを解決する手法は至ってシンプルです。弾を「使いまわせ」ば、メモリ解放や確保の機会が極端に減ります。この手法を「オブジェクトプール」と呼びます5

挫折の経緯があって自分の中ではとりあえずオブジェクトプールを実装できればシューティングゲームは作れるのではないかという認識になっています。(もっと作り込んでいくとなると話は別ですが...)

今回のGodot+Rustによる制作では、Godotのシグナル機能がオブジェクトプール制作にとても役立ちました。

送信側(画面外に出たときに実行)
#[export]
fn cartridge_fallen(&self, owner: &Area) {
    // 親ノードは現在ステージで、ステージの管理下から外す
    unsafe {
        let parent = owner.get_parent().unwrap().assume_safe();
        parent.remove_child(owner.assume_shared());
    }
    // 所有権に関してユニークと仮定 (areaにはポインタが入ります)
    let area = unsafe { owner.assume_unique() };
    // ポインタ(あるいはアドレス)をVariantにキャストしてシグナルとして送信
    owner.emit_signal("collect", &[Variant::from_object(area)]);
}
受信側(シグナルを受け取り、チャージ)
#[export]
fn collect_bullet(&mut self, _owner: &Area, bullet_var: Variant) {
    // 動的な変数Variantの値をAreaのポインタにキャスト
    let bullet_area: Ref<Area, Unique> =
        unsafe { bullet_var.try_to_object().unwrap().assume_unique() };
    // マガジンに貯める
    self.mut_magazine().charge_bullet(bullet_area);
}

シグナルを受け取ったら弾を格納する配列に戻せば、それはオブジェクトプールです。今回は自機弾、敵であるエイリアン、敵の弾をオブジェクトプールで実装しました。Unityと比べるとGodotは仕組みがシンプルで扱いやすいように感じたのですが、特にシグナルという仕組みはUnityのイベントハンドラ等と比べてもわかりやすいように感じます。

NULL安全なRustはゲーム制作向き言語なのか?6

この節は完全にポエムになりますが、ゲームをデプロイしたのでポエムる資格はあるだろうということでポエムって行きます!

...と思いましたが長いので折りたたみます

推敲を全くしていない駄文

今回GodotとRustを連携させてゲームを作ったわけですが、当然大変でした!!

なぜかって、継承等が存在するクラス型オブジェクト指向をGodotはめちゃくちゃ使っているのに対して、Rustは真っ向からクラス型オブジェクト指向を否定する言語ですし、かみ合わないのは当然です!

しかしgdnativeクレートの作りが良いのか、Godotの仕組みが良いのかわかりませんが、むしろノードたちやそのAPIとそれを操るロジックの分離という観点ではそこまで不快な感じはまったくなかったです。

そして書いているうちにGodotだけの問題ではないんじゃないかという以下の問題に気づきました。

  • グローバル変数しかないのか?というようなunsafeやunwrapの嵐(これはGodotのせいであるとも言える)
  • まるでシングルトンパターンかのように構造体に連なる変数の数々

なぜこうなってしまったのか...それは「ゲームは組合せ爆発を楽しむものだ」からではないか??と自分は考察しました。

情報量が多いほど楽しいのがゲームです。

例えばポケモン。色違いのポケモンというのはめちゃくちゃ レア ですよね?例えばアチーブメント機能。多くの実績を解除したというのは多くの経験をしたということなので珍しいものです。珍しいということはそれだけ情報量が多いといえます7。情報量の多さは組み合わせ爆発につながります。将棋や囲碁なんかは、まさに組合せ爆発をAIがどう攻略するか?みたいな話が出ますよね。

情報量や組み合わせが多いと条件分岐は複雑になりがちで、フローチャートに起こしにくくなります。麻雀を実装しよう!ってなると楽しそうに聞こえますが、麻雀のフローチャートを描けって言われたら「無理だろ!」ってなります。でも、ゲーム制作というのは究極的にはこのフローチャートをどう実現するかということに等しいです!(強引)

麻雀を打つときに脳内にフローチャートを描いたりはしないのと同様、ゲーム制作をしている最中はおそらく多くの「仮定」を置くことで、組み合わせ爆発に対抗し、制作を楽にしていると言えます。

例えば「交通止めをしているNPCを横切るときに主人公がモンスターの卵を孵化させる」ようなことはないだろうみたいな仮定です。こんな珍しい現象をいちいち気にしていたらゲームなんて作れません。ゲーム制作は多くの仮定の元で成り立っているのでしょう。

ここでRustやElmなどを引き合いに出します。Elmアーキテクチャは強引に言えば「最初からすべての場合に対応できるようにすればバグは生まれないよね」というようなものですし、Rustは持ち前のOption型やResult型を使いつつ、グローバル変数などを禁止しできるだけ「起きうる状況を仮定ではなく確定させる」ような特徴を持っていると言えるでしょう。

もちろん、そのゲームを"完全"なものとしたいならば、RustやElmのこういった性質は喉から手が出るほど欲しくなります。ここまでロジックエラーとその他のエラーをごちゃまぜに書いてきましたが、RustやElmは時としてこのロジックエラーまでもを防ぐ可能性を持っているスーパー言語ですから。

しかしそれが「仮定駆動開発」とは正反対であるというのは明らかです。こういう場面ではリフレクションを持っている言語や動的型付けの言語のほうが楽に事が進みます。

...ふぅ...

つまり結論ですが、当たり前のようなことですが開発速度と正確性はトレードオフであり、「すべてを」Rustで行うゲーム開発は苦行であるというのは言い逃れができそうにないということが言いたいことになります。

じゃあどうしろと?

今回、GUIなどはPythonライクな言語であるところのGDScriptで書いたのですが、GDScript良いですね。ゲーム制作に必要十分な言語というような感じがしました。でもRustacean的には物足りない部分があったのも事実です。

結局のところ、両方を上手に使い分けるのが一番開発効率が良さそうです。パフォーマンスや正確性を求められる場所はRustで、仮定駆動開発をしたい箇所はGDScriptで書くようにするのが、Godotで開発する場合は最適解ではないかと思います。次回作はそのように作ってみたいです。

終わりに

とは言ったものの、まだECSをやたら主張するbevyとAmethystにしっかりと入門できていなかったので、次回の記事はECSを通してRustによるゲーム制作の可能性の模索を続ける8ようなものになる気がします。

Unityを含め、ゲーム制作のベストプラクティスはなんだろうっていつも妄想しているので、そういった関連のコメントを頂けると大変嬉しいです! ここまで読んでいただきありがとうございました!

参考・引用等

シューティングゲームに使用させていただいた素材

制作環境

楽曲等


  1. 「3D」をタイトルに付けましたが「ほぼ2Dじゃない?」というようなゲームなのである意味タイトル詐欺のようにも見えますが、3Dモードで作ったので誰がなんと言おうと3Dゲームです... 

  2. 日本語のドキュメントも存在したのですが、記事執筆中に404になってしまったので代替として英語のドキュメントのURLを載せています。 

  3. アクティベーションが必要じゃないかって?そんなゲームエンジンがあるんですか? 

  4. 一応PowerShellなどWindowsのみの環境での構築も図ってみたのですが、clangあたりで面倒になって結局wslで行うことにしました。 

  5. 余談ですが、東方紅魔郷に登場する咲夜さんも画面の外に出たナイフは回収して使いまわしているそうです。人力オブジェクトプール... 

  6. この節の作業中にフランちゃんのテーマを聞いていたのでオマージュしました。シューティングゲーム繋がりということで...(レベルが違いすぎますが) 

  7. そういう傾向があるというだけで正確な話はしていません、ポエムなので許して...情報理論とか情報のエントロピーとか確率理論とかやってる人からみればむしろ誤りな解説かもです 

  8. もっとも、GodotもECSでしたし、bevyに触れてみたところ結局シングルトンパターンで頑張っているような印象を受けたので、Rustのゲーム制作にまつわる大変さを軽減できるものであるのかどうかと聞かれると今は懐疑的になっていますが...いや!やってから考えるべきですね!精進します! 

96
84
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
96
84