クォータニオンを使った、トラックボール風カメラ制御

画面写真をクリックするとサンプルのWebGLビルドに飛びます。

こんにちは。技術部平山です。

今回は、トラックボール 状のものを操作してカメラを動かす機能を作ってみました。 上のサンプルを触ってみてください。 右上の球をトラックボールだと思って回してみれば、 だいたいそんな感じにカメラが動きます。 また、ドラッグしながらマウスボタンを離す(タッチなら指を離す)と、 慣性でグルグル周ります。

実用性には疑問がありますが、 これはクォータニオンを無理矢理応用してみた結果です。 せっかくなので紹介いたします。

動機

クォータニオンはUnityの基幹的な部分でも使われていますが、 直接触ることはあまりありません。 しかし、クォータニオンがあると楽にやれることや、 クォータニオンでないとできないこともあります。

そういうわけで、クォータニオンを布教したくてたまらないのですが、 そもそも私がクォータニオンをよくわかってない という重大な問題があります。 そこで、数学はともかくとして、 「クォータニオンを使ったらこういうのが作れた」という例を出して、 誰かが興味を持ってくれるのを待とう、と思ったわけです。

さて、問題は何にクォータニオンを使うかです。

最近たまたま計算でメッシュを作ることがありまして、 それをいろんな角度から見たくなりました。 Sceneビューなら自由にカメラを回せるのですが、 Gameビューで見たいこともありますし、 ビルドで見たいこともあります。 デバグ用のカメラを実機で使えるようにすることも多いですよね。 今回はそれにクォータニオンを使ってみることにしました。

今回のソースコードは別の用途で作ったサンプル の中にあります。BallCameraController.csがそれです。

回転をTransformに反映させる所

まず、現在の回転状態をクォータニオンで保持します。 クォータニオンは回転行列やオイラー角と同じく、 3軸の回転状態を保持できるデータです。 東西南北のどの方角を向いて、 上下にどれくらい首を動かして、 左右にどれくらい傾いているか、 という3軸ですね。 よくこの「3軸の回転状態」のことを「姿勢」と 呼んだりします。短いのでこの記事でもこの言葉を使いましょう。

このクォータニオンに、画面右上のボールの操作を反映させて、 姿勢を回していきます。ここの詳細は後にしましょう。

そして、毎フレーム姿勢からカメラ位置と、 カメラの向きを生成し、 カメラのTransformにセットします。

カメラの位置は、 (0,0,-1)をクォータニオンで回して、指定の距離(サンプルでは3) を掛けたベクトルを、 設定した中心座標(サンプルでは(0,0,0))に 加えることで得られます。

f:id:hirasho0:20190902192911p:plain

なお、(0,0,-1)である必要はありません。 クォータニオンの回転の基準方向をどこに取るかは自由です。 しかしUnityに合わせないと不便なので、 ここではUnityに合わせて(0,0,-1)にしてあります。 クォータニオンがデフォルト値の時に、カメラが+Z方向を向く、 という設定です。

コードはこんな感じです。

var v = orientation * new Vector3(0f, 0f, -1f);
attachedCamera.transform.position = o + (v * distance);
attachedCamera.transform.rotation = orientation;

orientationはクォータニオンで、 これに(0,0,-1)を「作用させる」と、回ったvが出てきます。 「作用させる」の詳細は今回は触れませんが、

Vector3 v = orientation * new Vector3(0f, 0f, -1f);

というようにQuaternionクラスの乗算演算子でやれます。 一見掛け算に見えますが、クォータニオンの掛け算は別にあり、 これは掛け算ではないことに注意が必要です。

さて、このvに、指定した距離distanceを掛けて、 カメラが動く際の中心座標であるoを足せば、 カメラの位置が出来ます。

そして、カメラのTransform.rotationには orientationをそのままつっこみます。 これで、クォータニオンが カメラに反映されるようになりました。 次はクォータニオンそのものをどう計算するかを見ましょう。

