Unity 特有のパフォーマンス劣化の落とし穴 2018年歳末まとめ - part 2

今年遭遇した、気づかないうちに嵌ってしまったUnityのパフォーマンス上の落とし穴を振り返っています。 part 2 です。

今回は主に、Unity 独自の c# 実行環境である IL2CPP と、 Unityエンジン部分の c#API についての経験談を書いてみました。

4. IL2CPPの吐くコードサイズの爆発

IL2CPP の吐くc++コードサイズが非常に大きくなったことによって、困った、ということがありました。 IL2CPP は、monoのAOTなどと比較して 実行時の性能の良さを謳っていたと思いますが、実行時の速度にフォーカスしている代償としてか、現状だととにかく c++コードサイズが肥大化する傾向があると思います。

IL2CPPとはなんぞや

ちなみに、IL2CPP というのは、Unity 社が独自に開発した、 C# を ネイティブバイナリとして事前コンパイルして実行する仕組みの名前です。(sugoi。)

元々、c# という言語は、.NET という共通言語基盤(CLI) 上で動作するプログラミング言語のの代表格の1つでした。
基本的には、c#コンパイラは、この CLIVM上でのみで動作する 中間コード(CIL)を成果物として吐くのが仕事です。

Unity の IL2CPP というやつは、この、VMがなければ走らせられないはずの 中間コード を、力業でネイティブのc++コードに翻訳してしまい、さも最初からネイティブコードだったみたな顔で走らせてしまう技術です。ゲームを実行したい端末に .NETVMがはいってなくてもぜんぜん関係ありません。 独自に内包している VMの機能とともに、元々c#だったはずのコードがc++コードして走ります。

現在の Unity の c# コンパイラは、.NET のオープンソース実装である mono (のフォーク)が使われています。
以前は、ネイティブへの事前コンパイルも、mono がもっているAOTコンパイラ実装に依っていた時代があったらしいのですが、 クロスプラットフォームの保守性やパフォーマンス、完成度などを改善するため、ここをUnity独自のランタイム実装へ踏み切ったようです。 (sugoi……。)

いつのまにか、最近では、VMを載せることが許されない iOSだけでなく、Android でもIL2CPPでゲームを走らせることができるようになりました。 スマホアプリでは だいたい IL2CPPでビルドするのではないでしょうか。

IL2CPPについては、すこし古いですが、Unity Blog の一連のシリーズが詳しいです。 An introduction to IL2CPP internals – Unity Blog

( というか、これの他に実装についてのまとまった情報を見たことがありません )

IL2CPP コードサイズ肥大化によって起こる問題

Il2CPPは速くて良いものだ、となんとなく思っていたのですが、 通常のc# では気にしていなかったことを気にしないといけない場面はやはりあるようです。

ビルドが通らない

IL2CPPの吐く c++ のコードが、c++だけで 1ギガバイトを大きく越えるというすごい量に達してしまった結果、ビルドが通らない、という症状に見舞われたことがありました。 (これはパフォーマンスとは関係ないですが..)

Unity 上で iOSビルドを実行し、 Xcode プロジェクトを生成する、ここまでは問題ないのですが、 Unity と il2cpp が吐いたc++コードを含んだ Xcodeのプロジェクトのビルドがエラーになります。

 Showing Recent Messages
 :-1: b(l) ARM64 branch out of range (155837104 max is +/-128MB): from _main (0x100005B70) to __Unwind_Resume@0x00000000 

どうも、 コンパイルは通るのですが、その後のリンクのフェイズでコケる模様。

同じエラーを踏んでいる人が世の中にそれほど多くなかったのですが、色々検証してみると、問題はビルド対象のc++のシンボルか何かの数が多すぎることのようで、とにかくコードサイズを削減する必要に迫られ、泣きました。

.NET 3.5 → .NET 4.6 でさらにコードサイズが爆発

件のプロジェクトでは、長らく .NET3.5 コンパチでc#を動かしていました。 これを、 .NET4.6 に上げることで、インライン展開だとか、コンパイル時の最適化が積極的に行われるようになる、との情報を得たため、 .NET 4.6 に上げてみることになったのですが、 やってみたところ、成果物の c++ コードのサイズが.NET3.5 の場合と比較してさらに 何割か膨らむ結果になり、 .NET3.5 だとビルドが通るんだけど、4.6 だと通らない、という状況が生まれました。

.NET 4.6 にすれば速くなることがわかっているのに、上げることができない、という事態に見舞われてしまったのです。これには涙しました。

メモリ使用量と実行速度

Il2cpp の吐く c++ コードの削減を大胆にやっていくと、結果的に、当然ですが 実行時のコード部分が抱えていたメモリ使用量の削減につながりました。 実行時の メモリを減らしたくて困っている場合、費用対効果が高い場合があるかもしれません。

また、後述の sealed 修飾子を明示的につける、などの、ちょっとした変更によって、c++の実行コードステップ数が大幅に減ったりすることがあったため、通常のc#コンパイラの気持ちになるのではなく、IL2CPPの気持ちになるかならないかで 実行速度が変わってくる場面もありそうです。

対策1. ジェネリック型の 型パラメータには int か enum を使う

ここからは、IL2CPP の吐くコードサイズを減らすための対策として、実際にやってみて効果があったやつをちょっと紹介してみます。

il2cpp は、型パラメータに値型をとる Generics を使うと、型パラメータの数だけ c++表現の実装が出力されるという特徴をもっています。

このことは、 IL2CPP Internals: Generic sharing implementation – Unity Blog にも書かれています。

普段、c# を書いているとき、ヒープアロケーションができるだけ発生しないようなコードを書くように気をつけるのですが、 とにかくコードサイズを減らしたくて困っている場面で、値型をつかっておけばアロケートを減らせるというケースでも、参照型のほうがc++変換後のコードサイズを減るためにトレードオフになってしまい、涙することがありました。

上記のブログによると、型パラメータ(つまり <T> の部分)が参照型、int、enum いずれかである場合には、ジェネリックの実装がシェアされ、いちいち重複して吐かれることはない、と説明されています。

元々の .NETジェネリック実装も、型パラメータが値型の場合はボクシングせず特殊化する実装であるので、その辺の表現をそのまま引き継いでいるのでしょうか。

UniRx.Unit.Default

僕達のプロジェクトでは、UniRx をけっこうヘヴィに使っていたのですが、(いつもお世話になってます)、 ジェネリック実装の重複削減の施策として地味に効果がみられたのは、 UniRx の Unit.Default の実装を置き換えてみる、というけっこうhackyな行為でした。

c# は、型パラメータにvoid が使えないため、 UniRx は、型パラメータがないことを表現するためだけのstruct 、Unit を用意しています。 この Unit は、型パラメータとして使われる頻度がけっこう多いわけですが、 型パラメータに Unit があらわれるたびに、それに対応するc++ジェネリック型の実装が大量に吐かれれてしまっていました。 Unit は、基本的にはただひとつの値、 Unit.Default しか持たないため、これをstruct ではなく、enum の実装に無理矢理書き換えてしまうことで、 il2cpp変換時のジェネリック型のサイズを減らすことができました。

namespace UniRx
{
    enum Unit { Default }
}

これについては、セマンティクス的にはちょっと間違っているのかもしれませんが、
現在のUnity環境下で c++サイズ削減の効果が示せるのであれば、UniRx本家のほうにPRを出してみても良いのかもしれないな、とおもいました。

Dictionary<TKey, TValue>

Dictionary<> も、不用意に使ってしまうと、c++になったときのコードサイズが爆発する筆頭でした。 どうもジェネリック版のDictionary は、実装がそもそも巨大で、1パターン吐かれることによるサイズへの影響が大きいみたいです。

もともと、 Dictionary<> は、直感よりも遅い場合がある (不要意にGetHashCodeが遅いキーをつかえば遅いし、入れた要素数よりも多くの領域を走査するためイテレータが実は遅い) ので、使いどころを気にするのがやはりよさそうです。

対策2. seald 修飾子

il2cpp は 、classのvirtualなメソッド呼び出しを実現するために、c++コードによるvtableのような仕組みを自動生成します。

この件については下に説明があります。

IL2CPP Internals: Method calls – Unity Blog

