[JavaScript] Quaternion 入門 #02 : なぜオイラー角は壊れるのか

はじめに

「Quaternion 入門 #02」の今回は、オイラー角について。

前回の記事は以下。

ほぼ自分用の学習記事ですが、全6回を予定してます。

1. オイラー角は「3回回す命令」だから順序が命

オイラー角は (x, y, z) の3つの数字を持っているから、 まるで「3つの角度」みたいに見える。

でも実体は、

X軸で回す → Y軸で回す → Z軸で回す

という 3回連続で回転させる命令セット。

そして、この「どの順番で回すか」がヤバい。

同じ (x, y, z) でも、

  • XYZ
  • ZYX
  • YXZ

…順番が違うだけで、まったく別の姿勢になる。

ここが オイラー角の最大の地雷。


Three.jsの最小デモ(順序だけで姿勢が変わる)

import * as THREE from "three";

const e1 = new THREE.Euler(0.3, 0.7, 1.0, "XYZ");
const e2 = new THREE.Euler(0.3, 0.7, 1.0, "ZYX");

const v = new THREE.Vector3(1, 0, 0);

const v1 = v.clone().applyEuler(e1);
const v2 = v.clone().applyEuler(e2);

console.log("XYZ:", v1.toArray());
console.log("ZYX:", v2.toArray());

ここで大事なのは、

  • “XYZ” が正しい
  • “ZYX” が正しい

そういう話ではまったくない。

言いたいのはただひとつ。

“同じ値でも、順番が違えば姿勢が変わる”

それがオイラー角の本質。

2. 「回転順序」って結局なに

ここが一番読者がつまずく場所。

「角度が3つあるのに、なんで順番なんてあるの?」 「なんで“Zから回す”とか言い出すの?」

この疑問は完全に正しい。 ただ、答えは激しくシンプルだ。


3D回転は「一回回した後の軸」が変わる

たとえば、最初は

  • X軸
  • Y軸
  • Z軸

がきれいに直交して存在する。

でも、X軸で一回回すと、Y軸とZ軸の方向は変わる。

X回転(1回目)
Y軸とZ軸の向きがもう最初とは違う

2回目・3回目の回転は、もはや“元の軸”では回ってない

読者がよく誤解するのが、

pitch → yaw → roll この3つの回転は、それぞれ独立している

という認識。

実際は違う。

1回目の回転で軸がひしゃげるので、 2回目・3回目の回転は 「ねじれた新しい軸」で回される。

つまり、

  • 順序が違う
  • 回す軸が違う
  • 結果が違う

という、ごく当たり前の話に落ちる。


角度は「状態」ではなく「命令」

オイラー角 (x, y, z) を「姿勢そのもの」だと思うと必ず破綻する。

実際は、

Xで回せ → 次はYで回せ → 最後にZで回せ (順序も込みの命令セット)

にすぎない。

だから、同じ (0.3, 0.7, 1.0) でも、 順序が違うだけで全然違う角度になる。

3. ジンバルロックは“事故”じゃなくて「自由度が潰れる現象」

ジンバルロックという言葉は、 どうしても「不具合」「バグ」「想定外の事故」みたいに聞こえる。

でも実際に起きていることは、驚くほど単純。

  • もともと 3軸(X, Y, Z)で姿勢を表現していた
  • ある姿勢になると 2つの軸が同じ向きを向く
  • 結果として 実質2軸しか残らなくなる
  • つまり 自由度が1つ消える

これだけ。

起きるべくして起きる現象であって、 偶然でもミスでもない。


なぜ「自由度が潰れる」のか

オイラー角は「回転を順番に適用する仕組み」だった。

その結果、

  • 特定の角度(典型的には ±90度付近)で
  • 回転軸が重なり
  • 「別の回転」のはずだった操作が、同じ方向の回転になる

こうなると、

  • yaw を回しても
  • roll を回しても

見た目がほぼ同じ回転になる。

これは「入力が効いていない」のではなく、 効く自由度が物理的に存在しなくなっている。


Three.jsで「自由度が潰れる」雰囲気を見るデモ

FPSカメラなどでよく使われる "YXZ" 順序を例にする。

import * as THREE from "three";

const e = new THREE.Euler(0, 0, 0, "YXZ"); // FPSでよく使う順序
const obj = new THREE.Object3D();

function setAngles(yaw, pitch, roll) {
  e.set(pitch, yaw, roll); // order = YXZ の意味で適用される
  obj.rotation.copy(e);
}

setAngles(0.0, Math.PI / 2, 0.0); // pitch = 90deg
// ここから yaw と roll をいじると挙動が「同じ軸を回している感」になる
setAngles(0.5, Math.PI / 2, 0.0);

console.log(obj.rotation.toArray());

コード自体は壊れていない。 Three.js も正しく動いている。

壊れているのは「3軸で制御できる」という前提。


図で説明した方が早い理由

