はじめに
この記事はJavaScriptのAdventCalendar2025の13日目の記事です。
p5.jsが2.0になったときにorbitControlがちょっと壊れちゃった(スマホで動かす場合、キャンバスが小さいときにタッチで一度でも画面外をスワイプすると機能不全になる。マウスの場合は正常)ので、まあだからというわけじゃないんですが、
orbitControl作ってみようよ
というのが趣旨です。ただジオメトリまで作るとなるとめんどうなので、p5のジオメトリーをパクります。実はVAOっていう便利な道具があるんで、それ使ってアトリビュートを掠め取ればいいんですね。アトリビュートに関してはそんな感じでp5にお任せすることとします。この記事では、カメラを動かす部分だけを取り扱おうと思います。
orbitControlって?
マウスやタッチでカメラが動く仕組みです。p5.jsだとorbitControl()とWebGLの描画で一行書くだけで機能します。ThreeだとorbitControlsっていうなんか、コードの塊みたいなのを追加する、と、使える、ようです。あんま詳しくないです。すみません。
Threeについての参考記事:
それで、この記事でやるのは描画に必要ないわゆる「カメラ情報」というものを、インタラクションで変化させることができるようにしようというわけです。ベクトルとクォータニオンを使います。インタラクション、いわゆるイベントについては自作の愛用してるのがあるのでそれを使います。カメラ情報により描画する仕組みについては、次の記事でやったのでそれに基づいて実行しています。
aspectは割り算でしたが、掛け算にする凡ミスをしていたので修正しました。
ここでやるような3D描画においてはカメラ情報を扱っているんですが、それをインタラクションで動かそうというわけです。ベクトルはp5のがあるんですが、今ちょっと面倒なことになっているので必要充分なだけ自作で用意したものを使うことにします。クォータニオンはそもそも存在しないので作りました。これも必要最低限です。
結果的にp5.jsに全く依存しない内容になりました。なのでjavascriptの記事であるということにします。バージョン更新に振り回されないのは利点ですし、他の枠組みでも使える可能性があるのでその方がいいですね。
そういうわけで説明を始めます。その前に補足など。
注意点など
まず、行列を使っていません。カメラは透視投影のみ扱います。平行などとまとめて扱う必要が無いので使いませんでした。ビューと射影の処理はシェーダー内部で実行しています。内容的には行列の掛け算と同じことをしています。
汎用性について。まあこれと同じことをすればいいだけですが、行列出力をする形に書き換えれば他の環境でも使えるようになるかもしれないですね。以上です。
コード全文
orbitControl関連のあれこれは1000行近くになります。ここには載せられないので、メインコードだけおきます。
// 3D.
// p5だとらくちんですね。
const vs =
`#version 300 es
in vec3 aPosition;
in vec3 aNormal;
uniform float uScale;
uniform vec3 uEye;
uniform vec3 uViewX;
uniform vec3 uViewY;
uniform vec3 uViewZ;
uniform vec4 uProj; // fov,aspect,near,far
out vec3 vNormal;
void main(){
vNormal = aNormal;
vec3 p = aPosition * uScale;
p -= uEye;
vec3 q = vec3(dot(p, uViewX), dot(p, uViewY), dot(p, uViewZ));
float fov = uProj.x;
float aspect = uProj.y;
float near = uProj.z;
float far = uProj.w;
float factor = 1.0/tan(fov/2.0);
gl_Position = vec4(
q.x * factor / aspect,
q.y * factor,
((near+far)/(near-far)) * q.z + (2.0*near*far)/(near-far),
-q.z
);
}
`;
const fs =
`#version 300 es
precision highp float;
in vec3 vNormal;
out vec4 fragColor;
void main(){
fragColor = vec4(0.5+0.5*vNormal, 1.0);
}
`;
function setup(){
createCanvas(400,600,WEBGL);
smooth();
const gl = drawingContext;
const cam = new Camera({
eye:[0,0,200*sqrt(3)], center:[0,0,0], top:[0,1,0],
fov:PI/3, aspect:width/height, near:20*sqrt(3),far:2000*sqrt(3)
});
const ctl = new Controller(document.querySelector('canvas'), {
cam, mode:'free'
});
const sh = createShader(vs, fs);
shader(sh);
// トーラスのジオメトリをVAOで掠め取る
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
noStroke();
torus(1,0.4,48,48);
gl.bindVertexArray(null);
gl.enable(gl.CULL_FACE);
const loopFunction = () => {
ctl.update();
background(0);
sh.setUniform("uScale", 60); // 60倍。拡大だけ。
const {eye, side, up, front} = cam.getView();
const {fov, aspect, near, far} = cam.getProj();
sh.setUniform("uEye", [eye.x, eye.y, eye.z]);
sh.setUniform("uViewX", [side.x, side.y, side.z]);
sh.setUniform("uViewY", [up.x, up.y, up.z]);
sh.setUniform("uViewZ", [front.x, front.y, front.z]);
sh.setUniform("uProj", [fov, aspect, near, far]);
// VAOでトーラスを復元。
gl.bindVertexArray(vao);
gl.drawElements(gl.TRIANGLES, 48*48*6, gl.UNSIGNED_SHORT, 0);
gl.bindVertexArray(null);
}
draw = loopFunction;
}
camを自作のCameraクラスで用意します。さっき言ったように透視投影です。それだけ。んでctlがControllerで、要するにこれがオビコンです。p5ではすべて隠蔽されていますが(そもそもクラスではない)、こっちではクラスオブジェクトとして生成します。
これを毎フレームアップデートするわけですね。
あとはカメラから情報を取得してuniformで送るだけ。とてもシンプルな構成になっています。
それで、この「Camera」と「Controller」を作るのに1000行要るわけです。内容については以下のサイトを参照してください。Qiitaにそんな行数のコードは載せられないですから。
VAOでトーラスのジオメトリをパクっています。三角形の枚数が分かれば復元するのは簡単です。noStrokeを用意しないと線の情報まで入ってしまい話がややこしくなるので、noStrokeは必須です。これでトーラスが作れました。
なおライティングはしていなくて単純な法線彩色です。そこら辺はp5のshaderの改変処理...いわゆるbaseMaterialShaderでやってもいいんでしょうが、この記事の趣旨に合わないので不採用にしました。
どうでもいい補足をすると、createCanvas()のあとでsmooth()を実行しています。実はp5.jsはFirefox系ではWebGLのアンチエイリアスを切っているので、これをしないとFirefox系の見た目が汚くなるんですね。自分は今Waterfoxを愛用しているので、これが必須になってしまいました。
以下、かいつまんでorbitControl.jsの中身を説明していきます。
ベクトル
ちょっと前の記事で作った、特にエラー処理などをしていない簡易版です。オビコン動かすだけ...と思って作ったのに割と大きくなってしまいました。
_createVector
createVectorが予約されているので_を付けました。Vectorを生成するんですが、一応配列、列挙、ベクトルコピーの3種類を用意しました。さらに列挙では2番目と3番目は未指定の場合オートで0になります。
class Vector
ベクトルクラスです。コンストラクタは数を並べるだけです。2つでも3つでもOKです。p5はなんか多次元化とかしてるらしいですが3つで充分でしょう。
メソッド一覧については上の記事を見てください。ざっくり並べると、
show, add, addScalar, sub, cross, mult, normalize, mag, magSq,
dist, angleBetween, copy, set, dot, rotate
で全部です。
p5との違いについて簡単に触れます。まずmultはスカラー限定です。CPUサイドでベクトルとglslのような掛け算をする必要性を感じなかったので。実際そういう機会は、無いですから。同じような理由でdivも作りませんでした。逆数を掛ければいいだけだし、そういう機会もほとんど無いので。rotateは3次元回転と2次元回転、両方できます。angleBetweenは0~PIの0以上の数値だけを返します。いわゆる「なす角」ですね。最後にstaticも作っていません。copyで代用できるので。そういうわけでミニマムベクトルクラスです。
クォータニオン関連
「関連」と書いたのは関連メソッドを別立てで作っているからです。staticで作っても良かったんですがやめました。
function getQuatFromAA(axis, angle){
const a = axis.copy().normalize();
const s = Math.sin(angle/2);
return new Quarternion(Math.cos(angle/2), s*a.x, s*a.y, s*a.z);
}
function getQuatFromAxes(x, y, z){
// 正規直交基底から出す。正規直交基底でないと失敗する。
// 3つの引数はすべてベクトル限定とする。列ベクトル。
// 参考:https://github.com/mrdoob/three.js/blob/r172/src/math/Quaternion.js#L294
const {x:a, y:d, z:g} = x;
const {x:b, y:e, z:h} = y;
const {x:c, y:f, z:i} = z;
// a b c
// d e f
// g h i
const trace = a + e + i;
// 角度がPIに近いと割り算ができないが、
// traceが正ならそれは起きえない。
if(trace > 0){
// ここだけあっちと違う計算だが、意味的に分かりやすいので。
const w = Math.sqrt((trace + 1) / 4);
const factor = 0.25/w;
return new Quarternion(w, (h - f)*factor, (c - g)*factor, (d - b)*factor);
}else{
if(a > e && a > i){
// aが最大の場合
const s = 2 * Math.sqrt(1 + a - e - i);
return new Quarternion((h - f) / s, 0.25 * s, (b + d) / s, (c + g) / s);
}else if(e > i){
// eが最大の場合
const s = 2 * Math.sqrt(1 + e - i - a);
return new Quarternion((c - g) / s, (b + d) / s, 0.25 * s, (f + h) / s);
}else{
// iが最大の場合
const s = 2 * Math.sqrt(1 + i - a - e);
return new Quarternion((d - b) / s, (c + g) / s, (f + h) / s, 0.25 * s);
}
}
return new Quat(1,0,0,0);
}
function getAxesFromQuat(q){
const {w,x,y,z} = q;
// 列ベクトル。
return {
x:_createVector(2*w*w-1 + 2*x*x, 2*(x*y + z*w), 2*(x*z - y*w)),
y:_createVector(2*(x*y - z*w), 2*w*w-1 + 2*y*y, 2*(y*z + x*w)),
z:_createVector(2*(x*z + y*w), 2*(y*z - x*w), 2*w*w-1 + 2*z*z)
}
}
class Quarternion{
constructor(w,x=0,y=0,z=0){
this.w = w;
this.x = x;
this.y = y;
this.z = z;
}
copy(){
return new Quarternion(this.x, this.y, this.z, this.w);
}
set(w,x,y,z){
if(w instanceof Quarternion){
this.set(w.w,w.x,w.y,w.z);
return this;
}
this.w = w;
this.x = x;
this.y = y;
this.z = z;
return this;
}
mult(q){
// qはスカラー/クォータニオン。それ以外は、なし!あと右乗算。
if(typeof q === 'number'){
this.w *= q;
this.x *= q;
this.y *= q;
this.z *= q;
return this;
}
const {w:d, x:a, y:b, z:c} = this;
const {w,x,y,z} = q;
const w2 = d * w - a * x - b * y - c * z;
const x2 = d * x + a * w + b * z - c * y;
const y2 = d * y + b * w + c * x - a * z;
const z2 = d * z + c * w + a * y - b * x;
this.set(w2, x2, y2, z2);
return this;
}
multRight(q){
return this.multRight(q);
}
multLeft(q){
// まあ一応用意するか。左乗算。左なので気を付けて。右とは可換。
if(typeof q === 'number'){
this.w *= q;
this.x *= q;
this.y *= q;
this.z *= q;
return this;
}
const {w:d, x:a, y:b, z:c} = q;
const {w,x,y,z} = this;
const w2 = d * w - a * x - b * y - c * z;
const x2 = d * x + a * w + b * z - c * y;
const y2 = d * y + b * w + c * x - a * z;
const z2 = d * z + c * w + a * y - b * x;
this.set(w2, x2, y2, z2);
return this;
}
}
getQuatFromAA
ベクトルと角度から、「そういう」クォータニオンを作ります。ざっくりいうとベクトルの周りに角度だけ回転させるものを表現しているんですが、説明が面倒なので割愛します。
getQuatFromAxes
正規直交基底のベクトルを3本用意して、そこからクォータニオンを作るものです。直交行列に対応するものを作る処理で、Threeでもやってるんですが、同じものです。内容的にはちょっといじっています。ベクトルは列で並べています。自分は「外」では行列を左から列ベクトルに掛ける立場を取っているので、変なことはしたくないんです。まあこの記事行列が出てこないのでその辺りはどうでもいいですが...数学リソースを素直に使えないのが不便なんですよね。
getAxesFromQuat
逆にクォータニオンから正規直交基底を復元するのがこの関数です。さっきのと比べると非常にシンプルですね。見ればわかりますがqと-qは同じものを作ります。さっきのメソッドではいずれか一方しか生成されません。この1 to 1でないという事実は実際のところ、全く影響を及ぼさないので、あんま気にしなくて大丈夫です。
multQuat
2つのクォータニオンを然るべき順で掛け算した結果を返すものです。もともと使ってたんですが、Quarternionクラスに乗算メソッドを移植したら不要になってしまいました。
class Quarternion
クォータニオンクラスです。必要最低限だけ用意しました。コンストラクタも列挙です。copy,set,あと乗算ですね。multとmultRightは一緒です。右から掛けることを明示しようと思いました。multLeftは逆乗算です。左から掛けます。Quarternionは結合律を満たすので右乗算と左乗算は可換です。
右乗算の意味ですが、正規直交基底のローカル回転を意味しています。たとえばx軸方向の単位ベクトルでローカル回転(右乗算)すると、すべてのベクトルがx方向のベクトルの周りに回転します。つまりy方向とz方向が、ということです。つまりxに相当する軸は動かないんですね。
左乗算の意味。これは正規直交基底のグローバル回転です。一般にx方向に相当するベクトルは(1,0,0)とは限らないことに注意してください(当然ですが...)。これにさっきのx軸方向単位ベクトルのクォータニオンを左から掛けると、グローバルのx軸の周りにすべてのベクトルが回転します。これらは全く別の処理というわけですね。
カメラ
透視投影で3D描画をするための仕組みです。これも必要最低限です。視点、中心、暫定上方向、視野角、アスペクト、ニアクリップ、ファークリップをまとめて用意しています。透視投影はこれらが揃えばできます。
ここでクォータニオンを使っています。回転関連はすべて、クォータニオンを使って実行したのち、軸ベクトルに解釈しなおしています。その方が書きやすいので。p5の方はそういうことをしてないんですが、まあ時間が無かったのと知識が無かったので作れませんでした。
setAxesFromParam
eye, center, topから正規直交基底であるside,up,frontを算出する処理です。これらがx,y,z軸に相当します。なぜ暫定上方向を取るかというと、ダイレクトに「上」を設定するには直交するようにしなければならず、はっきり言って面倒だからです。
setQ
正規直交基底を元にしてクォータニオンを設定する処理です。このコードでは基本的にコンストラクタでしか使いません。もっとも再設定とかする場合にはお世話になります。回転メソッドはすべてこのクォータニオンが対象となります。そこからsideなどを抽出するわけですね。
setAxesFromQ
それがこの処理です。クォータニオンから軸を作るわけです。
verticalRotation
orbitControlのデフォルト、垂直方向の回転に相当する処理です。p5の方では無制限に回転させていますが、この記事では±90度で切っています。Threeもそうしていますし。内容的にはx軸正方向の周りのローカル回転です。回転の後でeyeをfrontにより補正しています。orbitControlの回転メソッドはすべて「中心固定回転」なので然るべくeyeを補正しないといけないんですね。
horizontalRotation
orbitControlのデフォルト処理のもうひとつ、水平回転です。こっちは無制限ですね。どの軸の周りの回転かというと、最初に定めた暫定的上方向のベクトルです。つまり暫定的上方向のベクトルは最初に設定したものがそのまま使われるわけです。グローバル回転なので、multLeftを使っています。verticalRotationは右乗算なのでこれらは可換です。可換であるがゆえに、マウスの移動方向をx,yに分解してそれぞれ適用しても不具合が起きないんですね。
scale
視点の位置を中心に近づけたり離したりします。内容的には距離に正の数値を掛け算しています。なぜ掛け算で処理するかというと、
スケールに依らない挙動を実現するため
です。オブジェクトの大きさが4でも40でも400でも同じように動かせるようにするには、スケールは対数でやった方がいいです。p5のオビコンは昔はそういう感じの処理では無かったんですが自分が直しました。
moveNDC
平行移動ですがちょっと変わった処理になっています。というのもこれもスケールに依らない処理にしないと、小さいときに吹っ飛んだり、大きいときに全然動かなかったりするからです。じゃあ何を基準にするかと言えば正規化デバイス座標です。とはいえそこまで難しいことをしていなくて、要するに「画面」の範囲で±1基準で動かしているだけですね。移動距離が決まったらeyeとcenterはまとめて平行移動させています。要はsideとupを使って動かせばいいわけです。クォータニオンは据え置きです。
freeRotation
自由回転です。easyCamを触ったことのある人なら知っているでしょう。マウスを動かすとその方向に回転します。スケッチによってはその方が有用な場合もあります。使い分けです。それで、内容的にはクォータニオンでちょちょいのちょいです。xy平面に平行なベクトルでローカル回転するだけ。クォータニオンは本当に便利ですね...
なお、(x,y)がゼロベクトルだとNaNが発生するので地味に回避しています。ほんとはエラー処理とかしないといけないんですが省いています。getQuatFromAAでaxisがゼロベクトルの場合ですね。省いています。きちんと書きたい人は適宜修正してください。
getProjとgetView
射影関連の変数と、ビュー関連の変数を取得します。これをuniformで送って3Dの処理をします。
キャンバスインタラクション
マウスでもタッチも同じように動かせるような仕組みをVanillaで作るのは面倒です。そこで自分が愛用しているキャンバス専用のインタラクションの枠組みをそのまま移植しました。こういうのも結局ライブラリという形で隠蔽されてしまうとモヤモヤするでしょう。そういう理由からです。たとえばスマホのピンチインアウトとかもここに書くだけで実現できます。とても便利です。
class PointerPrototype
マウスにしてもタッチにしても、マウスダウンやタッチにより何かしらの「Pointer」が生成されて、離すと消えます。その仕組みを利用してオビコンを整えるわけです。その際にできるPointerをインスタンスの形で扱えるようにすることで、マウスでもタッチでも同じようにメソッドが書けるようにできるわけですね。
this.xやthis.yで位置が出ます。
class Interaction
キャンバスとオプションを引数に渡すと、マウスやタッチのメソッドを勝手にキャンバスと紐付けてくれる魔法の道具です。基本的に継承して使います。継承でメソッドを上書きするわけですね。最終的にこれを使って、Controllerを構成します。
たとえばマウスが動くときの処理を書きたい場合はmouseMoveDefaultActionをいじればいいわけです。なおwheelActionのpreventDefaultはデフォルトでは実行してないので、やってほしくない場合は適宜追加する必要があります。
ダンパー(減衰器)
この記事でやったようなことをしています。
単純に数値を足したり引いたりする代わりに、数値が毎フレーム減衰していく仕組みにすることで滑らかに動くようにする仕組みです。きわめて単純な内容になっています。
class Damper{
constructor(){
this.value = 0;
this.factor = 0.85;
this.threshold = 1e-6;
}
addForce(f){
this.value += f;
}
update(){
this.value *= this.factor;
if(Math.abs(this.value) < this.threshold){
this.value = 0;
}
}
getValue(){
return this.value;
}
reset(){
this.value = 0;
}
}
addForceで撃力を足し引きして、それをupdateで減衰させて、一定以下になったら0にする。おわり。これだけなんですが、次に紹介するControllerで大活躍します。
コントローラー
Interactionクラスの継承で、ようやくオビコンを作ります。
// マウスとタッチで同じ挙動。
class Controller extends Interaction{
constructor(cvs, params){
super(cvs, params);
const {cam, mode = 'axis'} = params;
this.cam = cam;
this.mode = mode; // axis:通常、free:フリー
// 計5つ。
this.damperRotationX = new Damper();
this.damperRotationY = new Damper();
this.damperScale = new Damper();
this.damperTranslationX = new Damper();
this.damperTranslationY = new Damper();
// factor関連...
this.mouseScaleFactor = 0.0001;
this.mouseRotationFactor = 0.001;
this.mouseTranslationFactor = 0.0008;
this.touchScaleFactor = 0.00025;
this.touchRotationFactor = 0.001;
this.touchTranslationFactor = 0.00085;
}
update(){
this.scale();
this.rotate();
this.translate();
}
scale(){
this.damperScale.update();
const s = this.damperScale.getValue();
this.cam.scale(Math.pow(10, s));
}
rotate(){
// 回転
this.damperRotationX.update();
this.damperRotationY.update();
const rx = this.damperRotationX.getValue();
const ry = this.damperRotationY.getValue();
const angle = Math.hypot(rx, ry);
switch(this.mode){
case "axis":
// ryは都合上ひっくり返さずそのまま
this.cam.horizontalRotation(-rx);
this.cam.verticalRotation(-ry);
break;
case "free":
// (rx,-ry)を90°まわして(ry,rx)にしてマイナス。
this.cam.freeRotation(-ry, -rx, angle);
break;
}
}
translate(){
// 平行移動
this.damperTranslationX.update();
this.damperTranslationY.update();
const tx = this.damperTranslationX.getValue();
const ty = this.damperTranslationY.getValue();
// aspect比が変わっても平行移動がおかしくならないようにする
const aspectFactor = this.cam.aspect;
if(aspectFactor > 1){
this.cam.moveNDC(-tx/aspectFactor, ty);
}else{
this.cam.moveNDC(-tx, ty*aspectFactor);
}
}
mouseMoveDefaultAction(dx,dy,x,y){
// dx,dyの方が先に来るのはdx,dyの方が重要なため(たとえばオビコンにx,yは不要)
// pointersが0の場合
if(this.pointers.length === 0) return;
// 回転・平行移動(マウスボタンで分岐)
const btn = this.pointers[0].button;
if(btn === 0){ // 左
// 左の場合
this.damperRotationX.addForce(dx * this.mouseRotationFactor);
this.damperRotationY.addForce(dy * this.mouseRotationFactor);
}else if(btn === 2){ // 右
this.damperTranslationX.addForce(dx * this.mouseTranslationFactor);
this.damperTranslationY.addForce(dy * this.mouseTranslationFactor);
}
}
wheelAction(e){
// 画面が一緒に動くのを防ぐ
e.preventDefault();
// 拡大縮小
// 今回scaleは単純乗算なのでマイナスは不要
this.damperScale.addForce(e.deltaY * this.mouseScaleFactor);
}
touchSwipeAction(dx, dy, x, y, px, py){
// Interactionサイドの実行内容を書く。
// dx,dyが変位。
// 回転
this.damperRotationX.addForce(dx * this.touchRotationFactor);
this.damperRotationY.addForce(dy * this.touchRotationFactor);
}
touchPinchInOutAction(diff, ratio, x, y, px, py){
// Interactionサイドの実行内容を書く。
// diffは距離の変化。正の場合に近づく。ratioは距離の比。
// 拡大縮小
this.damperScale.addForce(-diff * this.touchScaleFactor);
}
touchMultiSwipeAction(dx, dy, x, y, px, py){
// Interactionサイドの実行内容を書く。
// dx,dyは重心の変位。
// 平行移動
this.damperTranslationX.addForce(dx * this.touchTranslationFactor);
this.damperTranslationY.addForce(dy * this.touchTranslationFactor);
}
}
Interactionのコンストラクタはキャンバスとパラメータオブジェクトを受け取る仕組みになっています。そういうわけでキャンバスを取るんですが、パラメータオブジェクトの中身に必要なオブジェクトを追加で含めることで、引数が増えないようにしています。まあ面倒ですからね。
ここではカメラとモード変数文字列を受け取るようにしました。この2つだけ追加です。カメラは事前に作っておきます。モードは、カメラを動かす際の動かし方として水平、垂直に分ける通常のやり方とfreeRotationとあるわけですが、この2種類ですね、これを最初に決めるわけです。なおこのデモでは「free」を使っていますがデフォルトは「axis」です。未指定の場合はaxisとなります。
そのあと各種Damperクラス、先ほど説明しましたが、それを用意しています。回転のX,Yと、スケールと、平行移動のX,Yです。これらをどうするかというと、結局マウスでもタッチでも動かしたいわけですが、マウスやタッチでこれらにaddForce,力を加えて、あとはそれが毎フレーム減衰するので、滑らかに動くわけですね。それを実装する流れです。
update
スケール変換、回転、平行移動を順繰りに実行しています。
scale
damperScaleを減衰させつつ、その値でカメラのスケール変換を実行します。見ればわかりますが指数の肩に乗せる形ですね。0のとき1になるわけです。
rotate
回転です。回転用のDamperを減衰させつつ、結果をもとに実行します。axisの場合とfreeの場合で処理が異なります。ここ符号がめんどくさいんですが、落ち着いて見て行きましょうか。ここくらいしかまともに説明できる場所が無いので。今ここで頭を使いましょう。
axisの場合
まず水平方向は右に動かすと正の値が入ります。オブジェクトがマウスを動かした方向に回転するように見えるのがオビコンである、という哲学に基づくのであれば、カメラは逆回転します。上から見た場合右は正の向きですから、カメラは負の向きに回るわけで、それで-rxですね。
垂直方向ですが、マウスを下にドラッグすると正の値が入ります。さっきと同様、オブジェクトはこの場合、下に回転するように見えないといけないですから、カメラは上に向かいます。つまりfrontはtopと同じ方向に近づいていくんですね。なので逆回転です。それで-ryになっているわけですね。ややこしいなぁ。
freeの場合
sideが右でupが上、という座標系にマウスのx,yを持ち込むと(rx,-ry)になるわけです。この方向にマウスをドラッグした時にオブジェクトがその方向に回転するように見えないといけないわけです。x右、y奥、z上でそれを考えると、これはzがxですから、yの先っちょから見た場合、カメラは逆方向ですね。それで(rx,-ry)を反時計回りに90°回転させると(ry,rx)になるんですが、これにマイナスして、結局(-ry,-rx)が正解です。まあ難しいんですが、落ち着いて考えればそこまで難しくないですね...
translate
平行移動のDamperを減衰させて然るべく動かします。符号は、マウスの上下と正規化デバイス座標の上下が逆なので、両方マイナスにした後(オブジェクトがマウスの動く方向と同じ方向に動くように見えないといけない都合上マイナスになるという意味です、念のため)、yだけまたマイナスにするので結局プラスになります。
それでmoveNDCは正規化デバイス座標ベースの移動になります。これにはちょっと問題があって、横長だったり縦長だったりする場合に「1」の長さが変わってしまうんですね。なのでその差を吸収する意味でaspect比を使ってこのように書いています。本当はカメラが透視投影の場合と平行投影の場合で場合分けしないといけないんですが(まさにここだけ)、今は透視投影しか扱っていないので、まあこれでいいでしょう。
メソッドの上書き部分
マウスのところに関しては左ドラッグで回転、右ドラッグで移動です。pointerPrototypeの配列が正の時だけです。要はアクティブな場合だけという意味です。あとはボタンを取得して場合分けしています。ここのメソッドはマウスでしか実行されないので、大胆にこういう書き方をしています。
ホイールでプリベントしているのは、これをやらないとキャンバスにマウスを合わせてホイールする際に外側も動いてしまうので、それを避ける意味でこうしています。
あとタッチは上書きするメソッドでことごとくDamperに力を加えているだけですね。ああらくちん。ところで係数ですが、なんとなく気持ちよく動く範囲で適当に決めただけです。好みの問題なので、好きに改変したらいいかと思います。
お疲れ様でした。説明は以上です。好きにトーラスを回転させて遊んでみてください。
補足
平行投影の場合は作りませんでしたが、カメラとして別の物を作れば問題ないかと思います。興味のある人は挑戦してみましょう。まああれは拡大縮小が面倒なのであんまorbitControlでやりたくないんですが...
以上です。
なおp5は2.1.1を使っています。来夏には2.0系がデフォルトになるそうです。早くから慣れておいた方がいいかもしれないですね。
おわりに
ここまでお読みいただいてありがとうございました。なおこれをProcessingのAdventCalenderに使おうと思っていたのですが、ほとんどp5.jsが出てこない内容になってしまったので、これを採用するのは諦めて、別の記事を書こうと思います。