吐かれたc++コードを覗いてみると、たとえば、classのメソッド呼び出し一種類につき、いちいち以下のようなc++が吐かれていることが確認できます。

struct VirtActionInvoker0
{
    typedef void (*Action)(void*, const RuntimeMethod*);

    static inline void Invoke (Il2CppMethodSlot slot, RuntimeObject* obj)
    {
        const VirtualInvokeData& invokeData = il2cpp_codegen_get_virtual_invoke_data(slot, obj);
        ((Action)invokeData.methodPtr)(obj, invokeData.method);
    }
};

これは、コンパイル時に継承をしていないclass 、つまり実質はvirtualじゃないメソッドであっても同様の挙動をしています。 (実行時にサブクラスが生成されないことが保証されていないからなんでしょうか、あるいは、今後改善されるのかな)

ところが、 class宣言に sealed をつけると、上記のような vtableを表現するための型の生成が抑制され、メソッド呼び出しがほとんどただの関数コールになります。

継承しないclassにいちいちsealedをつけるのはスタイルとしてちょっと冗長かなという気がしますが、sealedをつけるだけで、c++コードサイズの削減と、c++世界での実行パスの削減による性能向上の2つのメリットがあるため、 il2cpp環境ではいちいちsealedをつけまくることを検討しても見合うかもしれません。

5. 無害に見える UnityのAPIが実はアロケートを発生させてる件

続いて、とくに何も悪いことをしていないつもりなのに、UnityのAPIをふつうにつかっていたつもりなのに予想外のヒープアロケートが発生しているシリーズです。

なかでも厄介だったのが、 gameObject.name を呼び出すたびに 文字列が生成されているらしい、という件です。(これは同僚に教えてもらいました。来年もよろしくおねがいします)

たとえば transform プロパティなんかは、エディタ上で実行したときのみアロケートが発生するものの、実機で実行した際にはそういったことがない、という挙動をするようなのですが、どうも gameObject.name のほうは、実機上でもアロケートが発生しているようで、ちょっと参りました。

このプロパティを、イテレータの中で使って、オブジェクト同士を比較するコードなどがかなり頻出していたため、改善が非常にむずいポイントとなってしまいました。

ただのプロパティアクセスなのに、評価のたびに参照が変わるというのはかなり直感に反するし、こういうのは測ってみたいと知らないとわからない、落とし穴と言えるのではないでしょうか。

だいたいどの処理系でも、GCアロケートが多いと、GCの回収フェイズが発生せずとも、とりあえず遅い、といった傾向があると思います。 よく使う低レイヤの道具については、こういうのを把握しておきたいものです。 ( でも Unity のバージョンが上がると挙動かわったりしそう)

6. Demo/Example/Sampleはじめ、ビルド時に必要ないやつが含まれやすい

もうひとつ思い出したので書いておきます。 Unity のアセットストア経由でダウンロードしたアセットには、必ずサンプルのシーンなどがついてくると思います。 動かせるサンプルがあふということは当然、スクリプトもくっついてきます。

Unity は、とくに指定しなければ、全てのc#スクリプトがひとつのアセンブリとしてビルドされるので、全く必要のないスクリプトが本番ビルドに紛れ込みやすいです。

というか、かなり積極的に隔離したり省いていかなければ、普通にやると無駄なものが一緒にコンパイルされるはずです。

サンプルのタグの他にも、エディタだけの機能のつもりが、大量の Editor アセンブリじゃないスクリプトがついてくることもよくあります。(例: メモリプロファイラ)

対策としては、

  • Unityの提供しているAssembly Definitionでアセンブリを定義すると、どのビルドに含むか設定できる
  • ビルド前にに不用なアセットのファイルを削除するフローをつくる

このあたりでしょうか。

まとめ

ちょっと駆け足になってしまいましたが、Unity のc# まわりで踏んだ罠についてできる限り紹介してみました。 年内に今年はまった落とし穴をふりかえり、一回 供養することが目的だったので、一旦ここで終わりたいと思います。

思ったより反応をもらえたり、間違いの指摘などをもらえて大変有意義でした。 m(_ _)m

Unity 特有のパフォーマンス劣化の落とし穴 2018歳末ふりかえり - part 1

今年、Unty製プロジェクトのパフォーマンス改善をやる機会があったんですが、世の中のかっこいい事例に出てくるような、ハードウェアやVM/コンパイラの気持ちになったミクロなチューニング、フレームワークの制限を回避するための大胆な再実装…… そういうかっこいい作業、には思ったよりならず、なによりもまず先に、Unityを使っているが故の落とし穴から這い出る一本道の作業が多めになってしまった。

それというのも、Unityは非常に、そこそこのものを最小手順で 確認/動作できる、誰でもかんたんにモノをつくれる、という部分を大事にしているから、「パフォーマンスを考えると普通はこうなっていてほしいよね」といった部分が犠牲になっている、あるいは手が回っていない、という部分が実際まだまだあるように思えた。

simple よりも easy を取っているというやつだろうか。

仕事でやっていたプロジェクトは、まずコードベースが大きく、動かすものもリッチで数も豊富、そして走らせる対象はモバイル。と、速く動かすのが難しい環境ではあったものの、決してそれは Unityの考えるユースケースとして少数派ではないように思っていた。それ故に、たくさんの落とし穴にはまってしまっていたことがちょっと驚きだった。

なかでも穴だらけだったのが「AssetBundle」この機能にまつわる周辺。 不思議なことに、このAssetBundle という 機能、別にeasyというわけではなく、むしろ、かなりの部分をアプリケーション部分で実装しなければまともに使えないはずだ。にも関わらず、非常にパフォーマンスを落とす要因になりやすい穴が点在していた。

この穴の気づきにくさから推測して、おそらく同じ問題に一度は嵌りそして抜け出した経験を持つ人は、日本に軽く1,000人はいるのではないかと思っているのだが、不思議とあまり大きな声で注意が叫ばれていないように思える。 (このとき、大きく助けになったのは 中国語のブログでした)

世界中の人々は、意外とAssetBundleを使っていないんだろうか。それとも、遅いけど死にはしないんだろうか。あるいは、僕以外のAssetBundleを使っている人は、みんな友達なんだろうか。そんな謎の孤独感を覚えてしまうほどであった。

ここでは、自分が経験した、直感に反してパフォーマンスのボトルネックをつくってしまうUnityの落とし穴について振り返ってまとめてみた。

最初から知っていれば穴を避けて設計できていたことも多かったと思う。
しかし、これはあくまで、マイナスをゼロにもっていくための知識であって、そういう意味では、Unityの猛者たちにとっては基礎編なのかもしれない。 来年からは、ゼロをさらにプラスに持っていくための作業にもっと時間を遣えたら良いなあ。と思う。というかもうちょっと実装したことを書きたいなあ。

1. AssetBundle のビルドに無駄な Unityのデフォルトアセットが大量に含まれる

さて、1つめは、数ある AssetBundle の罠のなかでも一番思い出にのこってるやつです。

AssetBundleとはなんぞや

AssetBundle というのは、Unityにおける、ゲームのパッケージとは別にアセットを分離・配布できるようにする仕組みのことを言います。 この機能をつかってこしらえた、アセットをかためたファイルのことをまた「AssetBundle」と呼びます。

スマートフォンのアプリにおいては、非Wi-fi環境におけるアプリのダウンロードサイズの上限が決まっていた関係から、よく使われている機能だと思います。 ゲームのアセットは大容量になりやすいので、フレキシブルに配信できるようになっていると、更新の敷居かなり下がり、ユーザにも優しい。そういった機能です。

公式ドキュメントのシリーズ: アセットバンドルとリソースに関するガイド - Unity

AsetBundle のファイルの中身は、Unity独自のフォーマットになっていて、仕様や実装が公開されていないため、基本的には、AssetBundleをビルドしたり、実行中にAssetBundleをダウンロード/展開する 部分は、Unity組み込みの実装を使う以外の選択肢はありません。(僕の知る限り)

とはいえ、実際のゲームで必要なワークフローや、ロードの管理を実現していくにあたっては、プロジェクトごとに実装しなければいけない部分が多いです。 ある意味自由が提供されているといえるのですが、Unityドキュメント特有の、前提知識がないと理解が難しい書き方などもあいまって、断片的な情報を全貌を把握し、正しくAssetBundleを管理するなにかをつくるのが非常に面倒です。 結果として、世の中のAssetBundleユーザたちは、参考実装とかを元にかなり似たようなものを自作していると噂されています。

