この記事は?

※この記事は CCS Advent Calender 2017 3日目の記事です。
前記事: 完成度をワンランク上げるデジ絵加工技術

はじめに

こんにちは、CCS OBあるいはエンドレスシラフのNicolaiです。

昨年のアドベントカレンダーで書いた記事が非常にポエムだったので、
よ~しまた愚にもつかねえポエム書くぞ~書くぞ~と思っていましたが、
謎のポエマーだと思われるのも正直きついものがあるのでやめました。

その代わり今回は割と実用寄りというか、
エンドレスシラフの「∀kashicforce」というゲームで実際に使われた技術について取り留めなくお話します。
どういうゲームなのかは上のリンクを見てください。

上記ゲームはUnityを使って作られてるので、Unityメインの話です。
あとワークアラウンド的技術や運用で回避する方法、一時しのぎなどのよくない話とか諸々…

タイトルに【雑】と書いた通りかなり雑な記事です。
解説でソースコードが出てくる箇所がありますが、全部載せると分量がやばいので
エッセンス部分だけに抽出して載せるに留めます。
かなりとっ散らかってるけど許してね。

追記:

重要なことを書き忘れてた。
基本的に∀kashicforceはWindows向けの同人ゲームなので、WindowsのStandalone Playerビルド前提の話になります。
モバイルとかWebGLとか向けビルドの話は知見がないので知りません!!!!!注意!!!!

【一読の前に注意】

この記事中で言及する「Unity」は、特に記載がない限り
「Unity5.0.0f4」というクッソ古いversionのことを指します。
なんでそんな古いバージョンについて言及するかというと、上記のバージョンが
∀kashicforceで使ってたバージョンのUnityだからです。
開発始めた当時は最新版だったんだよ!!!

何が言いたいかというと、記事中で「Unityここがダメ!!!」みたいな話が出てきたら、
それは最新版のUnityでは直ってる可能性があるということです。
そういう直ってる箇所あったら教えてください。

自動アップデータの実装

∀kashicforceでは、ゲーム起動時に更新差分があった場合に
自動で差分パッチをダウンロードしてくる仕組みがあります。
仕組み自体はかなり単純で、以下のような感じで動作しています。


  • udm差分ファイル作成ツール などを用いて差分パッチを作成し、
    差分パッチの名前にバージョン番号をつけて自前サーバのどこかに上げておく。
    • 例:Akashicforce_patch_v110.exe
  • 自前サーバのどこかに、最新版のバージョン番号が書かれたテキストを上げておく。
  • ゲームのローカルディレクトリに、バージョン番号の書かれたテキストを配置する
  • ゲーム起動時のスクリプトで、バージョン番号テキストをサーバから読み取ってくる。

C#では以下のような感じ(適当にコードのエッセンス部分だけ載せる)

readtext_from_server
WebClient wc = new WebClient();
Stream st;
string url = ServerName + FileName;
try
{
    st = wc.OpenRead(url);
}
catch (WebException exc)
{
    return -1;
}
Encoding enc = Encoding.GetEncoding("Shift_JIS");
StreamReader sr = new StreamReader(st, enc);
string new_version = sr.ReadToEnd();
sr.Close();
st.Close();
return int.Parse(new_version);
  • 同様にローカル側のバージョンを読み取り、サーバ側から読み取った最新バージョン番号と比較。
    ローカルよりもサーバ側バージョンが新しいならば、サーバからパッチをDLする。
    DLするパッチの名前はのようにバージョン番号のついた名前で特定する。
    • 例:ローカルバージョンがver1.01、サーバ側最新バージョンがver1.03ならば、ver1.02とver1.03をDLする。
      名前は例えばAkashicforce_patch_v102.exeとかつけておくと特定できる。
  • DLしてきたパッチを起動し、Application.Quitでゲームを終了する。
    起動するのはバージョン番号の一番若いパッチ。
process_start
string path = Application.dataPath + "/../" + exe_name;
var exProcess = new Process();
exProcess.StartInfo.FileName = path;
//外部のプロセスを実行する
exProcess.Start();
Application.Quit(); // ゲーム終了

