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「実践編」になってくると思う。(いつになるやら・・・)

書記素分割(Unicode14)

皆さんはUnicodeの闇をどこまで知っているだろうか・・・

自分もそんなには知らないが、今回は思った以上に沼だった『書記素分割』について紹介していこうと思う。もはやうろ覚えだが詳細は他の人の記事にお任せする👻

そもそもUnicodeとは

まずUnicodeのざっくりした紹介からしていこうと思う。と言いつついきなりリンクをぺとり。

www.buildinsider.net

元々コンピューター上の文字はアルファベットだけで始まったが、コンピューターが世界中に普及して、異なる言語文化圏同士でのやり取りも増えていく中で後付け仕様が膨らんでいた当時の文字コードに限界を感じた人々が文字コードの世界統一規格を作ろうと立ち上がったのが始まりということだと思う。多分

3つのエンコード

Unicodeの最小単位ともいえるのがコードポイントである。コードポイントは21bitの数値で1つの文字に対応している。つまり端的に言ってしまえばUnicodeとは「1文字が21bitな文字コード」ということなのだが、そんな中途半端なビット数をどう格納しているのだろうか?

Unicodeには3つのエンコード方式が存在する。UTF-32,UTF-16,UTF-8だ。それぞれ後ろについている数字がエンコード後のビット数を表す。

UTF-32は21bitをそのまま32bitに拡張するだけのエンコード方式だ。一番単純だが1文字4バイトなので無駄が多く、通常は保存するデータに使用されることはない。

UTF-16は2バイト単位で格納する。当初のUnicodeとはUTF-16のことだったのだが、全ての文字が16bitに収まらなかったためエンコード方式が3種類に増えてしまったらしい。ある意味一番存在意義が無いエンコード方式と言える。UTF-16では16bitからあふれた文字をサロゲートペアと呼ばれるニコイチ表現で定義しているため、1文字が2バイトもしくは4バイトとなる。

UTF-8はちょっと複雑だが一番実用性が高い。1つの文字を1~4バイトで表現するのだが以下のように1バイト目の値で何バイト文字か分かるようになっている。

  • 192未満なら1byteで1コードポイント
  • 224未満なら2byteで1コードポイント
  • 240未満なら3byteで1コードポイント
  • それ以外なら4byteで1コードポイント

ASCIIコードの範囲の文字しか使わなければ1文字1バイトで済むのでかなり効率的である。

書記素クラスタ

世界中の文字をエンコードするのにこれだけのルールで済むなら悪くないだろう。しかし、そうは問屋が卸さない。なんとコードポイント同士も合体することがあるのだ。それが書記素クラスタと呼ばれるものである。

https://re.buildinsider.net/language/csharpunicode/01/05.gif
書記素クラスターの例 こちらから引用

書記素クラスターの利点は、限られたコードポイント空間の節約だと思われる。例えば肌トーンと呼ばれる絵文字があるが、これは「肌を含む絵文字」の直後に配置することによって直前の絵文字と合体し肌の色を変更するという機能がある。これによって「肌を含む絵文字」×「肌色バリエーション」個の組み合わせだけ必要な絵文字を「肌を含む絵文字」+「肌色バリエーション」個に抑えることができる。「肌を含む絵文字」も「肌色バリエーション」も今後増えることを考えると書記素クラスターの合体仕様は必須に思える。一方で、「ひらがな+濁点=濁点付き文字」になる仕様や「ハングル文字をパーツ連結で表現」する仕様においては疑問がある。なぜならこれらはバラバラのパーツも合体後の文字もコードポイントが割り当てられているので、むしろコードポイントを無駄に浪費しているとしか思えないからだ。目的がコードポイント空間の節約でないならそれでも良いとは思うが、なんというか「文字と文字を合体させて別の文字が作れる」という仕様で遊んでるだけなんじゃないのと言いたくなるような文字がちらほらある。

――この辺にややイラっとしたのは次で紹介する書記素分割の処理が非常に面倒だったからである。実装しながら何度も「これ要る?」と思わされたのだ。

書記素分割

ではここから本題の書記素分割を実装していく。エンコードUTF-8とする。

UTF-8からコードポイントへのデコードに関しては1バイトずつデータを見て変換するだけなので実装上問題ない。しかし、書記素クラスターの連結ルールに関してはそんなスマートな仕様は無く、ユニコードコンソーシアムが公開しているデータベースを元にハードコードで実装しなくてはならない。

それがこれである。

https://www.unicode.org/Public/15.0.0/ucd/auxiliary/GraphemeBreakProperty.txt

デデドン!!(絶望)

なんだこれは、たまげたなぁ・・・

なんと書記素を正確に取り出すにはDB検索をして力技で判定を行う必要があるらしい。しかもこのルール、Unicodeのバージョンによってどんどん更新されており新バージョンが出るたびに実装し直しが必要なのだ。この辺のことを記事にした先人の方がいらっしゃるのでぺとり。

ufcpp.net

上記ではUnicode12.0.0の実装が行われていたが、自分が実装した時は既にUnicode14.0.0が出ていたので上記を参考にしつつ14.0.0で同様の実装を行った。しかし、12.0.0と14.0.0では結構ルールもデータベーステキストの形式も異なっており公式の英語ドキュメントを読み漁る必要があったので大分しんどかった・・・。

先人の実装を参考に作ってみた結果がこちら↓

c++版(クリックで展開)

gist.github.com

gist.github.com

凄まじく長い判定関数・・・。えぐすぎるだろ!もちろん手書きではなく公式のDBを元に自動生成している。

c++版はc++20のコルーチンを利用して連結判定のステートマシンを実装した。公式でもステートマシンで実装するといい感じヨと書いていたので、めったにないc++コルーチンの使いどころやんけ!とウッキウキで実装した*1。ちなみにこれ↑このままじゃコンパイル通らないので使いたい場合は足りないヘッダや名前空間の付与など各自でお願いします。

c#版(クリックで展開)

gist.github.com

gist.github.com

コード生成はC#で書いたのでついでにC#実装も載せる。こちらもコルーチンでステートマシンを実装。

これで正しく文字描画が出来るぞ👍🏻

結局なんでこんな大変な思いをして書記素分割を実装してきたかというとゲームでユーザー入力の文字に含まれる絵文字などを正しく表示するためである。実はアカシックエンジンのラベルは書記素分割をやっていないので一部の文字で表示が崩れてしまう。