AssetBundle は面倒、AssetBundle は大変、といった話が色々な視点で語られがちなようですが、 しかし、僕がここで問題にしたいのは、面倒とかそういうことではなく、AssetBundleをビルドした結果の奇妙な振舞い、そしてそれが生み出す著しいパフォーマンス劣化についてです。

AssetBunleの重複コピー問題点

AssetBundle化したいアセットをビルドするとき、もし、そのアセットが AssetBundleじゃないアセットを参照していたとしても、エラーが出ずにビルドが成功します。

AssetBundleは、さも依存関係が解決できたような顔をして、「できました!!」と言ってくるのです。 「そうかーできたかー」と思って、しばらく使っていたのですが、しかし、実はできていなかった。彼は、期待するほど完璧にはできていなかった。

AssetBundleの中のアセット A が、AssetBundleじゃないアセット B に依存している場合、できあったAssetBundleに入っているのは、 Aと、 Bの重複コピーです

つまり、たくさんのAssetBundleが、AssetBundleじゃないアセットBに依存している場合、その数ぶんだけの、おびただしい量のBの重複がプロジェクトに含まれてしまう結果になります。というか、なりました。 どうも、 AssetBundleは、AssetBundleの中だけで必要なアセットが揃っている、という状態をつくろうとするらしく、それを実現するために容赦なく重複コピーをつくります。

僕達のプロジェクトは、UI を細かい単位でAssetBundleにしていた上、当初は、UnityのデフォルトのUIのMaterialをそのまま使ってる部分が大半だったため、結果として、プロジェクト内にすさまじい量の UIのデフォルトMaterial、ついでに UI/Default シェーダが ロードされるという参事が発生してびびりました。

とくにシェーダのロードはコストが高い上、GPU側の処理を待つ間、メインスレッドがブロックされるため、ロード時間の激しい劣化を引き起こします。同じものを重複してロードするなんて命取りです。 また、Material&シェーダが別々になっていることによる実行時の性能劣化もまったく無視できません。

この問題についてもっともくわしい説明は、以下の UWA ブログの記事(中国語)なので、くわしくは以下を。

Unity 5.x AssetBundle零冗余解决方案 - UWA Blog

追記: Materialの指定のないUIのコンポーネントについては、単純にデフォルトのMaterialが重複する挙動をしているわけではないようです。 Twitter にて 椿 (@tsubaki_t1) さんに教えていただきました。

対策

  1. AssetBundle を使わない
    • AssetBundle から重複を完全にとりのぞくのは非常にむつかしい
    • Unity は、AssetBundleにかわる新しい API、「Addressable Asset System 」を準備しています
      • こっちでは重複問題が解決される、かもしれない
  2. プロジェクト内のアセットは全てAssetBundle化する
    • あらゆるアセットがAssetBundle化されていれば、AssetBundleは、重複を自分に抱えることはない
    • AssetBundle 同士の依存関係は、期待どおり解決してくれるため
    • 外部からダウンロードする必要のないアセットもすべてAssetBundleにしておき、パッケージにAssetBundleを含める方法があります。
      • この方法は公式ドキュメントでも紹介されています。
  3. 重複の自動検知、自動修正の仕組みをつくる

