isobe_yakiのブログ

ニコ生ゲーム開発者向けの記事を書きます

WebAssembly(c++)でニコ生ゲームを作ろう その1

WebAssemblyの仕組みや、利点などに関してはネットで検索してもらえばいくらでも出てくるのでこの記事ではスパっと端折らせていただく。一応リンクを1つ載せておくのでどういうものか掴んでおいて欲しい。

developer.mozilla.org

小難しい説明は全て飛ばして、今回はランキングゲームで手っ取り早くwasmを使う方法を解説していく。

emscripten

タイトルにもある通り、言語はc++を使う。c++からwasmを生成するにはemscriptenというツールを使う。

インストール

Download and install — Emscripten 3.1.44-git (dev) documentation

チュートリアル

ついでに↓チュートリアルもクリアしておこう。

Emscripten Tutorial — Emscripten 3.1.44-git (dev) documentation

実装

インストールができたら実際にゲームから毎フレーム呼び出すようなサンプルを作成してみよう。説明は後でするのでひとまず以下の手順を実行してほしい。

手順1

コマンドプロンプトを開いてゲームを作りたいフォルダへ移動し、akashic initを実行。幅や高さは何でもいいので適当にエンター押して完了する。

手順2

次にゲームフォルダにc++フォルダを作成し、中にgame.cppとprefix.jsを作成する。

こうなってるはず
├─audio
│      se.aac
│      se.ogg
│
├─c++
│      game.cpp
│      prefix.js
│
├─image
│      player.png
│      shot.png
│
└─script
        main.js

game.cppに以下をコピペ

#include <emscripten.h>
#include <iostream>

int count;

extern "C" EMSCRIPTEN_KEEPALIVE void frame()
{
    printf("%d\n", count++);
}

int main()
{
    return 0;
}

prefix.jsに以下をコピペ

var Module = {
    locateFile: path => g.game._assetManager.configuration.wasm.path
};
module.exports = Module;
// 余計なrequireを発生させないため
const process = undefined;
const Buffer = undefined;
const setImmediate = undefined;

手順3

次にemscriptenをインストールしたフォルダにあるemcmdprompt.batを実行。(emsdkにパスが通っていればemcmdpromptと打ってエンターで起動すると思う)

c++フォルダへ移動し、ビルドコマンドを実行。

cd 今作ったc++フォルダのパス
emcc -sNO_EXIT_RUNTIME=1 -sENVIRONMENT=web game.cpp -o game.js

成功すればc++フォルダにgame.jsとgame.wasmファイルが生成されているはず。更に以下のコマンドを実行。

copy /y /b prefix.js+game.js ..\script\game.js
copy /y /b game.wasm ..\script\game.wasm

手順4

game.jsonのassetsに以下を追加。

       "game": {
            "type": "script",
            "path": "script/game.js",
            "global": true
        },
        "wasm": {
            "type": "text",
            "path": "script/game.wasm"
        }

手順5

script/main.jsにコードを追加する。(コメント強調した箇所)

function main(param) {
    var scene = new g.Scene({
        game: g.game,
        // このシーンで利用するアセットのIDを列挙し、シーンに通知します
        assetIds: ["player", "shot", "se"]
    });

    ///////////////////////////////////////
    // wasm読み込み
    ///////////////////////////////////////
    const wasm = require('./game');

    scene.onLoad.add(function () {
        // ここからゲーム内容を記述します
        // 各アセットオブジェクトを取得します
        var playerImageAsset = scene.asset.getImageById("player");
        var shotImageAsset = scene.asset.getImageById("shot");
        var seAudioAsset = scene.asset.getAudioById("se");
        // プレイヤーを生成します
        var player = new g.Sprite({
            scene: scene,
            src: playerImageAsset,
            width: playerImageAsset.width,
            height: playerImageAsset.height
        });
        // プレイヤーの初期座標を、画面の中心に設定します
        player.x = (g.game.width - player.width) / 2;
        player.y = (g.game.height - player.height) / 2;
        player.onUpdate.add(function () {
            // 毎フレームでY座標を再計算し、プレイヤーの飛んでいる動きを表現します
            // ここではMath.sinを利用して、時間経過によって増加するg.game.ageと組み合わせて
            player.y = (g.game.height - player.height) / 2 + Math.sin(g.game.age % (g.game.fps * 10) / 4) * 10;
            // プレイヤーの座標に変更があった場合、 modified() を実行して変更をゲームに通知します
            player.modified();
            ///////////////////////////////////////
            // c++のframe()関数を呼び出し
            ///////////////////////////////////////
            if(wasm.calledRun) {
                wasm._frame();
            }
        });
        // 画面をタッチしたとき、SEを鳴らします
        scene.onPointDownCapture.add(function () {
            seAudioAsset.play();
            // プレイヤーが発射する弾を生成します
            var shot = new g.Sprite({
                scene: scene,
                src: shotImageAsset,
                width: shotImageAsset.width,
                height: shotImageAsset.height
            });
            // 弾の初期座標を、プレイヤーの少し右に設定します
            shot.x = player.x + player.width;
            shot.y = player.y;
            shot.onUpdate.add(function () {
                // 毎フレームで座標を確認し、画面外に出ていたら弾をシーンから取り除きます
                if (shot.x > g.game.width)
                    shot.destroy();
                // 弾を右に動かし、弾の動きを表現します
                shot.x += 10;
                // 変更をゲームに通知します
                shot.modified();
            });
            scene.append(shot);
        });
        scene.append(player);
        // ここまでゲーム内容を記述します
    });
    g.game.pushScene(scene);
}
module.exports = main;

