[Unite 2018]誘導ミサイルはどうやって作るのか? 基礎からCompute Shaderによる実装まで
安原氏はかつてPlayStationで3Dスペースシューティング「オメガブースト」を作った人だ。……と聞いて「つまり神プログラマですね」と理解してもらえるかどうかは昨今では微妙だが,初代PlayStationの限界を超えるようなゲームを作った人であり,毎年Uniteでは「非力なマシンで軽々動いてるけどこれ本当にUnity?」と言いたくなるようなデモを披露している。
今年の演題は「誘導ミサイル完全マスター」ということで,オメガブーストでもお馴染みの誘導弾一般を扱うコツが披露された。
追尾レーザーと誘導ミサイルの2種類だ。「なにが違うのか?」と思う人もいるだろう。大雑把に言えば,レーザーのほうが物理法則的に緩い感じの動きで加速したり減速したりも自在なのに対し,ミサイルは一定の速度を基準にしているようだ。光速不変とはなんだったのかという気もしないではないが,実際の動きを見れば,ともに「それっぽい」感じなのでたいていの人は納得するだろう。
それを実現するための方法として,最初に運動方程式が提示され,それを加速度を求めるように変形して,加速度での運動制御を行う様子が示された。敵は動いているのでフレームごとに目標位置を補正しつつ必要な3次元方向の加速度を計算していく。
なお,加速度ではなく,線形補間を用いても似たような処理は実現できるのだが,それは推奨しないとのこと。理由としては,そういった数学的な処理よりは物理学的な処理にしたほうがいろいろと応用が利くからだそうだ。例としては,初速を与えて,例えば横に打ち出されたレーザーが曲がりながら敵に迫っていくような実装も簡単にできたり,スクロール処理を加えたり,揺らぎを加えたりなどもやりやすいという。同時にロックオンして複数のターゲットに命中するミサイルなどは,同時に爆発するより時間差をつけたほうが見栄えがよい。
このように「n秒後に当たる」ことを前提にしているので,この処理では衝突判定はまったく不要となる。このようなゲームを作る場合は,ロックオンするまでの部分でゲーム性を組み立ててるのがよいという。敵が避けるような要素はストレスにしかならないのだ。
逆に敵がこのような絶対当たる誘導弾を撃ってくると,ちょっとゲームとしては面白くないだろう。
敵側の追尾レーザーでは,動きに制限を加えることで避ける余地を残しておく。ここでは加速度で制御しているので,その加速度の絶対値に上限を設けることで,なにがなんでも当てにこれないようにしてやる例が示された。
誘導ミサイルの実装
誘導レーザーに続いて,誘導ミサイルの実装が解説された。冒頭で書いたように,ミサイルの特徴は前進速度が一定である部分にある。この処理はQuaternion.Lerpで実装できるとするものの,安原氏はそれを推奨しない。先ほどの線形補間と同じく,物理的な処理で実装するほうが応用範囲が広いという。
そこで導入されたのがバネトルクだ。誘導ミサイルでは敵との角度差に対して,差が大きければ強く小さければ弱く角度制御が働くようなバネを使って目標へと誘導していくことになる。
なお,バネトルクそのものについては,昨年の「Unityちゃんがうねりまくる海上を飛行しつつ弾を撃ちまくる」デモでUnityちゃんのアニメーション制御の実装で詳しく語られているため,そちらを参照してほしいとのことだった(講演動画)。
バネトルクでのミサイル制御では,
- バネ係数
- 角度ドラッグ
- 速度
という3つのパラメータが使われる。それぞれをどのようにしたらどのような挙動になるのかがデモで示された。角度ドラッグについてはとくに説明はなかったが,UnityのRigidbodyでのAngular Drag(空気抵抗)を表す。バネの強さとはまた別の回りにくさ要因といったところか。
最初に示された例ではミサイルは発射したあたりをクルクル飛び回り,敵に向かっていく様子がない。角度ドラッグを1に上げると前進していき,4くらいにするとわりとよい感じで追尾する様が紹介された。角度ドラッグはあまり上げると収束してしまい,どのミサイルも同じような軌道になるので揺らぎを与えるのがよいという。また,極端に大きくするとまったく当たらなくなることも示されており,バネ係数や速度なども合わせて挙動を見ながら調整するのがよいようだ。
トレイルの描画
追尾レーザーにせよ誘導ミサイルにせよ,多くの場合,軌道上にトレイル(軌跡)を残しながら飛んでいくことが多い。こういったトレイルの描画での注意が行われた。
まず,トレイルに使用するテクスチャでは,両端の1ドット分は透明にしておくと,描画されたときに適度にブレンドされて自動でアンチエイリアスがかかるのでお得だとのこと。
次に,トレイルの捻じれについて。UnityのTrailRendererで描画された例が示されたが,どうしても飛び回っているうちに捻じれが出てくるのだという。これはTrailRendererを使わずに自前で実装しても防ぐのは難しいだろうとのこと。
捻じれるとなにがよくないのかというと,軌道を横から見たときなどに折り返し部分の半透明テクスチャが不自然になるのだ。
根本的な解決は難しい。安原氏は描画時の2D座標で前後のノードの点の内積から折り返し状態を調べ,折り返しが発生していた場合に透明度を変えることで対応を行ったとのこと。実行例を見ると確かに不自然さはなくなっている。どうしても気になる場合はこのような処理で対応可能ということだ。
ここで安原氏が取った方法は,「フレームごとに加速度を計算し直さない」というものだった。割り算の楽な2のべき乗数のフレームのときだけ加速度を更新してやるのである。これにより処理速度は格段に速くなる。
問題は計算が粗くなり,本来の軌道とは一致しなくなることだが,実際のデモで見ても,多少の違いはあるものの動きの滑らかさなどではほとんど差がないことが分かる。
安原氏によると,運動を加速度で扱っている限りは多少いじっても不自然になることはないのだという。これを称して氏は「神は二階微分に宿る」と表現していた。安原氏のゲーム開発経歴のなかでも最大の気付きだったとのこと。
iPhone 6でのCompute Shaderの実装
まず注意されたのは,Compute Shaderはどう動くものなのかということだった。Compute Shaderでは,CPUはGPUに計算してほしい内容を送り(SETDATA),実行を指示する(Dispatch)。その際に,GPUが演算した内容をCPUが受け取って処理……といった流れは通常取らない。GPUの演算中にCPUが止まってしまうからだ。CPUはひたすらGPUに指示を投げ続け,GPUは黙々とデータを処理するというのがCompute Shaderの一般的な使い方となる。とはいっても,これはCompute Shaderに限ったことではなく,一般的なシェーダはすべて同じように動いている。Compute Shaderもそこから逸脱するものではないということだ。
さらに重要なのは,Compute Shaderは通常の描画よりも必ず先に実行されるということだ。描画スレッドにとっては,CPUが演算したデータであろうが,Compute Shaderが演算したデータであろうが処理に大きな違いはないということだ(データの渡され方は少し違うかもしれない)。
また,Compute Shaderでは演算器の数だけループを展開するような感じで実行が行われることが多い。Compute Shaderのバージョンによって一度に呼び出せるシェーダ数は異なり,またこれはデバイスによっても異なる。iPhoneは現状では512並列が標準的とのことだ。
呼び出し時は,3つのパラメータで使用数を設定するのだが,512器を使いたい場合,
(8,8,8)
(512,1,1)
などのように記述でき,(8,8,8)のほうは,シェーダプログラム中でx,y,zなどのようにそれぞれのインデックスを参照できる。1次元配列の場合は(512,1,1)でかまわない。わざわざこういった指定が可能にされているのは,インデックスを多用する用途が想定されているのだろうと安原氏は指摘していた。3次元配列や3重ループでは非常に便利に使えそうだ。
今回ターゲットとするiPhone 6では512個の並列処理ができるので,ミサイル512個の処理に対応した誘導処理を実装することになった。できるだけGPU内で処理を完結させるためには,GPU側に512個分のミサイルバッファを持たせる必要があるので,そのバッファにどんな変数を入れるかについて,安原氏は順を追って必要な要素を説明していった。
まず,ミサイルそれぞれの位置,姿勢(向き),そして角速度だ。ミサイルを動かすためには必須の要素といえる。
次に,誘導ミサイルなので目標となる座標を示す必要がある。512発のミサイルは(おそらく)一か所目掛けて飛んでいくわけではない。それぞれが独自のターゲットを追尾していくのが普通だ。よってミサイルごとにターゲットを指定する必要がある。ということで,ミサイルバッファにターゲットIDの項目が追加された。
そのターゲットIDの参照先となるターゲットバッファは256個分の大きさが取られており,ターゲットの3次元座標を格納するものとなる。これは毎フレームCPUが計算してSetDataコマンドでGPUに転送される。
まず,生成処理では,生成バッファを作り,ミサイルの初期位置や初期の向きなどを与え,さらに利用可能なミサイルIDを割り当てる。バッファの大きさは32個分で,これは1フレームで同時に32発まで発射できるということを意味している。生成時に,乱数のシードを一緒に与えておくといろいろ捗るようだ。
ミサイルの描画では,描画コマンド自体はCPUがGPUに指示するので,描画する(生きている)ミサイルのリストを作ってGPUに描画させることになる。しかし,先ほどミサイルの生死はCPU側で管理すると紹介したわけだが,ミサイルの位置情報や命中などの判定はすべてGPU側が行っているので,どれが生きているのかを知っているのは実はGPUだけであり,GPU側で生きているミサイルのリストを作ってCPUに渡すという処理が必要になってくる。そのリストを使って描画を指示するわけだ。
なんとなく無駄な感じがしなくもないが,GPU自体がGPUに描画指示を出すような構造になっていないのだろう。
描画時はミサイルだけ描けばよいというわけではない。ミサイルそれぞれはトレイルを引きつつ飛んでいくので,軌跡を残すために過去の位置情報を32個分トレイルバッファを持っている。それを使って半透明な尻尾を描いていくわけだ。
ミサイルの処理では,ミサイルの生存というのはトレイルが完全に消えるまでの期間であることに安原氏は注意を促していた。ミサイル本体が爆発してなくなってもトレイルが残っている間は,ミサイルIDを解放してはいけないのだ。
このくらいまでは作り込んでないと実機での確認ができないことは,Compute Shader開発の難点であるという。試行錯誤がやりにくいわけだが,それにはシミュレータを作っての対応が推薦されていた。同じロジックをC#で記述して,動作に問題がなければCompute Shaderに落としていくというやり方だ。今回もそういったアプローチが取られていたとのことだ。
ミサイル側の処理はだいたい出来上がってきたわけだが,一度に多くのミサイルを扱う関係上,命中前にターゲットがなくなるといったゲースも発生してくる。そのときはどうするのだろうか。
前述のように,ターゲット自体はCPU側が管理しており,描画時にはCPUから毎フレーム,ターゲットの位置情報リストが送られてくる。ミサイルの命中判定はGPUが行っているので,ターゲットに命中した場合は,命中したことを添えて,そのリストをCPUに返せばよい。安原氏はそのための情報に死亡時刻を使用していた。初期値には大きな値を入れておいて,現在時刻から死亡時刻を引くことで生きているかどうかを判定できる。これでターゲットがすでに死亡していたら加速度計算の処理をキャンセルし,ミサイルを惰性で飛ばすようにしているという。
このように時間を比べることで判定を行う場合に,安原氏は相対時刻ではなく絶対時刻を使うことを推奨していた。相対時刻ではミサイルごとに時間を進める必要があるが,絶対時刻であれば一つのグローバル変数だけの処理で済むからだ。
そのために用意されているコマンドであるComputeBuffer.GetDataを使うと,描画処理を含めてGPUでのすべての処理が終了するまでCPU側の処理が停止してしまうのだという。
問題はiPhone 6で現在使えないということであり,これはどうしようもない。そこで安原氏が考えたのは,CPUの待ち時間を最小にするにはどうすればいいかということだったという。結論としては,GPUでの一通りの処理(演算から描画まで)が完了した時点で改めてGetDataを呼び出すということだった。フレーム切り替えの直前に実行することで,待ち時間を最低にするという処理だ。
これにより改善はされたものの,フレームレート次第では逆効果になるかもしれない。安定した運用はなかなか難しいという。今回のデモでは2フレームごとに2フレーム分のデータを送っているのだそうだ。
前述のようにCompute Shaderの負荷は非常に低いものの,モバイルの3Dゲームでは描画部分がネックになるのは最初から見えている。そこで安原氏が行ったのは,扱うミサイル数を16倍の8192発まで増やすということだった。もちろん,これでは描画処理が追いつかないので,積極的にカリングを行い,優先度の高い1024個についてのみ描画するという処理が実装された。
通常はシステムが行うのでユーザーが扱う機会の少ないカリングだが,優先順位で表示を決める関係でそれに頼ることができず,今回は視錐台カリングを自前で実装するための解説が行われた。
視錐台カリングはカメラから見える範囲の視錐台の外にある物体は表示しないという分かりやすい処理だが,視錐台を構成する平面との距離調べてその符号により内か外かを判定する。視錐台を構成する6枚の平面すべての内側であれば表示し,さもなくば表示しない。
すべてのミサイルに優先度を付けたら,優先度順でソートを行う。これには並列実行向きのBitonic Sortが用いられている。なお,ソート時にメモリアクセスを減らすことは非常に有効だそうで,8バイトのデータを4バイトに押し込んだら倍速くなったとのこと。
そんなこんなで,8192発の誘導ミサイルのうち,1000発を選んで表示しているのが次の写真だ。このようにカリングされている。
さらに,実機での最終的なデモの様子を示したのが以下の動画となる。数世代前のiPhoneでもこれくらいの動きができるのだ。
一般的な誘導ミサイルの作り方から,モバイルでのGPUを使った実装まで非常に幅広い話題に満ちた講演であり,Unityから扱うCompute Shaderの威力にも驚かされた。演算能力では非力といわれるモバイル機器だが,Compute Shaderの活用はモバイルでは大きな武器となりそうだ(消費電力は少し心配ではあるが)。今回のプログラムはすでにGitHUB上で公開されているので,ド派手なシューティングゲームを作りたい人はぜひ参考にしてみよう。