インボリュート曲線

\displaystyle 下記の記事で現在制作してるゲームの中でのキャラクターの経路制御について書きました.

friendsea.hateblo.jp

この中での曲線の定義にインボリュート曲線を使っています.
理由は

  • 端点の指定によって制御可能
  • 曲線の長さが近似なしに求まる
  • 曲線上の最近傍点が探索なしに求まる

といった感じ.安く高精度で色々できる.
数式は観賞用です.

インボリュート曲線

ここで言うインボリュート曲線は円の伸開線のこと.円柱に巻き付けた糸を弛まないように解いたときの糸の端点の軌跡がインボリュート曲線です.

f:id:FriendSea:20181116075840p:plain

数式にすると次の通り.

$$ \begin{pmatrix} x \\ y \end{pmatrix} = r\begin{pmatrix} \cos\theta + \theta\sin\theta \\ \sin\theta - \theta\cos\theta \end{pmatrix} $$

とりあえず微分すると

$$ \begin{pmatrix} \frac{dx}{d\theta} \\ \frac{dy}{d\theta} \end{pmatrix} = r\theta \begin{pmatrix} \cos\theta \\ \sin\theta \end{pmatrix} $$

このベクトルの向きが接線方向,長さが曲線上の点の移動の速さなので,\(\theta\)がそのまま接線方向の角度であり,点の移動速度は\(\theta\)に比例してます. タートルグラフィックスで言えば,亀さんが一定の角速度で回転してるとして,前方向への移動速度が線形に増加するときに描かれる曲線になります.

端点を指定して制御したい

曲線を扱おうと思ったらやっぱりスプライン曲線のように端点の位置と角度を指定して形を決めたいです. これはインボリュート曲線の渦巻きの中から目的の端点にピッタリ合う部分を探して,適当に拡大縮小/回転すればできます.

f:id:FriendSea:20181116162431p:plain

つまり,端点の指定による制御は,ピッタリ合う部分の始点と終点を求める問題になります.

積分形式で表現

与える入力は始点と終点ですが最終的に適当な座標変換を挟めばいいので始点は原点に持っていきます.接線方向も\(x+\)に固定. 曲線の形の定義としては,さっき出てきた接線の角度と移動量の関係だけで十分だったりします.
角度に対する移動量(曲率半径)が線形ならインボリュート曲線になるので,これを定数\(a, b\)を使って\(a + b\theta\)とします.このとき曲線は

$$ C(\theta) = \int_{0}^{\theta}(a + b\theta) \begin{pmatrix} \cos\phi \\ \sin\phi \end{pmatrix} d\phi = \begin{pmatrix} b\cos\theta + (a + b\theta)\sin\theta - b \\ b\sin\theta - (a + b\theta)\cos\theta + a \end{pmatrix} $$

みたいに表せます.

f:id:FriendSea:20181116080335p:plain

いまこんな状態,曲率半径が\(a\)の点が原点に来ました.
定数\(a\)が始点での曲率半径なので,結局の所うずまきの中での始点の位置を定める値になってます. 終点の位置は始点と終点の接線の角度差から勝手に決まります.

係数を解く

欲しい値は曲率半径\(a + b\theta\)の係数\(a, b\)で,これは普通に方程式を立てて解けば出ます.\(\theta_E\)を終点での接線の角度,始点と終点の間の変位を\(V\)とすると.

$$ C(\theta_{E}) = V $$

書き下すとこんな感じ

