ドローンは4つの回転の差だけで飛び、制御がなければ倒れる

前へ進むのも向きを変えるのも、4つのプロペラの推力の差でこなす。翼も舵もない機体は自分では水平に戻らないので、制御が1秒に何百回も差を直し続けている。

記事

ドローンは4つの回転の差だけで飛び、制御がなければ倒れる

前へ進むのも向きを変えるのも、4つのプロペラの推力の差でこなす。翼も舵もない機体は自分では水平に戻らないので、制御が1秒に何百回も差を直し続けている。

作成日 2026年11月4日・更新日 2026年11月4日・執筆・監修 佐々木 航

現象

止まって見える機体は、差を直し続けている

画面の機体は、高さ2.5 mの一点に止まって見える。右上の図の棒がプロペラごとの推力で、どれも約2.2 N。4つを合わせた8.8 Nが、質量0.9 kgの機体の重さと釣り合っている。

この機体に翼や舵はない。動かせるのは4つのモーターの回転数だけだ。それでも前へ進めるし、その場で向きも変えられる。前後の推力に差をつければ機体が傾き、傾いた推力が機体を押す。回る向きの違う2組に差をつければ、プロペラの反動で機体が回る。

では、差をつけなければ機体はじっとしているのだろうか。

そうはならない。傾いても、機体は自分では水平に戻らないからだ。シミュレーションの機体には、作りの差として最大1.5%の推力のばらつきを入れてある。差を直す制御を切ると約1秒で30°傾き、そのまま落ちる。空中に止まっていられるのは、制御が1秒に何百回も傾きを測り、回転数を直し続けているからである。

画面のスティックか、矢印キーとWASDのキーで操縦できる。地面をタップすれば、そこへ飛ぶ。右上の図のプロペラをタップすると、飛んでいる最中に止まる。

仕組み

傾く、進む、回るは、4つの差の組み合わせ

プロペラの推力(N)は、回転数(rad/s)の2乗に比例する。

はプロペラの大きさと形で決まる係数で、シミュレーションではホバリングの回転数が約4,270 rpmになる値にした。2乗なので、回転数を1%上げれば推力は2%増える。動かせるものはこれだけなのに、組み合わせ方で3種類の動きが生まれる。

上がる 前 4つとも速くする 推力の合計が増える 前へ傾く 前 後ろの2つを速く、前の2つを遅く 機体が前へ傾く 左へ回る 前 右回りの組を速く、左回りの組を遅く 反トルクの差で左へ回る
上から見た4つのプロペラで、太い輪を速く回すと、全部なら上がり、後ろの2つなら前へ傾き、右回りの組なら左へ回る

傾けるときは、前の2つと後ろの2つに差をつける。重心から前後に測った腕の長さを(m)、後ろの2つと前の2つの推力の合計をととすると、機体を前へ倒すトルク(N·m)は次の式になる。

差はわずかでも、機体はすぐに傾く。を0.134 m、前後に傾く軸の慣性モーメント(回りにくさ)を0.0065 kg·m²として、後ろの2つを0.1 Nずつ増やし前の2つを0.1 Nずつ減らしてみよう。回転数なら2%ほど。それだけで、空気の抵抗を別にすれば約0.4秒で30°傾く。

傾いた機体では、推力も一緒に傾く。水平に向いた分が機体を前へ押し、空気抵抗と釣り合った所で速さが一定になる。質量を(kg)、重力加速度を(9.81 m/s²)、傾きを、空気の密度を(1.2 kg/m³)、抵抗係数と面積の積を(m²)、速さを(m/s)とすると、釣り合いの式はこう書ける。

DJIの公開仕様では、離陸重量895 gの機体の最大傾斜角度が35°、無風の最高速度が21 m/sである1。最高速度がこの釣り合いで決まるとして式に入れると、は約0.023 m²。抵抗係数を1とすれば、15 cm四方の板を押して飛ぶ計算になる。シミュレーションの機体にも、この値を使った。

向きを変える仕組みは、これとは別物である。モーターがプロペラを回すと、機体は同じ大きさの反動を逆向きに受ける。この反トルクは推力に比例し、係数を(m)とすれば、機体を左へ回すトルクは次のとおり。

とは、それぞれの向きに回る2つの推力の合計だ。対角の2つを同じ向きに回してあるので、4つが同じ回転数なら反トルクは打ち消し合う。右回りの組を速く、左回りの組を遅くすれば、推力の合計を変えずに機体だけが左へ回るわけだ。

ただし、この力は弱い。シミュレーションの機体では、腕の長さ13.4 cmに比べては1.6 cmしかなく2、回りにくさも向きの軸のほうが大きい。同じ推力の差で比べると、向きを変える角加速度は傾ける角加速度の約15分の1にとどまる。ドローンがすばやく傾くわりに、向きをゆっくり変える理由はここにある。

仕組み

翼のない機体は、自分では水平に戻らない

機体が傾いても、推力は機体と一緒に傾くだけで、元へ戻すトルクを生まない。だから小さな差でも一方向にたまり続け、傾きはふくらんでいく。しかも同じ形のまま小さくすると、腕が短くなる以上に慣性モーメントが小さくなるので、傾きの角加速度は大きさに反比例して増える。

制御は、この傾きを測って直す。ただ、位置のずれから4つの推力を一度に計算するのではなく、段に分けて順に目標を渡していく。カスケード制御と呼ばれる形だ。

外の段ほどゆっくり、内の段ほどすばやく直す ゆっくり すばやく 位置 ゲイン1 (1/秒) 速度の目標 速度 ゲイン2.5 (1/秒) 傾きの目標 傾き ゲイン7 (1/秒) 角速度の目標 角速度 ゲイン25 (1/秒) トルク 配分 トルクを 推力に分ける 4つの推力 機体 モーターの遅れ 測った位置、速度、傾き、角速度が、各段へ戻る
位置、速度、傾き、角速度、配分の5段で、各段が次の段の目標を作り、測った値が各段へ戻る

位置のずれから出した速度の目標を、次の段が傾きの目標に直す。傾きの段は角速度の目標を作る。

は角速度(rad/s)、は傾きのゲイン(1/秒)だ。いちばん内側の段は角速度のずれから欲しい角加速度を決め、ずれを足し続けた分も加える。

は角速度のゲイン、は足し続けた分の係数で、に慣性モーメントを掛けると欲しいトルクになる。最後に、このトルクと推力の合計を4つに配分する。ゲインは、ずれが縮まる速さと思えばよい。シミュレーションの既定は外から順に1、2.5、7、25(1/秒)。内側が追いつける速さで、外側が目標を出す形である。

制御の回数が足りないと揺れるのは、遅れがあるからだ。シミュレーションのモーターは指令から約0.03秒遅れて回転数を変え、指令そのものも次の計算まで変わらない。回数が少ないと、古い傾きを見て作った差が今の機体にかかるので、行き過ぎては戻す往復が始まり、ある回数を下回るとふくらむ一方になる。ゲインを上げるほど、許される遅れは短くなる。

プロペラが1つ止まると、話はもっと難しくなる。残る3つでは、機体を傾けずに反トルクを打ち消す組み合わせがないので、向きを保つこと自体をあきらめるしかない。機体を回し続け、機体に固定した1本の軸を真上に保つのだ。止まったプロペラの側を下げた形で回ると、こまのように傾きを保とうとするトルクが生まれ、弱めた対角のプロペラの推力と釣り合う。

回る軸(真上) 機体の真上の向き 約16° 対角のプロペラ 1.9 N 止まったプロペラ 0 N 手前と奥のプロペラ(3.5 Nと3.8 N)が主に重さを支える
プロペラが1つ止まった機体を横から見た図で、止まったプロペラの側を下げて約16°傾き、真上を向く軸のまわりを回る

地面のすぐ上でも、力の釣り合いが変わる。吹き下ろしの行き場を地面がふさぎ、同じ回転数で推力が増すのだ。プロペラの半径を(m)、地面からプロペラの面までの高さを(m)とすると、増え方の見積もりには、ヘリコプターのために作られたCheesemanとBennettの式がある3。

地面に近いほど、推力は大きく増える。

試す

6つの場面で、差の作り方を見比べる

左上のグラフには、制御の各段の目標が点線で、実際が実線で出る。タップすると前後、左右、向き、上下が切り替わる。右上の図の棒は推力で、横の印がホバリングに要る値だ。

  1. 2.5秒ホバリングしたあと、制御を切る。4つの回転数がそろうだけになるので、推力のばらつきがそのまま傾きになる。機体は左へ傾き始め、約1.1秒で30°を超え、切ってから約2.2秒で地面に落ちる。「位置まで」を押して飛び直し、制御の回数を25にすると、揺れが数秒でふくらんで落ちる。40でも10秒あまりで高さを失い、60にすると収まる。ここで角速度のゲインを100に上げると、60でも100でも揺れが止まらず、500でようやく収まる。制御を「切る」にしてキーで水平を保とうとすると、その難しさが手で分かる。
  2. 目標の点を順に飛ぶ。動き出しの0.04秒で前の推力が1.4 N、後ろが3.0 Nまで開き、0.2秒余りで傾きは30°を超える。傾きが上限の35°に届くと、差はすぐに小さくなる。あとは傾いた推力が押し続け、約6秒で20 m/sに達して21 m/sで頭打ちになる。このときの推力の合計は14 Nで重さの1.6倍。傾いた分に加えて、機体の上面が受ける風に押し下げられる分も支えているからだ。機体の重さを1.5 kgにすると、35°傾いたままでは推力の上向きの分が足りず、数秒で地面に届く。
  3. その場で左へ半周する。右回りの組(左前と右後)が3.5 N、左回りの組(右前と左後)が0.7 Nまで開く。手順2で傾けたときより差はずっと大きいのに、半周に約1.5秒かかる。向きの角速度には2.5 rad/sの上限を設けてあるが、上限を外して計算しても約1秒はかかる。合計の推力はほぼ同じなので、高さは動かない。
  4. 5 m/sの向かい風の中に、7秒ごとに8 m/sの突風が来る。機体は風上へ最大17°ほど傾いて耐え、約0.5 m流されてから戻る。速度のゲインを6にすると約0.2 mに縮み、0.5にすると約2 mに増えて、突風のあとも前後に揺れ続ける。傾きのゲインを30にすると、内側の段が追いつけず機体が20°以上揺れ続け、高さも1 m以上下がる。逆に1へ下げても、今度は外の段のほうが速くなり、ゆっくりした大きな揺れになる。制御の段を「傾きまで」にすると水平は保つが、位置を測らないので風に流されたままになる。
  5. 3秒後に右前のプロペラが止まる。機体はすぐに回り始め、約3秒で1秒に2.8回転ほどに落ち着く。推力は左前が3.8 N、右後が3.5 N、対角の左後が1.9 N。止まった側を下げて約16°傾いたまま軸を真上に保つので、高さの変化は3 cm足らずで済む。これは止まった瞬間に制御を切り替える前提の値だ。制御の段を「傾きまで」にすると場面が最初から始まり、向きも保とうとする機体は止まって0.4秒で80°を超え、約1.2秒で墜落する。止まる前に重さを1.3 kgにしておくと、回転が上がるまでの数秒を支えきれず、高さ5 mから地面に落ちる。回転が上がりきったあとに増やすなら、同じ1.3 kgでも飛び続ける。
  6. 高さ1.2 mからゆっくり降りる。プロペラの面が半径の5倍の高さでは推力の増え方は0.25%、2倍で1.6%、地面すれすれの1.33倍で3.6%。重さは変わらないので、回転数のほうが約4,270 rpmから約4,200 rpmへ下がる。制御の段を「傾きまで」にすると、推力の指令を重さの97%に固定して1.2 mから落ちる。何度かはずんだあと、機体は地面に触れずに浮いたまま止まる。プロペラの面の高さは半径の約1.5倍で、式で推力が約3%増える高さだ。