ゲーム画面のスクショ
このスクショは「黒い肌の人差し指」をアカシックエンジンのラベルと自前のラベルで比較したものである。左上がアカシックで右下が自前のもの。アカシックはそもそも色が付いていないという話もあるが、重要なのはそこではなく、2文字に分離してしまっていることである。この絵文字はコードポイント的には2文字で構成されており、アカシックはコードポイント単位でしか文字を認識しないためこのような描画になってしまうのだ。これを正しく描画するには2文字まとめてキャンバス2DのfillText()に渡す必要がある。

ちなみになぜアカシックエンジンがわざわざ1文字ずつfillText()しているかというと、DynamicFontのテクスチャアトラスにレンダリング結果をキャッシュするためだ。UnityのTextMeshProなんかでもそのようにしている。

youtu.be
TextMeshProで動的に文字がテクスチャ化される様子

文字のレンダリングというのは結構重い。数十点~数百点の制御点からなるベクター画像のレンダリングに他ならないからだ。そんな重い処理を頻繁に行っていてはゲームの邪魔をしてしまう。そこで、巨大なテクスチャにレンダリング結果を詰め込んでおいて、次回以降はそのテクスチャの部分描画によって文字の表示をすることでかなりの軽量化になる。まぁその辺の実装めんどいので毎回文字をレンダリングしてるゲームも多いだろうが、アカシックは割とちゃんとしてるので1文字ずつキャッシュしている。しかし、書記素分割まではやってないために絵文字の分離という問題が起きてしまったのだ。*2


ゲームにおける動的な文字描画に関しては別記事でまとめるつもりだが、今回はそれを実現する上で書記素分割まで実装しているとより見た目が良くなるよというお話でした。

*1:ちなみにコルーチンはこう見えて結構メモリアロケートするのでnew/delete演算子をオーバーライドしていい感じにアロケータをカスタマイズすることをお勧めする。当方のゲームではもちろんメモリを使い回している。

*2:というか書記素分割くらいブラウザAPIとして提供してくれよとは思う・・・ブラウザはやってるんだからさ

【超小ネタ】アカシックエンジンでSEをループ再生したい

アカシックエンジンで再生できるオーディオタイプには'music'と'sound'がある。それぞれの特徴は以下だ。

  • music
    • ループ再生される
    • musicは一度に一曲ずつしか再生できない
    • BGM向け
  • sound
    • 単発再生
    • いくつでも同時に再生できる
    • 効果音向け

今回作っているゲームでBGMを止めることなくカッチコッチ🕑という短いSEをループ再生したい場面があったのだが、どちらもループのためにmusicタイプにしてたせいで同時再生ができなくて困った。

SEのループしたい時間は約3~7秒と決まっているしそんなに長くは無いので音源自体を10秒くらいのループに伸ばしてしまえば概ね解決できる案件ではあったのだが、なぜできないのかちょっと調べてみた。

そうしたところ、musicアセットを再生する際にAudioPlayerを作成するMusicAudioSystemが、1つのAudioPlayerを使い回しており、新たにplayしようとすると前回の再生をstopするような作りになっていた。

一方で、soundの再生に使われるSoundAudioSystemでは毎回新しいAudioPlayerを作成してくれていた。

ということで、手っ取り早く以下のようなハックをした。

// game.json
"clock": {
    "type": "audio",
    "path": "audio/clock",
    "systemId": "music",
    "global": true,
    "duration": 1000
},


// musicアセットだが多重再生できるようにする
g.game.assests.clock._system = g.game.audio.sound;

Game.audioのSoundAudioSystemをAudioAsset._systemへ上書きした。

これでBGMと同時にループするSEを再生することができた。もちろんニコ生の音量調整も適用される。(別解の書き方もあるのだがそっちだと音量調整が無視されてしまう)

ち・な・み・に

書くの忘れてたので追記させてもらうと、アカシックエンジンでは途中でリロードしたりゲームがスキップされたりするといったんすべての音の再生が止められてしまうのだが、その時再生中と見なされるmusicに関しては自動で再開される仕組みがある。ところが、この記事のハックをしたmusicはこの自動再開が効かなくなるので、ほんとにBGMとして流したいオーディオにはすべきではない。あくまでSEだけどループしたいオーディオに関してだけ使うべきである。


ということで今回もハックじみた方法で突破してしまったが、もし正式な方法で実現できるよとかご存知の方いたら教えていただきたい。というかこれも公式に要望として出した方がいいのかなと思いつつ締めたいと思う。

怖い部屋3D振り返り

今年の4月に開催されていた「ニコ生 自作ゲームパーティー-春-」という企画に怖い部屋3Dというゲームで参加してました。 それから大分空いてますが今回はその振り返りでもしてみようと思います。

ちなみにこんなホラーゲームでした。→リンク先へ飛ぶとすぐにゲームが始まります。

参加しようと思ったきっかけは、たまたま見かけたふんすけさんの配信でした。その日の配信は6月末にサ終するアツマールの話題を中心にニコ生ゲーなどの話をされてたんですが、その中で直近の話題として前述のゲームイベントの話が出ていました。イベントの締め切りが4/16(日)だったんですが、配信を見たのが確か4/11とかで、イベントに参加する気は無かったんですが急に気になりだしたんですよね。

4/11(火) 4/12(水) 4/13(木) 4/14(金) 4/15() 4/16()
ココ

後々気づいたんですが、ゲームイベントはニコ生ゲームのイベントであって別にアツマール終了後も続くんですよね。ただ、その時は何を勘違いしたかラストチャンスなら参加してみるかという気持ちになって参加を決意したのでした(アホ)。


そんなこんなで、翌日から何を作るか考え始めます。

まず圧倒的に時間が無いですから、ある程度ありものを活用する必要があるなと

で、手元にあるものって言うと3D迷路くらいしかない。3D迷路ではPC・スマホで一人称視点のキャラを操作し、フィールドの中を歩き回るという処理まで実装していました。

元々、3Dホラゲー作りたいなという願望はあったんで、迷路を活用したお化け屋敷ゲームの企画を脳内で練り始めます。

まずニコ生ゲーでホラーをやるのってどうなの?ということを考えていました。応募できるのがランキングゲームのみなので数十秒の中でスコアを競うゲームしか作れません。しかも繰り返しプレイされるのが前提なのでホラーって相性悪い気がします。

そこで、とあるゲームのことを思い出します。影廊です。

www.gamespark.jp

一言で説明するならホラーな不思議のダンジョンという感じです。 個人開発らしいんですが、とてもよくできてます。