↓ゲーム起動直後にこんな感じの表示を行う
image.png

  • 差分パッチが実行されるので、更新時テキストとかでどこのフォルダにパッチを適用すればいいかなどをフォロー 。
    下は差分パッチ実行時の表示。
    image.png

  • udm差分ファイル作成ツールは「差分適用後に実行するコマンド」という設定項目が存在するため、
    それを用いて次のバージョン番号のパッチを連鎖的に起動する。
    ちなみに指定した名前のコマンドやファイルが存在しない場合には何も行わないという仕様らしい。


ざっとこんな感じの流れです。
ちなみにネットワーク接続自体がない場合にはバージョン確認などを行わず、そのまま起動するようにします。

最後のパッチ適用のあたりをもっと効率化できるとほんとうはいいんですけどね。
例えば差分適用も裏で勝手にやってくれるとか。
そこまではちょっと力量不足とかいろいろあってやってません。

開発中はUnityのバージョンは固定しよう

ワークアラウンドみたいな話です。
開発中にUnityのバージョンを変えるのはお勧めしません。

というのもUnityのプロジェクトコンバータにバグがあり、
大きめのUnityプロジェクトをアップコンバートしようとすると
Invalid AABB aabbという謎のエラーが出まくったり、IsFinite(outDistanceAlongView)というエラーが出たり、
バグが修正されたパッチリリースをインストールしようとしたら
別のところでエンバグしていたとかいう実績があるからです。
詳しくは↓とか。

参考:
https://gamedev.stackexchange.com/questions/105921/invalid-aabb-aabb
http://cdecrement.blog.fc2.com/blog-entry-205.html
https://issuetracker.unity3d.com/issues/game-object-transform-dot-position-is-incorrect-after-object-dot-instantiate-after-awake-function-call

まあ上の現象自体はもう解消されている話なのかもしれませんが、
このようなユーザーレベルでは解消不能の不具合に出くわす可能性がある
ということは頭に留めておくべきかと思います。

「新機能を追加することは、バグを埋め込むこと」という格言(?)もあります。
安易に魅力的な新機能に釣られてホイホイバージョンを変更するのは、特に開発中真っただ中のプロジェクトにおいては大きなリスクがあることは意識しましょう。
これは何もUnityに限った話ではなく、使用しているアセットやミドルウェア、ツールなどすべてにおいて言えることです。

ただし、テスト環境が潤沢にそろっており、テストを全てパスすることで品質を担保できる場合は上記の限りではありません。
また、モックアップの作成時や、~1か月程度で完成する規模のプロジェクトの場合、
むしろ新しい機能を積極的に試していくことの方が重要だと考えます。

ちなみに

∀kashicforceの場合、Unity5.0.0f4を使用して作成していましたが、
途中でバージョンを上げようとして全然ダメだった記憶があります
(たしか5.1へのコンバートは成功して5.2へコンバートしたらダメだったような気がする)

StreamingAssets以下のファイルを暗号化せよ

Unityで外部リソースを用意したい場合、StreamingAssetsディレクトリを用意して、
その中のファイルを参照するという手段を取ることがあります。

参考: https://docs.unity3d.com/jp/540/Manual/StreamingAssets.html

例えば、∀kashicforceではムービーファイルや内部設定データをStreamingAssetsに配置しています。
しかし、StreamingAssetsディレクトリに配置したファイルは全く暗号化とか何もされないため、
ユーザーがデータを書き換え放題という問題があります。
最悪のケース、ゲームのシナリオデータをStreamingAssetsに平文で配置してしまったらもはや目も当てられません…

そこで、前もって暗号化ツールでStreamingAssetsディレクトリ以下のデータを暗号化しておき、
ゲーム側でデータを読み込む際に復号してから使用するというアプローチをとります。

手っ取り早いのはAES暗号化方式で暗号化してしまうことです。
詳しくは以下のブログの暗号化そのまんまでいいんじゃないでしょうか。

参考: 【Unity】AESでデータを暗号化

初期化ベクトルだけ変えておくのを忘れないようにNE!(ユーザーの復号防止のため)