角速度を積分する

毎フレーム小さな回転を作って、 その回転でクォータニオンの値を微妙に変えて行くわけですが、 これはつまり積分です。 ここでは理屈はすっ飛ばして、結果だけ使いましょう。

orientation += orientation * (angularVelocity * (0.5 * dt))

一番簡単な、オイラー法による積分です。 数学めいた記法を使わずコードっぽく書きましたが、 これはUnityでは動きません。

  • Quaternionクラスに+演算子がない
  • Quaternion * Vector3は先程の「作用させる」になり、「掛け算」にならない。

といった理由によります。

さて、dtは要するにTime.deltaTimeで、 フレームレートが高いほど小さくなって、より細かな単位で更新します。

angularVelocityは角速度で、オイラー角と同様Vector3です。 「1秒あたりのxyz角度変化」を意味します。

つまりこの式は、 Time.deltaTimeの半分を角速度に掛けて、 現在の姿勢を表すクォータニオンで回し、 できたものを、現在のクォータニオンに足す、 ということを意味しています。

では、実際に動くコードにしてみましょう。

static Quaternion UpdateOrientation(Quaternion q, Vector3 w, float dt)
{
    var t = w * (0.5f * dt);
    var dq = q * new Quaternion(t.x, t.y, t.z, 0f);
    q = new Quaternion(
        q.x + dq.x,
        q.y + dq.y,
        q.z + dq.z,
        q.w + dq.w);
    q.Normalize();
    return q;
}

qが姿勢(先程までのorientation)で、wが角速度(先程までのangularVelocity)です。

1行目で、角速度に0.5*dtを掛けてtに入れています。 そして、ここからQuaternionのコンストラクタを使ってクォータニオンに変換し、 クォータニオン同士の乗算を行います。これで先程の式の右辺ができました。 最後に、加算演算子がないので、コンストラクタに要素ごとに加算したものを渡して 加算します。ちょっと面倒ですが方法がみつかりませんでした。

なお、最後のNormalize()は、加算によって大きさが狂うのを補正するためのもので、 ないとだんだん大きさが狂ってきます。

さて、これで、角速度があればクォータニオンを更新できる、 という状態になりました。角速度を作りましょう。

入力から角速度を作る

今回はトラックボール を模しています。なので、

  • ボールを前後に回すと、カメラが上下に動く。回転軸はビュー空間のX軸。
  • ボールを左右に回すと、カメラが左右に動く。回転軸はビュー空間のY軸。
  • ボールの側面をこする、あるいは指でつまんで独楽のように回すと、視界が傾いて天地がひっくり返る。回転軸はビュー空間のZ軸。

といった感じになってほしいわけです。

画面上にあるボールのどこをタップして、どうドラッグするかで、 この3種類の回転をブレンドすればいいのですが、少々の数学を使います。

f:id:hirasho0:20190902192907p:plain

カメラから見えるのは手前の半球だけなので、図には半分だけ球を書きました。 カメラが右側にある感じです。

まず、視点から出たレイが入力用の球に当たる点Pを求めます。 球の中心をOとしましょう。 さらに、ドラッグ方向ベクトルをVとします(手前を向いているつもりで図を描きました)。 PをつかんでV方向に引っ張る回転は、 図中のAを軸とした回転です。 AはVともOPとも直交しているので、 OPとVの外積で作れます。

角速度(x,y,z)は、ベクトル(x,y,z)を 軸とした回転を意味し、 ベクトルが大きいほど回転が速いことになるので、 今計算したAは角速度そのものとして使えます。 Vが大きいほど、つまりドラッグが速いほど、 Aも長くなりますから、回転は速くなります。

というわけで、やることは、

  • レイが球と当たる場所Pを求める
  • 球の中心OからPへのベクトルを用意する
  • ドラッグベクトルVを用意する
  • 外積で軸を求める
  • 軸をカメラのビュー座標系に移して角速度とする