自分は見る専だったのでプレイしたことは無いですが、このゲームの面白いところは、最初は怖いし慣れてないしで牛歩プレイで死にまくるところから始まって、何度も死んで慣れてくるとプレイヤーの動きが洗練されてきてRTA化してくるところなんですよね。バイオハザードとかもそうだと思うんですが、最初ホラーでビクビクしてるところから蹂躙プレイに変わっていくその落差が痛快で面白いんです。

そう考えたときに影廊で面白かったRTA的要素をゲームのメインにするのはどうかと思いました。つまり、最初は「雰囲気怖いしマップもわからないから全然点取れない」というところから始まって繰り返すうちにプレイヤーが成長して「お化けも無視して頭に叩き込んだマップを最速で駆け抜ける」ところまで持っていけたら楽しそうだしニコ生ゲーになるかなという発想でした。

あとは、何をスコアとしてどういうアクションをさせるかですが、ここは割と見切り発車でした。『一人称で移動しつつ「銃を撃つ」みたいなアクションはさすがに難しすぎるかな』とか『マップのギミックを解いて先へ進むゲームだと進行度をスコアにするのか?プレイヤーごとのスコアの差がつけにくそうだな・・・』とか考えつつも、『まぁいいや最後余った時間で実装出来そうなものを作るだけだ!』って感じで余り物で晩御飯を作る主婦みたいなノリで進めちゃいました。

結果は遊んでもらえばわかりますが、アクションは移動のみ。スコアは"アイテム回収でプラス"&"お化けヒットでマイナス"のみです😅。ほんとは鍵付き扉とか敵撃退アイテムとか影廊っぽい要素も考えてたんですが全く時間足りませんでしたね。


というのも、ゲームの構想とアセットの作り方などの下調べで2日使ってしまって実際にモノづくりに着手できたのって4/14(金)くらいからで〆切まで3日に迫っちゃってたんですよね。

こういう時自分の作業優先順位は見た目作りからになります。体裁さえ整ってればできてるっぽく見えるからっていうのと、自分の中で見た目作りがボトルネックになっているからです。うまく作れる自信が無いところほど先に片付けないと最終的に終わらない確率が上がると思ってます。

というわけで、最低限必要そうなアセットを書き出してみました。

  • 床・天井・壁のテクスチャ
  • フィールドのモデルデータ
  • 敵のグラフィック(画像のみ?3Dモデル?)
  • アイテム(画像のみ?3Dモデル?)
  • BGM・SE

まず、床・壁のテクスチャですが、これはBlenderのテクスチャ生成プラグインを使って作りました。何となくイメージに合うテクスチャが生成できるまでそれぞれ10回くらいやりなおした感じですね。ちゃんと上下左右の柄がつながってループできるので便利です。

天井のテクスチャはGIMPで自作しました。ノイズフィルタとかで適当に気持ち悪いぼつぼつした柄を作ります。イメージとしては結露で黒カビが生えまくった天井という感じで、不潔な不快感が出したかった感じです。

で、これらを張り付けるフィールドですが、フィールドはモデリングまでする時間無いんでプログラムで生成することにしました。以下のマップデータを元にゲーム起動時に3Dメッシュを生成しています。

"wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"
"wwwwwwwhhhhwwwwwwwwwww     wwwwwwwwwMwwwwwwwwww  $  wwww      ww"
"wwwhhhhh~~hhwwwwwww_          wwwwww wwwwwwww         ww www  ww"
"wwwhwwhhhhhhwwwwwww   wwwww    wwwww wwwwwww     M       ww    w"
"wwwhwwwhhhhwwwwwwww   wwwwwwww wwwww wwwwww             www_  _w"
"wwwhwwwwHHwwwwwwww   wwwwwwwww ww@      www      *      www __ w"
"wwwhwwww^^wwwwwww   wwwwwwwwww wwwwwwww ww _    www    _ ww __ w"
"wwwhwwwwhhwwwwwww   wwwwwwwwww wwwwwwww ww     wwwww     ww_  _w"
"whhhhhww  wwwww    wwwwwwwwwww www    w ww     wwwww     wwhhhhw"
"whwwwhww        _ wwwwwwwwwwww www ww   ww     wwwww     wwhhhhw"
"whw~whww       wwwwwwwwwwwwwww*www  wwwwww _    www    _ wwhhhhw"
"whwWwhww  wwwwwwwwwwwwwwwwwww   www      ww      *      wwwhH^hw"
"whhhhhwwllwwwwwwwwwwwwwwwwww  w  wwwwwww ww     _ _     wwwhhhhw"
"wwwwwwwwjjwwwwwwwwwwwwwwwww       wwww   www     _     wwwwhhhhw"
"wwwwwwwwjjwwwwwwwwwwwwwwww  w _ w  www wwwwww         wwwww    w"
"wwwlllllllllllllwwwwwwwww           ww     wwww     wwwwwww    w"
"wwwllwwwwwwwwwllwwwwwwww  w _ w _ w  wwwww wwwww   wwwwwwwww$$ww"
"www  wJlllllJw  wwwwwww               wwww wwwww   wwwwwwwwwwwww"
"www  wwwlllwww  wwwwww* w _ w I w   w *www wwwwwww wwwwwwwwwwwww"
"wwwhhh  ljl  hhhwwwwwww               wwww wwwww   wwwwwwwwwwwww"
"wwwhhh  ljl  hhhwwwwwwww  w _ w _ w  wwwww wwwww wwwwwwwwwwwwwww"
"www  wwwlllwww  wwwwwwwww           www    wwwww   wwwwwwwwwwwww"
"www  wJlllllmw  wwwwwwwwww  w _ w  wwww wwwwwwww   wwwwwwwwwwwww"
"wwwllwwwwwwwwwllwwwwwwwwwww       wwwww wwwwwwww   wwwwwwwwwwwww"
"wwwlllllLLLLllllwwwwwwwwwwww  w  wwwwww                     wwww"
"wwwwwwwwwwwwwwwwwwwwwwwwwwwww   wwwwwww                 w   wwww"
"wwwwwwwwwwwwwwwwwwwwwwwwwwwwww wwwwwwwwwwwwww  wwwwwwwwwwhhhwwww"
"wwwwwwwwwwwwwwwwwwwwwwwwwwwwww wMwwwwwwwwwwww  wwwM     whhhwwww"
"wwwwwwwwwwwwwwwwwwwwwwwwwwwwww w wwwwwwwwwwwwllwwwwwwww whhhwwww"
"wwwwwwwwwwwwwwwwwwwwwwwwwwwwww w       llllllllwwwwwwwwhwhhhwwww"
"w   w_w_w_w_w_w_w_ wwwwwwwwwww wwwwww wwwwwwwllwwwwwwwwhwhhhwwww"
"w w w w w w w w w  wwwwwwwwwww w           ww  wwwwwwwwhwhhhwwww"
"w w        M        wwwwwwwwww wwwwww wwwwwww  wwwwwwwwhwhhhwwww"
"w w                 wwwwwwwwww ww___   ___wwwhhwwwwwwwwhwhhhwwww"
"w w    ww     ww    wwwwwwwwww ww*wwwwwww*www^^wwwwwwwwhwhhhwwww"
"w w    ww     ww    wwwwwwwwww wwwwwwwwwwwwwwhhwwwwwwwwhw   wwww"
"w w                 ww          _hhhhhhhhhhhhhhwwwwwwwwhw   wwww"
"w w                 ww  wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwhwlllwwww"
"w wjjjjLLjjjjjLLjjjjww wwwww                    hhhhhhhhwlllwwww"
"w w                 ww wwww       wwwwwwwwwwwwwwwwwwwwwwwljlwwww"
"w w           *_    w_ _www   $                 llllllllljjjwwww"
"w w   _ww     ww    ww wwww       wwwwwwwwwwwwwwwwwwwwwwwljlwwww"
"w w   _ww     ww    ww wwwww     wwwwwwwwwwwwwwwwwwwwwwwwlllwwww"
"w w                 ww wwwwwwwwwwwwwwwww*         wwwwwwwlllwwww"
"w w                 ww wwwwwwwwwwww            ww wwwwwwwlllwwww"
"w w                 ww wwwww               ww  ww wwwwwww   wwww"
"w w                                        wM  ww        ***wwww"
"w wwwwwwwwwwwwwwwwwwww wwwww               ww  wwwwwwwwwwwwwwwww"
"w w                    wwwwwwwwwwww            wwwwwwwwwwwwwwwww"
"w w  wwwww wwwwwwwwwwwwwwwwwwwwwwwwwwwww         _    _   _wwwww"
"w w  w   w   wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww wwwww"
"w w  w w wwwwwwwwwwwwwwwwwwwwwwwwwwww    wwwwwwwwwwwwwwwww wwwww"
"w w    w$wwwww_________wwwwwwwwwwww        wwwwwwwwwwwwwww wwwww"
"w wwwwwwwwwwww_________wwwwwwwwwww   wwww   wwwwwwwwwwwwww wwwww"
"w         wwww_________wwwwwwwwww   wwwwww   wwwwwwwww      _  w"
"w w w w w wwww_________wwwwwwwwww   wwwwww   wwwwwwwww w w w w w"
"w  _ _ _  wwwwwwww wwwwwwwwwwwwwww wwwwwwww_wwwwwwwwww  *      w"
"w w w w w wwwwwwww wwwwwwwwwwwwwww wwwwwwwwwwwwwwwwwww w w w w w"
"w  _ M _                                       wwwwwww_       $w"
"w w w w w www wwwwwwwwwww  wwwwwwwwwwwwwwwwwww wwwwwww w w w w w"
"w  _ _ _  w$w w*www    w       wwww   $ w       wwwwww     *   w"
"w w w w w w w w www M    w  w  wwww @w     w    wwwwww w w w w w"
"w*********w     www    w       wwww   $ w       wwwwww$   M  _ w"
"wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"

細かい最適化の話をすると、上記3つのテクスチャを1つのテクスチャ配列にまとめて、各頂点にはUV座標とテクスチャインデックスを持たせてシェーダーで参照するテクスチャを切り替えるようにしました。これによってフィールドは床・天井・壁まで含めて1ドローコールで描画出来るようにしています。


次に敵のグラフィックですが、これもモデリングはしたくなかったんで、ペラペラの画像で済ますか単純な立方体とか球で済まそうかと考えていました。ただ、いくら雰囲気ホラーと言ってもそこまで雑だとあまりに怖くないなと思ったので初心者ながらにBlenderモデリングをすることにしました。

カオナシではありません!⚠

スカルプティングモードを使って立方体から大まかに切り出し、押し出しツールなどで徐々に形を整えていきました。Blenderはローポリ化とかも手軽でいいですね。モデリングする際に気を付けた点は以下。

  • スキニングはできないので手足は無し
    • キャラクターをアニメーションさせる時に関節部分を滑らかに変形させる処理をスキニングといいますが、機能としてまだ実装していなかったのとそもそもアニメーションを付ける時間も無かったので動かない手足を生やすくらいなら削ぎ落してしまおうということでツルっとしてます。
  • ターンしたり、坂を移動したりするので足元は細く
    • その場で回転する際に接地部分がスライドして見えたり坂で地形とめり込んでほしくないので、接地部分を小さくして違和感を少なくしています。カオナシみたいな下に広がった形状をイメージしてもらえば何がまずいか分かると思います。
  • 円筒状にして回転時のバランスをよく
    • お化けの方向転換は手抜き実装なので単純にY軸回転しているだけです。そのため体が中心線からずれていたり「?」みたいな歪んだ形状だと回転時の見栄えが悪いです。できるだけ円筒に近い方が違和感が少なくなります。

後は顔の部分をやや突き出して接近時の威圧感を出して完成です。

顔のテクスチャですが、こちらはGIMPで1~2時間ででっち上げました。怖い顔の画像といえばやっぱ真っ黒背景に浮かび上がる白塗り顔が定番っしょみたいなノリで描きました。

レイヤーは4枚のみ。よく見るとすごい雑

最後にテクスチャをモデルに張り付けるんですが、ここでうっかりミスをして顔が上下反転に張り付いてしまいます。

「でもあれ?こっちの方が良くない・・・?」

元々正立の状態で張り付けてたんですが、このお化けプレイヤーより背が高いので目を上に剥いてしまうとどこ見てんの状態になってしまってたんですが、倒立で張り付けることで上からプレイヤーをにらみつけているような効果が生まれた上に、首を大きく後ろに反らした姿勢のようになり一気に異形感が出てきました!(個人の感想です)こういう偶然も重なって割と満足のいくお化けが出来上がりました。

最後はアイテムのグラフィックですが、これはもうなんとかなるやろと思って後回しにしてしまいました。