まあ、一番いいのはStreamingAssetsにそういう危険なデータを置かないことなんですが…。
実際、後からテキスト類を差し替えたい場合とかに結構重宝するので捨てがたいんですよね~

nullの判定にnull合体演算子(??)を使用してはいけない

ワークアラウンド話です。
UnityのUnityEngine.GameObjectDestroyすると1フレーム後に参照がnullになりますが、
こいつはnullを自称しているnullではないObjectであることが知られています。
そして上記のObjectは==演算子でnullと比較するとtrueが返りますが、??演算子での比較結果はtrueが返りません。
(??演算子が適切にオーバーライドされてないらしい?)
?.演算子は試してません。

つまり、nullチェックのつもりでnull合体演算子を用いると思わぬところでバグったり、
意図しない挙動を起こす可能性があります。
例えばnullチェックしてnullならば新しいGameObjectをInstantiateするというケースにnull合体演算子を用いる場合ですね。
いつまで経ってもnullにならないので、いつまで経ってもInstantiateされません。
要注意です。

null_coalescing
// やばい
cache_obj = instantiated_obj ?? GameObject.Instantiate(orig_obj);
null_check
// 冗長だがOK
if (instantiated_obj != null) {
   cache_obj = instantiated_obj;
} else {
   cache_obj = GameObject.Instantiate(orig_obj);
}

あと、∀kashicforceで引っかかったわけではないですが、
System.Objectにキャストしたオブジェクトをnullチェックする場合にも罠があるようです。

参照: Unityのnullはnullじゃないかもしれない

OnCollisionEnterが呼ばれない問題

よくある問題一覧:

  • CollisionColliderは違うんだよ!
  • Collision2DCollider2DCollisionColliderは全部違うものなんだよ!
  • 関数ごとに必要な引数型が全部違うんだよ!
  • 関数の引数型が違っててもUnityは警告を出してくれないんだよ!
  • OnTriggerEnterを呼ぶときはIsTriggeredにチェックしてなくちゃいけないし
    OnCollisionEnterを呼ぶときはIsTriggeredは未チェックじゃなければいけないんだよ!
  • 3Dの衝突の時にはrigidbody、2Dの衝突の時にはrigidbody2Dがアタッチされていないといけないんだよ!
  • 勿論関数名をタイポしてたりしても警告は出ないんだよ!

と、めちゃくちゃたくさん罠があるので注意!
あとrigidbodyColliderより先にアタッチしとかないと動作しないとかそういうなんかがあったような…。
(よく思い出せない)

iTweenを1つのオブジェクトに対して複数同時に使うとなんか挙動がおかしくなるんだが?

DOTween使えばおk

ポーズ画面の実装

  • DOTweenをUnityにインストールします
  • ポーズしたい時にTime.timeScale = 0fにします
  • Time.deltaTimeとかTime.fixedDeltaTimeなどの値が0になります
  • 上記の値を使用して動き制御しているオブジェクトが停止します
  • 逆に言うとTime.deltaTimeに依存しないで移動するオブジェクトの動きを止めることはできません
    ただ、大抵そういうのは意図していない挙動であることが圧倒的に多いため、 意図したものでない限りTime.deltaTimeを掛けて制御しましょう
  • Unity上部「Tools→DOTween Utility Panel」を開きます。 ユーティリティパネル上部の「Preference」をクリックし、「TimeScale Independent」のチェック設定を有効にします。
    DOTween.gif
  • これで、DOTweenを使用した制御はTime.timeScaleに依存しなくなります。
    つまりポーズ画面で、通常のキャラクターは動かないけど、
    メニュー画面のUIとかカーソルだけDOTweenを使って動かす
    ということが実現できます。

下のような感じ
pause.gif
「suspended」が表示されている間(ポーズ画面中)は、カーソルとUI以外は動いていません。
解除するとオブジェクトが動き出します。
当たり前ですがDOTweenを使って動かしてるオブジェクトは容赦なくポーズ中も動くので注意しましょう。

Time.timeScale = 0f; の時のコルーチンの中断の挙動

Time.timeScale=0の時に、コルーチンの中断方法によって止まる止まらないの挙動が変わります。

  • yield return null → 止まらない
  • WaitForSeconds →止まる
  • WaitForFixedUpdate →止まる
  • WaitForEndOfFrame →止まらない

