はじめに
クォータニオンを含む自作ライブラリを作ったのでそれを使って座標系を自由にいじってみようと思いました。p5のwebGLの3D描画でです。援用はそれほど難しくなかったです。
ああこうやってリンク貼るんですね(今知った)
感謝します。うれしい!
本題に入ると、p5.jsのapplyMatrixという関数があるんで、それとこっちのライブラリのクォータニオンを連携してよろしくやろうというわけです。
ただ行列の流儀がちょっとこっちと違う(縦横フリップ)のでそこだけ気を付ける感じになってます。数学のリソースを素直に活用する立場ならフリップは必要ないはずなんですが、気になる人は気になる(=郷に入りては郷に従え)ので仕方ないですね。
コード全文
とりあえず座標系を確かめるためにFと書かれたグラフィックテクスチャを使用します。これの向きで座標軸の向きが分かります。なおz軸が正方向を向いてることは描画位置から明らかではありますが、厳密を期すためカリングを併用します。
quarternion rotation F
/*
quarternionを使ってすべての方向に角柱を用意する実験
クォータニオン便利ですね
p5は適用順が逆だから
transposeしないといけないんだ
両方localにしてtransposeで送ったらうまくいきました。
*/
const {Quarternion, MT4} = fox3Dtools;
const qArray = [];
let tx;
function setup() {
createCanvas(400, 400, WEBGL);
const q = new Quarternion();
const baseArray = [];
baseArray.push(q);
// グローバルの方が分かりやすいだろ。4本の軸でそれぞれPI/2してどれか一本で
// PIすればいい。
baseArray.push(q.globalRotate(1,0,0,PI/2,true));
baseArray.push(q.globalRotate(-1,0,0,PI/2,true));
baseArray.push(q.globalRotate(0,1,0,PI/2,true));
baseArray.push(q.globalRotate(0,-1,0,PI/2,true));
baseArray.push(q.globalRotate(1,0,0,PI,true));
// 逆に言うとこうした後でローカルで4つずつ作れば簡単に網羅できるはず。
// zで4つ作って同じ方向にxで傾ければ完璧だ?多分ね。
for(const b of baseArray){
qArray.push(b.copy());
for(let i=0; i<4; i++){
qArray.push(b.localRotate(0,0,1,PI*i/2,true).localRotate(1,0,0,PI/7));
}
}
// チェック用テクスチャ
tx = createGraphics(80,80);
tx.noStroke();
tx.background(255);
tx.textSize(80);
tx.textAlign(CENTER,CENTER);
tx.textStyle(ITALIC);
tx.text("F",40,40);
const gl = this._renderer.GL;
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.FRONT); // BACK描画
}
function draw() {
orbitControl(1,1,1,{freeRotation:true});
background(0);
lights();
fill("silver");
specularMaterial(32);
noStroke();
sphere(100, 24, 24);
const m = new MT4();
texture(tx);
for(const q of qArray){
// 転置
m.setMatrixFromQuarternion(q).transpose();
push();
applyMatrix(...m.array());
translate(0, 0, 100);
plane(40);
pop();
}
}
使うライブラリ
こちらのライブラリを使います。
https://inaridarkfox4231.github.io/fox3Dtools/src/fox3Dtools.js
これをOpenProcessingのLIBRARIESにコピペして使っています。CDNならこっち:
<script src="https://inaridarkfox4231.github.io/fox3Dtools/src/fox3Dtools.js"></script>
ですね。入ってるのはベクトル、クォータニオン、行列、3Dカメラです(ビューのみ、射影は無し)。
使うクラスと関数
使うクラスはクォータニオンと行列だけです。関数もちょっとしか使いません。まずクォータニオンはそれ自体、正規直交基底と対応しています。もっとも$q$と$-q$は同じものに対応するので1:1ではないですが。それを回転させる関数を用意しています。localRotateとglobalRotateです。次に行列の方は、クォータニオンからそれが表現する正規直交基底に対応する直交行列、を3x3部分に持つ4x4の回転行列を与える関数、setMatrixFromQuarternionを使います。ですが、こちらと流儀が違うため、転置を取って適用します。
localRotate
クォータニオンに対して、それが表現する正規直交基底を、その系に基づいたベクトルの周りに回転します。具体的には基底を
v_x,~~v_y,~~v_z
として、回転に使うベクトルが$(a_x,a_y,a_z)$だとして、
a_xv_x + a_yv_y + a_zv_z
というベクトルの周りに回転します。たとえば(1,0,0)であれば全体をv_xの周りに回転させるわけです。なお回転の向きはベクトルを先っちょから見た場合の向きです。
globalRotate
クォータニオンに対して、それが表現する正規直交基底を、そのベクトルの周りに回転します。たとえばベクトルが$(1,1,1)$であれば全部$(1,1,1)$の周りに回転します。
なお、これらは内部的には回転を表現する単位クォータニオンを、localRotateの場合は右から、globalRotateの場合は左から掛けることで実現しています。内部実装は今回表に出てこないので、気になる人はリンク先を見てください。たいして難しいことはしてないです。メソッドの数もThreeに比べると半分以下です。なおレファレンスは準備中です。
setMatrixFromQuarternion
クォータニオンに対して、それが表現する回転行列を生成して、対象の4x4行列に当てはめるものです。こっちの流儀ではベクトルを列ベクトルとみなして、そこに掛け算する形で作用させており、その行列の成分を横に見ています。そういうわけで、applyMatrixにぶち込む際にはそれを転置する必要があります。p5はベクトルを外でも行ベクトルとみなしているからです。めんどくさいですね...
回転の実際
クォータニオンはここで生成しています。
const q = new Quarternion();
const baseArray = [];
baseArray.push(q);
// グローバルの方が分かりやすいだろ。4本の軸でそれぞれPI/2してどれか一本で
// PIすればいい。
baseArray.push(q.globalRotate(1,0,0,PI/2,true));
baseArray.push(q.globalRotate(-1,0,0,PI/2,true));
baseArray.push(q.globalRotate(0,1,0,PI/2,true));
baseArray.push(q.globalRotate(0,-1,0,PI/2,true));
baseArray.push(q.globalRotate(1,0,0,PI,true));
// 逆に言うとこうした後でローカルで4つずつ作れば簡単に網羅できるはず。
// zで4つ作って同じ方向にxで傾ければ完璧だ?多分ね。
for(const b of baseArray){
qArray.push(b.copy());
for(let i=0; i<4; i++){
qArray.push(b.localRotate(0,0,1,PI*i/2,true).localRotate(1,0,0,PI/7));
}
}
まず基本となる正規直交基底に対応するデフォルトのクォータニオン(というか$1$)を$q$という形で用意します。それをグローバルの回転で複製しています。trueというのはimmutable指定で、「対象を変化させず実行結果を複製して別のオブジェクトとして返す」という意味です。ここでは$x$軸の正方向、負方向、$y$軸の正方向、負方向の合計4つの方向にPI/2ずつ回転させています。最後に$x$軸の周りに$\pi$だけ回転させて、球面上のすべての位置を網羅します。
次にローカルの回転で4つずつ複製します。すべて、z軸の周りにPI/2の整数倍だけ(4つだけ)回転させて、それから$x$軸のまわりに$\pi/7$ずつ回転させます。そうすると回る方向が違うので、それぞれ異なる座標系になります。
確かめる
はじめに半径100の球を描きます。そのあとクォータニオンごとに行列を作り転置してapplyMatrixに適用します。ただし転置の後で、です。
const m = new MT4();
texture(tx);
for(const q of qArray){
// 転置
m.setMatrixFromQuarternion(q).transpose();
push();
applyMatrix(...m.array());
translate(0, 0, 100);
plane(40);
pop();
}
txには「F」の文字が書いてあります。クォータニオンで決められた座標系の下で$z$軸に沿って100だけ移動してから$xy$平面に平行に「上」が$y$軸負方向の平面を描画します。結果は:
こんな感じですね。GIF載せるのめんどうなのでリンク先を見ていただけると...ほんとは座標軸でやりたかったんですが、
// 転置
m.setMatrixFromQuarternion(q).transpose();
push();
applyMatrix(...m.array());
translate(0, 0, 100);
//texture(tx);
//plane(40);
// こんな感じ
stroke("red");
line(0,0,0,20,0,0);
stroke("lime");
line(0,0,0,0,20,0);
stroke("blue");
line(0,0,0,0,0,20);
pop();
赤、ライム、青が$x,y,z$軸に対応しています(正方向)。ただどういうわけか激重になってしまいました。なので不採用です。自分の富士通のノートパソコンで激重なのでスマホだとどうなるかは想像したくないところです。本来線を引くだけのはずなんですが...
ともあれ、座標系がきちんと回転してることを確認できたので良かったです。
「F」の字をレンダリングするのもスマホだと重いですね...まあいいか。なお、フロントカリング(p5はすべてバック描画)なのでちゃんとz軸は球面に垂直な方向です。
なお、orbitControlは今回freeRotationを採用しています。こういうあらゆる方向から見たい場合はfreeRotationが便利ですね。vRoidHubのように特定のモデルを水平が保証された状態で見たい場合はこれを使わない方がいいですね。せっかく2種類あるので、スケッチの目的に応じて自由に使い分けしましょう。
おわりに
クォータニオン、p5との親和性がどうかみたいなことについて懐疑的だったんですが、割と簡単に適用できて良かったですね。転置するの思いつくのに若干時間かかりましたが...球面上の座標系を用意するのに使えそうです。
ここまでお読みいただいてありがとうございました。?
あ、忘れてた。
p5もクォータニオンを導入する予定がありそうなので、興味がある人は開発に協力してみてください。素人の作った怪しげなライブラリより、たくさんのレビュアーに裏打ちされた大規模ライブラリの方が信頼できるでしょう。でもそのためには行動を起こさないといけないです。
後で思ったんですが、クォータニオンをそのまま行列にするんだったら最初から行列でもいいですね...じゃあクォータニオンを使う利点は何かといえば、ずばり補間です。回転は線形補間すると回転にならないので、球面線形補間をする必要があって、クォータニオンが強いのはそこです。いずれ取り上げられればと思います。スケールと平行移動は線形補間でも良いかと思います(スケールは対数補間のほうがいい場合もありますが)。
重要な補足:applyMatrixの挙動について
applyMatrixはモデル行列に乗算する処理であり、モデル行列にその行列をセットする処理ではないです。この記事ではセットすることをイメージしてこのようなコードになっていますがそれはpush~popで挟んでるからで、実は常に単位行列スタートです。p5にはresetMatrix()という関数があって、これを使うとモデル行列をリセットできます。これを使えばpush~popは不要です。気になる人は書き換えてみてください。
以前(~1.5.0)、resetMatrixはモデル行列だけでなくビュー行列まで単位行列にしてしまう極めて不自然な関数でした。自分が直して2Dと同じようにモデル部分だけリセットする仕様に直しました。懐かしいですね。バグ対応、環境づくり、大事です。地味で目立たないし感謝もされないですが、重要な仕事です。モデル行列は、乗算する処理はあってもセットする処理は無いので、いちいちリセットしないといけないです。ちょっと不便な気もするので、そういう関数があればとは思いますね。
自分がバグとみなすのは、想定された挙動をしない場合ですね。個人のわがままやメソッドの構築ミスでそういう挙動になっていたら直すべきだと思います。orbitControlの拡大縮小も、明らかなメソッドの構築ミスで、とんでもなく不自然な挙動になってました。1.5.0のorbitControlを実行してみてください。すごいです。いろいろと。加えてスマホでも動くようにしたり。懐かしいですね。もう携わることはないですが、開発は理解が深まるので経験しておいて損はないと思います。