アイテム完成図
まぁ結果を見てもらえば分かる通りあまりなんとかなりませんでした・・・。


と、ひとまず見た目だけは何とか整えたので後は締め切りまでにできるだけ遊びを実装していきました。

結構時間がかかったのが敵の動きとかですね。追跡モードになったらA*とかで経路探索して追いかけるみたいにしたかったんですがそこまで時間足りなかったし、ちょっと心残りがあります。

あとはフィールド関連がとにかく時間食いまくりました。まずフィールドデータの作成ですが、覚えゲーにしたかったのでランダムではなく固定マップにしました。でもすぐには覚えられないように結構広めにしてしまったがためにフィールドを考えるのが大変でした。時間が無くて難易度調整が全くできてなくて、例えば最高報酬の10万点アイテムは2つだけあるんですが、できるだけ離して配置したのでどちらか一方しか取れないつもりでいたんですが、頑張ればギリギリ両方取れるらしく最初チートか?とか思ってしまいましたw。完全に調整不足なだけでした笑。 また、フィールドには3段階の高低差があります。はっきり言ってゲーム性には何の関係も無いんですが、入り組んだ地形にしたいというホラー演出的な意図のみで実装してます。ほんとにそういう効果があったかはわかりませんが・・・高低差のおかげでメッシュ構築処理がやや複雑になって実装もデバッグも時間を食ってしまいました。

BGMとSEに関しては完全に〆切後に入れることになってしまったんですが、アイテム取得音などはやはり大事でグラフィックだけだとアイテムを取得する際アイテムが視界に無かったりするので獲れたかどうかがわからないんですよね。音が付くだけで大分遊びやすくなります。また、これは意図を理解できた人はあまりいなかったと思いますが、とあるタイミングで鳥の鳴き声が鳴るようにしました。単なる環境音ではなさそう、までは感じた人も結構いると思いますが、実はあれは前方以外のお化けに見つかった時に鳴ってたんですよね。自分の視界にお化けがいる状態で気づかれる分にはいいですが、後ろから音もなく近づかれたら全く気づけないのでその合図として流してます。もともとお化けには吐息SEを付ける想定で、影廊のお化けみたいに距離に応じて音量を変えるみたいなことをしようと思ってたんですが、いい吐息も見つからなくて土壇場で「鳥が鳴く方がおしゃれ演出じゃね?このお化けが声とか出すの想像できん!」と思ってしまいあのような分かりにくいことになってしまいました・・・。

まぁ自分の場合は遊んでもらうことがモチベというより、ゲームづくりの実験場と言う感覚が大きいので分かりにくくても思いついた演出を試したい欲求の方が強いんですよね。

そういうとこもあって遊び方の説明はいつもめっちゃ手を抜いてしまうんですが、最初に画像ぺら1枚で済ませちゃってます。チュートリアルってちゃんとやろうと思うとかなり手間なので嫌なとこですよね。


春のイベントに応募されたゲームはニコニコ超会議の配信内で有名配信者達がスコアアタックをするという企画で遊ばれました(イベントの怪しげなサイト)。怖い部屋3Dはもこうがプレイしてましたね。まぁ初見一発目のプレイだったんでグダってましたが何にせよ厳しかったかなとは思います。企画が良くなかったですね。

また、イベント期間中起動された回数が多かったゲームトップ3には賞が与えられるんですが流石に逃してしまいした。作りこみが圧倒的に足りなかったので妥当とは思います。まぁ単に3Dゲームがうまく動かない人や操作苦手な人も弾いてしまうので、遊ばれたいならこの辺りも工夫は必要でしょうね。

一方で一部の方にはホラー要素や3D要素が受けたようなのでそこはやったぜという感じでした笑。

3Dのニコ生ゲームを作ろう その3

前回まででWebGLを使い始めるところまで行った。

あとは普通にWebGLを駆使して作ってけばいいだけなので公式リファレンスや色々な解説サイトなどを参考に作ってください。で終わりなのだが、さすがにそれだけでは味気ないので自分が作って行く中で気づいた点などをちらほら書いていこうと思う。

まずは描画の最適化について。

描画のボトルネックを知る

WebGLなどのハードウェア支援で描画する際にパフォーマンス上のボトルネックになる要素は何なのか?もちろんGPU処理自体が重ければフレームレートは落ちるが、他にも、というか割と多くの場面でCPU負荷でフレームレートが下がっていることも多い。というのも、GPUにコマンドなどを送る処理というのは結構重いらしく、大量の表示物があるとGPU的には余裕でもCPUが先に限界になってしまうというのだ。

そこでWebGL2.0からはコマンド転送量を減らすことのできる機能がいろいろ使えるようになっているので、その辺をしっかり活用してできるだけ処理を軽くしよう。ただし、その分動かないブラウザがやや出てくるので多少のバグ報告は来るだろうが諦めよう。

WebGL2.0のサポート状況

自分のブラウザのサポート状況が確認できるページ

というわけでここからは3D描画システムを作るうえで押さえたいポイントを紹介していく。

CPU最適化機能

1.UBO

wgld.org

もともとOpenGLでシェーダー変数の値を設定するにはパラメータごとに転送関数を呼び出す必要があった。しかも前回の値を使い回すこともできないため*1、ほぼ毎回再設定が必要。そのため毎フレーム「オブジェクト数*パラメータ数」分glUniform1fなどの設定関数を呼び出すことになる。複雑なマテリアルになれば当然パラメータもどんどん増えるので爆発的に関数呼び出しが増えそうというのは想像できるだろう。そしてこの大量の呼び出しがかなりのCPUボトルネックになってしまう。

そこでUBOを使うと何ができるかというと、これらのパラメータ設定を1個にまとめることができる上に使いまわすことができるのだ。例えば以下のシェーダー変数がある時に

mat4 world;
mat4 viewProj;
vec4 color;

UBOなしだと描画ごとに3回ずつパラメータ設定を呼び出す必要があるが、UBOありならば1回で済むし、何なら前フレームと値が同じ場合は0回で済む。ざっくり言えば毎フレームのパラメータ設定を「オブジェクト数」以下に抑えることができるのだ。これはCPU負荷をめちゃくちゃ軽減できるので例えシェーダーの変数が1つでもやった方がいいだろう。

2.VAO

wgld.org

wgld.org