つまり、上の項のようなやり方でポーズを実装した場合、
ポーズ中にyield return nullWaitForEndOfFrameで動作制御しているオブジェクトは停止しません。
別途コルーチン内でTime.timeScaleの値を見て制御しましょう。

成果物をリリースビルドする前にUnityのキャッシュをクリアせよ

Unityは内部で素材キャッシュのような仕組みを持っているらしく、
しかもそのキャッシュのデータがビルド時に優先的に使用されてしまうという問題があります。

大抵は問題ないのですが、エクスプローラから直にUnityプロジェクト内の素材ファイルなどの差し替えを行った場合、
「プロジェクト内の素材自体は差し変わっているが
実際のゲーム画面、およびビルド成果物には差し替え前のファイルが使われ続ける」という現象が発生します。
つまり、意図しないファイルがビルド成果物に含まれることがあるということですね。
大問題です。

解決法ですが、割と単純ですべてのアセットを再インポートすると解決します。
ただし再インポートはめちゃめちゃ時間かかるので注意しましょう。
image.png

System.Text.EncodingでShift-JISを使用するとエラーになる

参考: System.Text.Encoding で Shift JIS を使いたい

ざっくり言うとUnity本体のインストールディレクトリの/Mono/lib/mono/2.0/にあるI18*.*.DLL
Assetディレクトリにインポートすると解決します。

直接関係ないですが、リソースを内部でzip化しておいて、Ionic.Zip.dllを使用して
ゲーム実行時に動的に解凍する場合にも同様の問題が発生します。
(この場合Encoding name 'IBM437' not supported.とかいう一見意味不明のエラーが発生してめっちゃ焦る)
この場合も同じようにI18*.*.DLLを配置しましょう。
参考: Unity C# Zip化したアセットバンドル(ファイル)を解凍

ネイティブプラグイン

C#から利用できない、あるいは利用しづらい機能(例えば生ポインタなど、メモリをそのまま扱いたいケース)や、
既存のC/C++言語のライブラリをC#で利用したい場合は、ネイティブプラグインの仕組みを利用します。
まあこれは実際は、ほとんどUnityとは関係ないC <--> C#間の一般的なマーシャリング(P/Invoke)の話ですが…。

∀kashicforceでは、ムービーのデコードにネイティブプラグインを使用しています。
TCP/IP通信のコア部分もネイティブプラグイン化していますが、こちらは結局未使用です…。

