クォータニオンからオイラー角を求めてみる

概要

Unityは基本的に回転をクォータニオンで管理しています。
その他のエンジンも、おそらくクォータニオンで回転を制御していると思います。
というのも、オイラー角では「ジンバルロック」などの問題があるため、制御するのに不安定さがあるためです。

しかし、場合によってはオイラー角で値を求めたい場合があります。

Unityであれば、クォータニオンから簡単にオイラー角を取得することができますが、では内部ではどういう処理が行われているのか。
それを色々な記事を参考にまとめてみたいと思います。

クォータニオンから回転行列を取り出す

まずはクォータニオンから回転行列を取り出します。
回転行列の要素から、オイラー角を推測するためです。

まるぺけさんのこちらの記事(その58 やっぱり欲しい回転行列⇔クォータニオン相互変換)を参考にさせてもらうと、クォータニオンの各成分から回転行列を求めることができるようです。

任意軸の回転行列

まず、任意軸の回転行列は以下のように構成されます。
nは回転の軸ベクトルの各成分です。

|nx2(1cosθ)+cosθnxny(1cosθ)+nzsinθnxnz(1cosθ)nysinθ0nxny(1cosθ)+nzsinθny2(1cosθ)+cosθnynz(1cosθ)nxsinθ0nxnz(1cosθ)+nysinθnynz(1cosθ)nxsinθnz2(1cosθ)+cosθ00001|

クォータニオンの成分

クォータニオンの各成分は、回転軸ベクトルNと角度θを用いて以下のように構成されます。

x=nxsin(θ2)y=nysin(θ2)z=nzsin(θ2)w=cos(θ2)

回転行列の各要素を計算する

行列の位置を明確化するために、行列を以下のようにナンバリングしておきます。

|m00m01m02m03m10m11m12m13m20m21m22m23m30m31m32m33|

※ m03, m13, m23, m30, m31, m32はすべて0、m33は1です。

さて、ではまずはじめに、m00の値(nx2(1cosθ)+cosθ)を見てみましょう。
これを、クォータニオンの成分から表すと12y22z2となるようです。

一瞬、「なんでやねん」と思うかもしれませんが(自分は思いました)、倍角の公式などを使って展開してやるとしっかりとその値が出現します。