現実では

本物の飛行制御は、1秒に400回から8,000回

実機の飛行制御ソフトも、同じ多重の形をしている。オープンソースのPX4の資料は、位置の段を外側に、角速度の段をいちばん内側に置いたカスケード制御を標準の構成として示す4。その角速度の段は既定で1秒に400回動き、100回から2,000回のあいだで変えられる。

もっと回数の多いソフトもある。Betaflightの資料によると、ジャイロセンサーの読み取りは種類によって1秒に8,000回や3,200回で、制御の計算は同じ回数か、処理の遅いマイコンなら半分にするのがよいという。ArduPilotの資料は、回数を読み取りに近づけると、推力のわりに慣性モーメントが小さい機敏な機体で、より精密で反応の速い制御になりうると説明する5。手順1で見たとおり、回数に余裕があるほど高いゲインを使える。

小さな機体は風にも弱い。重さは大きさの3乗で減るのに、風を受ける面積は2乗でしか減らないからだ。DJIの公開仕様では、最大傾斜角度はどちらも35°なのに、離陸重量895 gの機体は最大風圧抵抗が12 m/s、最高速度が21 m/s、249 g未満の機体はそれぞれ10.7 m/sと16 m/sである1。

プロペラが止まっても飛び続ける方法は、チューリッヒ工科大学のMuellerとD'Andreaが2014年に示している。機体に固定した1本の軸のまわりに回り続け、その軸を傾けて移動するやり方で、プロペラが1つ止まった場合は実機の実験でも確かめられた6。

地面効果については、小型のマルチコプターで測った研究が、影響はプロペラの半径の5倍の高さまで見え、古典の式の予測より高い所まで届くと報告している7。シミュレーションの式では5倍の高さでの増え方は0.25%しかないので、本物の機体ではもう少し上から効き始めるのだろう。

日本で飛ばす決まりにも、重さと高さの線引きがある。国土交通省によると、100 g以上の機体を屋外で飛ばすとき、地上または水上から150 m以上の空域や、人や家屋の密集している地域の上空では、原則として許可が要る8。

省いていること

シミュレーションと本物の機体の違い

  • 腕の長さ0.19 m、プロペラの半径0.12 m、慣性モーメント、プロペラ1つの最大の推力5 N、モーターの遅れ0.03秒、推力のばらつき、上下の向きの空気抵抗、回転を止める空気の力は想定の値だ。市販の空撮機の大きさに合わせて決めたもので、特定の機体を測ったものではない。手順5の1秒に2.8回転という速さは、この回転を止める力で決まる。重さを変えても形は同じとし、慣性モーメントは重さに比例させた
  • 推力は回転数の2乗に比例し、上下の動きで最大10%増減するだけの簡単な形にした。前へ進むと推力が増える効果や、羽根のはためきは入れていない。空気抵抗は1点にかかるとし、吹き下ろしどうしの干渉も省いた
  • 位置と傾きと角速度は、誤差なしに測れるとした。本物の機体はGNSSや気圧、加速度と角速度のセンサーを組み合わせて推定するので、測り方の誤差とフィルターの遅れがさらに加わる。また、制御の回数は全部の段で同じにしたが、本物では段ごとに違う
  • プロペラが止まったことは、その瞬間に分かるとした。本物では気づくまでに時間がかかる。画面のつまみにはないが、気づくまでの遅れを入れて計算すると、0.02秒で機体は30°傾いて11 cm落ち、0.1秒では100°を超えて傾き、高さ5 mからは墜落する。高さ30 mなら約9 m落ちて立て直す。2つ以上止まると、このシミュレーションの制御では飛び続けられない
  • 地面効果は、プロペラ1つの式を4つに別々に使っている。プロペラは見やすいように、本当の回転数の3%の速さで回して描いた
参考文献

参考文献

  1. DJI「DJI Mavic 3 Classic サポート(スペック)」「DJI Mini 4 Pro サポート(スペック)」(2026年11月4日確認). https://www.dji.com/jp/support/product/mavic-3-classic https://www.dji.com/jp/support/product/mini-4-pro
  2. PX4「PX4 Guide: Controller Diagrams」「Parameter Reference」. https://docs.px4.io/main/en/flight_stack/controller_diagrams https://docs.px4.io/main/en/advanced_config/parameter_reference
  3. PX4「PX4-gazebo-models: x500」. https://github.com/PX4/PX4-gazebo-models
  4. Mueller, M. W., D'Andrea, R.「Stability and control of a quadrocopter despite the complete loss of one, two, or three propellers」『2014 IEEE International Conference on Robotics and Automation』45–52, 2014.
  5. Mueller, M. W., D'Andrea, R.「Relaxed hover solutions for multicopters: Application to algorithmic redundancy and novel vehicles」『The International Journal of Robotics Research』35(8), 873–889, 2016.
  6. Cheeseman, I. C., Bennett, W. E.「The effect of the ground on a helicopter rotor in forward flight」Aeronautical Research Council Reports & Memoranda 3021, 1955. https://reports.aerade.cranfield.ac.uk/handle/1826.2/3590
  7. Kan, X., Thomas, J., Teng, H., Tanner, H. G., Kumar, V., Karydis, K.「Analysis of Ground Effect for Small-Scale UAVs in Forward Flight」『IEEE Robotics and Automation Letters』4(4), 3860–3867, 2019. https://par.nsf.gov/servlets/purl/10181219
  8. 国土交通省「無人航空機の飛行許可・承認手続」(2026年11月4日確認). https://www.mlit.go.jp/koku/koku_fr10_000042.html

注

  1. 参考文献1。Mavic 3 Classicは、水平方向の最大飛行速度21 m/s(海抜ゼロ地点、無風。EU地域では19 m/s以下)、展開時の大きさ347.5×283×107.7 mm(プロペラなし)。Mini 4 Proの最大水平速度はSモードの値。シミュレーションの機体の質量0.9 kgと最高速度は895 gの機体に合わせたが、形や慣性モーメントは想定の値である。 ↩ ↩2

  2. 参考文献3。PX4のシミュレーション用の機体モデルX500(質量2 kg)は、反トルクと推力の比を0.016 m、モーターの時定数を上げるとき0.0125秒・下げるとき0.025秒としている。シミュレーションの0.016 mと0.03秒は、この値を目安に決めた。 ↩

  3. 参考文献6の式を、参考文献7の書き方で引いた。参考文献7の式には横へ動く速さの項があり、シミュレーションもこの項を入れて、横へ飛ぶと効きが弱まるようにした。式はが小さいと発散するので、計算ではを半径の0.6倍より小さくしない。 ↩

  4. 参考文献2。角速度の段の回数はIMU_GYRO_RATEMAX(既定400 Hz)。位置のゲインMPC_XY_Pの既定は0.95、速度のゲインMPC_XY_VEL_P_ACCの既定は1.8で、シミュレーションの1と2.5に近い。 ↩

  5. ArduPilot「Aggressive Rate Loop Tuning」 https://ardupilot.org/copter/docs/high-loop-rate-tuning.html 、Betaflight「Configuration Tab」 https://betaflight.com/docs/wiki/app/configuration-tab 。ArduCopter 4.7以降は、角速度の段をジャイロセンサーの読み取りに近い回数で回せる。 ↩

  6. 参考文献4と5。参考文献5の要旨によると、実験で確かめたのは1つが止まった場合と対角の2つが止まった場合で、対角でない2つと3つが止まった場合は数値シミュレーションである。シミュレーションの機体の回り方は、この考え方を、軸の向きを減衰つきのばねで戻す簡単な制御で作り直したものである。 ↩

  7. 参考文献7。参考文献6の式は全体の傾向には合うが、実測と差があるという。 ↩

  8. 参考文献8。航空法第132条の85、86。機体認証や操縦者技能証明の有無などで、手続きを省略できる場合がある。夜間飛行や目視外飛行などには承認が要る。 ↩

プログラム
プログラム

このシミュレーションのプログラム

このシミュレーションは、1/1000秒刻みで「決まった回数で制御を動かす → プロペラの回転数と推力を求める → 空気と地面の力を足す → 機体を1つの剛体として動かす」をくり返している。機体や地面を立体に描き、影を落とし、視点を回すのはthree.jsの受け持ちで、記事の計算はその前に終わっている。画面に重ねたグラフとプロペラの図は、計算の結果を2Dの面に描いたものだ。

長さはメートル、機体は市販の空撮機の大きさ

長さはメートル、時間は秒、力はニュートンで数える。質量0.9 kgと傾きの上限35°は市販の空撮機の公開仕様に合わせ、空気抵抗の係数は、傾き35°で21 m/sになるようにから逆算した。腕の長さや慣性モーメント、プロペラの最大の推力は想定の値である。

定数と単位(src/sims/drone-flight.ts の 21〜55 行)
const G = 9.81;                  // 重力加速度 m/s²const RHO = 1.2;                 // 空気の密度 kg/m³const DEG = Math.PI / 180;const H_STEP = 1 / 1000;         // 運動を進める刻み 秒// 機体: 離陸重量 895 g・水平の最高速度 21 m/s・最大傾斜角 35° の市販の空撮機を手本にした大きさconst M0 = 0.9;                  // 基準の質量 kgconst L = 0.19;                  // 重心からプロペラの中心まで mconst A = L / Math.SQRT2;        // 前後と左右に測った腕の長さ mconst R_P = 0.12;                // プロペラの半径 mconst H_ROTOR = 0.03;            // 重心から見たプロペラの面の高さ mconst FOOT = 0.06;               // 重心から脚の先まで mconst T_MAX = 5;                 // プロペラ 1 つの最大の推力 Nconst K_T = 1.1e-5;              // 推力の係数 N/(rad/s)²。推力 = K_T Ω²(ホバリングで約 4,270 rpm)const KAPPA = 0.016;             // 反トルク ÷ 推力 mconst TAU_M = 0.03;              // モーターの遅れ(回転数が目標へ近づく時定数)秒const J_R = 2e-5;                // プロペラ 1 つの慣性モーメント kg·m²const I_T0 = 0.0065;             // 基準の質量のときの慣性モーメント(前後・左右に傾く軸)kg·m²const I_S0 = 0.0115;             // 基準の質量のときの慣性モーメント(向きを変える軸)kg·m²const CDA_SIDE = 0.023;          // 抵抗の係数 × 面積(前後と左右)m²。傾き 35° で 21 m/s になる値const CDA_TOP = 0.046;           // 抵抗の係数 × 面積(上下)m²const CP_Y = 0.015;              // 空気の力がかかる点の、重心からの高さ mconst GAMMA = 0.005;             // 回転を止めようとする空気の力の係数 N·m·s/radconst TILT_MAX = 35 * DEG;       // 傾きの上限 rad/** 4 つのプロペラ: 前(x)・右(z)の位置 m と、回る向き(+1 = 上から見て左回り) */const ROTORS = [  { name: "右前", x: A, z: A, s: 1 },  { name: "左前", x: A, z: -A, s: -1 },  { name: "左後", x: -A, z: -A, s: 1 },  { name: "右後", x: -A, z: A, s: -1 },];const EPS = [0.015, -0.005, -0.01, 0.008]; // プロペラごとの推力の差(割合)/** 地面に触れるか調べる点(機体に固定した座標): 4 本の脚の先と、プロペラの上と、機体の上面 */const TOUCH: [number, number, number][] = [];for (const r of ROTORS) TOUCH.push([r.x, -FOOT, r.z], [r.x, H_ROTOR + 0.01, r.z]);TOUCH.push([0, 0.05, 0]);

推力の係数K_Tは、ホバリングの推力2.2 Nで約4,270 rpmになる値だ。上下を向く軸の慣性モーメントI_S0が、傾く軸のI_T0より大きいのは、機体の重さが軸から離れた所に広がっているからである。

