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

Unity に mruby 組み込んでる

実験的に、Unityにmrubyを組み込んで使ってみてる。

基本的には Unity内のコードは全て c# で書くわけだけど、ゲームの基盤部分もすべてc#で書くだけに、それとは別に、ゲームのコンテンツ部分だけを集中して記述できる分離された薄いレイヤがあると便利だ、という話がある。

たとえば、ゲーム内のキャラクタの会話や演技、そういった設定たちが、再コンパイルせずに実行中にホットリロードで変更を確認しつつ開発を進められると嬉しい。

そこで、 mruby を使ってみようかなと思い立った。

もちろん、なにもmrubyを組み込まなくたって、構造化されたただのデータ(たとえばyamlとか…) なんかでも充分、ゲームの内容を表現することはできそうだ。

しかし、それはそれで、決して安い買い物とも言えないような気がする。 複雑なデータ構造は、invalidでないことを完璧に保証するつまらないコードを書くのが案外大変だし、専用のエディタをつくるのはもっと大変だ。 ( あの人間に優しいことで有名なRailsですら、database.yamlを間違えて書いたまま起動したとき、何行目の何が間違っているかなんて教えてくれないんだから…)

しかし、言語を組み込んでしまえば、サポートされない記法を書いちゃった場合のエラーメッセージが自ずと手厚くサポートしてもらえる。構造化された内部DSLをつくるのもrubyは得意中の得意だし。

というわけで 試しに mruby つかってる。

Unity ネイティブプラグイン

さて、mruby で Unity のゲームを操縦するためには、

mrubyコード → c/c++ 等のネイティブコード → Unity C#

と、3つの段階を踏むことになる。

こう考えるとちょっと面倒臭いんだけど、 Unity はネイティブコードをビルドに含める仕組みがあるので、難しいことで悩むことはない。

Unity の Assets 内のどの階層であろうと、 Plugin と名付けられたディレクトリは特別扱いされ、この中に C#のdll、あるいはネイティブの c/c++ ソース、あるいはビルド済みのネイティブのライブラリを置くことで、ビルド時にコンパイルないしリンクしてくれることになってる。 これがいわゆる Unityの「プラグイン」という仕組みで、ネイティブコードの方を「ネイティブプラグイン」と呼ぶ。

Plugin/ 以下にはプラットフォームごとに実装を置く必要があって、たとえば、

こんなかんじのサブディレクトリをきったら、それぞれの名前に対応するプラットフォームの実装をがんばって用意して配置していく。

今回は、あらかじめビルドしたmruby をビルドして組み込みたいので、Plugins/ 以下には、ビルド済みバイナリを置いていくことになる。

C#のメソッドをネイティブコードから叩く

というわけで、ネイティブプラグインをビルドしてみよう。

僕はmacOSで開発をしてるので、まずは XCodeを開く。

  1. XCode を開いて、新規プロジェクトをつくる。
  2. プロジェクトテンプレートは macOS → Framework & Library 内の 「Bundle」 を選択。
  3. iOSで動かしたい場合は、iOS → Framework & Library 内の 「Cocoa Touch Static Library」。
  4. ちなみに、後からプロジェクトの設のターゲットを追加して、同じ操作をすると、macOSiOS 向けビルドが同じプロジェクトでやれます。

まずは、 Unity C# のメソッドをcから操縦するネイティブプラグインをつくってみるところからやってみる。

c から Unity のメソッドを叩くやりかたは、だいたい2とおりある。 1. Unityが提供している UnitySendMessage(const char *gameObject, const char *methodName, const char *message) をcから叩く 2. C#からcへ関数ポインタを渡してあげる

今回は、2の方法をとることにした。

( 1 のUnitySendMessage はめっちゃfuzzyだし遅いけど、今回の用途では充分に要件を満たすことはできそう。ところが、一旦ビルドしたやつをUnity側にリンクさせる方法をとる場合、Unity.app内にヘッダがバンドルされてない上、そもそもシンボルがなくてビルドがこけてしまう。ソースを直接 Plugins/ に置いてあげる場合でないと使えないのかもしれない )

以下は、C#の関数ポインタを受けとるcのコード。

r4u.h