ちなみに三角関数の公式などは前にまとめた記事があるのでそちらも参照してみてください。([数学] 三角関数について整理する

三角関数の公式を使って導き出す

倍角の公式は以下になります。

sin(α±β)=sin(α)cos(β)±cos(α)sin(β)cos(α±β)=cos(α)cos(β)±sin(α)sin(β)

試しにやってみましょう。

12y22z2

まずは2y2から展開してみましょう。

y=nysin(θ2)

なので、2乗すると、

2y2=2(nysin(θ2))(nysin(θ2))=2ny2sin2(θ2)

となります。
さて、ここで「cos(θ)=12sin2(θ2)」を使います。
上記にこれを代入すると、

cosθ=12sin2(θ2)2sin2(θ2)=cos(θ)1ny22sin2(θ2)=ny2(cos(θ)1)

となります。

同様に、2z2を展開して以下を得ます。

ny2(cosθ1)nz2(cosθ1)

さてここで、各n成分は正規化したベクトルなので以下が成り立ちます。

nx2+ny2+nz2=1...1nx2+ny2+nz22=12nx2+ny2+nz2=1

つまり、

nz2=1nx2ny2

求めたい値は12y22z2です。

現状の状態で整理すると以下のようになります。

1+(ny2(cosθ1))+(nz2(cosθ1))

これに「nz2=1nx2ny2」を代入すると、

1+(ny2(cosθ1))+((1nx2ny2)(cosθ1))=1+(1nx2ny2+ny2)(cosθ1)=1+(1nx2)(cosθ1)

ゴールが見えてきました。
これを展開すると、

1+(cosθ1nx2cosθ+nx2)1+(nx2(1cosθ)+cosθ1)nx2(1cosθ)+cosθ

最初に示したm00の値が出てきました! なんともすばらしいですね。

あとは、他の要素もこうして算出していくと、最終的に以下のようになります。

|12y22z22xy+2wz2xz2wy02xy2wz12x22z22yz+2wx02xz+2wy2yz2wx12x22y200001|

回転行列からオイラー角を求める

回転行列からオイラー角を求める部分についてはこちらの記事(回転行列からオイラー角のパラメータ抽出を行う | It_lives_vainlyの日記)を参考にさせてもらいました。

そして、Unityでの回転行列の作り方(各軸の回転行列のかける順番)は、上記記事と同じくZXYです。
ドキュメント→ Unity DOCUMENT | Transform.eulerAngles

実際に行列をそれぞれ合成していくと以下のようになります。
※ CnはCos、SnはSinを表し、それぞれの添字はどの回転行列のものか、を示しています。

-----------------------------------------------------------------
【z x y合成(Unityの場合)】

Z:         |  X:          |  Y:        
---------- |  ----------  |  ----------
Cz -Sz  0  |  1   0   0   |  Cy  0  Sy 
Sz  Cz  0  |  0  Cx -Sx   |   0  1   0 
 0   0  1  |  0  Sx  Cx   | -Sy  0  Cy 

ZX:
----------------
Cz  -SzCx  SzSx
Sz   CzCx -CzSx
 0     Sx    Cx

ZXY:
----------------
 CzCy-SzSxSy  -SzCx  CzSy+SzSxCy
 SzCy+CzSxSy   CzCx  SzSy-CzSxCy
-CxSy            Sx         CxCy

さて、ここでm21を見るとSin(x)となっています。
ここから、

m21=sin(x)θx=Asin(m21)

が導き出されます。
次に、

tan=sincos

を利用して、

SzCz=m01m11=tan(x)θz=Atan2(m01,m11)

として、θzが求まります。

同様にして、

SyCy=m20m22=tan(x)θy=Atan2(m20,m22)

と、θyが求まります。

これで無事、各値が求まった・・と思いきや、特殊なケースが存在します。
それが、cos(θx)=0のときです。

cos0の場合、そう、θxπ2π2のときですね。
Cx0の場合、すべての値0になってしまって答えを求めることができなくなってしまいます。

そこで、その特殊なケースを場合分けして以下のように考えます。
まず、cos(θx)=0のとき、回転行列がどうなっているかを見てましょう。

|CzCy±SzSy0CzSy±SzCySzCy±CzSy0SzSy±CzCy0±10|

Cxを使っている部分がすべて0となり、Sxの部分が±1になります。

※ cos(θx) = 0のとき、sin(θx) = ±1 ... θx = 90° or 270°

さて、ここで三角関数の加法定理を持ち出すと、

sin(α±β)=sin(α)cos(β)±cos(α)sin(β)cos(α±β)=cos(α)cos(β)±sin(α)sin(β)

これを使って上の行列は以下のように整理することができます。

|cos(θz±θy)0sin(θz±θy)sin(θz±θy)0cos(θz±θy)0±10|

なんともきれいに加法定理の形になってますねw
さて、これだけでは加減算された形での値しか求まっていないので、一意に角度を求めることができません。

そこで、適当にθy=0と置くと、

m10m00=sin(θz)cos(θz)=tan(θz)

となります。つまり、

θz=Atan2(m10,m00)

ですね。

問題点として、適当にθy=0とおいているため、回転を表すオイラー角としては正しい姿勢になるものの、もともと設定したyzの値が復元できないことが分かります。

とはいえ、θx=90°or270°という限定された状態のみなので、だいたいの場合は問題にならないでしょう。

つまり、m21=1の場合はθx=π2=90°m21=1の場合はθx=π2=270°となります。
この状態のときだけ条件分岐してやればいいわけですね。

ここまでをまとめると、

sin(θx)=±1以外のとき、

θx=Asin(m21)θy=Atan2(m20,m22)θz=Atan2(m01,m11)

sin(θx)=1のとき(m21=1のとき)、

θx=π2θy=0θz=Atan2(m10,m00)

sin(θx)=1のとき(m21=1のとき)、

θx=π2θy=0θz=Atan2(m10,m00)

となります。

Unityで実行してみる

これを元に、実際にUnity上で実行してみました。
実行したコードは以下になります。

private Vector3 Calc()
{
    Quaternion r = transform.rotation;
    float x = r.x;
    float y = r.y;
    float z = r.z;
    float w = r.w;

    float x2 = x * x;
    float y2 = y * y;
    float z2 = z * z;

    float xy = x * y;
    float xz = x * z;
    float yz = y * z;
    float wx = w * x;
    float wy = w * y;
    float wz = w * z;

    // 1 - 2y^2 - 2z^2
    float m00 = 1f - (2f * y2) - (2f * z2);

    // 2xy + 2wz
    float m01 = (2f * xy) + (2f * wz);

    // 2xy - 2wz
    float m10 = (2f * xy) - (2f * wz);

    // 1 - 2x^2 - 2z^2
    float m11 = 1f - (2f * x2) - (2f * z2);

    // 2xz + 2wy
    float m20 = (2f * xz) + (2f * wy);

    // 2yz+2wx
    float m21 = (2f * yz) - (2f * wx);

    // 1 - 2x^2 - 2y^2
    float m22 = 1f - (2f * x2) - (2f * y2);


    float tx, ty, tz;

    if (Mathf.Approximately(m21, 1f))
    {
        tx = Mathf.PI / 2f;
        ty = 0;
        tz = Mathf.Atan2(m10, m00);
    }
    else if (Mathf.Approximately(m21, -1f))
    {
        tx = -Mathf.PI / 2f;
        ty = 0;
        tz = Mathf.Atan2(m10, m00);
    }
    else
    {
        tx = Mathf.Asin(-m21);
        ty = Mathf.Atan2(m20, m22);
        tz = Mathf.Atan2(m01, m11);
    }

    tx *= Mathf.Rad2Deg;
    ty *= Mathf.Rad2Deg;
    tz *= Mathf.Rad2Deg;

    return new Vector3(tx, ty, tz);
}

これを実行すると無事、同じ回転を設定することができました。
が、実はよーく見てもらうと上で求めた式とは若干違う部分があります。

tx = Mathf.Asin(-m21);
ty = Mathf.Atan2(m20, m22);
tz = Mathf.Atan2(m01, m11);

引数に与えている成分のマイナス記号が逆になっているんですね。
そして、なぜここが逆転するのかは分かりませんでした・・。
ただ、マイナスを逆転したら正常に回転がコピーできました。

そもそも、UnityのAPIである`Quatarnion.eulerAngles`の値はさらに違った値になっていました。(360°から該当の角度を減算したものになっていた)

このあたりがもしかしたら逆転している理由かもしれません。
が、値としては想定した値になっていたので、ひとまずはOKとしました・・。
もしなにかご存知の人いたら教えてください(´・ω・`)

これについてはコメントで指摘をもらって、以下に追記しました。

[追記]
コメントで指摘してもらいましたが、そもそも参考にした記事が、座標変換のための回転行列と、位置ベクトルの回転行列による話で、そもそも対象としているのが違うためでは、ということでした。
「教えて!goo」に似た質問とそれに対する回答があったので追記しておきます。

https://oshiete.goo.ne.jp/qa/8764676.html

見てもらうと分かりますが、まさに符号だけが逆転しているのが分かります。

その他メモ

最初間違えてUnityのつもりで違う順番で行列を計算しちゃったけどメモとして残しておきますw

-----------------------------------------------------------------
【z y x合成】

Z:          |  Y:          |  X:
----------  |  ----------  |  ----------
Cz -Sz  0   |  Cy  0  Sy   |  1   0   0
Sz  Cz  0   |   0  1   0   |  0  Cx -Sx
 0   0  1   | -Sy  0  Cy   |  0  Sx  Cx

ZY:
-----------------
CzCy  -Sz  CzSy
SzCy   Cz  SzSy
-Sy     0    Cy

ZYX:
-------------------------------
CzCy  -SzCx+CzSySx   SzSx+CzSyCx
SzCy   CzCx+SzSySx  -CzSx+SzSyCx
 -Sy          CySx          CyCx
215contribution

以前Unityと全然関係ないところで同様のことやりましたが、
その時のコード見ると確かに符号がこの記事と逆、例えばroll角がAsin(-m21)になってました。

詳細を覚えていないのでここからまるっきりの勘ですが、
回転移動と座標変換では回転行列の一部符号が変わるのでそのあたりでは?

12983contribution

@ozwk コメントありがとうございます!
他でも同様になったんですね・・。となると確かにそうした理由はあるかしれませんね。
ちなみに座標変換は、ビュー座標変換あたりのことを指してますか?(ちょっとどのことを言っているのかピンとこず( ;´Д`))