これはもう上記URLの内容をしっかり読んでいただければそれでよいのだが、、、簡単に言うと「ポリゴンを描画する際どんな頂点情報を送るかブラウザに教えるための煩雑な手続きを一括で処理できるようにしたもの。」である。WebGL2.0では描画するポリゴンの頂点情報を結構柔軟に設定できる。

  • 頂点バッファありか無しか、使うならどのバッファを使うか
  • インデックスバッファありか無しか、使うならどのバッファを使うか
  • 頂点のデータの並びはどんなか
  • など・・・

柔軟な分設定項目が多く、ポリゴンを描く前にいちいちこれらの設定をしないといけないのが非常にCPUボトルネックを生み出していた。しかしなにかオブジェクトを描くにあたって毎フレームこれらの設定が変わるということはまずないため、最初に1回設定したら後は同じ設定を使いまわすのが正道だろう。それを実現するのがVAOであり、これも全てのポリゴン描画で使うべきといえる。

3.インスタンシング

wgld.orgwgld.org

同じポリゴンを大量に描画したい時、素直にやるなら単純にその数だけ描画すればよいのだがその数が数千~数万などになってくるととてもじゃないがCPU処理が間に合わない。そこで、通常の描画関数の代わりにインスタンシング版の関数を呼び出し、シェーダーなどもインスタンシング用に書き換えることで数千回分の描画を1回の関数呼び出しで行うことができるようになる。ちなみにこれで削減できるのはCPU負荷でありGPU負荷はほぼ変わらない。

例:ドロー関数をインスタンシング用に

glDrawArrays() → glDrawArraysInstanced()

in vec3 pos;
uniform mat4 wvp;

void main(void) {
    gl_Position = vec4(pos, 1.0) * wvp;
}

↓シェーダーをインスタンシング用に

in vec3 pos;
uniform mat4 wvp[256];

void main(void) {
    gl_Position = vec4(pos, 1.0) * wvp[gl_InstanceID];
}
ちなみに

3D迷路(仮)という実験用に作っていたゲームでは129*129=16641マスに対して立方体ポリゴンの壁と床を1個ずつ配置するという方法でシーンを構築していた。 壁8000個超、床16000個超でしかも、深度パス・カラーパス・シャドウパスの3回描画していたのでそれぞれ3倍になるのだが、ドローコールは確か20~30くらいで済んでいた。これだけのポリゴンを毎フレーム描画してもスマホで60fps近いフレームレートが出せていたのは間違いなくインスタンシングの力が最もデカかっただろう。


ここまでで紹介した3つのテクニックはどれもオプションであり、これらを使わなくても同じ描画結果は出せる。しかし、使えるならできるだけ使うべき機能たちなので描画システムを設計するときには最初からこれらを織り込み済みで設計した方が後々楽である。

まぁなぜオプションなのかといえばサポートしていない環境も存在するからという話でもあるので本来は使えるかどうかチェックして、使える時だけこれらのテクニックをONにするというのが正式な対応なのだが、これらはどれもパフォーマンス上めちゃくちゃ重要であり、これらが使えないならそもそもまともな速度で動かないことも多いので、そこまでして広い環境をサポートするくらいなら動く環境だけサポートでもいいんじゃないのというのが個人的な感想です。

GPU最適化テク

以下はCPU負荷を抑えるわけではないがGPUに結構効くテクニック集。こっちも大事よね。

1.Early-Z pass

3Dのオブジェクトを描画する際、普通はカラーバッファに加えて深度バッファにも書き込む。これがあると同じピクセルに手前のものを描画した後奥のものを描画しようとしても、書き込めないようにしてくれるので描画順に関わらず見た目の奥行きが正しくなるのだ。(図解が多くてわかりやすい解説があったので貼っておく)

さらに頂点シェーダーを実行したあとラスタライズというどのピクセルに書き込むか算出する処理を経てピクセルシェーダーが呼び出されるのだが、どうせ深度バッファで弾かれるならピクセルシェーダーの呼び出し前に弾いたほうがいいよねということで、Early-Z Culling(Early Fragment Test)という最適化処理がGPUに実装されている*2。 下図で言うとtesting and blendingが通常の深度テストタイミングなのだが、Early-Zがサポートされているとfragment shaderのところでテストされる*3

https://graphicscompendium.com/intro/figures/graphics-pipeline.png画像引用元:https://graphicscompendium.com/intro/01-graphics-pipeline

これを利用した最適化手法がEarly-Z passである。手法の内容は簡単で通常描画の前にシーン全体の深度だけを書き込むパスを追加するというものだ。

深度だけ書き込むにはどうするのかというとピクセルシェーダーを設定しないで描画するだけで良い。この描画では一切ピクセルシェーダーが呼び出されないので高速に実行できる。

次にglDepthFuncにGL_EQUALを設定して通常描画を行う。こうするとシーンの最前面、つまり見えるピクセルのみ深度テストに合格するようになるので、ピクセルシェーダーの呼び出しが最小限となるのだ。

ここまで読んでみて、「シーンの描画が2倍に増えてるじゃん。ほんとに速くなんの?」と思った方もいるだろう。そう、この手法が効果的な場面にはいくつか条件がある。まず、ドローコールが倍に増えるのでそもそもドローコールを抑える工夫ができている必要がある。それは前半に書いたような最適化やフラスタムカリングを行えば良い。 次にピクセルシェーダーが重く、しかもオブジェクト同士が何重にも重なるシーンであること。この手法で回避できるのは重複したピクセルシェーダーの実行なのでそもそもピクセルシェーダーが激軽だったら効果は薄いし、オブジェクトの重なりが少なければ重複ピクセルも少ないのでこれもまた効果は薄くなる。 次に超ハイポリモデルでないことだ。頂点シェーダーは普通に2倍実行されるため単純に頂点シェーダーが重いと逆効果になる可能性がある。 あと、当然だが深度バッファに書き込みを行うので半透明オブジェクトには適用出来ない。

これらの条件を満たすシーンであればこの手法は結構強力なのでやるべきだろう。

おまけ

自作ゲームでいうと怖い部屋3Dでは壁がかなり重なるのでこのテクを使っているが、ニコニコ迷宮では壁を取っ払ったせいでほぼ効果がないので使っていなかったりする。 上図は怖い部屋3Dの描画結果。下図は同じシーンのポリゴンの重なりを可視化したもの。明るいところほど重複した描画が発生していることを表している。これらのピクセル数分だけシェーダー処理が省けるのだからかなりの効果があると言える。逆にこうならないのが分かっているゲームならこのテクは特に不要である。

2.Z sort