完成!

これでakashic-sandboxコマンドでゲームを起動し、デバッグコンソールにインクリメントされた数字がドバ―っと出力されれば成功だ!お疲れさん

解説

手順2でコピペしてもらったソースコードについて

#include <emscripten.h>    // EMSCRIPTEN_KEEPALIVE のために必要なインクルード
#include <iostream>

int count;

// extern "C"はコンパイラによって関数名が自動割り当ての名前に変更されるのを防ぐ
// EMSCRIPTEN_KEEPALIVE はemscriptenコンパイラにjsから呼び出す関数であることを伝える
extern "C" EMSCRIPTEN_KEEPALIVE void frame()
{
    // グローバル変数countをインクリメントしてコンソールへ出力している(フラッシュのために改行が重要)
    printf("%d\n", count++); 
}

// メイン関数は一応実行されるが何もしない
int main()
{
    return 0;
}
// このファイルはemscriptenが出力したjsファイルの直前に連結される

// emscriptenがexportするModuleオブジェクトに予め機能を定義しておくことでオーバーライドができる
var Module = {
    // 今回は.wasmファイルのパスをオーバーライドする
    locateFile: path => g.game._assetManager.configuration.wasm.path
};
module.exports = Module;
// これらは主にサーバー実行時用だったはず・・・(無いと読み込みエラーを起こす)
const process = undefined;
const Buffer = undefined;
const setImmediate = undefined;

c++はともかくjsはややトリッキーだ。通常emscriptenから出力されたjsファイルはそのままrequireすれば使えるのだが、AkashicEngineは動作が特殊なため上記のようなコードを付け足さないと動かない。というのもニコ生上ではwasmファイルの名前が出力時と異なってしまうからだ。以前の記事でも書いたが、ゲームをエクスポートするとアセットファイルの名前が全てハッシュ値に変更されてしまう。

isobe-yaki.hateblo.jp

もしオーバーライドしないとデフォルトではビルドオプションの出力名.wasmをfetchするようなコードになっている。ローカルで実行する分にはそれでも動くが、このオーバーライドによってニコ生上でも動くようになる。

末尾3行の定数定義はサーバー側で実行する際に必要で、正直「これを書いたらエラーが出なくなった」程度のおまじないでよく理解していない。

手順3で実行したビルドコマンドについて

emcc -sNO_EXIT_RUNTIME=1 -sENVIRONMENT=web game.cpp -o game.js

emccがコンパイルとリンクを行うコマンドである。今回は最小限の引数で実行してみた。

まず、-sNO_EXIT_RUNTIME=1だが、これはc++のmain関数を抜けてもプログラムを終了しないというフラグだ。emscriptenでwasmを読み込むと自動的にmain関数が実行され、その後グローバル変数の破棄処理が実行されてしまう。フレーム更新をする前に変数が破棄されてしまっては困るのでこのフラグを付けている。例えばグローバル変数std::string hoge;を用意して、main()でhoge = "hoge";、frame()でprintf("%s\n", hoge.c_str())とした場合にこのフラグを付けずにビルドしたら恐らくエラーになると思う。(もしくは空文字扱い)

次に-sENVIRONMENT=webだが、これは実行環境を明示するフラグである。wasmはいろいろな環境で実行できるが、環境によって使えるAPIが異なるのでその差を吸収するためのコードも仕込まれるのだが、ビルド時に予め想定する実行環境を明示することによって無駄な判定処理を省けるようにしている。今回はブラウザで動くコードなのでwebを指定した。

手順5で追加したコードについて

main.jsに追加したコードは2か所。

const wasm = require('./game');

まずwasmの読み込み部分だが、これは普通にgame.jsをrequireすることで実現できるので、特に説明することはない。ただし、requireを実行した時点で読み込まれているのはgame.jsだけでgame.wasmはまだ読み込み中なため、関数呼び出しはできないことに注意。

if(wasm.calledRun) {
    wasm._frame();
}

次に毎フレーム呼び出しの個所だが、ここではwasmの読み込み完了をポーリングチェックしている。wasm.postRunにコールバックを代入することで非同期読込完了のタイミングを知ることもできる。 また、c++ではframeという名前の関数だったが、jsからはアンダースコアがついた名前で呼び出すことになっているので注意だ。引数に文字列や配列を渡す関数の呼び出し方法はまた別の書き方になるが、今回はそこまでやらない。

まとめ

今回やったこと

  • c++をwasmにコンパイルできるようになった
  • ランキングゲームでwasmを実行できた

最初にも書いた通り今回の記事ではマルチプレイまではカバーしていない。そのためakashic serveコマンドで実行するとサーバーがエラーを吐いて止まるだろう。

というわけで次回は「マルチプレイでもwasmを使いたい!」をやろうと思う。内容は軽いはず…。重くなるのはその3「実践編」になってくると思う。(いつになるやら・・・)