この現象は、コードより図の方が圧倒的に伝わる。

  • 3つの輪っか(ジンバル)
  • 90度回した瞬間
  • 2つの輪っかが重なる

「90度で輪っかが重なる」 このイメージだけで十分。

数式も理屈も要らない。


ここまで来ると、読者の頭にはこう残る。

  • ジンバルロックは事故じゃない
  • オイラー角の構造上、避けられない
  • 「角度を持っている」設計自体が危うい

次の流れとしては自然にこう繋がる。

「じゃあ、角度を足して運用するのは何がまずいのか?」

4. 「角度を足す設計」の限界:角度はベクトルじゃない

ここが オイラー角を実務で使うときの最大の落とし穴。

よくある実装はこうだ。

yaw   += inputX;
pitch += inputY;
roll  += something;

見た目は完全に正しい。 でも実際には、ここで全てが壊れ始める。


そもそも「回転は足し算で合成できる量ではない」

角度を (x, y, z) という数字で持っているから、 つい「3次元ベクトルみたいに足せる」と錯覚する。

しかし、回転はベクトルではない。

回転は本質的に 作用(オペレーション) であって、 「位置」や「量」ではない。

だから、

  • 足し算で合成できない
  • 順序に依存する
  • 状態として安定しない

という性質が必ず出てくる。


足した結果の意味が「順序(order)」に依存する

Euler角の (pitch, yaw, roll) は、

  • どの順番で回すか
  • 途中で軸がどうねじれるか

を含めて初めて“意味”が決まる。

同じ数値でも、

  • XYZ
  • YXZ
  • ZYX

これだけで全く違う姿勢になる。

つまり、足し算の結果は “値” ではなく “命令セット” になっている。


足すたびに「次に回る軸」が変わっていく

1ステップごとに軸がズレるため、

yaw += 0.1;

この一行の意味が、 毎フレーム違う“軸”の回転として適用される可能性がある。

見た目は滑らかでも、内部では地獄。

長い時間で見れば、 本来の向きとは別の方向にどんどん流れていく。


姿勢によっては自由度が潰れ、入力が別の回転に化ける

第3回のジンバルロックで触れたように、 自由度が1つ消える瞬間がある。

その状態で角度を足すと:

  • yaw のつもりが roll になる
  • roll のつもりが yaw になる
  • pitch入力が「効いてない」ように見える

つまり、入力が違う意味に変換される。

これはバグじゃない。 Euler角の仕様。


結論:Euler角は「UI用」には便利だが「内部状態」には向かない

ここが一番重要なメッセージ。

Eulerは「UI表示」や「デバッグ表示」としては便利。 でも、内部の状態として保持すると破綻しやすい。

  • いつ破綻するか予測が難しい
  • 視点やキャラ制御で特に壊れやすい
  • 入力を蓄積すると破綻が加速する

だから、実務では

  • Eulerで入力を受け取る
  • 内部ではEulerを使わない

という設計が多い。

(Quaternionの話は次回で良い。ここではまだ出さない。)

5. ここまでの結論(次回への導線)

ここまでで、オイラー角が「なんとなく不安定」ではなく 構造的に壊れる理由がはっきりした。

まとめると、こういう話だ。

  • Euler が壊れるのは仕様であって事故ではない
  • 回転順序(order)が “結果の意味” を決めてしまう
  • ある姿勢で自由度が潰れるのは避けられない構造
  • 角度を足す設計は、短期は動いても長期で必ず破綻する

つまり、 「Euler角で姿勢を蓄積する」こと自体が地雷。

ここまで読んだ人は必ずこう思うはずだ。

「じゃあ、実際どんな実装がダメなんだ?」

次回はそこに答える。

[JavaScript] Quaternion 入門 #03 “やってはいけない実装集”

  • 足して壊れる
  • 順序を間違えて壊れる
  • lerp して壊れる
  • 正規化しないで壊れる

実際に破綻するコードだけを並べる回になる。

付録:記事の“読みやすさ”を上げる小技

この記事は、理解させるより 詰まらせない ことを優先している。 そのために、意図的にやっている工夫をいくつか補足しておく。


回転順序は「理屈」より先に「結果」を見せる

「回転順序とは何か」を言葉で説明し始めると、 多くの読者はその時点で置いていかれる。

だから最初に出すのは理論じゃない。

“同じ角度でも結果が変わるログ”。

  • 値は同じ
  • order だけ違う
  • 出力が違う

この事実を先に見せるだけで、

「あ、これはそういう性質のものなんだ」

という理解が一気に進む。


ジンバルロックは「自由度が1個消える」だけで十分

ジンバルロックを説明するときに、

  • 数式
  • 回転行列
  • 微分

は一切いらない。

言うべきことはこれだけ。

「3軸で表現していたのに、ある姿勢で2軸になる」

自由度が1個消える。 それ以上でもそれ以下でもない。

ここが伝われば、 「なぜ入力が効かなくなるのか」は自然に腑に落ちる。