上に書いたように深度テストでいかにピクセルシェーダーの不要な実行を避けるのが大事なことかわかったと思うが、他にも素朴なやり方で減らす方法がある。それがZ sortだ。

これはシーンを描画する前に全オブジェクトを奥行き順にソートしてから描画しようというもので、手前のものから先に描画していけばそれだけ深度テストに失敗するピクセルが増えるというのは直感的にもわかりやすいだろう。さっきはカラーバッファへの書き込みにだけ言及していたが当然深度バッファへの書き込みも減らせるに越したことはない。手前から描くことで深度バッファへの書き込みを減らせるというわけだ。また、パフォーマンスとは関係なく半透明オブジェクトを正しく描画するためには奥から順に描く必要があるので半透明と不透明で描画処理をしっかり分けることが重要だ。

このように描画周りの処理の設計への影響が大きいので最初からこの辺を意識して設計しておくとよい。

3.レンダーステート

レンダーステートとはglBlendFuncglCullFaceglDepthFuncなどのハードコードされた描画処理を制御する各種設定のことである。これらは地味なところではあるが、適切な設定をすることでより負荷を減らすことにもつながる大事な要素である。基本的には実現したい描画のために設定を行う(加算モードで描画したいとか奥行きを無視した描画がしたいとか)のだが、例えば、ブレンドモードが半透明描画用の設定になっている状態で完全不透明なオブジェクトを描画した場合、正しい描画結果自体は得られる。しかし、本来不要なブレンド計算が描画した全ピクセルに対して実行されてしまい余計なGPU負荷が発生する。であればブレンドモードをオフ(glDisable(GL_BLEND))にして描画する方が、同じ結果でGPU負荷が少なくなるのでよりベターだ。

このようにレンダーステートは欲しい結果が得られてかつGPU負荷を減らせるように細かく設定していくのが大事である。レンダーステートの設定もGPUコマンドの生成であるためCPU負荷はややかかるが、その設定1つで数十万ピクセルの無駄なGPU処理を減らせるので時には払うべきコストだろう。


描画最適化の話題でよく出てくるフラスタムカリングやZソートだが実は今のところ自作のゲームでは採用していない。実はこれらはCPUで結構な計算負荷が発生するためできればやりたくないのだ。オープンワールドゲームみたいに超広範囲に不規則にオブジェクトが大量におかれてたりしない限りは他の最適化手法だけで大体事足りるので意外と優先度が低いと思っている。迷路ゲームだって真面目にフラスタムカリングしようと思ったらCPUで数千個~数万個のオブジェクトに対して判定を行う必要がある(まぁほんとはもう少し最適化できるのだが・・・)。マルチスレッドも使えない環境ではこれらの負荷はかなりきついので、安易に実装しても余り意味はないかも。

まとめ

今回紹介した内容は最適化手法の内のごく一部に過ぎない。実際自作ゲームではこれ以外にもいくつかの最適化を行っているが、あまり汎用的でないものも多いので紹介は省いた。実装コストと改善するパフォーマンスでみてコスパが良さそうなものを抜粋したので、この辺を優先して実装するだけでも結構パフォーマンス改善するのではないかと思う。

*1:できなくはないと思うがかなりケースは限られるだろう

*2:今どきのPC・スマホなら大体実装されてるっぽい?タイルベースレンダリングをしているスマホも多そうなので一概に全部とは限らないが

*3:ただし、Early-ZされるかどうかはGPUによって条件が異なる模様。ピクセルシェーダー内で深度値を変更する機能(gl_FragDepth)を使っていればまずダメだし、discard命令を使っている場合もダメなことが多いらしい。Early Fragment Test - OpenGL Wiki Limitationsの項目より。

akashic export改善

ニコ生ゲーをアップロードする際必ずakashic exportコマンドを実行するが、開発後半など異様に時間がかかるようになったことはないだろうか。

怖い部屋3Dを作っているときこまめにエクスポートと動作確認を繰り返していたら、あまりにエクスポートが遅すぎて投稿が間に合わなくなりそうなことがあったので原因を調べたことがある。その際のメモと対処法を載せておこうと思う。

⚠️追記

公式で対応されたので本記事の内容は最新のakashic-cliであれば不要になりました。

対応コミット

調査

まずエクスポートコマンドのデバッグ方法を確認する。ただし、他の方法がわからないので自分の開発環境(Windows)での手順で書かせてもらう。

1. VSCodeでプロジェクトを開く

2. package.jsonに以下の記述をする

"scripts": { "export": "akashic export html --minify --atsumaru --output ./game.zip -f", ... }

3. ExplorerタブのNPM SCRIPTSのpackage.json>exportをデバッグ実行する

これでエクスポートコマンドをステップ実行できるようになるので処理を追いかける。

原因

調べた結果一番重かったのがゲームフォルダのコピーだった。ゲームフォルダの全ファイルをtempフォルダにコピーして、コピー先で色々パッケージング処理を行っていたのだ。恐らく時間のかかるエクスポート中も安全にゲームの編集ができるように、いったん作業スペースを確保したかったのだろう。 しかし、そんな挙動と知らずに怖い部屋3Dではゲームフォルダにc++の中間ファイルも出力するようにしていたのでとんでもない数ととんでもないサイズのファイルが全てコピーされてしまっていた。それ以外にもgimpファイルやblenderファイルなども置いていたし、node_modulesフォルダはどうしてもエクスポートに関係ない大量のファイルが含まれてしまう。

つまり最終データと関係ないファイルも区別なくコピーしてしまっていたのが問題だった。

改善

そこで、コピー処理をしているjsスクリプトにひと手間加えることにした。 akashicコマンドが存在するパスからの相対パス

\node_modules\@akashic\akashic-cli\node_modules\@akashic\akashic-cli-export\lib\html\exportHTML.js

がコピーを行っているスクリプトだ。gitだとここ

このファイルの一番下にcreateRenamedGameという関数があるはず。これを以下のように改変した。