215contribution

座標系自体が回転する変換ですね。
「その58 やっぱり欲しい回転行列⇔クォータニオン相互変換」に出てくる回転は座標系の回転で、
「回転行列からオイラー角のパラメータ抽出を行う | It_lives_vainlyの日記」に出てくる回転は位置ベクトルの回転です。

12983contribution

なるほど!
確かにそれは関係ありそうですね。
時間があったら調べて追記してみようと思います!
ありがとうございます!

12983contribution

このへんがちょうどまさに同じことを回答してますね。
https://oshiete.goo.ne.jp/qa/8764676.html

例えば x, y 座標系で (rcosα, rsinα)の点を反時計回りに角度β
回転させると、角度の加法定理から

(rcos(α+β), rsin(α+β)) = (r(cosαcosβ-sinαsinβ), r(sinαcosβ+sinαcosβ))

元の座標位置を (a, b) = (rcosα, rsinα) とすれば

(r(cosαcosβ-sinαsinβ), r(sinαcosβ+sinαcosβ)) = cosβa - sinβb, sinβa + cosβb

これがいわゆる回転変換。

座標軸の回転では XY座標の左回転に対して、点は右に回転するから、角度の符号を反転すればよい

(cos(-β)a - sin(-β)b, sin(-β)a + cos(-β)b) = (cosβa + sinβb, -sinβa + cosβb)

これが回転の座標変換の式です。角度の向きが逆なだけです。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.