(追記)Unity×フライトシミュレーションでは、当サイトを見るよりも
このページ⤵の動画とコードを参考にする方がずっと良いです。
このページではUnity / C#での翼のシンプルな実装について書いています。
Unity上で物理演算(PhysX)の力を借りつつ、基本的な二次元翼の揚力・抗力を適用した翼を実装するのが目的です。
シンプル実装なので、何よりもコードが長くなるのを避けて一番基本的な概念だけで、いわゆる三次元翼的な影響は完全に無視しています。
(実のところフライトシムと名乗るのが恥ずかしいレベル)
また、書いてる人は専門家でも何でもないので普通に間違ってる可能性があります。
赤=ピッチ(上を向く・下を向く)
緑=ヨー(右を向く・左を向く)
青=ロール(機体の中心線を軸に回る)
を示します
フライトシムを作るうえで主役になるのは翼ですが、翼が生み出す力は主に3つあります。
揚力
抗力
回転力(回転モーメント)
今回はシンプルなシミュレーションということで③の回転力については無視します(正直変化があまりパッとしないのでシンプルな前提なら別になくてもいい)
というわけで、ここでは揚力と抗力だけを(簡易化した形で)扱います。
揚力の量は、次の計算式で計算できます。
(1/2) * p * ( V * V ) * S * Cl
つまり「0.5 × 空気密度 × (速度 × 速度) × 翼面積 × 揚力係数 」で計算できるということですね。
public float CalculateLift(float coefficient, float surfaceSize, float velocity, float airDensity)
{
return 0.5f * airDensity * (velocity * velocity) * surfaceSize * coefficient;
}
どのパラメーターも意外と簡単です。順番に見ていきましょう。
最初は1.2くらいの定数でOK
高度とか気温が上がると薄くなる
単位はkg/㎥(キログラム / 立方メートル)
空気密度は高度が上がるにつれて下がっていきますが、気温などの要素でも変動する値です。とはいえ他の値と比べると毎秒ポンポン変わるものでもないので、
とりあえず最初はなんか基準になってるらしい定数1.293(kg/㎥)で実装しています。
翼が受けている風の速度
空間が無風なら翼の移動ベクトルの長さがそのまま使える
単位はm/s(メートル/毎秒)
翼が空気中を進んでいる速度(=受けている風の速度)で、Unityの場合は無風前提ならrigidBody.velocity.magnitudeがそのまま使えます。
求められているのはm/sの値で、Unityの物理演算で使われている速度の単位もm/sなので単位変換をする必要はありません。
float v = rigidBody.velocity.magnitude;
rigidBody.velocityはワールド空間ベクトルで、その長さ(magnitude)だけを取っているので、ここでは「どの方向から風が来ているか」は無視していることになりますね。
「翼が揚力を生むのは前から風が来てる時なんだから、前方からくる風の速度成分だけが必要なのでは?」と思うかもしれません(思いました)が、方向性絡みの調整は後述する揚力係数というやつがしてくれるので大丈夫です。
翼を上から(垂直尾翼なら横から)見たときの投影面積
単位は㎡(平方メートル)
通常、例えば車のボディとかだったら代表面積は前方からの投影面積とかを使うらしいですが、翼の場合は上から(垂直尾翼なら横から)の投影面積です。
水平翼の面積
オレンジの部分は胴体と交差した部分。
(※同じ図に色付けしていますが主翼と尾翼は別々の翼として扱います)
垂直尾翼の面積
ドーサルフィン(紫の部分)は面積に含めるべきなのか否かはちょっと調べた限りではわかりませんでした.(多分含めない)
そもそもこんな細かな部分を気にするほど精密な実装ではないので別に気にするものでもないですが。
面積の計算のやり方は探せばいろいろ出てくると思いますが、とりあえず単純な矩形(四角)翼であれば、「幅(m)×弦長(m)」で計算できます。
例えば幅12m、弦長1.5mの翼だとすると、1.5*12 = 18(㎡)が面積になります。
float wingChordLength = 1.5; // 翼弦長
float wingSpan = 12; // 翼幅
float surface = wingChordLength * wingSpan; // 面積
翼が特定の迎角の時にどれだけの揚力を生むかを計算する素
迎角によって変わる(普通の翼なら~1.5くらい)
単位はなし(無次元の値)
様々な翼型の値が過去の実験からデータ化されており、インターネットでも探せば見つけることができます(「airfoil database」とか「NACA0012」とかで検索)
典型的なものは下図のような形のカーブになります。
気流に対する翼弦線の角度(ピッチ角)
翼弦線は翼の2次元断面の最前縁の点と最後縁の点を貫く線
地球の地平線に対する角度ではないので注意
単位はここでは度で扱っていますが、ラジアンで扱われることもあるようです
上の揚力曲線カーブのX軸は迎角になっていますが、この画像の通り『気流』と『翼弦線』との間にできる角度が迎角です。空間が無風の場合には、飛行機の移動ベクトルを逆にすると、それがそのまま飛行機に向かってくる気流になります。
ここでは話を簡単にするために無風を想定しているので、(-rigidbody.velocity)ベクトルに対する翼弦線のピッチ角を求めればよいです。
下の計算では、rigidBody.velocityを翼自身のローカル空間に取り込んでAtan2で角度を求めています。Atan2の結果の単位がラジアン(Radian)で返ってきますが、今回の実装では迎角は度(Degree)で扱っているのでMathf.Rad2Degをかけて度に変換しておきます。
// Convert to local vector ローカルなベクトルに変換
Vector3 localVelocity = this.transform.InverseTransformVector(rigidBody.velocity);
// Get Angle-of-attack(pitch) from local vector ローカルなベクトルから迎角(ピッチ角)を計算
float aoa = -Mathf.Atan2(localVelocity.y, localVelocity.z) * Mathf.Rad2Deg;
※今思えば、もっと簡単にVector3.SignedAngle(this.transform.forward, rigidBody.velocity, this.transform.right);とかでよかったかも迎角がわかったので、この迎角を引数として揚力係数を算出します。
フライトシミュレーターの場合はNACA翼とかの過去の実験データから値を引っ張ってくる(下の②のやり方)のが普通っぽいです。しかしシンプル実装としてはちょっと面倒なので、今回はもっと簡単なやり方にします。
揚力係数の算出 ①簡略化バージョン
さっきの揚力係数曲線カーブを見てもらえばわかりますが、失速角(ストンと線が落ちはじめるところ)直前までは揚力係数の増加は完全に迎角に比例した直線です(実データはすこし凸凹がありますが、だいたい直線で近似できます)。
なので失速角以降のふるまいさえ無視すれば、迎角×係数という単純な計算式で近似的な揚力係数を得ることができます。そしてこの係数ですが、翼型にもよりますが大体0.1前後くらいのようです。
迎角 × 0.1がそのまま揚力係数として使えるので簡単ですね。
float cl = aoa * 0.1f;
当たり前ですが、この計算式では失速角を超えた後も揚力が増え続けていきます。
そのため、あくまで失速角より浅い常識的な飛行状態にある間だけを考えた値ですが、カジュアルなゲームならこの簡易計算式で充分かもしれません。
揚力係数の算出 ②揚力係数カーブを使って求めるバージョン
上の適当なやり方に対して、フライトシミュレーターの実装でおそらくオーソドックスであろうやり方がこちら。
現実世界で過去に行われた翼型の性能実験をもとに得られたデータを拾ってきて、その揚力曲線カーブから迎角を引数として値を引っ張ってきます。
UnityでいえばAnimationCurveに(手作業とかで)プロットし、Evaluate(aoa);で迎角に対応する揚力係数値を引いてくるような感じです。
こちらのやり方であれば失速のふるまいも(一応)射程に入りますが、シンプル実装としてはコードとか準備とかが複雑になるので今回は簡単な①を使います。
(蛇足)極端な角度で生じるジッターについて
揚力の量に方向性を持たせたベクトル
単位はN(ニュートン)
前の項までで、翼がどれだけの揚力を発生しているかはわかりました。揚力の計算結果の値の単位は[N](ニュートン)です。Unityの物理演算で使われる単位も[N]なので、あとはこの揚力を適用する(rigidBodyにAddForceする)だけです。
しかしまだ最後に、その揚力量をどの方向に向けてAddForceするか、という問題が残っています。
揚力は翼を上に引っ張る力なので、翼の真上(Y+)方向に適用すればいいだけ・・・と考えてしまいそうになりますが、揚力は翼に対して直角ではなく、気流に対して直角に適用されます。
(ここを勘違いしてて一時期ハマりました。まあ実際は翼の真上方向に適用しちゃっても挙動は一見そんな変わらないんですが・・・)
気流に直角な方向を求める計算としては、翼の翼幅方向と気流(風上方向)との外積を取ればいいように思えますが、単純な外積だと翼が後ろに進んでいる場合に揚力のベクトルが上下逆になってしまいます。
そのため実装はこんな感じで、外積が2回入れ子みたいになった計算になっています。
//Calculate lift direction (normalized) vector 揚力ベクトルを計算
Vector3 liftVector = Vector3.Cross(Vector3.Cross(rigidBody.velocity, this.transform.up),rigidBody.velocity).normalized;
(参考:たしか『ゲーム開発のための物理シミュレーション入門』に載ってたやり方だったような気がする 違うかも)ややこしいですが、入れ子になってるのは『飛行機が後ろに進んでいる場合でも揚力ベクトルが上下反転してしまわないようにしている』だけです。
※この外積の入れ子は今回のように迎角に応じて無限に増えていく簡易な揚力係数を使っているときは必要ですが、「揚力係数カーブから揚力を求める」場合には不要です。
揚力係数カーブを使って揚力係数を求めている場合は、揚力ベクトルも外積一回だけで求めたものが使えます。
Vector3 liftVector = Vector3.Cross(rigidBody.velocity, this.transform.right).normalized;
抗力は次の計算式で計算できます。
(1/2) * p * ( V * V ) * S * Cd
p:空気密度 Pressure [kg/m^3]V:速度 Velocity [m/s]S:翼面積 Surface [m^2]Cd:抗力係数 Coefficient of Drag [-]これは、上で紹介した揚力の計算式と全く同じですね。
抗力係数が違うだけで、揚力のメソッドを再利用すればよいです。
翼が特定の迎角の時にどれだけの空気抵抗を生むかを計算する素
単位はなし(無次元の値)
揚力に比べて一桁くらい小さい
こちらは典型的には下のような感じのカーブになります。
揚力係数と比べると1桁小さな値です。
揚力が直線的な増加カーブだったのに対し、こちらはなんか・・・2乗っぽいカーブ?です。
特徴として、失速迎角あたりから急激に値が増えていきます。
揚力係数と同じで、こちらも実験データの抗力曲線カーブから値を取ってくるのがオーソドックスなやり方ですが、今回のカジュアル実装では揚力係数の方に簡易な定数を使いました。なのでこちらの抗力係数の方もカーブを使うのではなく簡単な式で求めたいです。
そこで上と似たような曲線になる計算式を適当にでっち上げたものがこちらです。
//Drag coefficient 抗力係数
float cd = Mathf.Clamp01(0.005f + Mathf.Pow(Mathf.Abs(aoa)*0.0315f,5));
まあ、大体上と同じなのでこれで良さそうですね(適当)
(パッと見は似ていても14度くらいまでの傾斜が全然違うので実は近似としても微妙なんですが・・・。)
グラフ外の20度以降の値なんかは実際の翼型とは全くかけ離れているであろう、超適当な感じになっています(33度くらいで頭打ちになり、以降1.0の横ばいが続く)。
シンプルな実装なので、揚力と同じく失速角以降は無視する方針です。
迎角をAbs(絶対値)で取ってるのは、揚力係数は負の迎角では負の値になるのに対して、抗力係数は負の迎角でも係数の符号は反転しないからです。符号が負になるとしたら抗力がマイナス、つまり逆に加速する力が働くということで、そんなわけないですね。
(下の両グラフ参照、なお下図はいずれも上下が対称な翼型の場合)
抗力係数がわかれば、あとは揚力と同じ計算式を使って速度・面積・空気密度と掛け合わせることで、最終的な抗力が得られます。
揚力の時は揚力ベクトルを計算しましたが、抗力を適用する方向は、無風の場合は単に翼が進んでいる方向の逆、rigidBody.velocityの逆ベクトルです。これを正規化(Normalize)して単位ベクトルにするのを忘れると過大になるので要注意。
using UnityEngine;
[RequireComponent(typeof(Rigidbody))]
public class SimpleWing : MonoBehaviour
{
// 翼面積 [m^2] Wing area
public float wingArea;
void FixedUpdate()
{
Rigidbody rigidBody = this.GetComponent<Rigidbody>();
// ローカルなベクトルに変換 Convert to local vector
Vector3 localVelocity = this.transform.InverseTransformVector(rigidBody.velocity);
// 速度 Speed
float v = rigidBody.velocity.magnitude;
// ローカルなベクトルから迎角(ピッチ角)を計算 Get Angle-of-attack(pitch) from local vector
float aoa = -Mathf.Atan2(localVelocity.y, localVelocity.z) * Mathf.Rad2Deg;
//------------------------
// ▼ 揚力
//------------------------
// 揚力係数(簡易版) Lift coefficient
float cl = aoa * 0.1f;
// 揚力を計算 Calculate lift
float lift = CalculateLiftOrDrag(cl, wingArea, v);
// 揚力ベクトルを計算 Calculate lift direction (normalized) vector
Vector3 liftVector = Vector3.Cross(Vector3.Cross(rigidBody.velocity, this.transform.up), rigidBody.velocity).normalized;
//------------------------
// ▼ 抗力
//------------------------
// 抗力係数(簡易版) Drag coefficient
float cd = Mathf.Clamp01(0.005f + Mathf.Pow(Mathf.Abs(aoa)*0.0315f,5));
// 抗力を計算 Calculate drag
float drag = CalculateLiftOrDrag(cd, wingArea, v);
// 抗力ベクトルを計算 Calculate drag direction (normalized) vector
Vector3 dragVector = -rigidBody.velocity.normalized; //ベロシティの逆ベクトルを正規化するだけ
//------------------------
// ▼ 力を適用
//------------------------
// 揚力を適用 Apply lift
rigidBody.AddForce(liftVector * lift);
// 抗力を適用 Apply drag
rigidBody.AddForce(dragVector * drag);
}
// 揚力・抗力を計算する共通メソッド
public static float CalculateLiftOrDrag(float coefficient, float surfaceSize, float velocity, float airDensity = 1.293f)
{
float q = 0.5f * (velocity * velocity) * airDensity; //動圧
return q * surfaceSize * coefficient;
}
}
後々サンプルプロジェクトを見てもらえばわかりますが、このスクリプトは左の画像のように
という計5枚のRigidbody&コライダーを持った別々の翼にそれぞれこのスクリプトコンポーネントをアタッチして使うという前提になっています。
それぞれの翼は胴体とは別のRigidbodyオブジェクトとして、同じくRigidbody+Colliderを持った胴体オブジェクトにジョイントで接続しています。
なぜわざわざ別々のパーツにしてジョイントで接続するという面倒なことをしているのか?(飛行機全体を1つのRigidbodyオブジェクトにした方がシンプルでは?)
という感じがしますが、その理由は、Rigidbodyオブジェクトの挙動が『慣性テンソル』という、物体の回転のしやすさみたいなものを決める値に大きく左右されるためです。
この慣性テンソルの値自体は1オブジェクトで完結させられるパラメーターなので、機体全体の質量密度が均一な場合には、複数の子コライダーをアタッチすることで、1つのRigidbodyにまとめることは可能です。
が、左図のように、翼部分が密度的に胴体より重かったり軽かったりする場合の違いを作り出したい場合など、パーツ別にしておいた方が融通が利きます。
各パーツの重量を個別に決めておくことで、ジョイントで接続されている場合に各パーツの質量(mass)も全体の挙動に影響し、いわば各パーツのローカルなmassや慣性テンソルが統合されて機体全体での慣性テンソルができるみたいな感じで、パーツの重さが全体の挙動に影響します。
ちなみに抗力の話で、UnityのRigidBodyは元からDrag(抵抗)やAngulerDrag(回転抵抗)という値を持っていて動きに抵抗を生じさせてくれますが、これは何かUnity独自の計算式を元に使用される値らしく、少なくとも一般的な抗力や抗力係数とは関係ないらしい(*)ので使いません(0に設定してあります)。
* https://forum.unity.com/threads/drag-factor-what-is-it.85504/次のページでは、翼をコントロールする動翼の実装について書いてます。