となります。なお最終的な角速度は、 回転するもののローカル座標での値が必要です。 今はカメラが回るので、 「カメラのローカル座標」といえばビュー座標系です。

ところで、サンプルでは触り心地を良くするために計算をスクリーン座標系で行っており、 そのために少し計算が複雑になっています。 しかし、ワールド座標系で行って、できた軸をビュー座標系に移す方が簡単です。

入力用の球をSphereCollider として用意した場合、 OnBeginDrag に渡ってくるPointerEventDataから、

var p = data.pointerCurrentRaycast.worldPosition

であっさりワールド座標系の点Pが取れます。

慣性について

このサンプルでは、勢いよくボールを回すように入力すると、 慣性でしばらくカメラが回り続けます。 上のgif動画にも出てきますね。

これは単に角速度を毎フレーム減衰させているだけです。

angularVelocity *= (1f - (dt * Drag));

しかし、この慣性を表現するには、回転を積分できる必要があります。 これがクォータニオン以外ではなかなかうまくできません。 オイラー角だと綺麗に回りませんし (ホーミングの時にも述べましたね)、 行列でやると上の積分計算が結構面倒くさくなります。 クォータニオンだとこれが恐ろしく楽なのです。

クォータニオンの用途について整理

私が知る範囲での話ですが、クォータニオンが使える用途はだいたい 以下のような感じです。

  • ある回転を他のものにも適用する。
  • 物をある向きからある向きへゆっくり回す、あるいは補間をする。
  • 積分する。
  • 回転に使うデータ量を減らす

「回転を他のものにも適用する」というのは、 例えば、東を向いている人が、南を向きながら上を向くような 回転をしたとして、これと同じ回転を、西を向いた人にも行う、というような話です。 これには行列やクォータニオンが必要で、 オイラー角だけではそれができません。

そして、物をゆっくり回す場合は、 間の状態を作れないといけないので、補間が必要になります。 これはクォータニオンの独壇場です。 球面線形補間が有名ですね。 積分ができるのも、これと似た長所です。 未来の結果がわかっているのが補間で、わかっていないのが積分、 というだけで、「アニメーションする」ということでは同じです。

そして最後の「データ量を減らす」ですが、 クォータニオンはfloat4つで済み、行列のfloat9個より小さくて済みます。 シェーダに送る、というような時にはそれがおいしい時もあるでしょう。

なお、私が思うクォータニオンの利点として、 「角度を経由しないで済む」ということがあります。 角度をベクトルにするには、三角関数が必要です。 面倒で重い上に、ベクトルから角度に戻す際には激しく精度が落ちます。 角度は中学数学までの範囲でも理解できるので 安心感はありますが、ベクトル演算が使えるのであれば、 角度はできるだけ通らない方が楽な気がします。

クォータニオンはベクトルとの親和性が良く、 大抵のことは角度を経由せずにやれます。 クォータニオンを使っていてsin/cosが出てきたら、 無駄な遠回りをしているかもしれません。

参考文献

さて、どうでしょう。 クォータニオンが気になってきましたか? そういった方には、 もっとちゃんとした説明をおすすめしておきます。

私の数学力ではこれらのサイトのちゃんとした理解はまだ難しいのですが、 逆に言えば、それでも便利と感じるくらいには使える、ということでもあります。

終わりに

「わからなくても使えて便利だよ!」的なノリで書いてきておいて何ですが、 「使うためにあった方がいい知識」 というものは当然のようにあります。 クォータニオンは勉強しないで使うには危険すぎる道具です。

例えば私は角速度をワールド座標系で用意して バグっていましたが、 それは根本的な理解があれば絶対やらかさないようなバグです。

積分の式は、 「ローカル座標系の角速度に、クォータニオンを作用させてワールド座標系の小さな回転を作り、それを加算している」 という意味があるのですが、それがわかっていないからこそ 角速度をワールド座標系で用意するようなバグをやらかすのです。