// 文字列を1つ引数にとる void 関数のポインタ型
typedef void (*r4u_string_action)(const char *);

// 文字列を1つ引数にとるc# の関数ポインタを受け取る関数
void r4u_bind_debug_log(r4u_string_action);

// それを呼び出すだけの関数
void r4u_call_debug_log();

r4u.c

string_action debug_log;

void r4u_bind_debug_log(r4u_string_action action) {
   debug_log = action;
}

void r4u_call_debug_log(const char *arg) {
    debug_log(arg);
}

これで完成。 macOSのターゲットを選んでビルドすると、 ◯◯.bundle ができるので、 あとは、Unity の Plugins/macOS/ ディレクトリへそっと置いておこう。

C#の関数ポインタをネイティブ側へ渡す

続いてUnity側から、今つくったネイティブプラグインとのやりとりを書いていく。

C# には元々 P/Invoke と呼ばれる仕組みがあって、ネイティブコードとのやりとりを言語がサポートしている。Unityでも基本的には同じ記法を利用して ネイティブプラグインと通信する。

public class R4uBinding : MonoBehaviour
{
    // 相互に受け渡す関数ポインタ型は、delegate として表現する必要がある
    public delegate void StringAction(string arg);

    // R4u.bundle に実装されている関数をc#から使う宣言
    [DllImport("R4u")]
    // さっき書いたcの関数の宣言。
    public static extern void r4u_call_debug_log(string message);

    [DllImport("R4u")]
    // さっき書いたcの関数の宣言。 (次のステップで、これを直接呼ぶのではなくrubyコード経由で叩く)
    public static extern void r4u_bind_debug_log(StringAction action);

    void Start()
    {
       // こんなかんじで、関数ポインタを登録すると、
        r4u_bind_debug_log(DebugLogCallback);

       // c コードから自由にそれを呼べるようになっているはず
        r4u_call_debug_log("Hello! Hello! Hello1");
    }

    // ネイティブ側に渡せるのは、この属性のついた static メソッドのみ、という制約があるらしい
    [MonoPInvokeCallback(typeof(StringAction))]
    static void DebugLogCallback(string arg)
    {
        Debug.Log(arg);
    }
}

コンソールに Hello! Hello! Hello! がでたら成功。

mrubyのビルド

C#の 関数ポインタを受けとることができたので、あとは この関数ポインタをmruby 経由で操縦するバインディングを書くだけ。

まずは 適当な場所に mruby/mruby をクローンする。

ドキュメントは リポジトリ内の doc/ 内に書いてあるようだ。これを見たり、野良ブログで紹介してくれている build_config.rb を参考にビルドする。

iOS/Android についても既にDSL的にはサポートがあるようで、自分のマシン上のそれぞれのプラットフォームのビルド環境があれば、 toolchain での指定を変えるだけで、うまくビルドしてくれた。

# デフォルトでは 「host 」という名前でビルドされる
MRuby::Build.new do |conf|
  toolchain :clang
  enable_debug
  conf.gembox 'default'
end

# iOS
MRuby::CrossBuild.new('iphone-arm64') do |conf|
  toolchain :clang

  isysroot = '/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk'

  conf.cc do |cc|
    cc.flags << '-arch arm64'
    cc.flags << "-isysroot #{isysroot}"
    cc.flags << '-Wall'
    cc.flags << '-Werror-implicit-function-declaration'
  end

  conf.linker do |linker|
    linker.flags << '-sdk iphoneos'
    linker.flags << '-arch arm64'
    linker.flags << "-isysroot #{isysroot}"
  end
end

# Android たぶんこんなかんじ。 https://github.com/mruby/mruby/blob/master/doc/guides/compile.md
# MRuby::CrossBuild.new('android') do |conf|
#   toolchain :android
#   conf.gembox 'default'
# end

上のような設定ファイルをつくったら、ファイルパスを指定して ./minirakeする

$ MRUBY_CONFIG=../../mruby_build_config.rb  ./minirake

すると mrubyのリポジトリ以下のようにそれぞれのビルドができる。

build
├── host
│  ├── LEGAL
│  ├── bin
│  ├── lib
│  ├── mrbgems
│  ├── mrblib
│  └── src
└── iphone-arm64
   ├── lib
   ├── mrblib
   └── src

