斜面にキャラクターを這わせる。第03回
微妙に間が開きましたが、前回の続き。
「1.足元の点をいくつかサンプリングして、各点の地面の高さを得て、それらを結ぶ面の傾きを計算する」パターン
多少順番が前後しますが、この方法は計算が多少厄介なので最後に持って来ています。
もうちょっと細かい作戦を決めます。
- transformが示すボックスの4隅で地上高をサンプリングする事にする。
サンプリングについては
- 4隅それぞれ、適当な高さから鉛直下向きにSphereCastAll()してみて、衝突点の高さが一番高かった座標を記録
- 重心を求めて、前後で二つの三角形を作る
- 二つの三角形の傾きの平均を求め、その角度を採用
今回はイベントを使ってないのでCharacterControllerやRigidbodyがなくても動きます。
もちろんSphereCastでなくRayCastでも可能ですが、好みの問題です。SphereCastだと、球面で接地面を得るのでモリッという感じて乗り上げます。RayCastは単に線分と接地面の衝突をとるだけなので、かなり細かい溝でもガタガタッとシャープにトレースする瞬間があります。計算量はRayCastの方が低いと思いますが、個人的にはSphereCastの方が自然に感じたので採用しています。
ちなみに地面として検出するのはワールド座標系鉛直下向き側の地面に絞りました(*1)。また、重心は求めなくても三角形は2つ作れますが、ここは横着しない方が良いようです(*2)。
ではだいたい右のようなパラメータでSphereCastするとして、コードを。
public class GroundNormalTracer : BasicPlaneNormalTracer { // 基準になるtransformボックスは、斜面トレースで傾けるオブジェクトの親GameObjectのtransformボックス。 public float localGroundHeight = -0.5f; // モデルの床を示す高さ。transformボックスの中心からローカル指定 public float localUptrimLimit = 0.5f; // SphereCastする時の始点の高さ。モデルの床を基準にローカル指定 public float localDowntrimLimit = -0.5f; // SphereCastする時の終点の高さ。モデルの床を基準にローカル指定 public float localGroundTestSphearRadius = 0.2f; // SphereCastする時の球の半径。transformボックスのローカル単位 public LayerMask rayCastMask = -1; // SphereCastする時のレイヤーマスク。「床レイヤーだけに当てたい」時などに設定 protected override void traceImpl (GameObject groundTraceObject) { if ( groundTraceObject.transform.parent == null ) return; using (ColliderCanceler cc = new ColliderCanceler(this, ColliderCanceler.TargetFilter.TriggerOffColliderCancel)) { float absLocalUptrimLimit = Mathf.Abs(localUptrimLimit); float ofsXZ = 0.5f - localGroundTestSphearRadius; float ofsY = localGroundHeight + absLocalUptrimLimit; Vector3[] testOffsets = new Vector3[] { new Vector3( -ofsXZ, ofsY, ofsXZ ), new Vector3( ofsXZ, ofsY, ofsXZ ), new Vector3( -ofsXZ, ofsY, -ofsXZ ), new Vector3( ofsXZ, ofsY, -ofsXZ ), }; /* このradiusとdistanceをワールドスケールに戻す処理は正しいかちょっと不安 ;^_^ */ float sphereCastRadius = Mathf.Max(groundTraceObject.transform.parent.lossyScale.x, groundTraceObject.transform.parent.lossyScale.y); sphereCastRadius = Mathf.Max(sphereCastRadius, groundTraceObject.transform.parent.lossyScale.z); sphereCastRadius *= localGroundTestSphearRadius; float downDistance = groundTraceObject.transform.parent.lossyScale.y * (absLocalUptrimLimit + Mathf.Abs(localDowntrimLimit)); /* 4隅で高さテスト */ Vector3 pO = Vector3.zero; for (int idx = 0; idx < testOffsets.Length; idx++ ) { testOffsets[idx] = groundTraceObject.transform.parent.TransformPoint( testOffsets[idx] ); float highest = testOffsets[idx].y - downDistance; foreach (RaycastHit hit in Physics.SphereCastAll(testOffsets[idx], sphereCastRadius, Vector3.down, downDistance, rayCastMask)) { if ( hit.collider.isTrigger == false && highest < hit.point.y ) highest = hit.point.y; } testOffsets[idx].y = highest; testOffsets[idx] = groundTraceObject.transform.parent.InverseTransformPoint( testOffsets[idx] ); pO.y += testOffsets[idx].y; } pO.y *= 0.25f; /* 三角形の法線を平均して、upベクトル→平均法線に回転するQuaternionをセットする。 */ Vector3 vNormalA = Vector3.Cross( testOffsets[0] - pO, testOffsets[1] - pO ); Vector3 vNormalB = Vector3.Cross( testOffsets[3] - pO, testOffsets[2] - pO ); groundTraceObject.transform.localRotation = Quaternion.FromToRotation( Vector3.up, vNormalA + vNormalB * (vNormalA.magnitude / vNormalB.magnitude) ); } } }
ベースクラスのBasicPlaneNormalTracerについては前回に同じ。それと、途中で出てくるColliderCancelerについてはコチラの日記を。これやっとかないと自分用のコリダーがSphereCastに当たりまくることになります。自分のコリダーかどうかは条件判定で弾いても良いのですが、いっそ衝突しないのが一番処理コストが安そうですし…。
4隅の座標計算ですが、TransformPointしたりInverseTransformPointしたり座標系変換に大忙し。
- SphereCastはワールド座標系で判定する
- localRotationで設定しないとparent側のtransformを加味できない
という事情で、ローカル座標系→ワールド座標系→ローカル座標系に変換しながら計算しています。ややこしくなってきますが今回は地面の高さは「ワールド」での鉛直下向きベクトルで検査しています。最初に示した図では簡便のためにワールド/ローカル問わずに鉛直下向きの状態ですが、GameObjectが最初から傾いている場合には、ローカル上で斜め下の地面を読み取る事になります。なお重心はアバウトです。高さ検地が、どの床とも接触せずに底打ちする場合を考えて、高さ成分だけ重心を算出。もともとの理屈がかなりザックリした計算式なので、このへんは実際に動かして雰囲気を見ながら適当に調整するのが良いと思います。
Vector3 vNormalA = Vector3.Cross( testOffsets[0] - pO, testOffsets[1] - pO ); // 三角形(pO,[0],[1])の法線 Vector3 vNormalB = Vector3.Cross( testOffsets[3] - pO, testOffsets[2] - pO ); // 三角形(pO,[3],[2])の法線
この部分…外積をガッツリ解説すると重いのでサラッと流します。
2つの三次元ベクトルのクロス積で求まるベクトルは、2ベクトルの属する平面に直交する
というわけで外積をとるだけで、それぞれの三角形の法線ベクトルが求まります(高校数学の*3平面方程式あたりを復習すればもうちょっとちゃんといろいろ分かります)。ちなみにベクトルの順番には意味があります。面の表側から見下ろして時計まわりが表向きベクトルとして求まります(*4)。testOffsetsのもともとの位置関係を見て、今回は0→1、3→2の順で渡しました…ここを間違えるとキャラの上下がひっくり返ったり、前後で逆向きだと相殺してほとんど傾かなくなったりするんじゃないかしら。
最後のFromToRotationに渡すときには、向きの平均を出すので、vNormalBの長さをvNormalAの長さに揃えてから合計します(同じ長さじゃないと「向き」の平均になってくれません)。
使い方
前回同様にベースクラスを操作して、autoTraceOnUpdateをtrueに設定するか、別ComponentのUpdate()など任意の処理からTrace()を呼び出せばオッケーです。
発展とか考察とか
今回、4隅の4点をサンプリングしましたが、3点をサンプリングして三角形一個分で計算しても良いっちゃ良いと思います。前輪と後輪が4隅に張り出している場合を考えて4点でサンプルしましたが、ヤモリのように前後に細長い物体、あるいはコミカルなカートレースで、サーキットを周回するだけの場合はあまりバックはしないので、前輪シャフトの傾きだけ検知できれば問題ないような気がします。三角形が一個に減ると、サンプリング処理の負荷が75%に減って、重心も算出しなくて良いし、平均値算出でmagnitudeを求める(≒平方根計算)必要もなくなるので結構メリットはありそうな。
どうでも良い話
public LayerMask rayCastMask = -1; // SphereCastする時のレイヤーマスク。「床レイヤーだけに当てたい」時などに設定
どうでも良いですが-1とかじゃなくてLayerMask.AllLayerEnabledみたいな定数があればなぁーって思うんですよね。-1が0xFFFFFFFFなんてのは一応、2の補数環境でないと成立しない実装依存です。かと言って0xFFFFFFFFはC#だとuint扱いなのでintにするのに明示的キャスト推奨で…なんだか気持ち悪い。組込でもないんだから常識的に2の補数で考えて良いとは思いますが、それもそれでOOPらしからぬ…(もしかしてC#で符号付整数は2の補数という取り決めがあったりするのカシラ…ぐぬぬ)。
*1:ここでもし地面の検出を下向きに限らず、壁走りやスパイラルチューブの中を疾走するアクションなどに対応すると、どんな事が面倒になるでしょうか。理屈で言えば、地面を記憶するのと、速度に比例した地面吸着力と重力計算くらいだと思うので、計算自体はそんなに難しくはないと思います。でも、例えば今回で言う「鉛直下向きに限る」も「CharacterContorollerの重力ベクトルの想定がそうなっているから、Unity内での一般性を同じよう考えられるだろう」といった、エセ物理としての一般性に論拠がありますが、壁走りのロジックでは、そういった一般性を考える事がとても難しいような気がします。もし、いろんな人が同時に一斉に実装モデルを提示すれば、それらを総括して一般化することも難しくはないと思うのですけど、なかなかそんな現象が起こりません。
*2:重心を求めずに対角線を横断して三角形を分けるのもやってみましが、小さなコブだと、コブの位置によって二つの三角形が影響を受ける場合と一つしか影響を受けない場合が生じます。コブを左に寄せて乗り上げる時と右に寄せて乗り上げる時で平均傾斜が変わってきます。これがなんか微妙に気持ちが悪い。その点、重心を求めて前後に分けると、前の三角形のひねりと後ろの三角形のひねりを別々に算出してから前後の傾斜平均を出すので、ニュアンスとしては自動車のシャフト構造っぽいものに感じられて…いるのかな???という感じです。
*3:スイマセン、外積も平面式も高校の数1-3/A-Cじゃやらないですね。個人的には数学はほとんど独学なので間違えてしまいマシタ…でも内積と行列を習っているなら外積はそんなに難しい話ではないです。個人的には虚数や四元数とかの方が難しい気がします。
*4:反時計回りにすると裏面の逆向きベクトルが求まります。右手系左手系で説明する場合もありますが個人的には時計回り説の方が分かりやすいような。