プロペラの推力と反トルクを足し、剛体として動かす

機体の前・上・右の向きを四元数から求め、4つのプロペラを順に計算する。回転数は指令へ時定数0.03秒で近づき、推力はに、軸の向きの動きと地面効果の補正、作りの差を掛ける。推力の差はトルクtxとtzになり、反トルクtyは、プロペラが空気を押す反動に回転数を変える反動を足したものだ。

力と剛体の運動(src/sims/drone-flight.ts の 57〜188 行)
type Mode = "pos" | "angle" | "rate" | "off";interface Drone {  m: number; It: number; Is: number; t: number;  p: THREE.Vector3; v: THREE.Vector3; q: THREE.Quaternion; w: THREE.Vector3; // 位置・速度・向き・角速度(機体の座標。x 前・y 上・z 右)  fwd: THREE.Vector3; up: THREE.Vector3; right: THREE.Vector3;               // 機体の前・上・右の向き(世界の座標)  om: Float64Array; cmd: Float64Array; next: Float64Array; thr: Float64Array; ge: Float64Array; // 回転数 rad/s・推力の指令 N・次の指令・実際の推力 N・地面効果の倍率  stopped: boolean[]; dead: boolean; touching: boolean; hitV: number; tau: THREE.Vector3;  wind: THREE.Vector3; windMean: number; windYaw: number; gust: number; gustT: number; gustLen: number;  mode: Mode; hz: number; kv: number; kth: number; kr: number; acc: number; bias: number;  stick: { rx: number; ry: number; lx: number; ly: number };  target: THREE.Vector3; yawT: number; faceTarget: boolean; manual: boolean;  wf: THREE.Vector3; vf: THREE.Vector3; iv: THREE.Vector3; ir: THREE.Vector3;  refV: THREE.Vector3; refUp: THREE.Vector3; refW: THREE.Vector3; hasRef: [boolean, boolean, boolean];  spinning: boolean;}const V3 = () => new THREE.Vector3();function newDrone(m = M0): Drone {  const d: Drone = {    m, It: 0, Is: 0, t: 0, p: V3(), v: V3(), q: new THREE.Quaternion(), w: V3(), fwd: new THREE.Vector3(1, 0, 0), up: new THREE.Vector3(0, 1, 0), right: new THREE.Vector3(0, 0, 1),    om: new Float64Array(4), cmd: new Float64Array(4), next: new Float64Array(4), thr: new Float64Array(4), ge: new Float64Array(4).fill(1),    stopped: [false, false, false, false], dead: false, touching: false, hitV: 0, tau: V3(),    wind: V3(), windMean: 0, windYaw: 0, gust: 0, gustT: -99, gustLen: 2,    mode: "pos", hz: 500, kv: 2.5, kth: 7, kr: 25, acc: 0, bias: 0,    stick: { rx: 0, ry: 0, lx: 0, ly: 0 }, target: V3(), yawT: 0, faceTarget: false, manual: false,    wf: V3(), vf: V3(), iv: V3(), ir: V3(), refV: V3(), refUp: new THREE.Vector3(0, 1, 0), refW: V3(), hasRef: [true, true, true], spinning: false,  };  setMass(d, m);  return d;}/** 重さを変える: 形はそのままなので、慣性モーメントは重さに比例するとした */function setMass(d: Drone, m: number) { d.m = m; d.It = (I_T0 * m) / M0; d.Is = (I_S0 * m) / M0; }/** 空中に水平に置き、回転数と指令を、釣り合う値から始める */function placeDrone(d: Drone, x: number, y: number, z: number, yaw: number) {  d.p.set(x, y, z); d.v.set(0, 0, 0); d.w.set(0, 0, 0); d.wf.set(0, 0, 0); d.vf.set(0, 0, 0); d.iv.set(0, 0, 0); d.ir.set(0, 0, 0);  d.q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), yaw);  d.target.set(x, y, z); d.yawT = yaw; d.dead = false; d.touching = false; d.stopped.fill(false); d.spinning = false; d.manual = false;  const T = (d.m * G) / 4;  for (let i = 0; i < 4; i++) { d.om[i] = Math.sqrt(Math.min(T, T_MAX) / K_T); d.cmd[i] = d.next[i] = T; d.thr[i] = T; d.ge[i] = 1; }  frameAxes(d); d.refUp.set(0, 1, 0); d.refV.set(0, 0, 0); d.refW.set(0, 0, 0);}function frameAxes(d: Drone) {  d.fwd.set(1, 0, 0).applyQuaternion(d.q); d.up.set(0, 1, 0).applyQuaternion(d.q); d.right.set(0, 0, 1).applyQuaternion(d.q);}/** 風 = 一定の風 + ゆっくり揺れる成分 + 突風(1 − cos の形で強まって弱まる) */function updateWind(d: Drone) {  const t = d.t, u = (t - d.gustT) / d.gustLen;  const g = u >= 0 && u <= 1 ? 0.5 * d.gust * (1 - Math.cos(2 * Math.PI * u)) : 0;  const m = d.windMean, sway = m * (0.1 * Math.sin(1.1 * t) + 0.07 * Math.sin(2.7 * t + 1) + 0.04 * Math.sin(6.3 * t + 2));  const sp = m + sway + g, cy = Math.cos(d.windYaw), sy = Math.sin(d.windYaw);  d.wind.set(sp * cy, 0.08 * m * Math.sin(1.7 * t), -sp * sy);}const _air = V3(), _f = V3(), _tb = V3(), _c = V3(), _vc = V3(), _ft = V3(), _wc = V3(), _qt = new THREE.Quaternion();/** 1 刻み: 制御 → プロペラの回転数と推力 → 空気の力 → 地面 → 速度と位置、角速度と向きを進める */function stepDrone(d: Drone, h: number) {  frameAxes(d);  updateWind(d);  // 制御は決まった回数でだけ動き、次に動くまで同じ指令を出し続ける  d.acc += h;  const tick = 1 / d.hz;  if (d.acc >= tick - 1e-9) { d.acc -= tick; control(d, tick); }  const { fwd, up, right, w, p, v } = d;  _air.subVectors(v, d.wind);  const ax = _air.dot(fwd), ay = _air.dot(up), az = _air.dot(right), sp = _air.length();  let sum = 0, tx = 0, ty = 0, tz = 0, hr = 0;  for (let i = 0; i < 4; i++) {    const r = ROTORS[i];    // モーターの遅れ: 回転数は指令へ時定数 TAU_M で近づく    const goal = d.stopped[i] || d.dead ? 0 : Math.sqrt(Math.max(d.cmd[i], 0) / K_T);    const dOm = (goal - d.om[i]) / TAU_M;    d.om[i] = Math.max(0, d.om[i] + dOm * h);    const T0 = K_T * d.om[i] * d.om[i];    // プロペラが軸の向きに動くと、入ってくる空気の角度が変わって推力が増減する(上昇で減り、下降で増える)    const vax = ay + (w.z * r.x - w.x * r.z), inflow = 1 - 0.1 * Math.tanh(vax / 1.5);    // 地面効果: Cheeseman と Bennett の式。横に動くと吹き下ろしが流れ、効きが弱まる    const z = Math.max(0.6 * R_P, p.y + fwd.y * r.x + up.y * H_ROTOR + right.y * r.z);    const vi2 = T0 / (2 * RHO * Math.PI * R_P * R_P), vh2 = ax * ax + az * az;    const ge = 1 / (1 - ((R_P / (4 * z)) ** 2) / (1 + vh2 / Math.max(vi2, 0.25)));    // EPS: プロペラごとの作りの差で、同じ回転数でも推力が少し違う    const T = T0 * inflow * ge * (1 + EPS[i]);    d.thr[i] = T; d.ge[i] = ge; sum += T;    tx += -r.z * T; tz += r.x * T;    // 反トルク: プロペラが空気を押す反動と、回転数を変えるときの反動。プロペラと逆の向きに機体を回す    ty += -r.s * (KAPPA * T + J_R * dOm);    hr += r.s * J_R * d.om[i];  }  // 機体の空気抵抗(速さの 2 乗に比例。機体の座標で向きごとに違う)  const fx = -0.5 * RHO * CDA_SIDE * ax * sp, fy = -0.5 * RHO * CDA_TOP * ay * sp, fz = -0.5 * RHO * CDA_SIDE * az * sp;  // 合わせた力(世界の座標)  _f.set(0, -d.m * G, 0).addScaledVector(up, sum + fy).addScaledVector(fwd, fx).addScaledVector(right, fz);  // トルク(機体の座標): 推力の差 + 反トルク + 空気の力の点のずれ + 回転を止める空気の力  _tb.set(tx + CP_Y * fz - GAMMA * w.x, ty - GAMMA * w.y, tz - CP_Y * fx - GAMMA * w.z);  ground(d);  d.tau.copy(_tb);  // 速度と位置を進める(半陰的オイラー法: 先に速度を、新しい速度で位置を)  v.addScaledVector(_f, h / d.m);  p.addScaledVector(v, h);  // 角速度を進める: オイラーの運動方程式 I dω/dt = τ − ω × (Iω + プロペラの角運動量)  const Ix = d.It, Iy = d.Is, Iz = d.It;  const Lx = Ix * w.x, Ly = Iy * w.y + hr, Lz = Iz * w.z;  w.x += (h * (_tb.x - (w.y * Lz - w.z * Ly))) / Ix;  w.y += (h * (_tb.y - (w.z * Lx - w.x * Lz))) / Iy;  w.z += (h * (_tb.z - (w.x * Ly - w.y * Lx))) / Iz;  if (w.length() > 60) w.setLength(60);  // 向き(四元数)を進める: dq/dt = ½ q (ω, 0)。ω は機体の座標  const q = d.q, k = 0.5 * h, qx = q.x, qy = q.y, qz = q.z, qw = q.w;  q.set(qx + k * (qw * w.x + qy * w.z - qz * w.y), qy + k * (qw * w.y + qz * w.x - qx * w.z), qz + k * (qw * w.z + qx * w.y - qy * w.x), qw - k * (qx * w.x + qy * w.y + qz * w.z)).normalize();  d.t += h;}/** 地面: 脚の先などが地面に入ったら、ばねと減衰で押し返し、こする向きと逆に摩擦をかける。強くぶつかるか、大きく傾いて触れたら墜落 */function ground(d: Drone) {  const { p, v, w, fwd, up, right } = d;  d.touching = false;  if (p.y > 0.5) return;  for (const c of TOUCH) {    _c.copy(fwd).multiplyScalar(c[0]).addScaledVector(up, c[1]).addScaledVector(right, c[2]);    const depth = -(p.y + _c.y);    if (depth <= 0) continue;    if (!d.touching && v.y < -2.2) d.hitV = Math.max(d.hitV, -v.y);    d.touching = true;    // 接点の速さ = 重心の速さ + 回転の分(機体の座標で ω × r を求めて世界へ戻す)    const wcx = w.y * c[2] - w.z * c[1], wcy = w.z * c[0] - w.x * c[2], wcz = w.x * c[1] - w.y * c[0];    _vc.copy(v).addScaledVector(fwd, wcx).addScaledVector(up, wcy).addScaledVector(right, wcz);    const Fn = Math.max(0, 2500 * depth - 40 * _vc.y);    const vt = Math.hypot(_vc.x, _vc.z), mu = (0.6 * Fn) / Math.max(vt, 0.1);    _ft.set(-_vc.x * mu, Fn, -_vc.z * mu);    _f.add(_ft);    const bx = _ft.dot(fwd), by = _ft.dot(up), bz = _ft.dot(right);    _tb.x += c[1] * bz - c[2] * by; _tb.y += c[2] * bx - c[0] * bz; _tb.z += c[0] * by - c[1] * bx;  }  if (d.touching && !d.dead && (d.hitV > 2.2 || up.y < 0.5)) d.dead = true;  if (!d.touching) d.hitV = 0;}

力は世界の座標で足し、トルクは機体の座標で足す。回転は機体の座標のオイラーの運動方程式で進める。は4つのプロペラの角運動量の和で、プロペラが1つ止まると打ち消し合わなくなる。

刻みを1/1000秒にしたのは、制御の回数を1秒に1,000回まで選べるようにするためである。1刻みの計算は1マイクロ秒ほどで、1コマに17回進めても負担にならない。地面に触れた点はばねと減衰で押し返し、強くぶつかるか大きく傾いて触れたら墜落として、モーターを止める。

位置から配分までの多重のPID

controlは決まった回数でだけ動く。先に前の回の指令をプロペラへ出してから新しい指令を計算するので、計算にかかる時間として1回分の遅れが入る。制御の回数を減らすと揺れがふくらむのは、この遅れと、次の回まで指令が変わらないことによる。

多重のPID(src/sims/drone-flight.ts の 190〜319 行)
const K_P = 1;                   // 位置のゲイン 1/sconst K_P_Z = 1.2;               // 高さのゲイン 1/sconst K_V_Z = 4;                 // 上下の速度のゲイン 1/sconst A_BRAKE = 4.5;             // 目標の手前で減速する加速度 m/s²const V_MAX = 25;                // 指令する速さの上限 m/s(21 m/s で頭打ちになるのは、傾きの上限と空気抵抗のため)const V_STICK = 10;              // スティックをいっぱいに倒したときの速さ m/sconst W_TILT = 6;                // 傾きを変える角速度の上限 rad/sconst W_YAW = 2.5;               // 向きを変える角速度の上限 rad/sconst TAU_GYRO = 0.004;          // 角速度の計測をならす時定数 秒const _qd = new THREE.Quaternion(), _qe = new THREE.Quaternion(), _m4 = new THREE.Matrix4(), _x = V3(), _y = V3(), _z = V3();const hasStick = (d: Drone) => Math.abs(d.stick.rx) + Math.abs(d.stick.ry) > 0.02;/** 位置 → 速度 → 欲しい加速度。傾きの上限の中で、機体の上の向きの目標(refUp)と推力の合計を返す */function positionLoop(d: Drone, dt: number) {  const { p, v, target, refV, up, iv, stick } = d;  const cy = Math.cos(d.yawT), sy = Math.sin(d.yawT);  if (hasStick(d)) {    const a = stick.ry * V_STICK, b = stick.rx * V_STICK;    refV.x = cy * a + sy * b; refV.z = -sy * a + cy * b;    target.x = p.x + v.x * 0.6; target.z = p.z + v.z * 0.6;  } else {    // 遠くでは一定の加速度で止まれる速さ(距離の平方根)、近くでは距離に比例する速さを目標にする    const ex = target.x - p.x, ez = target.z - p.z, dist = Math.hypot(ex, ez);    const sp = Math.min(K_P * dist, Math.sqrt(2 * A_BRAKE * dist), V_MAX), k = dist > 1e-6 ? sp / dist : 0;    refV.x = ex * k; refV.z = ez * k;    if (d.faceTarget && dist > 3) d.yawT = Math.atan2(-ez, ex);  }  if (Math.abs(stick.ly) > 0.02) { refV.y = stick.ly * 3; target.y = Math.max(0.1, p.y + v.y * 0.3); }  else refV.y = clamp(K_P_Z * (target.y - p.y), -3, 4);  const ex = refV.x - v.x, ey = refV.y - v.y, ez = refV.z - v.z;  let ax = d.kv * ex + iv.x, az = d.kv * ez + iv.z;  const ay = clamp(K_V_Z * ey + iv.y, -6, 8), gy = G + ay;  // 傾きの上限: 横の加速度は (g + 上の加速度) × tan(35°) まで。頭打ちの間は積分を止める(ワインドアップを防ぐ)  const ah = Math.hypot(ax, az), lim = gy * Math.tan(TILT_MAX), sat = ah > lim;  if (sat) { ax *= lim / ah; az *= lim / ah; }  else { iv.x = clamp(iv.x + 0.5 * d.kv * ex * dt, -7, 7); iv.z = clamp(iv.z + 0.5 * d.kv * ez * dt, -7, 7); }  iv.y = clamp(iv.y + 2 * ey * dt, -5, 5);  d.refUp.set(ax, gy, az).normalize();  // 推力の合計: 欲しい力のうち、機体の今の上の向きに沿う分  return Math.max(0.1 * d.m * G, d.m * (ax * up.x + gy * up.y + az * up.z));}/** 傾き → 角速度: 目標の向きと今の向きの差(四元数)に、傾きのゲインを掛ける。向きを変える分は弱くする */function attitudeLoop(d: Drone) {  _y.copy(d.refUp);  _x.set(Math.cos(d.yawT), 0, -Math.sin(d.yawT));  _z.crossVectors(_x, _y).normalize();  _x.crossVectors(_y, _z);  _qd.setFromRotationMatrix(_m4.makeBasis(_x, _y, _z));  _qe.copy(d.q).invert().multiply(_qd);  const s = _qe.w < 0 ? -2 : 2;  d.refW.set(clamp(d.kth * s * _qe.x, -W_TILT, W_TILT), clamp(0.4 * d.kth * s * _qe.y, -W_YAW, W_YAW), clamp(d.kth * s * _qe.z, -W_TILT, W_TILT));}/** 角速度 → トルク → プロペラごとの推力。PI 制御に、回転の項(ω × Iω)を足す */function rateLoop(d: Drone, dt: number, Tc: number) {  const { wf, refW, ir } = d;  const ex = refW.x - wf.x, ey = refW.y - wf.y, ez = refW.z - wf.z;  ir.x = clamp(ir.x + d.kr * ex * dt, -30, 30); ir.y = clamp(ir.y + 0.1 * d.kr * ey * dt, -5, 5); ir.z = clamp(ir.z + d.kr * ez * dt, -30, 30);  let hr = 0;  for (let i = 0; i < 4; i++) hr += ROTORS[i].s * J_R * d.om[i];  const Lx = d.It * wf.x, Ly = d.Is * wf.y + hr, Lz = d.It * wf.z;  mix(d, Tc,    d.It * (d.kr * ex + ir.x) + (wf.y * Lz - wf.z * Ly),    d.Is * (0.25 * d.kr * ey + ir.y) + (wf.z * Lx - wf.x * Lz),    d.It * (d.kr * ez + ir.z) + (wf.x * Ly - wf.y * Lx));}/** プロペラの配分: 欲しい推力の合計とトルクを、4 つの推力に分ける。限界に当たるときは、傾きを先に守り、推力の合計、向きの順に譲る */function mix(d: Drone, Tc: number, tx: number, ty: number, tz: number) {  const rp = _rp;  let hi = -1e9, lo = 1e9;  for (let i = 0; i < 4; i++) {    const r = ROTORS[i];    rp[i] = (-r.z * tx + r.x * tz) / (4 * A * A);    hi = Math.max(hi, rp[i]); lo = Math.min(lo, rp[i]);  }  const span = hi - lo;  if (span > T_MAX) { const k = T_MAX / span; for (let i = 0; i < 4; i++) rp[i] *= k; hi *= k; lo *= k; }  const c = clamp(Tc / 4, -lo, Math.max(-lo, T_MAX - hi));  let yLo = -1e9, yHi = 1e9;  for (let i = 0; i < 4; i++) {    const b = c + rp[i];    if (ROTORS[i].s > 0) { yHi = Math.min(yHi, b); yLo = Math.max(yLo, b - T_MAX); }    else { yHi = Math.min(yHi, T_MAX - b); yLo = Math.max(yLo, -b); }  }  const y = clamp(ty / (4 * KAPPA), Math.min(yLo, yHi), Math.max(yLo, yHi));  for (let i = 0; i < 4; i++) d.next[i] = c + rp[i] - ROTORS[i].s * y;}const _rp = new Float64Array(4);/** 制御の 1 回: 前の回に計算した指令をプロペラへ出し(計算にかかる時間の分だけ遅れる)、新しい測定から次の指令を求める */function control(d: Drone, dt: number) {  d.cmd.set(d.next);  d.wf.lerp(d.w, 1 - Math.exp(-dt / TAU_GYRO));  d.vf.lerp(d.v, 1 - Math.exp(-dt / 0.25));  const st = d.stick;  d.spinning = false;  if (d.dead) { d.next.fill(0); return; }  const nStop = d.stopped.filter(Boolean).length, hover = d.m * G;  if (d.mode === "pos" && nStop === 1) { spinControl(d, dt); return; }  if (d.mode === "off") {    // 制御なし: スティックの量がそのまま推力の差になる    d.hasRef[0] = d.hasRef[1] = d.hasRef[2] = false;    const base = (hover * (1 + 0.4 * st.ly + d.bias)) / 4, dd = base * 0.03;    for (let i = 0; i < 4; i++) {      const r = ROTORS[i];      d.next[i] = clamp(base - Math.sign(r.x) * st.ry * dd - Math.sign(r.z) * st.rx * dd - r.s * st.lx * base * 0.1, 0, T_MAX);    }    d.ir.set(0, 0, 0); d.iv.set(0, 0, 0);    return;  }  d.yawT = wrapAngle(d.yawT + st.lx * 1.5 * dt);  let Tc = hover;  if (d.mode === "pos") {    d.hasRef[0] = d.hasRef[1] = d.hasRef[2] = true;    Tc = positionLoop(d, dt);    attitudeLoop(d);  } else if (d.mode === "angle") {    d.hasRef[0] = false; d.hasRef[1] = d.hasRef[2] = true;    // 傾きまで: スティックが傾きの目標になる。推力の合計は、傾いても重さを支える分にする    const cy = Math.cos(d.yawT), sy = Math.sin(d.yawT), a = Math.tan(st.ry * 25 * DEG), b = Math.tan(st.rx * 25 * DEG);    d.refUp.set(cy * a + sy * b, 1, -sy * a + cy * b).normalize();    Tc = (hover * (1 + 0.4 * st.ly + d.bias)) / Math.max(0.5, d.up.y);    attitudeLoop(d);    d.iv.set(0, 0, 0);  } else {    d.hasRef[0] = d.hasRef[1] = false; d.hasRef[2] = true;    // 角速度まで: スティックが角速度の目標になる。傾きを水平に戻すのは操縦する人    d.refW.set(st.rx * 2.5, st.lx * 1.5, -st.ry * 2.5);    Tc = hover * (1 + 0.4 * st.ly + d.bias);    d.iv.set(0, 0, 0);  }  rateLoop(d, dt, Tc);}

positionLoopは位置のずれから速度の目標を作り、速度のずれとその積分から欲しい加速度を求める。遠い目標では、一定の加速度で止まれる速さ(距離の平方根)を目標にする。欲しい加速度に重力を足した向きが機体の上の向きの目標で、35°を超えないように横の加速度を削る。削っている間に積分を進めないのは、たまった分がブレーキを遅らせるワインドアップを防ぐためだ。

attitudeLoopは、目標の向きと今の向きの差を四元数で求め、傾きのゲインを掛けて角速度の目標にする。向きの軸はゲインを弱くし、角速度の目標は傾きの軸で6 rad/s、向きの軸で2.5 rad/sまでに抑える。rateLoopは角速度のずれのPI制御で、記事のの式にあたる。これに慣性モーメントを掛け、回転の項を足して欲しいトルクにする。mixはトルクと推力の合計を4つに分ける。上限に当たったときは傾きを先に守り、推力の合計、向きの順に譲る。

回りながら飛ぶ制御

プロペラが1つ止まると、位置まで制御する段ではspinControlに切り替わる。計算には、機体の上を、対角のプロペラへの向きをとする座標を使う。世界の真上の向きをこの座標で表したを、目標のへ、減衰つきのばねの形で戻す。

回りながら飛ぶ制御(src/sims/drone-flight.ts の 321〜363 行)
// プロペラが 1 つ止まると、向きの釣り合いは取れない。そこで向きを保つのをあきらめ、機体を回しながら、// 機体に固定した 1 本の軸 n を真上に保つ。残る 3 つの推力は、この軸のまわりに回り続けても n の向きがぶれない値に決めるconst N_Y = 0.28;                // 軸 n を、機体の真上から、止まったプロペラの対角へ傾ける量(sin)const W_N = 7, ZETA_N = 0.9;     // 軸 n の向きを戻す速さ rad/s と減衰比const K_ARM = 0.0156;            // 空気が回転を止める力の見込み N·m·s/radfunction spinControl(d: Drone, dt: number) {  d.spinning = true;  d.hasRef[0] = true; d.hasRef[1] = true; d.hasRef[2] = false;  const i = d.stopped.indexOf(true), o = (i + 2) % 4;  // 計算に使う座標: e3 = 機体の上、e2 = 対角のプロペラ o への向き、e1 = e2 × e3(a はその先のプロペラ、b はその反対)  const ox = ROTORS[o].x / L, oz = ROTORS[o].z / L, e1x = -oz, e1z = ox;  let a = 0, b = 0;  for (let j = 0; j < 4; j++) if (j !== i && j !== o) { if (ROTORS[j].x * e1x + ROTORS[j].z * e1z > 0) a = j; else b = j; }  const wf = d.wf, P = wf.x * e1x + wf.z * e1z, Q = wf.x * ox + wf.z * oz, R = wf.y;  // 位置と高さ: 機体が回って重心が小さな円を描く分をならした速度(vf)で、弱いゲインで戻す  const { p, target, vf, refV, iv } = d;  const sx = clamp(0.8 * (target.x - p.x), -1.5, 1.5), sz = clamp(0.8 * (target.z - p.z), -1.5, 1.5), sy = clamp(1.2 * (target.y - p.y), -1.5, 2);  refV.set(sx, sy, sz);  let ax = 1.2 * (sx - vf.x), az = 1.2 * (sz - vf.z);  const ah = Math.hypot(ax, az); if (ah > 2.5) { ax *= 2.5 / ah; az *= 2.5 / ah; }  iv.y = clamp(iv.y + 1.5 * (sy - vf.y) * dt, -5, 5);  const ay = clamp(3 * (sy - vf.y) + iv.y, -5, 6);  d.refUp.set(ax, G + ay, az).normalize();  // 欲しい軸の向きを、計算の座標で表す(nx = 0・ny = N_Y がつり合いの形)  const u = d.refUp, bx = u.dot(d.fwd), bz = u.dot(d.right), nz = Math.max(0.2, u.dot(d.up));  const nx = bx * e1x + bz * e1z, ny = bx * ox + bz * oz;  // n の変わり方(機体から見た dn/dt = −ω × n)  const dnx = R * ny - Q * nz, dny = P * nz - R * nx, dnz = Q * nx - P * ny;  const nyT = N_Y * Math.min(1, (R / 16) ** 2);  // 目標の 2 階微分(減衰つきのばね)と釣り合うように、角加速度を決める  const ddx = -2 * ZETA_N * W_N * dnx - W_N * W_N * nx, ddy = -2 * ZETA_N * W_N * dny - W_N * W_N * (ny - nyT);  let hr = 0, tr = -GAMMA * R;  for (let j = 0; j < 4; j++) { hr += ROTORS[j].s * J_R * d.om[j]; tr += -ROTORS[j].s * KAPPA * d.thr[j]; }  const dR = tr / d.Is;  const dQ = (dR * ny + R * dny - Q * dnz - ddx) / nz, dP = (ddy - P * dnz + dR * nx + R * dnx) / nz;  // 欲しいトルク = 慣性モーメント × 角加速度 + 回転の項 + 空気が回転を止める力(GAMMA と、プロペラの腕が上下に動いて推力が変わる分 K_ARM)  const t1 = d.It * dP + (d.Is - d.It) * Q * R + Q * hr + K_ARM * P, t2 = d.It * dQ - (d.Is - d.It) * P * R - P * hr + K_ARM * Q;  // 配分: o が e1 まわりのトルク、a と b の差が e2 まわりのトルクを受け持つ  const F = (d.m * (G + ay)) / Math.max(0.3, d.up.y);  const fo = clamp(t1 / L, 0, T_MAX), sum = Math.max(0, F - fo), diff = t2 / L;  d.next[i] = 0; d.next[o] = fo;  d.next[a] = clamp((sum - diff) / 2, 0, T_MAX); d.next[b] = clamp((sum + diff) / 2, 0, T_MAX);}

機体から見たはで動く。これをもう一度微分して、の2階微分が目標の値になる角加速度を解き、運動方程式から欲しいトルクを求める。釣り合った形では、プロペラの角運動量と空気の力を別にすると、対角のプロペラの推力と腕の長さ、向きの軸と傾く軸の慣性モーメントの差についてが成り立つ。回転が速いほどが増え、反トルクの差が縮んで、回転の速さが1か所に落ち着く。

対角のプロペラの推力は負にできないので、回り始める前は逆向きのトルクが出せない。そこで目標のを回転の速さの2乗に合わせて増やし、回り始めの機体を無理に傾けないようにした。

スティックとキー、場面の進み方

スティックと図のプロペラは描画面に直接付け、視点を回す操作より先に受け取るので、2本の指で同時に使える。キーは0.12秒かけて倒す。

操縦と目標の点(src/sims/drone-flight.ts の 365〜508 行)
type Side = "l" | "r";interface StickState { id: number | null; x: number; y: number }const stickGeom = (api: Api) => {  const r = api.u(40), m = api.u(14), y = api.h - m - r;  return { r, l: { x: m + r, y }, r_: { x: api.w - m - r, y } };};const btnRect = (api: Api, i: number) => {  const w = api.u(58), h = api.u(30), g = api.u(8), y = api.h - api.u(14) - h, x0 = api.w / 2 - w - g / 2;  return [x0 + i * (w + g), y, w, h] as const;};const panelTop = (api: Api) => (api.full ? Math.max(api.u(10), 58) : api.u(10));function rotorPanel(api: Api) {  const u = api.u, W = Math.min(api.w * 0.36, u(150)), H = W + u(44), x0 = api.w - W - u(10), y0 = panelTop(api);  const cx = x0 + W / 2, cy = y0 + u(18) + W / 2, k = (W * 0.25) / A, rd = k * R_P;  return { x0, y0, W, H, cx, cy, k, rd };}function chartPanel(api: Api, s: S) {  const u = api.u, narrow = api.w < 560, rows = AXES[s.axis].rows.slice(narrow && AXES[s.axis].rows.length > 4 ? 1 : 0);  const W = clamp(api.w * 0.42, u(190), u(250)), rh = u(narrow ? 33 : 32), H = u(36) + rows.length * rh;  return { x0: u(10), y0: panelTop(api), W, H, rh, rows };}/** スマホで操作パネルを開いて描画面が低くなったときは、機体が隠れないようにグラフを出さない */const chartsFit = (api: Api) => api.w >= 560 || api.h >= 360;function setStick(api: Api, s: S, side: Side, x: number, y: number) {  const g = stickGeom(api), c = side === "l" ? g.l : g.r_, k = g.r * 0.62;  let dx = (x - c.x) / k, dy = (c.y - y) / k;  const n = Math.hypot(dx, dy); if (n > 1) { dx /= n; dy /= n; }  s.pads[side].x = dx; s.pads[side].y = dy;}/** スティック・ボタン・図のプロペラは描画面に直接付け、視点を回す操作より先に受ける(2 本の指で同時に使える) */function bindInput(api: Api, s: S) {  const el = api.ctx.canvas;  const at = (e: PointerEvent) => { const b = el.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top]; };  const take = (e: PointerEvent) => { e.stopImmediatePropagation(); e.preventDefault(); };  const inRect = (x: number, y: number, r: readonly [number, number, number, number]) => x >= r[0] && x <= r[0] + r[2] && y >= r[1] && y <= r[1] + r[3];  const down = (e: PointerEvent) => {    const [x, y] = at(e), g = stickGeom(api);    for (const side of ["l", "r"] as Side[]) {      const c = side === "l" ? g.l : g.r_, P = s.pads[side];      if (Math.hypot(x - c.x, y - c.y) < g.r * 1.2 && P.id === null) {        take(e); try { el.setPointerCapture(e.pointerId); } catch { /* 取れなくても押した状態は保つ */ }        P.id = e.pointerId; setStick(api, s, side, x, y);        return;      }    }    if (inRect(x, y, btnRect(api, 0))) { take(e); startGust(api, s); return; }    if (inRect(x, y, btnRect(api, 1))) { take(e); s.follow = !s.follow; return; }    const rp = rotorPanel(api);    if (x >= rp.x0 && x <= rp.x0 + rp.W && y >= rp.y0 && y <= rp.y0 + rp.H) {      take(e);      for (let i = 0; i < 4; i++) {        const r = ROTORS[i], cx = rp.cx + r.z * rp.k, cy = rp.cy - r.x * rp.k;        if (Math.hypot(x - cx, y - cy) < rp.rd * 1.05) { toggleRotor(api, s, i); return; }      }      return;    }    const cp = chartPanel(api, s);    if (chartsFit(api) && x >= cp.x0 && x <= cp.x0 + cp.W && y >= cp.y0 && y <= cp.y0 + cp.H) { take(e); s.axis = (s.axis + 1) % AXES.length; }  };  const move = (e: PointerEvent) => {    for (const side of ["l", "r"] as Side[]) if (s.pads[side].id === e.pointerId) { take(e); const [x, y] = at(e); setStick(api, s, side, x, y); return; }  };  const up = (e: PointerEvent) => {    for (const side of ["l", "r"] as Side[]) if (s.pads[side].id === e.pointerId) { take(e); s.pads[side].id = null; s.pads[side].x = 0; s.pads[side].y = 0; return; }  };  el.addEventListener("pointerdown", down, true); el.addEventListener("pointermove", move, true);  el.addEventListener("pointerup", up, true); el.addEventListener("pointercancel", up, true);  return () => {    el.removeEventListener("pointerdown", down, true); el.removeEventListener("pointermove", move, true);    el.removeEventListener("pointerup", up, true); el.removeEventListener("pointercancel", up, true);  };}function startGust(api: Api, s: S) {  const d = s.d;  d.gust = 8; d.gustLen = 2; d.gustT = d.t;  api.note("8 m/s の突風が、2 秒かけて強まって弱まります");}function toggleRotor(api: Api, s: S, i: number) {  const d = s.d;  if (d.dead) return;  d.stopped[i] = !d.stopped[i];  // 動かし直したら、今いる所で止まり直す  if (!d.stopped[i]) { d.iv.set(0, 0, 0); d.ir.set(0, 0, 0); d.target.copy(d.p); }  const n = d.stopped.filter(Boolean).length;  api.note(!d.stopped[i] ? `${ROTORS[i].name}のプロペラを動かしました` : n >= 2 ? "プロペラが 2 つ止まると、このシミュレーションの制御では飛び続けられません" : d.mode === "pos" ? `${ROTORS[i].name}のプロペラを止めました。向きを保つのをあきらめ、機体を回しながら高さを保ちます` : `${ROTORS[i].name}のプロペラを止めました。向きも保とうとする制御では釣り合いが取れません`, 3000);}/** キーとスティックを合わせて、制御へ渡す入力にする。キーは 0.12 秒かけて倒す */function readSticks(s: S, dt: number) {  const K = s.keys, st = s.d.stick, k = 1 - Math.exp(-dt / 0.12);  const blend = (now: number, pad: number, a: boolean, b: boolean, active: boolean) => (active ? pad : now + ((a ? 1 : 0) - (b ? 1 : 0) - now) * k);  st.ry = blend(st.ry, s.pads.r.y, K.ArrowUp, K.ArrowDown, s.pads.r.id !== null);  st.rx = blend(st.rx, s.pads.r.x, K.ArrowRight, K.ArrowLeft, s.pads.r.id !== null);  st.ly = blend(st.ly, s.pads.l.y, K.KeyW, K.KeyS, s.pads.l.id !== null);  // 左へ倒すと左へ回る(+ が上から見て左回り)  st.lx = blend(st.lx, -s.pads.l.x, K.KeyA, K.KeyD, s.pads.l.id !== null);  if (Math.abs(st.ry) + Math.abs(st.rx) + Math.abs(st.ly) + Math.abs(st.lx) > 0.05 && !s.d.manual) {    s.d.manual = true; s.route = []; s.d.faceTarget = false;  }}/** 地面の点をタップすると、そこへ飛ぶ */function tapGround(api: Api, s: S) {  const d = s.d;  if (d.dead) { revive(api, s); return; }  const hit = s.v.onPlane(api.pointer.x, api.pointer.y, s.plane);  if (!hit || Math.hypot(hit.x - d.p.x, hit.z - d.p.z) > 400) return;  if (d.mode !== "pos") { api.note("目標の点へ飛ぶのは、制御の段が「位置まで」のときです"); return; }  s.route = []; d.manual = false;  d.target.set(hit.x, Math.max(d.target.y, 1.5), hit.z); d.faceTarget = true;  api.note(`目標の点を置きました(${fmt(Math.hypot(hit.x - d.p.x, hit.z - d.p.z), 0)} m 先)`);}function revive(api: Api, s: S) {  const d = s.d, y = Math.max(s.homeY, 1), yaw = Math.atan2(-d.fwd.z, d.fwd.x);  placeDrone(d, d.p.x, y, d.p.z, Number.isFinite(yaw) ? yaw : 0);  s.ev.crash = false; s.ev.t30 = false; s.ev.cutT = d.mode === "off" ? d.t : -1; s.route = [];}/** 場面ごとの進み方(制御を切る・目標の点を順に飛ぶ・向きを変える・突風・プロペラを止める・降りる) */function scenario(api: Api, s: S) {  const d = s.d, t = d.t - s.t0, key = s.scene, ev = s.ev;  if (key === "cut" && !ev.cut && t > 2.5) { ev.cut = true; ev.cutT = d.t; api.set("mode", "off"); d.mode = "off"; api.note("制御を切りました。4 つのプロペラは同じ回転数になり、作りの差の分だけ推力がずれます", 2600); }  if (s.route.length && Math.hypot(d.target.x - d.p.x, d.target.z - d.p.z) < 2.5) {    s.routeI = (s.routeI + 1) % s.route.length;    const q = s.route[s.routeI]; d.target.set(q[0], q[1], q[2]);  }  if (key === "yaw" && !d.manual && d.mode === "pos") {    const n = Math.floor((t - 2) / 5);    if (n >= 0 && n !== ev.yawN) { ev.yawN = n; d.yawT = wrapAngle(d.yawT + (n % 2 ? -1 : 1) * Math.PI * 0.999); api.note(n % 2 ? "今度は右へ半周。左回りの組を速く、右回りの組を遅くします" : "左へ半周。右回りの組を速く、左回りの組を遅くします", 2200); }  }  if (key === "gust" && t > 3) { const n = Math.floor((t - 3) / 7); if (n !== ev.gustN) { ev.gustN = n; startGust(api, s); } }  if (key === "out" && !ev.out && t > 3) { ev.out = true; toggleRotor(api, s, 0); }  if (key === "ground") {    d.bias = d.mode === "pos" ? 0 : -0.03;    if (d.mode === "pos" && t > 2 && !d.manual) d.target.y = Math.max(0.13, s.homeY - 0.15 * (t - 2));  }}/** 出来事の知らせ(同じ知らせを毎コマ出さない) */function events(api: Api, s: S) {  const d = s.d, ev = s.ev;  if (ev.cutT >= 0 && !ev.t30 && d.up.y < Math.cos(30 * DEG)) { ev.t30 = true; api.note(`切ってから${fmt(d.t - ev.cutT, 1)}秒で、傾きが30°を超えました`, 2600); }  if (d.dead && !ev.crash) { ev.crash = true; api.note("墜落しました。描画面をタップすると飛び直します", 3600); }  if (!ev.heavy && d.m * G > 4 * T_MAX * 0.97) { ev.heavy = true; api.note("重さが、4 つのプロペラの最大の推力に届きました。傾ける余力が残りません", 3200); }  if (d.m * G < 4 * T_MAX * 0.9) ev.heavy = false;  if (!ev.alt && d.p.y >= 150) { ev.alt = true; api.note("高さ 150 m を超えました。日本では、この高さの飛行には許可が要ります", 3200); }  if (d.p.y < 140) ev.alt = false;}

scenarioは場面ごとの進み方で、決まった時刻に制御を切り、プロペラを止め、突風を起こす。読者がスティックを倒すと、決まった動きはやめて手の操作を優先する。

three.jsが受け持つ描画

機体は箱と円柱で作り、プロペラと推力の棒だけを毎コマ動かす。地面の線と目印の柱は、機体を追って区切りのよい所へ動かすので、どこまで飛んでも続いて見える。毎コマ作り直す部品はなく、風の線は最初に作った配列を書き換える。

three.jsで描く(src/sims/drone-flight.ts の 510〜632 行)
interface Mats { land: THREE.Material; grid: THREE.Material; fine: THREE.Material; body: THREE.Material; dark: THREE.Material; arm: THREE.Material; prop: THREE.Material; bar: THREE.Material; post: THREE.Material; mark: THREE.Material; line: THREE.Material; streak: THREE.Material }/** 機体: 胴体・カメラ・4 本の腕・モーター・脚・プロペラと、回る向きの矢印、推力の棒 */function makeDrone(s: S) {  const m = s.mats, g = new THREE.Group(), add = (geo: THREE.BufferGeometry, mat: THREE.Material, x = 0, y = 0, z = 0, parent: THREE.Object3D = g) => {    const o = new THREE.Mesh(geo, mat); o.position.set(x, y, z); o.castShadow = true; o.receiveShadow = true; parent.add(o); return o;  };  add(new THREE.BoxGeometry(0.2, 0.055, 0.09), m.body);  add(new THREE.BoxGeometry(0.11, 0.03, 0.07), m.body, -0.015, 0.04, 0);  add(new THREE.SphereGeometry(0.024, 14, 10), m.dark, 0.115, -0.012, 0);  const armG = new THREE.BoxGeometry(L, 0.012, 0.018), motorG = new THREE.CylinderGeometry(0.018, 0.018, 0.03, 16), legG = new THREE.BoxGeometry(0.007, FOOT, 0.007);  const bladeG = new THREE.BoxGeometry(2 * R_P * 0.98, 0.003, 0.016), ringG = new THREE.TorusGeometry(R_P, 0.0028, 6, 48); ringG.rotateX(Math.PI / 2);  const barG = new THREE.CylinderGeometry(0.005, 0.005, 1, 10); barG.translate(0, 0.5, 0);  const tipG = new THREE.ConeGeometry(0.013, 0.032, 12); tipG.translate(0, 0.02, 0);  const arcR = 0.078, arcG = new THREE.TorusGeometry(arcR, 0.0035, 6, 28, 1.5 * Math.PI); arcG.rotateX(-Math.PI / 2);  const headG = new THREE.ConeGeometry(0.012, 0.03, 10), yAxis = new THREE.Vector3(0, 1, 0);  ROTORS.forEach((r, i) => {    const arm = add(armG, r.x > 0 ? m.dark : m.arm, r.x / 2, 0.008, r.z / 2); arm.rotation.y = -Math.atan2(r.z, r.x);    add(motorG, m.dark, r.x, H_ROTOR - 0.014, r.z);    add(legG, m.arm, r.x, -FOOT / 2, r.z);    add(ringG, m.prop, r.x, H_ROTOR + 0.008, r.z);    const hub = new THREE.Group(); hub.position.set(r.x, H_ROTOR + 0.006, r.z); g.add(hub);    add(bladeG, m.prop, 0, 0, 0, hub);    s.blades[i] = hub;    // 回る向きの矢印: 左回りは弧の終わり、右回りは弧の始まりに矢じりを付ける    add(arcG, m.bar, r.x, H_ROTOR + 0.012, r.z);    const a = r.s > 0 ? 1.5 * Math.PI : 0, hx = arcR * Math.cos(a), hz = -arcR * Math.sin(a), tx = -Math.sin(a) * r.s, tz = -Math.cos(a) * r.s;    const head = add(headG, m.bar, r.x + hx, H_ROTOR + 0.012, r.z + hz);    head.quaternion.setFromUnitVectors(yAxis, new THREE.Vector3(tx, 0, tz));    const bar = new THREE.Mesh(barG, m.bar); bar.position.set(r.x, H_ROTOR + 0.03, r.z); g.add(bar); s.bars[i] = bar;    const tip = new THREE.Mesh(tipG, m.bar); tip.position.copy(bar.position); g.add(tip); s.tips[i] = tip;  });  s.v.scene.add(g);  return g;}/** 地面: 面と 5 m ごとの線、近くの 1 m ごとの線。機体を追って区切りのよい所へ動かすので、どこまで飛んでも続く */function makeWorld(s: S) {  const v = s.v, m = s.mats, grid = (half: number, step: number) => {    const P: number[] = [];    for (let a = -half; a <= half + 1e-6; a += step) P.push(a, 0, -half, a, 0, half, -half, 0, a, half, 0, a);    const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.BufferAttribute(new Float32Array(P), 3));    return g;  };  s.land = new THREE.Mesh(new THREE.PlaneGeometry(1600, 1600).rotateX(-Math.PI / 2), m.land); s.land.receiveShadow = true; v.scene.add(s.land);  s.grid5 = new THREE.LineSegments(grid(200, 5), m.grid); s.grid5.position.y = 0.03; s.grid5.frustumCulled = false; v.scene.add(s.grid5);  s.grid1 = new THREE.LineSegments(grid(10, 1), m.fine); s.grid1.position.y = 0.03; s.grid1.frustumCulled = false; v.scene.add(s.grid1);  // 目印の柱(高さ 2.5 m・15 m おき): 速さと高さの見当になる  const pg = new THREE.BoxGeometry(0.12, 2.5, 0.12); pg.translate(0, 1.25, 0);  s.posts = new THREE.InstancedMesh(pg, m.post, 121); s.posts.castShadow = true; s.posts.frustumCulled = false;  const M = new THREE.Matrix4();  for (let i = 0; i < 11; i++) for (let j = 0; j < 11; j++) s.posts.setMatrixAt(i * 11 + j, M.makeTranslation((i - 5.5) * 15, 0, (j - 5.5) * 15));  s.posts.instanceMatrix.needsUpdate = true; v.scene.add(s.posts);  // 目標の点: 目標の高さの輪と、地面までの柱と、地面の輪  s.mark = new THREE.Group();  const ring = new THREE.TorusGeometry(0.35, 0.012, 8, 40); ring.rotateX(Math.PI / 2);  s.markRing = new THREE.Mesh(ring, m.mark); s.mark.add(s.markRing);  const pole = new THREE.BoxGeometry(0.03, 1, 0.03); pole.translate(0, 0.5, 0);  s.markPole = new THREE.Mesh(pole, m.mark); s.mark.add(s.markPole);  const groundRing = new THREE.TorusGeometry(0.15, 0.005, 6, 32); groundRing.rotateX(Math.PI / 2);  const gr = new THREE.Mesh(groundRing, m.mark); gr.position.y = 0.04; s.mark.add(gr);  v.scene.add(s.mark);  // 機体から地面へ下ろす線と、風で流れる短い線  const dg = new THREE.BufferGeometry(); dg.setAttribute("position", new THREE.BufferAttribute(new Float32Array(6), 3));  s.drop = new THREE.LineSegments(dg, m.line); s.drop.frustumCulled = false; v.scene.add(s.drop);  const wg = new THREE.BufferGeometry(); s.streakP = new Float32Array(N_STREAK * 6); wg.setAttribute("position", new THREE.BufferAttribute(s.streakP, 3));  s.streaks = new THREE.LineSegments(wg, m.streak); s.streaks.frustumCulled = false; v.scene.add(s.streaks);  const rnd = s.rng;  s.streakQ = new Float32Array(N_STREAK * 3);  for (let i = 0; i < N_STREAK; i++) { s.streakQ[i * 3] = (rnd() - 0.5) * 2 * STREAK_BOX; s.streakQ[i * 3 + 1] = rnd() * 2 * STREAK_BOX; s.streakQ[i * 3 + 2] = (rnd() - 0.5) * 2 * STREAK_BOX; }}const N_STREAK = 160, STREAK_BOX = 14;const THRUST_LEN = 0.06; // 推力の棒の長さ m/N(5 N で 0.3 m)/** 計算の結果を絵の部品に写す。プロペラは見やすいように、本当の回転数の 3% の速さで回して描く */function syncView(api: Api, s: S) {  const d = s.d;  s.drone.position.copy(d.p); s.drone.quaternion.copy(d.q);  for (let i = 0; i < 4; i++) {    s.blades[i].rotation.y += ROTORS[i].s * d.om[i] * 0.03 * api.dt;    const len = Math.max(0.001, d.thr[i] * THRUST_LEN);    s.bars[i].scale.y = len; s.tips[i].position.y = H_ROTOR + 0.03 + len;    s.bars[i].visible = s.tips[i].visible = d.thr[i] > 0.02;  }  const sx = Math.round(d.p.x / 5) * 5, sz = Math.round(d.p.z / 5) * 5;  s.grid5.position.x = sx; s.grid5.position.z = sz;  s.grid1.position.x = Math.round(d.p.x); s.grid1.position.z = Math.round(d.p.z);  s.grid1.visible = api.quality > 0;  s.land.position.x = d.p.x; s.land.position.z = d.p.z;  s.posts.position.x = Math.round(d.p.x / 15) * 15; s.posts.position.z = Math.round(d.p.z / 15) * 15;  // 目標の点: 機体の近くでは輪を小さくし、遠いと柱を見せる  const tDist = Math.hypot(d.target.x - d.p.x, d.target.z - d.p.z);  s.mark.visible = d.mode === "pos" && !d.dead;  s.mark.position.set(d.target.x, 0, d.target.z);  s.markRing.position.y = d.target.y; s.markRing.scale.setScalar(clamp(tDist * 0.05, 0.5, 2)); s.markRing.visible = tDist > 1 || Math.abs(d.target.y - d.p.y) > 0.5;  s.markPole.scale.set(clamp(tDist * 0.03, 0.3, 3), Math.max(0.01, d.target.y), clamp(tDist * 0.03, 0.3, 3)); s.markPole.visible = tDist > 1.5;  const dp = s.drop.geometry.attributes.position as THREE.BufferAttribute, da = dp.array as Float32Array;  da[0] = d.p.x; da[1] = d.p.y; da[2] = d.p.z; da[3] = d.p.x; da[4] = 0.04; da[5] = d.p.z; dp.needsUpdate = true;  // 風の線: 風の速さで動かし、箱の外へ出たら反対側へ戻す  const wl = d.wind.length();  s.streaks.visible = wl > 0.3;  if (s.streaks.visible) {    const c = s.v.orbit.target, Q = s.streakQ, P = s.streakP, B = 2 * STREAK_BOX, st = 0.14;    for (let i = 0; i < N_STREAK; i++) {      const a = i * 3, b = i * 6;      Q[a] += d.wind.x * api.dt; Q[a + 1] += d.wind.y * api.dt; Q[a + 2] += d.wind.z * api.dt;      const x = c.x + ((((Q[a] - c.x + STREAK_BOX) % B) + B) % B) - STREAK_BOX, y = ((((Q[a + 1]) % B) + B) % B) + 0.2, z = c.z + ((((Q[a + 2] - c.z + STREAK_BOX) % B) + B) % B) - STREAK_BOX;      P[b] = x; P[b + 1] = y; P[b + 2] = z; P[b + 3] = x - d.wind.x * st; P[b + 4] = y - d.wind.y * st; P[b + 5] = z - d.wind.z * st;    }    (s.streaks.geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true;  }}const _e = V3();/** 視点: 機体を追いかける。前へ飛ぶ場面では、読者が動かしていなければ、機体の後ろへ回る */function updateCamera(api: Api, s: S) {  const o = s.v.orbit, d = s.d;  const k = s.camSnap ? 1 : 1 - Math.exp(-api.dt * 5);  // 目標の位置を速度の分だけ先へ置くと、速く飛んでも機体が画面の真ん中に残る  if (s.follow) o.target.lerp(_e.set(d.p.x + d.v.x * 0.2, Math.max(0.1, d.p.y + d.v.y * 0.2), d.p.z + d.v.z * 0.2), k);  if (s.chase && o.idle > 4 && s.follow) {    // 真後ろからでは傾きが見えないので、斜め後ろへ回る    const want = Math.atan2(-d.fwd.x, -d.fwd.z) + 1;    o.yaw += wrapAngle(want - o.yaw) * (s.camSnap ? 1 : 1 - Math.exp(-api.dt * 1.2));  }  s.camSnap = false;}

視点は、速度の分だけ先を注目点にするので、速く飛んでも機体が画面の真ん中に残る。白黒の網点に直す仕組みは、プログラムのページで説明している。

画面に重ねるグラフと図

sampleは1秒に40回、各段の目標と実際を輪の形の配列に記録する。目標が無い段(制御を切った段)はNaNを入れて、線を切る。

画面に重ねる図(src/sims/drone-flight.ts の 634〜802 行)
const TN = 240;                  // グラフに残す点の数const T_SAMPLE = 1 / 40;         // グラフの点の間隔 秒(240 点で 6 秒)interface Row { name: string; unit: string; min: number; a: Float32Array; b: Float32Array }const row = (name: string, unit: string, min: number): Row => ({ name, unit, min, a: new Float32Array(TN).fill(NaN), b: new Float32Array(TN).fill(NaN) });const AXES: { name: string; rows: Row[] }[] = [];function makeAxes() {  AXES.length = 0;  AXES.push(    { name: "前後", rows: [row("目標までの距離", "m", 1), row("速度", "m/s", 1), row("傾き", "°", 2), row("角速度", "°/s", 10), row("推力の差(後ろ−前)", "N", 0.2)] },    { name: "左右", rows: [row("目標までの距離", "m", 1), row("速度", "m/s", 1), row("傾き", "°", 2), row("角速度", "°/s", 10), row("推力の差(左−右)", "N", 0.2)] },    { name: "向き", rows: [row("向き", "°", 5), row("角速度", "°/s", 10), row("推力の差(右回り−左回り)", "N", 0.2)] },    { name: "上下", rows: [row("高さ", "m", 0.2), row("上下の速さ", "m/s", 0.5), row("推力の合計", "N", 0.5), row("回転数の平均", "rpm", 50)] },  );}/** 制御の各段の目標と実際を、グラフの配列に記録する */function sample(s: S) {  const d = s.d, th = d.thr, nx = d.next, i = s.head;  let fx = d.fwd.x, fz = d.fwd.z, fl = Math.hypot(fx, fz);  if (fl < 1e-3) { fx = 1; fz = 0; fl = 1; }  fx /= fl; fz /= fl;  const rx = -fz, rz = fx, [ok0, ok1, ok2] = d.hasRef, ex = d.target.x - d.p.x, ez = d.target.z - d.p.z, N = NaN, u = d.up, ru = d.refUp, RD = 1 / DEG;  const put = (ax: number, r: number, a: number, b: number) => { const R = AXES[ax].rows[r]; R.a[i] = a; R.b[i] = b; };  put(0, 0, ex * fx + ez * fz, ok0 ? 0 : N); put(1, 0, ex * rx + ez * rz, ok0 ? 0 : N);  put(0, 1, d.v.x * fx + d.v.z * fz, ok0 ? d.refV.x * fx + d.refV.z * fz : N); put(1, 1, d.v.x * rx + d.v.z * rz, ok0 ? d.refV.x * rx + d.refV.z * rz : N);  put(0, 2, Math.atan2(u.x * fx + u.z * fz, u.y) * RD, ok1 ? Math.atan2(ru.x * fx + ru.z * fz, ru.y) * RD : N);  put(1, 2, Math.atan2(u.x * rx + u.z * rz, u.y) * RD, ok1 ? Math.atan2(ru.x * rx + ru.z * rz, ru.y) * RD : N);  put(0, 3, -d.w.z * RD, ok2 ? -d.refW.z * RD : N); put(1, 3, d.w.x * RD, ok2 ? d.refW.x * RD : N);  put(0, 4, th[2] + th[3] - th[0] - th[1], nx[2] + nx[3] - nx[0] - nx[1]); put(1, 4, th[1] + th[2] - th[0] - th[3], nx[1] + nx[2] - nx[0] - nx[3]);  const yaw = Math.atan2(-d.fwd.z, d.fwd.x);  s.yawU += wrapAngle(yaw - s.yawP); s.yawP = yaw;  put(2, 0, s.yawU * RD, ok2 && !d.spinning ? (s.yawU + wrapAngle(d.yawT - yaw)) * RD : N);  put(2, 1, d.w.y * RD, ok2 ? d.refW.y * RD : N);  put(2, 2, th[1] + th[3] - th[0] - th[2], nx[1] + nx[3] - nx[0] - nx[2]);  put(3, 0, d.p.y, ok0 ? d.target.y : N); put(3, 1, d.v.y, ok0 ? d.refV.y : N);  put(3, 2, th[0] + th[1] + th[2] + th[3], nx[0] + nx[1] + nx[2] + nx[3]);  put(3, 3, ((d.om[0] + d.om[1] + d.om[2] + d.om[3]) / 4) * 60 / (2 * Math.PI), N);  s.head = (i + 1) % TN;}const _pts = new Float32Array(TN * 2);/** 1 本の折れ線。NaN の所で切る。配列はつなぎ目から古い順にたどる */function drawSeries(api: Api, s: S, v: Float32Array, x0: number, y0: number, W: number, H: number, lo: number, hi: number, dash: number[] | undefined, w: number) {  let n = 0;  const flush = () => { if (n >= 4) api.poly(_pts.subarray(0, n), { w, dash }); n = 0; };  for (let k = 0; k < TN; k++) {    const y = v[(s.head + k) % TN];    if (y !== y) { flush(); continue; }    _pts[n++] = x0 + (k / (TN - 1)) * W; _pts[n++] = y0 + H - ((clamp(y, lo, hi) - lo) / (hi - lo)) * H;  }  flush();}function drawCharts(api: Api, s: S) {  const u = api.u, { x0, y0, W, H, rh, rows } = chartPanel(api, s), fs = Math.max(u(10.5), 9.5), pad = u(7);  api.rect(x0, y0, W, H, { fill: "paper", stroke: "ink", w: u(1) });  api.text(`${AXES[s.axis].name}の制御(タップで切り替え)`, x0 + pad, y0 + u(15), { size: fs });  api.text("点線が目標、実線が実際", x0 + pad, y0 + H - u(5), { size: fs * 0.86 });  rows.forEach((R, ir) => {    const top = y0 + u(22) + ir * rh, py = top + fs + u(2), ph = rh - fs - u(5);    let lo = Infinity, hi = -Infinity;    for (let k = 0; k < TN; k++) { const a = R.a[k], b = R.b[k]; if (a === a) { if (a < lo) lo = a; if (a > hi) hi = a; } if (b === b) { if (b < lo) lo = b; if (b > hi) hi = b; } }    if (!(hi >= lo)) { lo = 0; hi = 1; }    if (hi - lo < R.min) { const c = (hi + lo) / 2; lo = c - R.min / 2; hi = c + R.min / 2; }    const last = (s.head + TN - 1) % TN, cur = R.a[last], d = R.unit === "rpm" ? 0 : Math.abs(cur) < 100 && R.min < 5 ? (R.min < 0.3 ? 2 : 1) : 0;    api.text(`${R.name} ${R.unit}`, x0 + pad, top + fs * 0.95, { size: fs * 0.92 });    api.text(fmt(cur, d), x0 + W - pad, top + fs * 0.95, { size: fs, font: "mono", align: "right" });    if (lo < 0 && hi > 0) { const zy = py + ph - ((0 - lo) / (hi - lo)) * ph; api.line(x0 + pad, zy, x0 + W - pad, zy, { w: u(0.5) }); }    drawSeries(api, s, R.b, x0 + pad, py, W - 2 * pad, ph, lo, hi, [u(2.5), u(2.5)], u(1));    drawSeries(api, s, R.a, x0 + pad, py, W - 2 * pad, ph, lo, hi, undefined, u(1.5));  });}/** 上から見た 4 つのプロペラ: 円の周りの矢印が回る向き、棒が推力(印はホバリングの推力)、真ん中の矢印が反トルクの合計 */function drawRotors(api: Api, s: S) {  const u = api.u, d = s.d, P = rotorPanel(api), fs = Math.max(u(10.5), 9.5), { cx, cy, k, rd } = P;  api.rect(P.x0, P.y0, P.W, P.H, { fill: "paper", stroke: "ink", w: u(1) });  api.text("上から見たプロペラ", P.x0 + u(7), P.y0 + u(15), { size: fs });  // 胴体と前の印  api.poly([[cx - u(8), cy + u(16)], [cx + u(8), cy + u(16)], [cx + u(8), cy - u(16)], [cx, cy - u(24)], [cx - u(8), cy - u(16)]], { fill: "paper", stroke: "ink", w: u(1.2), close: true });  api.text("前", cx, cy - u(8), { size: fs * 0.9, align: "center" });  ROTORS.forEach((r, i) => {    const x = cx + r.z * k, y = cy - r.x * k, stop = d.stopped[i];    api.line(cx, cy, x, y, { w: u(2.4) });    api.circle(x, y, rd, { fill: "paper", stroke: "ink", w: u(1) });    // 回る向きの矢印(左回り = 画面の上で角度が減る向き)    if (!stop) {      const R = rd * 0.86, arc: Pt[] = [], n = 22, a0 = -2.2, span = 1.5 * Math.PI, dir = -r.s;      for (let j = 0; j <= n; j++) { const a = a0 + dir * (j / n) * span; arc.push([x + R * Math.cos(a), y + R * Math.sin(a)]); }      api.poly(arc, { w: u(1.4) });      const ae = a0 + dir * span, hx = x + R * Math.cos(ae), hy = y + R * Math.sin(ae), tx = -Math.sin(ae) * dir, ty = Math.cos(ae) * dir, H = u(6);      api.poly([[hx + tx * H, hy + ty * H], [hx - ty * H * 0.55, hy + tx * H * 0.55], [hx + ty * H * 0.55, hy - tx * H * 0.55]], { fill: "ink", stroke: false, close: true });    }    // 推力の棒    const bw = u(9), bh = rd * 1.1, bx = x - bw / 2, by = y - bh / 2 + u(1), fill = clamp(d.thr[i] / T_MAX, 0, 1) * bh, hover = ((d.m * G) / 4 / T_MAX) * bh;    api.rect(bx, by, bw, bh, { fill: "paper", stroke: "ink", w: u(1) });    if (fill > 0.3) api.rect(bx, by + bh - fill, bw, fill, { fill: "ink", stroke: false });    if (hover < bh) api.line(bx - u(3), by + bh - hover, bx + bw + u(3), by + bh - hover, { w: u(1.2) });    // 数字は機体の外側へ置く    api.label(stop ? "停止" : fmt(d.thr[i], 1), x + Math.sign(r.z) * rd * 0.62, y - Math.sign(r.x) * rd * 0.88 + (r.x > 0 ? fs * 0.35 : fs * 0.5), { size: fs, font: stop ? "sans" : "mono", align: "center", pad: u(1.5) });    if (stop) { const q = rd * 0.7; api.line(x - q, y - q, x + q, y + q, { w: u(2) }); api.line(x - q, y + q, x + q, y - q, { w: u(2) }); }  });  // 反トルクの合計(+ が左回り)  let ty = 0;  for (let i = 0; i < 4; i++) ty += -ROTORS[i].s * KAPPA * d.thr[i];  const m = Math.abs(ty);  if (m > 0.002) {    const R = u(21), n = 16, dir = ty > 0 ? -1 : 1, span = clamp(m / 0.06, 0.25, 1) * Math.PI * 1.1, a0 = Math.PI / 2 - (dir * span) / 2, arc: Pt[] = [];    for (let j = 0; j <= n; j++) { const a = a0 + dir * (j / n) * span; arc.push([cx + R * Math.cos(a), cy + R * Math.sin(a)]); }    api.poly(arc, { w: u(2.2) });    const ae = a0 + dir * span, hx = cx + R * Math.cos(ae), hy = cy + R * Math.sin(ae), tx = -Math.sin(ae) * dir, ty2 = Math.cos(ae) * dir, H = u(7);    api.poly([[hx + tx * H, hy + ty2 * H], [hx - ty2 * H * 0.55, hy + tx * H * 0.55], [hx + ty2 * H * 0.55, hy - tx * H * 0.55]], { fill: "ink", stroke: false, close: true });  }  api.text(`反トルクの合計 ${fmt(m, 2)} N·m`, cx, P.y0 + P.H - u(14), { size: fs * 0.92, align: "center" });  api.text("タップで止める", cx, P.y0 + P.H - u(3), { size: fs * 0.86, align: "center" });}function drawSticks(api: Api, s: S) {  const u = api.u, g = stickGeom(api), fs = Math.max(u(10.5), 9.5), st = s.d.stick;  const one = (c: { x: number; y: number }, dx: number, dy: number, label: string) => {    api.circle(c.x, c.y, g.r, { fill: "paper", stroke: "ink", w: u(1.4) });    api.line(c.x - g.r * 0.7, c.y, c.x + g.r * 0.7, c.y, { w: u(0.7) }); api.line(c.x, c.y - g.r * 0.7, c.x, c.y + g.r * 0.7, { w: u(0.7) });    api.circle(c.x + dx * g.r * 0.62, c.y - dy * g.r * 0.62, g.r * 0.34, { fill: "ink", stroke: false });    api.label(label, c.x, c.y - g.r - u(6), { size: fs, align: "center", pad: u(2) });  };  one(g.l, -st.lx, st.ly, "上下と向き");  one(g.r_, st.rx, st.ry, "前後と左右");  const btn = (i: number, t: string, on: boolean) => {    const [x, y, w, h] = btnRect(api, i);    api.rect(x, y, w, h, { fill: on ? "ink" : "paper", stroke: "ink", w: u(1.2) });    api.text(t, x + w / 2, y + h / 2 + fs * 0.36, { size: fs, align: "center", color: on ? "paper" : "ink" });  };  btn(0, "突風", s.d.t - s.d.gustT < s.d.gustLen);  btn(1, "追う", s.follow);}const _vp = new THREE.Matrix4();/** 飛んだ跡: 3D の点を自分で 2D に写して、太さのある線で描く */function drawTrail(api: Api, s: S) {  const cam = s.v.camera;  _vp.multiplyMatrices(cam.projectionMatrix, cam.matrixWorldInverse);  const e = _vp.elements, T = s.trail, n = Math.min(s.trailN, N_TRAIL), start = s.trailN >= N_TRAIL ? s.trailN % N_TRAIL : 0, P = s.trailP;  let c = 0;  const flush = () => { if (c >= 4) api.poly(P.subarray(0, c), { w: api.u(1.6) }); c = 0; };  for (let k = 0; k < n; k++) {    const a = ((start + k) % N_TRAIL) * 3, x = T[a], y = T[a + 1], z = T[a + 2], w = e[3] * x + e[7] * y + e[11] * z + e[15];    if (w < 0.2) { flush(); continue; }    P[c++] = (((e[0] * x + e[4] * y + e[8] * z + e[12]) / w + 1) / 2) * api.w; P[c++] = ((1 - (e[1] * x + e[5] * y + e[9] * z + e[13]) / w) / 2) * api.h;  }  flush();}const N_TRAIL = 800;function overlay(api: Api, s: S) {  const d = s.d, u = api.u, fs = Math.max(u(11), 10);  drawTrail(api, s);  if (d.mode === "pos" && !d.dead) {    const dist = Math.hypot(d.target.x - d.p.x, d.target.y - d.p.y, d.target.z - d.p.z);    if (dist > 0.6) { const q = s.v.project(d.target); if (q.on) api.label(`目標 ${fmt(dist, dist < 10 ? 1 : 0)} m`, q.x + u(8), q.y - u(8), { size: fs, pad: u(2) }); }  }  if (d.dead) { const q = s.v.project(d.p); if (q.on) api.label("墜落 タップで飛び直す", q.x, q.y - u(26), { size: fs, align: "center" }); }  if (chartsFit(api)) drawCharts(api, s);  drawRotors(api, s);  drawSticks(api, s);  const tilt = Math.acos(clamp(d.up.y, -1, 1)) / DEG, sp = d.v.length(), sum = d.thr[0] + d.thr[1] + d.thr[2] + d.thr[3], rpm = ((d.om[0] + d.om[1] + d.om[2] + d.om[3]) / (d.stopped.some(Boolean) ? 3 : 4)) * 60 / (2 * Math.PI);  let geSum = 0, geN = 0;  for (let i = 0; i < 4; i++) if (!d.stopped[i]) { geSum += d.ge[i]; geN++; }  const ge = geN ? geSum / geN : 1, zr = (d.p.y + d.up.y * H_ROTOR) / R_P;  api.readout([    `高さ  ${fmt(d.p.y, 2)} m`,    `速さ  ${fmt(sp, 1)} m/s(${fmt(sp * 3.6, 0)} km/h)`,    `傾き  ${fmt(tilt, 1)}°`,    `推力の合計  ${fmt(sum, 1)} N(重さ ${fmt(d.m * G, 1)} N)`,    d.spinning ? `機体の回転  ${fmt(Math.abs(d.w.y) / (2 * Math.PI), 1)} 回/秒` : ge > 1.002 && rpm > 100 ? `地面効果  +${fmt((ge - 1) * 100, ge < 1.01 ? 2 : 1)}%(半径の ${fmt(zr, 2)} 倍)・${fmt(rpm, 0)} rpm` : `回転数の平均  ${fmt(rpm, 0)} rpm`,  ]);}

プロペラの図は機体の上から見た形で、棒の高さが推力、横の印がホバリングに要る推力を表す。飛んだ跡は、3Dの点を自分で2Dに写して太さのある線で描いている。three.jsの線は1画素の細さになるからである。

画面の部品(描画・場面の切り替え・画質の自動調整・GPU の網点)は全記事で共通です。シミュレーションの共通の仕組みで解説しています。

プログラム全体を見る(src/sims/drone-flight.ts・934 行)

読み込んでいます

この記事は、人が監修しながらAIを活用して執筆しています。執筆・監修: 佐々木 航(株式会社Game)

姉妹サイト

株式会社Game が運営するメディアです。