XCode のプロジェクトに mrubyをリンクする

mrubyができあがったら、 build/host/lib/libmruby.a を、XCodeプロジェクトからリンクする設定をしておく。

  1. 「Build Settings」 の Header Search Paths に、 mrubyリポジトリ内にある include ディレクトリを追加
  2. 例) $(PROJECT_DIR)/../../ext/mruby/include
  3. 同じく Build Settings の Library Search Paths に、ターゲットのプラットフォームに対応する libmruy.a があるディレクトリを追加しておく
  4. 例) $(PROJECT_DIR)/../../ext/mruby/build/host/lib
  5. ターゲットごとに、「Build Phases」の Link Binary With Libraries という項目に、libmruby.a をドラッグ&ドロップする

設定できたらテストしてみる。 mruby の hello worldXCodeからコンパイルできたら成功。

#include <mruby.h>
#include <mruby/compile.h>

void hoge() {
    mrb_state *mrb = mrb_open();
    mrb_load_string(mrb, "5.times {|i| p 'hello' }");
    mrb_close(mrb);
}

C# から rubyコードを渡してあげて実行する

あとは、さっき作ったネイティブプラグインの、c#関数ポインタ呼び出し部分をrubyで操縦できるようにするだけ!

typedef void (*r4u_string_action)(const char *);

void r4u_bind_debug_log(r4u_string_action);

void r4u_start();  // 初期化関数
void r4u_dispose(); // 後始末関数
void r4u_exec(const char *src); // rubyコードを渡すと実行してくれる関数
#include "r4u.h"

#include <mruby.h>
#include <mruby/compile.h>

struct r4u_context {
    mrb_state *mrb;
    struct RClass *module;
    
    r4u_string_action debug_log;
    r4u_string_action debug_log_warning;
    r4u_string_action debug_log_error;
};

struct r4u_context r4u; 

mrb_value _debug_log(mrb_state* mrb, mrb_value self)
{
    mrb_value arg;
    mrb_get_args(mrb, "o", &arg);
    mrb_value message = mrb_inspect(mrb, arg);
    char *message_cstr = mrb_str_to_cstr(mrb, message);
    debug_log(message_cstr);
    return mrb_nil_value();
}

void r4u_bind_debug_log(r4u_string_action action) {
    r4u.debug_log = action;
}

void r4u_start() {
    r4u.mrb = mrb_open();
    r4u.module = mrb_define_module(r4u.mrb, "R4u");
    
    mrb_define_module_function(r4u.mrb, r4u.module, "debug_log", _debug_log, MRB_ARGS_REQ(1));
}

void r4u_dispose() {
    mrb_close(r4u.mrb);
    
    r4u.debug_log("[R4u] dispose");
}

void r4u_exec(const char *src) {
    r4u.debug_log(src);
    
    mrbc_context *context = mrbc_context_new(r4u.mrb);
    int ai = mrb_gc_arena_save(r4u.mrb);
    mrb_load_string_cxt(r4u.mrb, src, context);
    
   // このブロックはエラー処理
    if (r4u.mrb->exc != 0) {
        mrb_value exc = mrb_obj_value(r4u.mrb->exc);
        mrb_value backtrace = mrb_get_backtrace(r4u.mrb);
        r4u.debug_log_error(mrb_str_to_cstr(r4u.mrb, mrb_inspect(r4u.mrb, backtrace)));
        
        mrb_value inspect = mrb_inspect(r4u.mrb, exc);
        r4u.debug_log(mrb_str_to_cstr(r4u.mrb, inspect));
        r4u.mrb->exc = 0;
    }
    
    mrb_gc_arena_restore(r4u.mrb, ai);
    mrbc_context_free(r4u.mrb, context);
}

Unity側も修正したら完成。 C# からは、好きなrubyコードを書いて実行できる。

using System.Runtime.InteropServices;
using AOT;
using UnityEngine;

public class HogeBinding : MonoBehaviour
{
    public delegate void StringAction(string arg);

    [DllImport("R4u")]
    public static extern void r4u_start();

    [DllImport("R4u")]
    public static extern void r4u_dispose();

    [DllImport("R4u")]
    public static extern void r4u_exec(string src);