$$ \left\{ \begin{array}{} (\sin\theta_{E})a + (\theta_{E}\sin\theta_{E} + \cos\theta_{E} - 1)b = V_x \\ (\cos\theta_{E} + 1)a + (\theta_{E}\cos\theta_{E} + \sin\theta_{E})b = V_y \end{array} \right. $$

\(\theta_E\)も\(V\)も定数なのでただの1次の連立方程式.超簡単なプログラムで解けます.
これで必要な値は全て求まったので,入力の端点にピッタリ合う曲線ができます.めでたい.

曲線の長さ

物体を曲線の上で一定の速さで動かしたければパラメータ(今回は\(\theta\))と長さの関係がわかってないといけません.
まずは始点から\(C(\theta)\)までの曲線の長さですが,そもそも点の移動量を定めているのでそれを積分すれば求まります.

$$ l(\theta) = \int_{0}^{\theta} (a + b\theta)d\theta = a\theta + \frac{b}{2}\theta^{2} $$

今度はこれの逆関数を取れば長さからパラメータ\(\theta\)が得られる.ただの2次式なので所謂解の公式を使って.

$$ \theta(l) = \left\{ \begin{array}{ll} \frac{-a + \sqrt{a^{2} + 2bl}}{b} & (b \neq 0) \\ \frac{l}{a} & (b = 0) \end{array} \right. $$

これを使えば\(l\)を一定の速さで増加させたとき,\(C(\theta(l))\)も等速で動いてくれます.

任意の点からの最近傍点

適当に点\(P\)が与えられたとき,曲線上で最も\(P\)に近い点を求めます.

f:id:FriendSea:20181116170613p:plain:w400

求める点\(C(\theta)\)と\(P\)を通る直線は\(C(\theta)\)での接線と直行する.2つのベクトルの内積が0になるはずなので

$$ (C(\theta) - P)\cdot \begin{pmatrix} \cos\theta \\ \sin\theta \end{pmatrix} = 0 $$

これを頑張ってなんやかんやすると

$$ \theta = \cos^{-1}\frac{b}{\sqrt{(P_y - a)^{2} + (P_x + b)^{2}}} + \tan^{-1}\frac{P_y - a}{P_x + b} $$

こんな感じで求まる.めでたい.

良くないところ

当然扱いづらい部分もあって,クリティカルなのを挙げると変曲点がある曲線(S字の曲線)を作れません. いくら渦巻きの中を探してもS字なんてないので当然です.S字ができるような端点を指定すると代わりに変な形のが出ます.

ゲーム内ではいくつかのインボリュートセグメントを連結して経路として扱っています.制御点配置から自動的に端点位置を定めていますが,このときできるだけ正しく曲線が生成できる端点になるように処理しています.

f:id:FriendSea:20181114215707p:plain:w400

2.5Dアクションゲームの経路制御

こんなゲーム作ってます

風のクロノア」や「星のカービィ64」に似たシステムで,基本的には2Dのアクションゲームですが予め定められた経路に沿って進行します.
適当な曲線に沿ってキャラクターを動かせばいいのですが,地形との干渉や経路の分岐などを考えると少し工夫がいります.

使う曲線

適当な曲線に沿って動かすと書きましたが,ゲームでよく使われるBezierやB-Splineなどのスプライン曲線は今回の目的だとあまり向いていません. 代わりにインボリュート曲線のセグメントを組み合わせて経路を定義しています.

f:id:FriendSea:20181114215707p:plain:w400

これには主に2つ理由があります.

  • 曲線上の任意の点までの曲線の長さが欲しい
  • 曲線の外の任意の点に対する曲線上の最近傍点を求めたい

これはスプライン曲線でやると近似や探索が必要になります.毎フレームキャラクターの数だけ呼ばれるので,できるだけ安い処理にしたくてインボリュート曲線を使っています.

friendsea.hateblo.jp

経路に沿った座標変換

曲線には空間の向きの定義も含めて座標変換として実装しています.直交座標が曲線に沿ってグニャッと曲げられるような変換です.

f:id:FriendSea:20181116040850p:plain

左の座標系でのZ軸での動きが右曲線に沿った動きになります.そこで,キャラクターにローカルな座標を持たせてそれをZY平面で普通の2Dアクションゲームのように動かしてやればいいわけです.このローカル座標を座標変換に通してやれば経路に沿った動きになります.

ここで変換後のグリッドも等間隔にしているのが曲線の長さが欲しい理由です.等間隔でないとキャラクターの足の速さが場所によってバラついてしまいます.

地形との干渉の回避

当然ですがキャラクターは地面や壁に埋まっちゃだめです.Unityエンジンを使っているのでRigidbodyコンポーネントを使って移動させれば勝手に埋まらないようにしてくれます.
しかし,キャラクターの挙動を座標変換前のローカル座標で書いてるのに干渉の回避がUnityのワールド座標系で行われるから値がズレてくる. そこで,さっきの座標変換の逆変換を使って干渉の回避後の座標をローカル座標に戻す処理を書いています.

ゲームエンジンの物理演算処理を順変換と逆変換で挟むような処理の流れです.

f:id:FriendSea:20181116045116p:plain

逆変換が必要なのが,曲線の最近傍点が欲しい理由です.求めた最近傍点までの曲線の長さが逆変換後のZ座標の値.

経路の分岐

ローカル座標の更新時に逆変換を使うと書きましたが,空間が曲がっている場合って逆変換が一意ではなくなります.例えばこういう例.

f:id:FriendSea:20181116051859p:plain:w400

この図で赤い点Pを逆変換したとすると答えは2つあります.

  • 上の曲がった経路でy<0のとこにいる
  • 下のまっすぐな経路でy>0のとこにいる

ワールド座標の位置は同じなので実のところどっちでもいいのですが,より近い経路に属するように実装しています.今回の例だと上の曲がった経路になりますね.
ここでy軸方向に移動するとワールド座標は連続でありながら,属する経路が途中で切り替わるのがわかると思います.これを経路分岐に使ってます.

これはゲーム内での経路分岐の例で,見づらいですが黄色い線が経路です.足場に飛び乗ると左に曲がる経路に,そうでない場合は右に曲がる経路に乗ります.

f:id:FriendSea:20181116053156p:plain:w400

その他にできること

経路の定義を工夫すれば様々な表現ができるようになります.

重力方向を変える

別に座標変換の上方向がワールド座標の上方向になっている必要はありません.上方向を変えれば天井や壁を歩けます.動的に方向を変えてもいいぞ.

f:id:FriendSea:20181116054036p:plain:w400

f:id:FriendSea:20181116054212g:plain

動く足場

キャラクターを乗せて動く足場.キャラクターを足場と一緒に動かすのが結構面倒だったりします.
足場に1つ経路を定義して,その座標変換ごと動かせば実装できます.

この画像の月は,重力方向を曲げた経路を回転させています.キャラクターはそれに乗って回る.

f:id:FriendSea:20181116055226g:plain

おしまい

今回紹介したような仕組みでの経路制御,作っていて面白いです. 複雑にすればするほど組んでて楽しくなるのですが,ゲーム内ではかなりシンプルになっています. なぜならカメラワークが爆発するから.
あまり賢いカメラ制御も思いつかず,経路分岐が複雑になれば手作業での指定ばかりになります.これがキツイので真横から映しただけでちゃんと見えるような分岐しか使えていません.

シーンからCubemap用テクスチャを作る

Unityで既存のシーンを撮影したcubemapを用意したい場合があります.
今作っているゲームでも遠景用のシーンを作って撮影してskyboxを作ってたりします.

f:id:FriendSea:20181107035605p:plain

今回はCamera.RenderToCubemapで作ったCubemapを1枚の画像に変換して保存するウィザードを用意しました.
画像に変換しないでもCubamapアセットとして保存もできますが,私の環境だとInspectorを触るとエディタがクラッシュするので使いたくないです.  

ソースコード

キューブマップの描画や画像の保存を行うウィザードのC#コードと,Cubemapから通常のテクスチャへの変換を行うシェーダを書きました.

A wizard to render a scene and create a cubemap im ...

Unityのプロジェクトにコードを追加するとメニューにWindow/Render2Cubemapが追加されるはずです.このウィザードのTransformShaderTransformCube2D.shaderアサインしてRenderを押せば画像が作られます.

やってること

  1. RenderToCubemapでCubemap生成
  2. シェーダでCubemapから通常のテクスチャに変換
  3. テクスチャを.png画像にして保存

Unityで読み込んだ画像のTexture ShapeをCubeにしてやると,いい感じにテクスチャを丸めて1枚の画像からCubemapを作ってくれます.
この,いい感じに丸める変換の逆変換(多分メルカトル図法と同じ変換)をしてやればCubemapから対応する1枚の画像になるはずです.その変換がシェーダの中身です.
Skyboxに使うだけならCubemapをそのまま6枚の画像にしてもいいのですが,シェーダに手を入れたい場合などはCubemapとして扱えたほうが楽です.

プロシージャルインタラクティブ草

Unity上で草を生やすシェーダを書きました.

f:id:FriendSea:20181030093918g:plain

github.com

コリジョンに応じて倒したり,草を刈ったりできます.

草を生やす

これはジオメトリシェーダを使っています.あらゆる面を草まみれにする.

f:id:FriendSea:20181030093244p:plain

Geometry Shader

ジオメトリシェーダでは描画するジオメトリの形を変更したり頂点数を増減させたりすることができます.
頂点シェーダで変換された頂点データが入力として与えられますが,このとき予め定めたプリミティブの単位でデータを受け取ることができます.例えばpoint,line, triangle等.
今回はtriangle, つまり三角形ポリゴンを入力として草となるポリゴンを生成してます.

草ポリゴン

今回は草1本を単純にポリゴン1枚で表現すこるとにします.元のメッシュの1ポリゴンにつき1本生成するとこんな感じ.

f:id:FriendSea:20181030094135p:plain

受け取ったtriangleの0番目の頂点の位置に草を生やしていますが,このメッシュでは位置が重なっているので同じ点に草が2本ずつ生えちゃう.まいっか.

草は常にカメラ方向を向くようにしてます.元のメッシュの法線方向に草を伸ばしているので,法線ベクトルと視線ベクトルの外積の方向に草の横幅をとるといい感じになる.

密度を上げる

このままだとスカスカなので元メッシュのポリゴン毎に草を何本か生やすことにします.入力の三角形を図の右側の様に分割して黒点のところに草を生やす.点がない部分は隣接するポリゴンが生やしてくれます.

f:id:FriendSea:20181030094154p:plain

図では1辺を4等分していますが,この分割数を変えて密度を調整しています.ただしジオメトリシェーダの出力用要素数の上限があるので,分割が多いと途中から描画できなくなります.
密度を上げる方法として,疑似乱数を使って入力ポリゴン内にランダムに生やす方法も試しました.しかし,あまりポリゴンの端の方には生えてくれずに偏って見えたので完全に整列させる方法をとっています.

不揃いにする

位置や高さが整列し過ぎなので疑似乱数を使って少し不揃いにします.急に草っぽくなりました.

f:id:FriendSea:20181030094214p:plain

乱数のシードとして草の位置に応じた元メッシュのUV座標を使っています.これなら草1本毎に違う値,毎フレーム同じ値が出てくれていい感じです.

風になびくようにする

見た感じ既にかなり草ですが,草は揺れてほしいです.これは時間の経過とともに頂点位置をズラせば簡単に表現できます.Unityでシェーダを書く場合は_Timeという定義済みの変数があり,実行開始からの時間を返してくます.便利.
草の先の頂点がsin関数に従って揺れるようにするとこんな感じ.

f:id:FriendSea:20181030094235g:plain

シェーディング

シェーダなのでシェーディングします.と言っても今回は環境光とシャドウしか扱っていません.
サーフェイスシェーダを変換したコードを追ってみると"ShadeSH9"という関数で環境光を取っているので今はそれを使うのがいいっぽいです.こういうのもっとわかりやすくドキュメントに書いといてほしい.
シャドウを受けるようにするにはForwardBaseのパスでシャドウマップをサンプリングする他に,ShadowCasterパスが必要になります.ShadowCasterが要るのはシャドウを落とす時だけだと勝手に思っていましたが,どうもスクリーンスペースのシャドウマップを作るので必要になるっぽいです.
環境光とシャドウだけ使ってシェーディングを行うと冒頭に載せた画像のような結果になります.

草を動かす

無事に草を生やせたので冒頭に載せたようにインタラクティブに動かします.

仕組み

草の傾きを決めるノーマルマップのようなテクスチャを用意し,これをシェーダでサンプリングして草を傾けています.
このテクスチャをキャラクターの動きに合わせていい感じに更新してやればリアクションのある草になるはずです.

草シェーダ

草のシェーダでに少し手を加えましょう.

草の先端の頂点を草マップ(なんて呼べば良いかわからないけどとりあえずこう呼ぶ)に合わせて移動させます. このときの変位ベクトルを草マップから取るわけですが,ノーマルマップを使う場合と同様に接空間での座標系に従います.
Unityではメッシュに対して適当に接線ベクトルを定めてシェーダに渡してくれるので.シェーダ側で法線と接線ベクトル,その外積の従法線ベクトルが得られます.この3つを接空間の基底として草マップから得られたベクトルを変換すればOKです.

f:id:FriendSea:20181030094435p:plain

図を描いていて気づきましたが,法線はデータ側で好き勝手に定義できちゃうので直行基底になるとは限りません.外積から出す従法線は一応正規化しておきます.

草マップ生成

あとはキャラクター等の位置に応じて草マップを生成すればうまく動くはずです. 草地を踏みしめたときの草の倒れ方として,とりあえずキャラクターを中心に放射状に倒れることにします.この倒れ方を作るノーマルマップを最初に作っちゃいました.

f:id:FriendSea:20181030094450p:plain:w300

これは球のノーマルマップを極座標系でrを反転させてやるとできます.   これをうまいこと合成すると草マップができるはずです.二通りの方法を試しました.

カメラで直接撮影する

f:id:FriendSea:20181030094511p:plain

乱暴ですがワールド座標とテクスチャ内の位置の関係などを考えないでいいので楽です.レイヤーマスクを使えば必要なものだけ映るようにできます.
草をはやしている平面に対してコリジョンがあったら,その位置に草を傾けるノーマルマップを生成してやればOKです. また,生成した草マップの傾きを少し減衰させたものを次のフレームでの合成時に用いることで,草の傾きが徐々に戻るような動きを作れます.

Graphics.Blitで合成

カメラを使った場合と同じことをGraphics.Blitで行っています.こっちのほうが素直だと思います.
これもコリジョンの位置に合わせて合成してます.
こっちの方法で動かしている草は今の所OpenGLでしか動かず,Unityエディタの描画APIをDX11などにすると変なことになります. 原因はそのうち調べます.

草刈り

頑張って生やした草ですが刈ってみます.草が刈られている領域をシェーダに渡し,シェーダで途中で切られたような形で描画すればいいです.ということでアルファチャンネルに草が刈られた長さを入れてます.
草マップを合成するときに使っているシェーダではアルファチャンネルの値を保持し続けるようにしているので,一度草を刈ればその状態がずっと続いてくれます. 草を刈りたいときはアルファチャンネルを上書きするシェーダを使います.カメラを使って草マップを作っている場合は斬撃エフェクト等にそのままアルファ上書きをくっつければ刈れます.

一番始めにの画像にある四角い箱が「草を刈るなにか」です.この例ではカメラを使ってマップを作っていて,TrailRendererで草を刈っています.

おしまい

草を生やして動かして刈りました.満足です.
今回動的な草を生やしたのはPlane1枚分だけで,面積を増やすと必要なRenderTextureも増えます.
超広い平原に無限に草が生えてるゲームとかどうやってんだろうね.