というわけなので、これからもより深い理解を得るべく勉強したいところですが、 それはそれとして、 「理解してから使おう」なんて言っていると使えなくなってしまうので、 「とにかく使ってみる」も大事かなと思います。 今回はその一例です。

地味にヤバい、シェーダ変数の精度について

画面写真をクリックするとサンプルのWebGLビルドに飛びます。

こんにちは。技術部平山です。

今日はシェーダでの精度指定について、地味なお話をいたします。 詳しいことに興味がなければ、 非常に簡潔にまとめられた記事 をLIGHT11さんが書いてらっしゃいますので、それを読めば足ります。

なお、この記事の結論を一言で言えば、「UVと位置にhalfを使うな」 です。 速度や電力も大事ですが、下手に攻めてバグる方が問題です。

サンプルのソースコードはgithubに置いてありますが、 今回に関してはサンプルを動かしていただけば十分かなと思います。 より詳細を知りたい方はご覧ください。

冒頭の写真について

冒頭の写真は、仮にシェーダでの浮動小数点数の精度が低かったらどんなことが起こるか? をシミュレートしたものです。

絵の左下がテクスチャ座標(UV)が(0,0)で、右上がだいたい(5,5)です。 右上ほど画像が粗いのがわかりますね。

本来、ピクセルごとに違っていなければならないUVが、 精度不足のために違った値を取れなくなり、 テクスチャの同じところを読んできてしまうためにモザイク状になってしまうわけです。

今回の記事のきっかけになったバグも、これと似たようなもので、 floatと書くべきところにhalfと書いてしまっただけのことです。 しかし、起こる現象はなかなかに厄介で、

  • 解像度が高くないと起きない
  • 機種によって起きたり起きなかったりする

といった状態でした。なぜそんなことが起こるのか?

浮動小数点数には精度がある

シェーダであってもなんであっても、浮動小数点数を コンピュータで使う以上、精度というものがあります。 浮動小数点数の代表的な規格と言えば、 IEEE754でして、 PCのCPUならば当然のようにこれに準拠していますし、 GPUであっても高級なものはこれを満たしています。 しかし、仕様を満たさないGPUもありますし、 満たすとしても、「どの仕様を満たすか」はGPUによります。

まず、簡単な例で浮動小数点数をどう表現するのかを理解してみましょう。

仮数2bit、指数1bit、を例にして

浮動小数点数も整数と一緒でいくつかのビットでできているわけですが、 浮動小数点数の場合は「仮数部」と「指数部」に分けて使います。 仮数部で作った整数に、指数部で作った整数で2を累乗したものを掛けることで、 数を表現するのです。

例えば、仮数部が2bit(0から3)、指数部が1bit(0から1)、の場合、 0から3のAと、0から1のBを用いて、数を、

x = A * 2^B

と表します。仮数部が2で、指数部が1だったら、2に、2の1乗=2を掛けて、4、 というような話です。

しかしこれだと、「仮数部に1入れて指数部が1」なのと「仮数部に2入れて指数部が0」 なのが同じ数になってしまいます。両方2です。 違うビットパターンが同じ数になる、ということは表せる数の種類が減っているわけで、 もったいないですね。

そこで、こんな工夫をします。

仮数部には常に4(2の2乗)を足すのです。こうすると、ゲタを履いた仮数部は、4から7になります。 こうすれば、以下のように同じ数は1通りでしか表せなくなります。

  • 4: 仮数部0、指数部0
  • 5: 仮数部1、指数部0
  • 6: 仮数部2、指数部0
  • 7: 仮数部3、指数部0
  • 8: 仮数部0、指数部1

7と8にご注目ください。7で仮数部が最大値になってしまったので、 これ以上増やす場合は一旦仮数部を0にして、代わりに指数部を上げることで対応するわけですね。

精度とは何か

さて、この浮動小数点数の精度はどうなっているのでしょうか。 精度、というのは「どれくらい小さな数の差でも表せるか」のことです。 今の場合計3bitしかありませんから、全パターンを表にしてみましょうか。