3については、そのアセットが依存しているアセットのパスを調べることで、AssetBundleからそれ以外への参照を検出することができるので、

        var assetBundleNames = AssetDatabase.GetAllAssetBundleNames();
        foreach (var assetBundleName in assetBundleNames)
        {
            var assetPaths = AssetDatabase.GetAssetPathsFromAssetBundle(assetBundleName);
            foreach (var assetPath in assetPaths)
            {
                var assets = AssetDatabase.LoadAllAssetsAtPath(assetPath);
                var dependedObjects = EditorUtility.CollectDependencies(assets);
                foreach (var dep in Dependencies)
                {
                    var path = AssetDatabase.GetAssetPath(dep);
                    if (path.Contains("/Resources/") || path.Contains("unity default"))
                    {
                        // ResourcesかUnityデフォルトを参照している!
                    }
                }
            }

ビルド時に自動で参照を差し替えてしまうなり、なんなりする仕組みをつくってがんばることが可能です。

追記: はてブコメントより。 Unity公式のツールは重複が考慮されているそうです。

Unity 特有のパフォーマンス劣化の落とし穴 2018歳末ふりかえり - part 1 - @hadashiA

今だとAssetBundleBrowser(重複は警告が出る)なりAssetBundleGraph(重複を自動で纏める)を使うのがお勧めかなぁ(どちらもUnity公式アセット)。何も知らないでAssetBundle系機能を自作するとこの問題にハマる

2018/12/31 12:34
b.hatena.ne.jp

2. AssetBundle に無駄に重複した Resource のアセットが大量に含まれる

さて、まだまだ問題のAssetBundleです。

AssetBundle が重複を抱えてしまう対象は、Unityのデフォルトリソースだけではありません。 AssetBundle化されていないものすべてです。

Resources/ 内のアセットも例外ではありません

Resources とはなんぞや

Unity において、 Resources というフォルダにおいたアセットは特別扱いされます。
この名前のフォルダにあるファイルだけは、実行中に、Resources.Load* などのAPIをつかって動的にロードすることができるようになっています。

また、裏を返せば、Unityにおいては、実行中に非同期にアセットをファイルから読み込むためには、

  • AssetBundleとしてかためておく
  • Resourcesにいれておいてパッケージに含める

この2択しかありません。

しかし、既にUnity公式ドキュメントで、Resources の機能そのものが 非推奨扱いになっているので、注意してください。

The Resources folder - Unity

> 2.1. Best Practices for the Resources System
> Don't use it.

非推奨の理由として、ドキュメントでは以下のような点が挙げられています。

  • フレキシブルにLoad/Unload をする方法がない
  • すべてをResourcesで管理することはできないゲーム起動が遅くなる

Unityは、ゲーム起動時に、Resourcesと名のつくすべてのフォルダに入れたすべてのアセットの索引のようなものをつくるようです。 そのため、Resources のなかにあるアセットが増えれば増えるほど、ゲームの起動が目にみえて遅くなっていきます。大規模プロジェクトにおいてすべてのアセットを Resources で管理することは想定されていないようですね。

問題点

しかし、僕としては、非推奨であるいちばんの理由は、 AssetBundle からResources を参照していると、大量に重複ができる問題を引き起こす点だと思います。

( ちなみに、Resources の中から AssetBundle を参照している場合も、Resources のほうに重複が含まれます)

「非推奨なら使わなければいいじゃん」と、思いますが、TextMesh Pro などに代表されるように、3rdパーティのプラグインが、 Resources/ 内にシェーダなどを入れていることがかなり多く、これも落とし穴です。

当然の結果として、Resources にあるText Mesh Pro のシェーダを AssetBundleから参照していると、AssetBundleひとつにつき一回、シェーダがいちいち重複してしまいます。

シェーダの重複は怖いです。ロード時間の劣化、そして実行時間の劣化を引き起こします。

対策

  1. Resources を使わない。
  2. AssetBundle から Resources への参照の検出を行ない、Resources 内のアセットをAssetBundle化して対応する

3. Shaderのコンパイルが実行時に走ることによってメインスレッドが長時間ブロックされる

プロファイラなどをがんばって眺めていると、Unity は、基本的には必要になったタイミングではじめてシェーダの初期化をはじめることがわかります。

グラフィックスAPIの種類によって、プロファイラに計上される名称などは変わってきますが、たとえば Metal であれば Unityプロファイラ上で 、 Shader.Parse といった名前でかなりの時間を消費していることがあります。 これは、まだ初期化されいないシェーダがはじめて描画される場合にのみ発生します。

f:id:hadashia:20181216122421p:plain

この画像は、実際のプロジェクトで採取した、XcodeGPUプロファイラの結果のグラフです。
一定のフレームレートで描画が実行されていたのに、あるフレームにくると、突然、数百ミリ秒の時間が消費され、描画が止まっています。 このフレームで起こっていることは、表示によれば「Shader Compilation」、シェーダのコンパイルのカテゴリということになります。

これを見てもわかるとおり、シェーダの初期化、コンパイルGPUにアップロードし、使える状態にする処理は非常に高価です。
とくにMetal は、OpenGLと比較して、初期化に時間がかかる傾向があります。

シェーダの初期化というやつは、可能な限り、事前にやっておきたいところなのですが、GPUも絡むためやっかいです。 期待とは裏腹に、Material がくっついた Unityのオブジェクトを Instantiate しただけでは、シェーダの初期化処理は行えていません。 あくまでCameraにはじめて映った時点で Shaderは初期化されるため、 ロード画面などでCameraに映さずに Instantiate をしたオブジェクトのShaderは初期化されていないのです。 (これは プロファイリング結果から判断してます)

もうひとつ、シェーダのコンパイル(RenderState)は、OSによってキャッシュされるような挙動をするため、問題に気がつきにくいことがあります。 アプリを終了してもこのキャッシュが生き続けていて、問題が観測できないんだけど、端末を再起動すると、キャッシュが消えてロードがめっちゃ遅い状況が再現したりしました。

対策

対策は、シェーダを使う前に初期化を実行することです。 幸い、現在のUnityには、シェーダの初期化を事前におこなう方法が提供されています。

  1. Shader.WarmupAllShaders を使う
    • Unity - Scripting API: Shader.WarmupAllShaders
    • このメソッドを叩くと、Unity がメモリ上にもっているShaderオブジェクトの、全variant を同期的にすべてロードします。
      • AssetBundleを使っている場合は、AssetBundle内のShaderをメモリに読み込んでから実行しないと意味がないので注意です。
    • 全variant なので、プロジェクト内のシェーダのキーワード数が多い場合、現実的な時間で処理を完了させるのはむずかしいです。
  2. ShaderVariantCollection.WarmUp を使う
    • ShaderVariantCollection は、読み込みたい シェーダと、そのvariant(同じShaderでも、キーワードのOn/Offによって実際には複数のシェーダとしてコンパイルされる)の一覧を定義したアセットです
    • これをあらかじめ作成しておくと、 そこに含まれているシェーダのみを WarmUp で事前ロードすることができます
    • 僕のプロジェクトでは、ビルド時にプロジェクトに含まれる Shader や Material を全てクロールし、ありえる全組み合わせの ShaderVariantCollection を自動生成する仕組みをつくりました
  3. AssetBundle を使わない場合は、Graphics Settings で事前ロードするシェーダを設定しておくこともできるようです。
    • 追記: Graphics Settings にある Always Includes に指定しただけでは、 シェーダの事前ロード迄やってくれるわけではありませんでした。
      • この設定は、ビルド時に参照が なかたっとしても必ずパッケージにシェーダを含める設定でした。
      • @piti6 さんに教えてもらいました。
    • 参考: [シェーダーのロード時間の最適化 - Unity マニュアル https://docs.unity3d.com/ja/2018.1/Manual/OptimizingShaderLoadTime.html]

ちなみに、何故これが落とし穴だと思ったかというと、こういった手法の情報がすくないこと、これを知らず、不用意にシェーダのvariantを増やしてしまい、事前ロード時間が爆発して困った。といったことが起きたためです。

また、AssetBundleの落とし穴によって シェーダが重複していた場合、 WarmUp をしても、重複コピーのほうはWarmUp されていない、といったことが起きるため、やはりAssetBundleの 重複はおおきな落とし穴といえるでしょう。

つづく

長くなってきてしまったので続きます。 次は c# 関連の話になる予定です。

思うに、Unityの挙動についての説明は、推測のような言い回しが多くなってしまったりして、なんというかとってもわかりずらい。書くのもなんかむつかしい。 オープンソースのソフトウェアであれば、ここまでこういう挙動をしてるっぽいとか、検証してみないとわからない、みたいなことってあんまりない気がするのですが。

追記: part2

Unity Technologies によるオンライン対戦FPSゲームの実装にふれてみた

Unity(ゲームエンジン)とGoogle が パートナーシップを結んだぜ、みたいなニュースが記憶に新しいですが、 先月末くらいに、彼らのオンライン対戦FPSのサンプルコードについての詳しい解説動画が上がってました。

ちなみに、このゲームのソースコードは、 アセットも含めて全て Github で公開されています。 Unity-Technologies/FPSSample

動画のなかでも、とくにこれが興味深いです。 Deep dive into networking for Unity's FPS Sample game - Unite LA - YouTube

UDPを使ったオンライン同期のサーバサイドの実装、とくに同期の詳細なメカニズムについて、ここまで噛み砕いた説明を見たことがなかったので、僕にとってはなかなかインパクトが大きかったです。ソースコードも完全公開されてるしなあ。

オンラインゲームのサーバサイドといえば、ノウハウを持っている人間たちが、それぞれのゲームに特化したミドルウェアをかなりの部分まで自前で設計/実装しているイメージがある。(自分のいまの会社もスクラッチで書いてるし)

とくに、インターネット越しに人々がゲームを同時にプレイしようとした際、ゲームの内容によっては、インターネットの遅延はすぐに無視できない大きさになってしまう。TCPが使えれば楽だし、最近はgRPC(http/2) といった便利な高レベルレイヤがあるのだけど、TCPのパケット再送の仕組みは、ゲームによっては同期の要件を満たすことができなかったりする。そのため、オンラインゲームの通信プロトコルというものは、ゲームに特化したものを考える必要に迫られる、と一般的には捉えられている。 (文献によってはこれを「インターネットがバグっている」などと呼称される)

単純なHTTPサーバのように、仕様も実装もオープンになっていて、誰でもありもののHTTP実装が一行も書かずに使えるように整備されている、といった領域とはまた違うのが、ゲーム開発のおもしろいところであり、筋肉が必要なところでもあり、薄暗いところである。即時性の高いゲームのサーバサイド開発というのは、参入障壁は低くはない領域にはなっているとおもう。

しかし、UnityとGoogleが協力することになったことを記念して公開された 特設サイト をみると、どうでしょう。彼らは、オンラインゲームのサーバサイド(特にモバイルらしいです)さえも民主化して、参入障壁を下げるような仕組みづくりを目指しているようだ。

Unity が世にデビューする以前は、ゲームエンジンというものは各社が独自に内製するもの、ゲームの内容ごとに特化した設計/実装をするもの、という空気が業界にはあったらしい。(業界のことはよくしらないが…)
Unityはそこのやりかたを覆した。ゲームエンジンというものを、スクラッチで実装できない下々の者でも扱えるものにすることに成功しちゃったのである。 (もちろん、Unityによって得たものもあれば失なったものもあるし、クローズドな部分も多いけれど、誰もが憎まれ口を叩きながらもUnityを使いUnityに金を落としUnityに毎晩お祈りしている)

そんなUnityが、今度は、Googleと協力して、ゲームのサーバサイドも民衆のものにしてしまうつもりのようだ 。

個人的には、サーバサイドの技術がものすごいスピードでコモディティ化してしまうのを見てきたので、ゲームといえどもどうせいつかはそうなることを避けられないとは思うけど。

Deep dive into networking for Unity's FPS Sample game

さて、 Deep dive into networking for Unity's FPS Sample game には、噂には聞いたことはあったけど、詳細な実装を知らなかったノウハウが色々と説明されていて、とても勉強になった。説明のしかたが視覚的にわかりやすくておもしろい。

こういうまとまった情報がなかなか少ないと思うので、この解説動画と完全に動くソースコードの存在だけでも、オンラインゲームのサーバ開発の民主化にかなり貢献しているのではないかと思う。

以下、この動画を見た内容のメモを残してみようとおもう。 (理解が充分でないところもけっこうあります)

ソースコード

ちなみにソースコードはここ。

Unity-Technologies/FPSSample

このゲームは、サーバもクライアントもどちらもUnityで実装されている。 サーバは、Unityをヘッドレスモードで走らせることで起動することができる。Unityでサーバを走らせるというのは、マジなのか冗談なのか、あくまでUnityのみですべての実装を完結させるためのパフォーマンスなのかは、明言はされていないが、すくなくとも本番で使うにはスケーラビリティが厳しいんじゃないか。

動画の説明に出てくる登場人物たちは、ソースコードでいうとこのあたりになると思う。

  • クライアントサイド
    • ClientGameLoop.Update() クライアントサイドのメインループ
    • ClientGameLoop.ProcessSnapshot(int ticks) サーバから到着したスナップショット(後述) を適用させる
    • ClientGameLoop.PredictionRollback() クライアントのみの推測で進行したゲーム内容のロールバック(後述)
  • サーバサイド
    • ServerGameLoop.Update() サーバサイドのメインループ
    • NetworkServer.commandsIn サーバサイドのコマンドキュー(後述)

みたかんじ、ソースの設計はとても手続き型パラダイムに依っている。
ClientGameLoop,ServerGameLoop と、メインループを司るエントリポイントがあり、各オブジェクトは、Updateも含めてすべて明示的に操作されるようになっている。 ただ、ゲームやネットワークの様々な状態も、かなり大きめのクラスが状態管理していて、どのメソッドによってどの状態が変わるかが制限されていないので、状態遷移は複雑。メソッド呼び出し順番の依存関係も激しそうである。 しかし、順番に頭からお尻まで、流れを追うのはやさしいし、パフォーマンス上の問題も把握しやすいのかもしれない。畑としてはとてもゲーム業界で育ってきたような雰囲気を感じました。

Dangers on net

なぜUDPをつかうのか?

インターネットのIPパケットは、送信したものが確実にその順番に届くことが保証されていない。 具体的には、送信したつもりのパケットが、相手に届く頃には以下のようになっちゃう問題がある。

  • Duplication (重複)
    • パケットが重複して飛んでくる
    • ルータの設定ミスなどによってありえる(?)
  • Reordering (順番違い)
    • 順番が入れかわる
    • つまり後ろのパケットが前のパケットを置い越して飛んでくる
  • Loss (消失)
    • パケロス。パケットが消失する。
    • 動画のなかでは、パックマンみたいなやつにパケットが食べられてしまっている。

UDPは、これらの問題への対策がされていない。

では何故TCPを使わないのか? - 相手から応答をもらってはじめて送信成功となる仕組みなのでレイテンシが悪い - うまく送れなかった場合にパケットを再送する仕組みなので、再送が発生した場合に、ゲームの流れを一旦止めて待たざるをえない

という問題がある。

UDP Problem detection

UnityのFPSSampleでは、UDPをつかった上で、上記のような問題を検出する実装がはいっている。

  • 送信データにフレーム番号をつける
    • 全てのデータ(パケット)に連番を振っておく → こうすることで、相手方が重複、順番違いの検出ができる
    • サーバからの送信、クライアントからの送信、どちらも同様に番号がついている。
    • 番号=ゲームのフレーム番号。
    • ただフレーム番号をつけるだけではなく、他にも以下のような情報をつける。
      • 最後に受信に成功したフレーム番号
        • サーバの送信データには、最後にクライアントから受信成功した番号がついている
        • クライアントの送信データには、最後にサーバから受信成功した番号がついている
      • AckMask
        • 過去16フレームぶんの、受信に成功したフレーム番号の一覧 → パケット消失の検出ができる
  • ラウンドトリップタイム=RTTの測定
    • 3フレームに一回、クライアントは、「データを送信してからサーバが応答するまで」の時間を計測して記憶しておく
    • この遅延の情報をつかうと、「実際にサーバがいま実行しているのは何フレームめなのか?」といったことを予測することができる

この辺はやはり動画で図解入りの説明をみるとわかりやすいです。

(ちなみに、最近、HTTP/2をさらに最適化するために、L4プロトコルUDPにしちゃおうぜ、HTTP/3だぜ、といったニュースが話題になっていましたが、結局は最適化をしようとするとTCPをやめてUDPの上に実装しなおしということになるんすな)

パケットの消失/順番違いの対応

クライアントからサーバに送信されたデータは、前述のとおりフレーム番号がついている。

サーバのメインループでは、到着したコマンドをすべて処理してしまうのではなく、「いま何フレームめなのか?」を逐一管理しており、適切なフレーム番号のデータのみを処理するようになっている。

  • そのため、もしいま現在のフレーム番号よりも、新しい番号のデータが先に到着してしまった場合、単純に、該当のフレームになるまでそのコマンドは処理を見送られる。
  • ソースをみたかんじだと、遅れてやってきた過去のフレーム番号のデータは到着した時点で処理している気がする。(未確認)
  • ソースをみたかんじだと、パケットの消失が起きた場合は、再送などはとくにせず、なかったものとして扱われる? (未確認)

サーバはゲームのスナップショットをどうやって圧縮するか

この辺もけっこうおもしろかった。

サーバは、「クライアントがどのタイミングまでサーバとの同期に成功したか」を知っている。何故なら、クライアントからのデータには、最後に受信に成功した番号がはいっているからだ。 だから、この「最後に送信に成功してるはずの状態」と、「現在の状態」との差分のみをクライアントに送ってあげれば済むことになる。 このときの、現在の状態との差分をとる基準のスナップショットを「Baseline」と動画/ソース内では呼んでいる。

クライアントサイドのPrediciton

クライアントもサーバも、60FPS(設定値)でゲームループがまわっているが、サーバからクライアントにデータを送信する頻度はもっと少なく、秒間3回(20FPS) になっている。 (60FPSで送信できるほどインターネットが速くないため) そのため、クライアントは、3フレームに一回しか、サーバの状態を同期できない計算になる。 キャラクタの移動とかミサイルの起動とか、なめらかに動いていなければいけないゲームの状態については、サーバからデータを受信できない 2/3のフレームでは、クライアント側で勝手に予測して動かさないといけない。

  • サーバとクライアントで同じソースを完全共有しているので、ミサイルの動きとかをクライアントだけでもエミュレートできる。そしてそのようにする。
  • サーバからのデータが到着したら、クライアントで勝手に推測してすすめていたゲーム状態は一旦ロールバックする実装になっている
  • ラウンドトリップタイム(RTT)から、サーバ側が現在何フレーム目を実行しているかの推測がきえる
    • ※このRTTのくだりの説明がよく理解できなかった。後で追記するかも

当たり判定どうするの?

クライアント側で銃をかまえて敵を撃ったとき、サーバではもう敵は逃げているかもしれない。 この矛盾をどう解決するのか。

ここでも、データにくっついているシーケンス番号が活躍する。 FPSSample では、サーバは、過去16フレームぶんの全員の当たり判定を記録しているので、クライアントが銃を撃った時点のタイミングの当たり判定をひっぱりだしてきて、命中判定をおこなえば良い。 (つまり、撃った側が命中に成功していればかならず当たる仕様ということになりそう)

まとめ

最近のUnityのロードマップはすごい。 全然関係ないけど、Google と協力関係になったってことで、gRPCがUnityにシームレスに統合されたりしないかなあ

高速なドロネー三角形分割

f:id:hadashia:20181011194454g:plain

「コンピュータジオメトリ」という書籍を参考にして、これに載ってるドロネー三角形分割のやりかたを実装してみた。

この本は、幾何アルゴリズムの「証明」や、計算量の解析が載っていて、しかも日本語で書かれているという徳の高い本。
理論どおりの計算量に近付けるための工夫というか、データ構造のもちかたも載っていて、その辺もまったく知らなかったので初学者にとっては貴重な情報源な気がする。
でもけっこう難しい。空間的な操作の翻訳の日本語での説明は、同じところを4回くらい読むという手法でなんとか読みすすめられたたけど、数式や知らない定理がでてくるところがけっこうあって、そっちの知識が不足しているからがんばってなんとか読めたかんじだった。

三角形分割は、好きな形状のなにかを画面に描きたいとおもったときに必要になりそうだったので、実装してみたかったアルゴリズム。 この本で紹介されているドロネー三角形分割は、隣接する三角形の辺を交換するというただそんだけの操作を再帰的にやるだけで実装できるため、総当たりな探索をほぼやらずににけっこう高速に実装できる余地があるものだった。 その辺の実装についてすこしだけ紹介してみようと思う。

ドロネー三角形分割とは

ドロネー三角形分割は、任意の点を与えてあげると、それらを線で結んで、三角形が敷き詰められた状態にするアルゴリズム

ドロネー三角形分割と呼ばれているけど、ボロノイ図のセル同士をあるルールで線で結んだ「ドロネー図」というグラフがあり、これをよく見ると、角度最適な三角形の集合になっとる、という特徴を持っている。ということらしい。
つまり、ドロネー三角形分割をするということと、ドロネーグラフを描くということはほぼ同じことのようです。

角度最適な三角形分割

任意の点の集合から三角形分割を得るには、結果はひとつではなく、幾通りかの結果がありえる。

また、そのうち、どの三角形分割が望ましいか、かっこいいか、についても、色々な基準があるみたいだけど、ドロネー三角分割は、「三角形の最小角度が最大になる」分割方法になる。

反対に、三角形の最小角度が最大になる三角形分割は、ドロネーグラフであることの必要充分条件であるので、これをアルゴリズムの判定につかう。

ある三角形が、ドロネーグラフの一部かどうかの判定は、ボロノイ図の性質を応用すると、その三角形の外接円の内部に他の点が含まれているかどうか? を調べるだけでわかる。
しかし、この判定のやりかただと、どの点を調べるべきかどうかが定まらないので、比較対象の点が多くなってしまう。
そこで、「三角形が不正かどうか」ではなく、「辺が不正かどうか」という概念を導入する。

不正な辺とは、もしその辺を「フリップ」して三角形をつくりなおした場合に、より角度が最適な三角形になる場合のことを言う。
辺フリップとは、つまり、その辺を一旦ぶち消して、2つの三角形を1つの四角形に戻した後で、別の対角線をつかって分割をやりなおす操作。

「不正な辺」かどうかの判定をするには、その辺と隣接している三角形だけが対象になるので、総当たりで探索する必要がなくなる。

分割の途中経過を有向グラフで管理する

もうひとつおもしろいと思った工夫が、三角形分割した結果だけをメモリに持つのではなく、分割する過程ででてきた親の三角形や、辺フリップ前の不正な三角形もとりあえずメモリにすべて残しておいて、どんな道筋で分割されてきたからの過程もすべてグラフで管理しておく。というもの。

このデータ構造をもっておくことによって、ある点が含まれている三角形を調べたいときに、ぜんぶの三角形を調べなくてもよくなる。 上から辿っていき、ヒットした三角形に子ども(つまり分割後や辺フリップ後の三角形)が存在したら、そっちも調べる。このやりかたを導入すると、高々グラフの高さの回数だけ判定すれば良くなる。

流れ

アルゴリズムの全体の流れ実装には、逐次添加法を使う。 三角形分割したい点の集合のうち、点をひとつずつ添加していて、その時点での三角形分割をつくっていく。

  1. 点をひとつ追加する
  2. 新しく追加された点を含んでいる三角形をみつける
  3. その三角形を、新しい点が頂点になるように3つに分割する (点が辺の上にある場合は4つに分割する)
  4. 分割してできた三角形は、新しく追加された辺を3つ含んでいる。この3つの辺が「不正な辺」かどうか判定する
  5. 「不正な辺」を、辺フリップする
  6. 辺フリップすると、新しい辺が生まれるので、この新しい辺についても「不正な辺」かどうか判定し、もしそうなら辺フリップする
  7. これを再帰的におこなう

サンプル

一応、このやりかたで実装してみたサンプルがこれ。

github.com

最近の学習計画

なんやかんやあって、ずっとやっていたWebサービス開発から、オンラインゲーム開発に仕事を変えた。
最近は、なんとか呼吸できるくらいには慣れてきたかなって思って気がついたら2年くらい経っていた気がする。
結局ソフトウェア開発だから、そこだけ聞くと、そんなに変わらないやん、と感じる人もいると思うけど、やるべきことや学習していくべきこと、方法論がけっこう違うと自分としては考えている。

今いる会社では、スマートフォン向けのオンラインゲーム開発を主な事業としている。
今やスマートフォンというのは、3Dの絵をリアルタイムに描くことができ、多少はリアルタイムにライティングをしたりとか色々な表現をするようなゲームが動く。びっくりである。
プロジェクトにもよるけど、自分の会社でもグラフィックに力を入れているプロジェクトは、なんというか、グラフィックに力を入れており、手描き風の品の良い絵にハッチング(線を重ねたような影?)の表現を取り入れた絵をリアルタイムにスマートフォン上で動かし、しかも多人数でオンライン同時プレイできるようなタイトルを出してる。
こうなってくると、昔ほど、携帯ゲームとコンシューマゲームで必要になる技術がまったく別物というわけでもなく、技術的には距離が縮まっていると言ってもよさそうである。実際、子どもの頃から名前を知っているようなゲーム会社が前職だった人というのもちらほらいる。

Webサービス開発をやっていた頃は、技術というのはコモディティ化するものだった。つまり、ある特定の製品でしかもっていない技術、というものが、全体でみるとそれほど重要ではないというか、目立たないというか、かなりのスピードで周辺の界隈が同じことを再現できるように同質化してしまう。そんな力が働いていた。
Webサービス開発において、有名なソフトウェアというのは、そのソフトウェアでないとつくれないものがつくれる、といった類のものじゃなくて、皆がつくっているものを圧倒的に効率化するアイデアや思想が含まれているもの、あるいは、パフォーマンスや開発コストを改善するもの、だったような気がする。
そもそもWebというものが、誰に対しても開かれていて、そこで開発をするということは、そこで特定の何かを利用するということは、それにbetする、ということであって、利用するだけではなく、発展させるような行動を取ることが基本戦略になっていたのかな。

そんななかで暮らす一兵卒エンジニアとしては「学習する」ということは、つまり、Web開発に参加する世界中の人、Googlefacebook,Appleをはじめとする界隈の巨人がリードし整備する周辺ツールや環境を知ること、最新情報を追うこと、ソフトウェアの使い方を知ること、とほぼイコールだった。誰かがつくったものや説明理解するのが学習っていうかんじ。(自分のレベルではね)
また、自分で何か応用してものをつくるということよりも、既存のソフトウェアを発展させるような行動を取ることが「偉い」とされるバイアスがあったようななかったような。まあそれは、すごくわかりやすい物差しだったから色々と利用されていた、というだけかもしれないけど。

まあ、それはいいんだけど、自分がゲーム開発はすこし違うと感じているのがこの点である。
まず、ことゲーム開発においては、エンジニアは「ツールやライブラリの最新情報を追っている、つかいかたを知っている」というレベルでは、つくりたいものをつくるのは難しいのではないかと感ずる。
そのゲームごとに、画面に出したい絵や、プログラマブルに実現したいこと、するべきことはそれなりに違っていると思うし、ハードウェアの制約によって省かなければいけなくなることも違ってくる。そういうとき、必要になってくるのは、ブラックボックスの中身とかアルゴリズムとか既存のテクニックの知識と、もっと大事なこととして、それをどう応用するかにかかっているという気がする。
そういう仕事にあこがれているのが、自分が仕事を変えてみた理由だった。

今のところ、つくりたいものがつくれるレベルにはまったく達していないし、学習のペースもむちゃ遅いんだけど、自分は基礎をすっとばしてきたので、もうちょい基礎をやりたいなあというかんじが現状。
とにもかくにも、吸う空気を変えたりした結果、学習すべきことがわかってきたといえるくらいにはなってきた気がする。
まず、もっとも大事なことは、良質な情報を読解する能力だと考えてる。これを上げていくと、全体的な学習する速度が上がるのでいろいろな能力が伸びるのが速くなる。また、世の中では、設計については広く解説が広まっていくけど、実装については、皆が皆紹介してくれるわけではないので、良質な情報をみる力がないとなんも読むものがなかったりする。

良質な情報を読解する力をつけるための基礎の基礎として、まず英語で、この分野はやっぱり英語じゃないとくわしい説明がないかんじがする。あと数学。数学は、大事なのかなとおもって細々と練習しているのだけど、意外となにをどう勉強すればいいのかわからなくなるのがはやかった。いろいろ読んでみて、最近はもうちょい解析という分野をやると、アルゴリズムとかグラフィック関連の内容を読解する能力が上がるわ。ということにようやく気がついた。「そんなの最初からわかるやろあほか」と思われるかもしれないけど、まあそういうのもわからない、そういう情報を得ることさえできてないレベルだったのがちょっと進歩してきたというかんじなのである。
ちなみに、Web屋に従事していた頃は、設計パターンや思想をすごく熱心に文学だとおもっていろいろ読んでいて、それは今でも役にたっているけど、設計よりも「実装」の知識が必要になる機会はやっぱ多いといっても良い気がする。サーバサイドの仕事もしてるけど、Socket のhalf connection 問題とか、c#ランタイムの中身とか、ヒープアロケートが発生する箇所とか、マルチスレッドでありがちな誤ちとか、Web開発をしていたことはそういうのは使ってるミドルウェアの仕事で、考える必要がそんなになかったような気がする。そして今挙げたトピックは、設計の問題というより実装の問題で、誰もが広く解説できるわけではないので、まともなドキュメントをがんばって読むのが一番はやい。
そんなかんじで、転職してからこれまでは、基礎をやるのとかインプットするのとかをしてきた。
あとは空気を吸ってるだけであっという間に時間が経ってしまって、「これつくった」て言えるものがいまだにとくにないんだけど、自分のなかの計画としてはまあそんなもんなかなと思っている。 勉強するやりかたがわかってきただけでも儲けものとおもってる。その程度の奴だしね。
でも、この後は、もっと得た情報を元に応用していかないといけないので、読んで理解するだけでは不十分で、知識を応用して分解して再構築して遊んでみないといけないことはたしかであるから、そろそろなにかつくって公開とかしていきたい、できたら良いなとおもってる。

ここになんか書いたりはそろそろしてみようかなとおもってる。

c# と非同期処理ついての初歩的なFAQ

c# でサーバを書くのおすすめです。処理の並列化がとてもやりやすいです。

golangが登場したとき、小さなコード辺をスレッドを意識せずに投げまくれる上、タイマーやI/Oをスレッドをブロックせずに使える、そんなお手頃価格な並列処理に注目が集まっていたように思いますが、それ、c# もできます。昔から。Taskとそのスケジューラ、さらにそれらを文法に統合する基盤である async/await 。

c# も、.NET Core の登場でブランドイメージが一新されようとしており、linuxでも普通に使えるしDockerコンテナも公式から配布されているし、開発もGithubで公開、ドキュメントの日本語も流暢になってる。 ふと気がついたら、サーバと名のつくものは全てc#でまかなえる時代がいつのまにか来てます。

ところで、c# でサーバを書く場合、非同期処理の挙動を意識する機会が多かったりするので、自分が入門するときに持った疑問点をまとめてみようと思います。

疑問1 awaitで非同期処理を待つと、続きは元のスレッド再開する?

答え: 場合による

c# には、デフォルトでは「メインスレッド」という概念があるわけではありません。await文も、いずれかのスレッドを特別扱いする機能ではありません。

await で非同期処理を待った後、どのスレッドで再開するかは、フレームワークに依存します。 もうちょっと正確に言うと、実行コンテキストの設定に依存します。

関連してくる設定は主に2つです。

  • TaskScheduler → Task がどのように実行されるかスケジュールする
  • SynchronizationContext → await 後の続きの処理がどのように実行されるかスケジュールする

特に明示しない限り、スレッドごとに設定されている TaskScheduler.CurrentSynchronizationContext.Current の値が自動的に使用されます。 (ちなみに、こんなかんじで、スレッドごとにどこからでも参照可能な設定に挙動が依存するパターンを Ambient Context と呼んだりします)

Task とは?

Task は、処理のかたまりを表現したオブジェクトです。 多言語でいうところの Promise や Future と呼ばれているものと思ってもらって差し支えないと思います。 「未来に実行されるかもしれないし、実行中かもしれないし、完了してるかもしれない」そんな抽象的な処理のカタマリをオブジェクトとして表現することで、非同期処理のチェインや、エラー伝搬なんかがわかりやすくなる強力なパターンです。

このTask ですが、それ自体は、どのスレッドでどのようにスケジュールされるのかはとくに決まっていません。 それを決めるのは TaskSchedulerです。

TaskScheduler.Current は デフォルトでは ThreadPoolTaskScheduler が使用されます。 これは、Task.Run(..) した場合、.NET の共通言語ランタイム(CLR) が持っているスレッドプールで実行されるというスケジューラです。(めっちゃ性能良い

ちなみにこのスケジューラは、完了の順番は保証されません。

Task.Run(() => Console.WriteLine("Im task1"));
Task.Run(() => Console.WriteLine("Im task2"));
Task.Run(() => Console.WriteLine("Im task3"));

// 出力例:
// Im task 1
// Im task 3
// Im task 2

SynchronizationContext とは?

SynchronizationContext を使うと、並行に走る処理たちを、ある一定のポイントで同期させることができます。

たとえば、別のスレッドからの要求であったり、そもそも別のネットワークのマシンからの要求みたいなものを処理するために、一定の間隔で要求が到着してないかチェックし、あれば実行してあげる、みたいなことが必要になってきますが、SynchronizationContext を使うとこうした振舞いを表現することができます。

SynchronizationContextは、 .NET 2.0 時代から提供されていて、await とは分離されているものですが、await の再開時に、継続行がどのようにスケジュールされるかを決めるものとして使われてるので、そのように紹介されてることが多い印象です。

SynchronizationContext.Current のデフォルト値ですが、 nullです。

これが何を意味するかというと、 c# の await は、デフォルトでは 同期を取ったりはしない、ということ。

つまり、別スレッドで走ったら走りっぱなし。メインスレッドには帰ってこない。

Console.WriteLine($"Thread: {Thread.CurrentThread.ManagedThreadId}");

await Task.Run(() => Console.WriteLine("Im a task1"));
Console.WriteLine($"Thread: {Thread.CurrentThread.ManagedThreadId}");

await Task.Run(() => Console.WriteLine("Im a task2"));
Console.WriteLine($"Thread: {Thread.CurrentThread.ManagedThreadId}");

await Task.Run(() => Console.WriteLine("Im a task3"));
Console.WriteLine($"Thread: {Thread.CurrentThread.ManagedThreadId}");

// 出力例:
// Thread: 3
// Im a task1
// Thread: 5
// Im a task2
// Thread: 4
// Im a task3
// Thread: 5

await で継続するたびに、実行スレッドがかわりました。

await は、内部的にはTaskとTaskのチェインなので、実行順番だけは保証されます。

しかし、どのスレッドに割り当てられるかは特に期待できないかんじです。

基本的には排他制御は必要ないわけですが、スレッドが変わってしまうということは覚えておくのが良さそうです。

疑問2. でも ASP.NET Core 環境では await すると元のスレッドで継続されるんでしょ?

答え: されません。もちろん処理の順番は保証されますが、元のスレッドで継続されるわけではありません。

現在の ASP.NET Core環境では、SynchronizationContext.Current はnullです。

ASP.NET Core SynchronizationContext この辺の説明に寄ると、パフォーマンスのためにリクエストスレッドでの同期というやりかたをやめたようです。

そもそも、ASP.NET Core は、1プロセスを多重化して 並行にリクエストを捌けるので、リクエスト時に、めちゃめちゃスコープが広いオブジェクトの状態を書き換えるようなことをするときは、排他制御が必要。ということになります。まあ、普通は状態を持つのは RDBMSとかRedisとかのちゃんとしたライブラリに任せるはずなので、意識する機会はほぼないかもいれませんが、知らないで シングルトンに書き込み可能な状態をつくってしまうとバグります。

疑問3. じゃあ await するとメインスレッドで継続される環境はあるのか?

答え: UI

UIはメインスレッドのメインループで描画が更新されるものなので、そういった環境では SynchronizationContextによって、メインスレッドから起動した非同期処理は継続時にメインスレッドに戻ってくるといった実装がなされているはずです。

つづく

c#の getter/setter はスレッドセーフか? とか、色々と書きたいことがまだあるんですが、ここからはTPLの話ではなくなってくるのでまた次回にしてみようとおもいます

キャットムル-ロム スプライン曲線

最近、ごく基本的なの曲線のアルゴリズムを実装したりしてる。

キャットムル-ロム スプライン(Catmull-Rom Spline)ていうアルゴリズムは、制御点を必ず通るという特徴があるので、絵を生成するような用途だととても使いやすい。しかも実装が簡単。 最近、これを実装する機会があったので、仕組みを説明してみようと思う。

スプライン曲線

ゲームのアルゴリズムの本をめくり、曲線について書かれているページを開くと、大抵は、次数がいくつであっても対応できる級数みたいなものの式が載っていて、難しそうである。

これはどうしてかというと、数学をもってすれば、めちゃめちゃ複雑な曲線でさえも、たった1つの式で表現できる能力があるし、数式は、低い精度から高い精度まで、どんな精度の近似にも当てはまる1つのルールを書き下すことも得意だから、一見すると、どうやって実装したら良いかよくわからない一般式が目に飛び込んできたりする。

しかし、目的が単に曲線を引くことだけであれば、全体の式がわからなくても、簡単な式をたくさんつなぎ合わせればできる。

中学校で習ったとおり、1次関数は直線、2次関数は頂点を境に折り返す放物線、3次関数はさらにもう1つ変曲点を持てる曲線-- という具合に、一般に、式の次数が増えていくと、複雑な線を近似する能力がどんどん上がっていく。

しかし、次数を上げてしまうと、計算するのも大変だし、制御するのも大変だ。

身のまわりでは、絵を描くツール等でよくみかけるベジェ曲線なんかも、すごく綺麗な線だけど、よくつかわれているのは制御しやすい三次関数だ。これをどうつなぎ合わせて作りたい線を引くかということをむしろ問題にしている。

ということからも想像できるように、一度に全体を計算するよりも、簡単な式をつなぎ合わせ、それを伸ばしていくアプローチの方が、プログラムとしては汎用性が高い。 三次ベジェ曲線だけであれば、バーンスタイン多項式というやつ読めなくても実装するのはけっこう簡単。

ベジェ曲線に代表されるように、たくさんの制御点をもとに、曲線を引くアルゴリズムのことを総称してスプライン曲線といい、ベジェ曲線の他にもいくつかある。 スプラインは、普通、簡単な式で表現できるカーブのアルゴリズムと、それらが連続するようにつなげるアルゴリズムの合わせ技でできている。

エルミート曲線 (Hermite Curve)

エルミート曲線は、曲線のアルゴリズムの1つ。 キャットムル-ロム曲線は、このエルミート曲線を連続するようにつなげるアルゴリズムなので、実装するにはまずはこれの理解が必要になる。

「制御点」を指定して多重補完するベジェ曲線とは違い、エルミート曲線は、始点と終点と開始ベクトルと終点ベクトルを入力に与えてあげる。

つまり、始点から始点ベクトルの方向に飛び出していった曲線が、終点付近では、終点ベクトルの向きで向かってきて到着する。そんな関数を考えるのである。

この関数は、始点からひとつめのカーブへ進み、ふたつめのカーブから終点へ進む、という形をしているので、3次多項式(cubic polinominal) で表現できるはずである。

\displaystyle{
f(t) = at^3 + bt^2 + ct + d \ \ \ ( 0 \leq t \leq 1)
}

こんな形をしているはず。 t は、曲線の始まりを0、終わりを1 とした場合の、曲線の進み具合のパラメータ。この式の解が、特定の位置 t における曲線の座標を表している。

式で書くことができたので、あとは4つの係数 a,b,c,d がわかれば、好きなパラメータtを代入してあげるだけで曲線の座標を得ることができる。

というわけで、4つの係数を求めてみる。

始点は t = 0 なので、t に 0を代入すると、その値は始点の座標になる。

\displaystyle{
f(0) = p_0 = d
}

反対に、tに1を代入すると 終点の座標になる。

\displaystyle{
f(1) = p_1 = a + b + c + d
}

位置だけではなく向きのベクトルも与えるので、速度の関数についても考えてみる。 一階微分したこの式が、特定の位置 t における 曲線の傾きの関数になるはずである。

\displaystyle{
f'(t) = 3at^2 + 2bt + c
}

t に 0 を代入すると、始点における曲線の傾きが得られる。

\displaystyle{
f'(0) = v_0 = c
}

t に 1 を代入すると、終点における曲線の傾きになる。

\displaystyle{
f'(1) = v_1 = 3a + 2b + c
}

ここまでの式を使って整理すると、始点&終点の座標と傾きと、係数a,bの関係が以下のようになる。

\displaystyle{
p_1 = a + b + v_0 + p_0
}

\displaystyle{
v_1 = 3a + 2b + v_0
}

あとは、連立方程式を解けば、a,bが求まる。

\displaystyle{
\begin{cases}
a + b = -p_0 + p_1 - v_0 \\
3c_0 + 2c_1 = -v_0 + v_1
\end{cases}
}

\displaystyle{
a = 2p_0 - 2p_1 - v0 + v1  \\
b = -3p_0 + 3p_1 - 2v_0 - v_1
}

これで、t を入力にとるエルミート曲線の関数を、始点/終点それぞれの座標と向きのベクトルから記述することができる。

\displaystyle{
f(t) = (2p_0 - 2p_1 - v0 + v1)t^3 + (-3p_0 + 3p_1 - 2v_0 - v_1)t^2 + v_0t + v_1
}

C#で書くとだいたいこんなかんじ。

    public struct HermitePoly
    {
        // 始点/終点の座標と ベクトルから、曲線の軌道上の座標を返す
        public Vector3 GetPoint(Vector3 p0, Vector3 p1, Vector3 v0, Vector3 v1, float t)
        {
            var c0 = 2f * p0 + -2f * p1 + v0 + v1;
            var c1 = -3f * p0 + 3f * p1 + -2f * v0 - v1;
            var c2 = v0;
            var c3 = p0;

            var t2 = t * t;
            var t3 = t2 * t;
            return c0 * t3 + c1 * t2 + c2 * t + c3;
        }

        // 始点/終点の座標と ベクトルから、曲線の傾きベクトルを返す
        // この関数もつくっておくと3Dの法線とかに使えて便利
        public Vector3 GetTangent(Vector3 p0, Vector3 p1, Vector3 v0, Vector3 v1, float t)
        {
            var c0 = 6f * p0 - 6f * p1 + 3f * v0 + 3f * v1;
            var c1 = -6f * p0 + 6f * p1 - 4f * v0 - 2f * v1;
            var c2 = v0;

            var t2 = t * t;
            return c0 * t2 + c1 * t + c2;
        }
    }

キャットムル-ロム スプライン (Catmull-Rom Spline)

キャットムル-ロム スプラインは、任意の数の制御点を入力して、そこの点をすべて通るような曲線が引けるアルゴリズム。 これは実は、エルミート曲線を連続するようにつなぎ合わせる手法。

エルミート曲線自体は、始点ベクトルと終点ベクトルを自由に入力することができるアルゴリズムだけど、傾きを自由に入力するのではなく、長いスプラインのなかの、前の制御点と次の制御点との傾きを求めて、それを入力に使う。

エルミート曲線が実装できれば、後は簡単。

    public class CatmullRomSpline
    {
        public IReadOnlyList<Vector3> ControlPoints => _controlPoints;

        readonly HermitePoly _poly;
        List<Vector3> _controlPoints = new List<Vector3>
        {
            new Vector3(0f, 0f, 0f),
            new Vector3(1f, 0f, 0f),
            new Vector3(1f, 0f, 1f),
            new Vector3(1f, 0f, 2f),
            // .. 制御点はいくつでも追加できる
        };

        public CatmullRommSpline(HermitePoly poly)
        {
            _poly = poly;
        }

        public Vector3 GetPoint(float t)
        {
            var l = _controlPoints.Count;
            var progress = (l - 1) * t;
            var i = Mathf.FloorToInt(progress);
            var weight = progress - i;

            if (Mathf.Approximately(weight, 0f) && i >= l - 1)
            {
                i = l - 2;
                weight = 1;
            }

            var p0 = _controlPoints[i];
            var p1 = _controlPoints[i + 1];

            Vector3 p2;
            if (i > 0)
            {
                p2 = 0.5f * (ControlPoints[i + 1] - ControlPoints[i - 1]);
            }
            else
            {
                p2 = ControlPoints[i + 1] - ControlPoints[i];
            }

            Vector3 p3;
            if (i < l - 2)
            {
                p3 = 0.5f * (ControlPoints[i + 2] - ControlPoints[i]);
            }
            else
            {
                p3 = ControlPoints[i + 1] - ControlPoints[i];
            }
            return _poly.GetPoint(p0, p1, p2, p3, weight);
        }

これだけで、制御点をすべて通るなめらかな線が引けた。

f:id:hadashia:20171230150523p:plain