function createRenamedGame(sourcePath, hashLength, logger) {
    var destDirPath = path.resolve(fs.mkdtempSync(path.join(os.tmpdir(), "akashic-export-html-")));

    ////////////////////////////////////////////////////////////////////////////////////
    // コピーすべきファイルのみを条件指定して高速化
    var includeList;
    const rulePath = path.join(sourcePath, 'rules.json');
    if (fs.existsSync(rulePath)) {
        const ruleText = fs.readFileSync(rulePath);
        const rules = JSON.parse(new TextDecoder("utf-8", {ignoreBOM: false}).decode(ruleText));
        includeList = rules.includeList.map(rule => new RegExp(rule));
    }
    fsx.copySync(sourcePath, destDirPath, includeList ? file => {
        if(fs.statSync(path.resolve(file)).isDirectory(file))return true;
        const relPath = path.relative(sourcePath, file);
        return includeList.some(rule => {
            return relPath.match(rule);
        });
    } : undefined);
    ////////////////////////////////////////////////////////////////////////////////////

    return Promise.resolve()
        .then(function () { return cmn.ConfigurationFile.read(path.join(destDirPath, "game.json"), logger); })
        .then(function (gamejson) {
        cmn.Renamer.renameAssetFilenames(gamejson, destDirPath, hashLength);
        return cmn.ConfigurationFile.write(gamejson, path.resolve(path.join(destDirPath, "game.json")), logger);
    }).then(function () { return destDirPath; });
}

コメント行で囲まれた部分が追加処理だ。簡単に解説すると、エクスポートしようとしているゲームフォルダ直下にrules.jsonというファイルが存在する場合それを読み込み、正規表現のフィルタパターンリストを作成し、パターンに適合するパスのみコピーを行う感じだ。rules.jsonの例は以下となる。includeList正規表現文字列を追加してコピーするファイルパスのパターンを追加していく。

{
    "includeList": [
        "image.*",
        "script.*",
        "game\\.json",
        "node_modules\\\\@akashic\\-extension.*"
    ]
}

imageフォルだとscriptフォルダとgame.jsonとnode_modules/@akashic-extensionフォルダ以下をコピー対象としている。パス区切り文字(\or/)やエスケープ文字に注意。

ほんとは.gitignoreとかと同じフォーマットで記述できるようにしたかったがめんどかったので妥協。誰か書き直せる人いたらお願い🙏

3Dのニコ生ゲームを作ろう その2

前回は色々と下調べしてニコ生ゲームでWebGLを使ったゲームを作ろうというところまでやった。

今回はひとまずAkashicでWebGL描画をする最小サンプルを作っていこうと思う。ちなみにWebGLそのものの使い方自体は当ブログでは取り扱わない。それに関してはネットに十分情報があるのでそれらを見てもらえば問題ないだろう。

webglに関してはこちらのサイトが詳しい。 wgld.org

最小サンプル

// 描画時に呼び出されるコールバックを指定できるスプライトクラス
class CustomSprite extends g.Sprite {
    constructor(params) {
        super(params);
        this.drawer = params.drawer;
    }

    renderSelf(renderer, camera) {
        this.drawer();
        super.renderSelf(renderer, camera);
    }
}

exports.main = () => {
    var scene = new g.Scene({
        game: g.game
    });
    g.game.pushScene(scene);

    scene.onLoad.addOnce(() => {
        // サーフェース作成
        const src = g.game.resourceFactory.createSurface(200, 200);
        // WebGL2作成
        const gl = src._drawable.getContext('webgl2');

        // シェーダー作成
        const shader = gl.createProgram();
        const vs = gl.createShader(gl.VERTEX_SHADER);
        const fs = gl.createShader(gl.FRAGMENT_SHADER);
        gl.shaderSource(vs, '#version 300 es\n' +
            'uniform float angle;' +
            'out vec3 color;' +
            'void main(void){' +
            '   gl_Position = vec4(sin(float(gl_VertexID) * 2.094 + angle), cos(float(gl_VertexID) * 2.094 + angle), 1., 1.);' +
            '   color = vec3(gl_VertexID == 0 ? 1.0 : 0.0, gl_VertexID == 1 ? 1.0 : 0.0, gl_VertexID == 2 ? 1.0 : 0.0);' +
            '}');
        gl.shaderSource(fs, '#version 300 es\n' +
            'precision mediump float;' +
            'in vec3 color;' +
            'out vec4 result;' +
            'void main(void){' +
            '   result = vec4(color, 1.0);' +
            '}');
        gl.compileShader(vs);
        gl.compileShader(fs);
        gl.attachShader(shader, vs);
        gl.attachShader(shader, fs);
        gl.linkProgram(shader);

        // シェーダーパラメータ(ユニフォームロケーション)取得
        const angle = gl.getUniformLocation(shader, 'angle');

        // レンダリング結果表示用エンティティ作成
        const sprite = new CustomSprite({
            scene,
            src,
            parent: scene,
            anchorX: 0.5,
            anchorY: 0.5,
            drawer: () => {
                // WebGL描画処理(画面クリアと三角描画のみ)
                gl.clearColor(0, 0, 0, 1);
                gl.clear(gl.COLOR_BUFFER_BIT);

                gl.useProgram(shader);
                gl.uniform1f(angle, g.game.age * 0.02);
                gl.drawArrays(gl.TRIANGLE_STRIP, 0, 3);
            }
        });
        // 適当にくるくる回しとく
        sprite.onUpdate.add(() => {
            sprite.x = Math.cos(g.game.age * 0.02) * 100 + g.game.width * 0.5;
            sprite.y = Math.sin(g.game.age * 0.02) * 100 + g.game.height * 0.5;
            sprite.modified();
        });

        // 後片付け
        scene.onStateChange.addOnce(state => {
            if (state == 'before-destroyed') {
                gl.deleteShader(vs);
                gl.deleteShader(fs);
                gl.deleteProgram(shader);
            }
        });
    });
};

このコードはスクショのような虹色の三角形がくるくる回るだけのサンプルである。適当にAkashicで新規プロジェクトを作成してmain.jsに上書きすれば動くと思う。 このようにAkashicとWebGLは共存させてもそんなに違和感なく使えるということが分かっていただけたと思う。まぁこれ以上特に解説することもないので今回はこれで終了だが何か質問等あればコメントにでも書いていただければ答えるかもしれないのでよろしく。

今後について

今後のWebGL記事についてだが、正直どうしようか迷ってる。ニコ生ゲーって大体はTypeScriptかJavaScriptで書かれていると思うので今回はjsで書いたが、元々GL関連の処理は全部c++で書いてるのでコード付きの解説などがしづらい。なので、恐らく具体的なコードを載せる感じではなくここはこんな感じでやりましたよ~という風に軽く紹介して流す感じになるかも。(もしくはc++で載せちゃうか)

一応c++でニコ生ゲームを作る記事も書こうかなと思っているがなかなか需要無さそうで今から頓挫する未来が見える見える・・・