仮数部 指数部
0 0 4
1 0 5
2 0 6
3 0 7
0 1 8
1 1 10
2 1 12
3 1 14

まず上半分を見れば、精度は1です。0.5の差は表せません。 しかし、下半分を見れば、精度は2になります。8の次は10で、9は表せません。 ということは、仮にこの型の変数aがあったとして、

a += 1;

と書いた場合、足しても増えないかもしれない、ということになります。 8に1を足そうとしても9が表せないので、切り捨ててしまい8に戻ってしまうのです(切り上げなら足せますが、2増えます)。

なお、「0はどうやって表すの?」「小数はどうやって表すの?」「マイナスはどうやって表すの?」 といったことについてはwikipedia 等をご覧ください。仕様が書かれています。

精度が足りなくなる、ということ

さて、上記のことがわかっていれば、問題を理解できます。

右上ほどガビガビになったのは、浮動小数点数が「数が大きくなるほど精度が悪化するから」ですね。 Uは右へ行くほど精度が悪化し、Vは上へ行くほど精度が悪化します。右上は両方ともダメになって最悪なわけです。

そして、「解像度が高くないと起きない」も容易に理解できます。

解像度が16ならば、16段階の値が表現できる精度があれば済みますが、 解像度が64ならば、64段階の値が表現できないといけません。 例えば解像度が1024であれば、0/1024、1/1024、2/1024、3/1024... といった感じに等間隔でUVが生成されますから、 それが表現できる精度が必要なわけです。とりわけ、大きい方が問題ですね。 1022/1024と1023/1024の差、つまり1/1024の精度を、1に近い大きさの時に 持たないといけないということです。

では、それには仮数部が何ビット必要でしょう?

答えは9bitです。

仮数部が9bitの場合、仮数部で作った数には、2の9乗である512を足します。 仮数部が0なら512、1なら513、という具合ですね。そして最後は1021,1022,1023 となって最大値となります。これに指数部で作った1/1024(2のマイナス10乗) を掛けて1付近にしており、1に近い所での精度は1/1024ということになります。

では、解像度が1025だったら?もう9bitでは足りません。

今回のバグでは、仮数部が10bitの機械で、2048を超える解像度の描画を行っていました。 1/2048を超える精度が必要なのに、1/2048の精度しかなかったわけです。 しかも悪いことに、タイリング(リピート)によってUVは1よりも大きな値が入っていました。 UVの幅が0から2であれば、2付近での精度は半分の1/1024になります。 実際、サンプルのuvScaleを大きくしてみてください。ガビガビが悪化します。

なぜ機種依存?

さて、今度は「同じく高解像度なのに起きない機械もある」 ということの謎を解きましょう。 これは簡単に言えば、機械によって仮数部のビット数が違うからです。

Unity公式の「シェーダーのデータ型と精度」 に書かれているように、シェーダにfloatと書いたりhalfと書いたりした時に、 実際どれだけの精度があるかは完全に機種依存です。

halfは普通に考えれば「32bitの半分で16bit、IEEE754の半精度に準拠ってことだよね!」 と思ってしまいますが、IEEE754に準拠しているかどうかはモノによりますし、 半精度かどうかもモノによります。 モノによるので、間違ってfloatと書くべき所にhalfと書いた時にどうなるかも、モノによるわけです。 エディタで出ず、特定の実機でだけ絵がおかしくなる、というかなり嫌なタイプのバグの原因になります。

今回のバグが出るか出ないかについては以下のような情報がありました。

  • エディタでは出ない
    • gameViewを高解像度にしても出ない
  • とある高級Android機は1440x2560という高解像度にも関わらず出ない
  • iPhone7では軽微、iPad Pro、iPhoneXSではガッツリおかしい
  • 同じiPhoneでもiPhone SEでは出ない