ちなみにムービプラグインの内部ではlibvpxを使用したリアルタイムVP9デコードを行っていますが、
∀kashicforce開発時点ではVP9をUnity上でデコードしてテクスチャにバインドするのは
ある意味世界初の技術だったと言えます
(ドヤアアア
なお現在はどうなのか知らない。多分公式のMovieTextureとかがVP9サポートしてるやろ(ホジ

ネイティブプラグインとムービ再生の話は正直言ってかなり踏み込んだ話なので、ザラっと流しで要点だけ書きます。
雑な記事で書くには割と重めなので…。
詳しくはググるか私に聞いてください。というかどっか別の機会で踏み込んだ記事書きます多分

マーシャリング

以下のように、C#側とC/C++側で共有したい構造体/クラスについて
[StructLayout(LayoutKind.Sequential)]で構造体のレイアウトをそろえる必要があります。
C#のクラスではメンバが宣言した順でメモリ的に連続に並ぶとは限りません。
(コンパイラ依存で並べ替えが発生する可能性がある)
構造体だとデフォルトでSequentialだった気がするのですが、
C#の場合構造体/クラスは値型と参照型になってるため使い分けが異なり、
今回はクラスを用いたいため明示的に指定しています。

C#側共有クラス
    [StructLayout(LayoutKind.Sequential)]
    public class ESMoviePlugin_MovieInfo
    {
        public int width;
        public int height;
        public int stride;
        public int now_frame;
        public int databuf_size;
        public float fps;
        public DECODE_STATUS status;
    };

    [StructLayout(LayoutKind.Sequential)]
    public class ESMoviePlugin_FrameBuffer
    {
        public IntPtr frame_y;
        public IntPtr frame_u;
        public IntPtr frame_v;
        public int num_frame;
        public bool is_used;
    };
C/C++側共有構造体
    typedef struct ESMoviePlugin_MovieInfo
    {
        int width;
        int height;
        int stride;
        int now_frame;
        int databuf_size;
        float fps;
        DECODE_STATUS status;
    };

    typedef struct ESMoviePlugin_FrameBuffer
    {
        uint8_t* frame_y;
        uint8_t* frame_u;
        uint8_t* frame_v;
        int num_frame;
        bool is_used;
    };

DECODE_STATUSはenumなのであまり気にしないでください。
で、Marshal.StructurePtr()IntPtrにしてネイティブ側に受け渡しします。
参考: https://msdn.microsoft.com/ja-jp/library/2zhzfk83(v=vs.110).aspx

    decode_intptr = Marshal.AllocHGlobal(Marshal.SizeOf(decode_framebuf));
    Marshal.StructureToPtr(decode_framebuf, decode_intptr, false);

Marshal.AllocHGlobalだとコピーになるじゃん…という人は大人しくGCHandle(下の方参照)使いましょう。

IntPtrですが、ネイティブ側では普通に構造体型のポインタ(ESMoviePlugin_MovieInfo*)で受け取ってよいです。
もっといいやり方(dllexportした公開関数にInAttribute/OutAttributeをつける)があるけど、
どうせ受け渡すのは連続メモリ領域なんだからポインタで扱った方が早いよね!(えー

勿論ですが、C#側に公開しなくていい構造体についてはマーシャリングする必要性はありません。

生ポインタを取り扱う

ネイティブ側との生ポインタの扱いはIntPtr経由で取り扱います。
C#側で確保したメモリをC/C++側でいじる場合、GCの対象から外したいため、以下のようにPinnedMemory化します。
今回のケースではデコーダが読み書きするメモリ(つまりテクスチャの領域)ですね。

GC対象から外したメモリ領域を作成(かなり端折っている)
    public ESMovie_DecodePtr(int size)
    {
        // YUVテクスチャのため注意
        decode_buf = new byte[size];
        // GCHandleでbyte配列のポインタを取得
        decode_hn = GCHandle.Alloc(decode_buf, GCHandleType.Pinned);
        decode_ptr = decode_hn.AddrOfPinnedObject();
    }

AddrOfPinnedObjectで実際のアドレスがIntPtrで取れます。
ちなみにToIntPtrメソッドは用途が全然違い、GCHandle.Allocしたハンドルのアドレスしか取得できません。
ムービ再生プラグインでは、マーシャリングしたESMoviePlugin_FrameBuffer構造体のframe_y,frame_u,frame_vメンバに
それぞれY,U,Vテクスチャとして使用するメモリを割り当てています。
もちろん使わなくなったらちゃんと破棄しましょうねー

    public void DestroyPtr()
    {
        decode_buf = null;
        decode_hn.Free();
        decode_ptr = IntPtr.Zero;
    }

デコードしたデータはTexture2DLoadRawTextureDataで読み込ませます。
その名の通りRawなバイト列を何も考えずに読み込めるので超便利です。

テクスチャへ適用
tex_Y.LoadRawTextureData(decode_Y.decode_buf);
tex_U.LoadRawTextureData(decode_U.decode_buf);
tex_V.LoadRawTextureData(decode_V.decode_buf);
tex_Y.Apply();
tex_U.Apply();
tex_V.Apply();

おわりに

他にもなんか色々書くことあるけど気力と時間が無くなりました(雑)
他に書いた方がよかったかもしれない話リスト

  • Mercurial + TortoiseHGによるUnityプロジェクトのバージョン管理
  • ADX2LEを利用したループ・フェードイン・フェードアウトなどの管理 / ネットワーク経由での効果音同期再生
  • LoadScene時にアニメーションを使う
  • 使ってるアセットについての話
  • etcetc...

今回の∀kashicforceは色々知見が得られたので、雑メモじゃなくてちゃんとまとめたいところですね…。