    [DllImport("R4u")]
    public static extern void r4u_bind_debug_log(StringAction action);

    [MonoPInvokeCallback(typeof(StringAction))]
    static void DebugLogCallback(string arg)
    {
        Debug.Log(arg);
    }

    void Start()
    {
        r4u_start();
        r4u_bind_debug_log(DebugLogCallback);

        // 好きなrubyコードを実行できる。あらかじめバインドしたC#関数もコールできる
        r4u_exec("include R4u\n" + "3.times{ debug_log 12345 }");
    }

    void OnDestroy()
    {
        r4u_dispose();
    }
}

うむ!

課題

  • このやりかただと、c#の操作したい関数が増えるたびに、mruby から のバインディングを書く手間がけっこう多い。
  • もし ダイナミックなバインディングが不可能であれば、コード生成する仕組みとかを入れても良いのかもしれない。
  • XCodeで完結しない Android NDK とかのビルドのワークフローをちゃんとしないと面倒

もうちょっと仕組みを整備する必要があるけど、個人開発では十分使っていけそう。

Optimizing graphics rendering in Unity games 読んだ

Optimizing graphics rendering in Unity games のメモ

https://unity3d.com/jp/learn/tutorials/temas/performance-optimization/optimizing-graphics-rendering-unity-games?playlist=44069

Unity 描画パイプライン

毎フレーム、CPUは以下を実行する 1. シーンにある全オブジェクトをチェックし、レンダリングすべきかどうか判定する。 - view frustum に入ってないオブジェクトを除外する - 他 2. レンダリング対象のオブジェクトのレンダリング情報を収集し、ソートする。 - MeshやMaterial - このフェイズで、特定の状況に当てはまると batchingが行われる 3. CPUは、「batch」と呼ばれるデータを作成し、draw call コマンドを作成。

Draw call のステップは以下に分別される。

  • Set pass call: CPUがGPUに render state を送信する。これを Set pass call と呼ぶ。Set pass call は、初めてのメッシュを描画するときや、render stat (たぶんMaterialの変数とか頂点データ)が変化わったときに送信されるコマンド。つまり実質、GPUへデータを転送しているのはこれ。
  • Draw mesh: CPUがGPUへ、 Set pass call で定義された内容を描画する命令をGPUへ送る
  • 複数パスでの描画必要な場合、都度 Set pass call、Draw call がそれぞれ走る

Set pass call 、Draw mesh、これらをひっくるめて Draw callと呼ぶ。 フレームデバッガなどでは分かれて表示されているときは、実質、GPUへデータを転送しているのは Unityの言うところのSet pass call。CPU負荷があるのもSet pass call。

if our game is CPU bound

CPU バウンドな処理をしらべるとき、まずはどのタスクが原因なのかを特定することが重要。 なぜなら、Unityの描画はマルチスレッドで動作しようとするため、重いスレッドで動いているボトルネックをピンポイントで潰さないと効果がないから。

たとえば、culling operationが遅いとき、Set pass callとか別のスレッドで動いているタスクを減らしても効果がない。

また、ハードウェアによってスレッドの本数や割り当てに違いがあるので、実機で確認する必要がある。

Unityには3種のスレッドの使いわけをする - main Thread - 1本 - 基本的にはゲームロジックが実行される - render thread → - 環境によってはマルチスレッドで動作 - マルチスレッドレンダリングは複雑かつハードウェア依存。 - GPUにコマンドを転送する特別なスレッド - worker thread - culling or mesh skinning その他のタスクを実行する - どのタスクがどのワーカスレッドを実行するかは場合による - たとえば、ハードウェアが複数のスレッドを持っていた場合、ワーカースレッドはその分多く生成される - そのため、実機でプロファイリングすることが重要

(裏技)Graphics jobs でマルチスレッド化

Player Settings の、 「Graphics jobs」にチェックを入れると、Unityがメインスレッドでやっているrender loop をworker thread へ逃がすことができる。 つまり、Camera.render 自体が並列になり、めちゃ速くなるということ。

※ただし まだ実験的な機能 なので注意

CPUの描画ボトルネックのみつけかた