種がわかっている今となっては、これは容易に理解できます。

  • エディタで出ないのは、PCではhalfと書いてもfloatと同じ32bit(仮数部が23bit)精度になるから。
  • 高級Android機はAdreno530搭載で、おそらくはhalfと書いても32bitになる。
  • iPhone系はhalfと書くとちゃんと16bit(仮数部10bit)になる。
    • iPhone7は横750、iPad Proは横2048、iPhoneXSは横1125。解像度が高いほど重症になる。
    • iPhone SEは横640で、2倍にすれば1024を超えるが、それほど大きくないので気づかれなかったのだろう。

なお、実はさらに厄介なことに、 「そのシェーダを差したマテリアルがAssetBundleに入っていて、プロジェクト内のシェーダコードのhalfをfloatに直しても直らない」 という罠まで待ち受けていました。 テストシーンに素で素材を置けば直るのに、公式なフローで素材を出すと絵がおかしいままなのです。 AssetBundleの再ビルドが必要なわけですね。

公式の「アセットバンドルの依存性」 を読むと、プロジェクトにあるシェーダを使うマテリアルをAssetBundleに入れれば、 シェーダのコピーが生成されてAssetBundleに同梱される、という仕様であることがわかります。 AssetBundleの数だけコピーが作られるとするとなかなか嫌な状態です。 そのシェーダをさらに別のAssetBundleに入れて、依存関係を解決する、 とすればコピーは避けられるのでしょうか?でも面倒ですね...

おわりに

理屈がわかったところで、やることは変わりません。

位置やUVにhalfを使うな、というだけのことです。

機械によってはhalfにした方が速かったり電気を食わなかったりしますから、 使った方がいい時は多いでしょう。しかし、それで機種依存バグを入れれば コストが馬鹿になりません。そしてシェーダはAssetBundleに入る可能性もあり、 もしバグったまま入れてしまえば、AssetBundleの再ビルドまで必要になります。

思い返せば、描画計算の計算精度というのは古くからある問題です。 昔、UVが20bitしかないハードウェアが市場を支配していた時期があり、 テクスチャがおかしな貼られ方をする事故は結構見ました。 GPUが進化して32bit変数を難なく処理できる時代になったというのに、 こういう問題が起こるのはどことなく釈然としません。 halfかfloatかで、そんなに電力や性能に差が出るんですかね?一度確認しておきたいものです。

また、これはGPUではありませんが、「float変数に毎フレーム値を加算していく」 ことが原因のバグを見た経験も今回は助けになりました。 floatは仮数部が23bitですから、1677万段階しか表現できません。 要するに、floatに1677万が入っていたら、もう+=1しても増えなくなるということです。 仮に60fpsのゲームであれば、1/60(0.0167)を加えても値が増えるだけの精度を確保する必要があります。 1677万/60=28万ですから、28万秒経つと、1フレームの時間増加を表現できなくなります。 28万秒は、4660分で、これは78時間、つまり3日ちょっとです。 「3日ゲームを置いておくとアニメーションが止まる」というような形で現れます。

さて、UnityのTime.time はfloatでどんどん増えていくので、この問題と無関係ではないはずです。 中身がdoubleやDateTimeであれば、止まりはしませんが、 それでも精度は落ちていきます。 業務用ゲーム機では電源を落とさず動かし続けるケースもあるでしょうし、 Unityは産業利用もしているはずです。一体どうしているのでしょう。

おまけ: 偏微分の利用

今回遭遇したバグは、ddxやddy命令によって偏微分を求めて使っていたため、 余計に厄介なことになっていました。しかもそれを除算の分母にしていたのです。 隣のピクセルと必ず違う値が入っていればいいのですが、 精度が不足すると同じ値が続いてしまい、偏微分は0になります。 それを分母にすれば、ゼロ割りで何が起こってもおかしくありません。

基本的に、ddxやddy、fwidth等で求めた値を除算の分母に使うのは避けた方が良いのでしょう。 そもそも偏微分が何を返すかは機種依存であり、用心が必要かと思います。