CPUバウンドになる最も一般的なボトルネックは、GPUへのコマンド送信。 このタスクは render threadで実行されると考えて良い。(PS4 とかでは worker threadで実行されるらしい)

最も重い処理は、Set pass call。Set pass callを減らすことはCPU負荷軽減に多くの場合に効果が見込める。 送信するデータの量や数を減らすことも効果がある。別々の render stateなオブジェクトはできるだけ減らす。

SetPass Call 回数の軽減になるテクニックは以下↓

  • レンダリングするオブジェクトを減らす
    • 例) カメラの farClipPlane を調整することで、範囲を調整し、カリングされるオブジェクトを増やす
    • 例) カメラの layerCullDistance を設定することで、レイアごとにカリングされる距離を調整することができる
    • 例) Occlusion cullingをつかう
  • 1つのオブジェクトのレンダリング回数を減らす
    • 例) シェーダのパスを減らす。シャドウマップ、dynamic light 他

バッチング

CPUの世界で同一のオブジェクトを収集し、同一のものとしてまとめてGPUに送る

  • 基本的 に同一のMaterial、(同一のMaterialプロパティ、シェーダ変数、テクスチャ) でないとバッチングされない
  • ほぼ同じMaterialで、テクスチャだけが違う、ていうときは、1つのでかいテキスチャに結合して無理矢理Materialを1つにするというテクニックがある
  • スクリプトで Renderer.material のプロパティを書き換えると、 内部ではMaterialが複製されている。sharedMaterialをつかうと、バッチングされているMaterialが対象になる

関連する参考情報や、関連するテクニック - Unity Manual - Optimizing Unity UI - Unite Bangkok 2015 - GPU Instanting - Texture atlas

カリング/バッチングで逆に遅くなるケース

  • カリングはコスト高。カメラの数 x オブジェクトの数 のオーダーで負荷がかかる
  • 使わないカメラはdisableに。使わないRendererもdisableに。
  • バッチングは、逆に負荷になることもあるので要注意

Skinned Meshes

ボーンアニメーション。 スキンメッシュの描画関係の処理は メインスレッド or ワーカースレッド。これもハードウェアに依る。 Skinned Meshes のレンダリングはコスト高。

以下、改善策。 - 現在SkinnedMeshRendererを使用している全てのオブジェクトに対して、本当に使うべきか再検討する - SkinnedMeshRendererが刺さっているけど、実際にはMeshRendererで事足りるオブジェクトがいないかチェック - 特定の一回しかアニメーションしないようなオブジェクトは、アニメーションが終わったら SkinnedMeshRenderrerをMeshRendererに差しかえる。( SkinnedMeshRenderer.BakeMesh という機能がある - 限られたプラットフォームでは、実験的に GPU skinning がサポートされている

メインスレッドの処理は、描画タスクとは無関係

CPUバウンドだからといって、メインスレッドの処理を最適化しても無意味。 なぜならスレッドが別だから。

if our game is GPU bound

GPUの足をひっぱるのは大抵は fill rate。 特にモバイルはGPUに見合わない解像度があるので問題が大きくなる。

memory bandwidth と、頂点の処理も同様に重要ではある。

Fill rate

Fill rate とは、1秒あたりにGPUが描画しなければいけないピクセル数のこと。

Fill rateが問題になっているかどうかは、 Player Settingsで、Screen Resolutionを減らしてみて、前後のGPU timeを比較することで確認できる

Fill rate問題の改善方法は以下 - フレグメントシェーダを最適化する - オーバードロー(同じピクセルを何度も描画すること)を減らす。でかいオブジェクトが描画された後、その上にさらにでかいオブジェクトが描画されるようなとき、2つぶんのFill rateになる。render queue を適切に管理して、フラグメントシェーダの前にカリングする等が対策 - ImageEffects はFill rateがめっちゃ上がる。ImageEffectsを2つ以上使う場合は倍々でFill rateが上がる

Memory bandwidth

GPUは専用のメモリを持っている。このメモリの read/writeが性能の限界になることがある。テクスチャがあまりにもでかすぎるとか。 Memory bandwidthが問題になっているかどうかは、Quality Settingsからテクスチャの解像度を落としてみて、前後の GPU timeを見比べることで確認できる

Vertex processing