ゲームなどではたまに目にする、球体に沿って移動するオブジェクト。
例えば小さい惑星の上を歩く、みたいのをイメージしてもらうと分かりやすいと思います。
それを実装する必要が出てきたので作ってみたものをメモとして残しておこうと思います。
ちなみにこちらの記事(球体に沿った移動 | ゲームつくろ~質問箱)を参考にさせていただきました。
今回はこれをThree.jsで実装した例となります。
ちなみに、今回のデモはGithubにアップしてあります。(動作デモはこちら)
/**
* プレイヤークラス
*
* @param character キャラクターオブジェクト
*/
function PlayerController(character) {
this.init(character);
this.initEvents();
}
PlayerController.prototype = {
constructor: PlayerController,
/**
* 初期化処理
*/
init: function (character) {
this.object = new THREE.Object3D();
this.character = character;
this.object.add(this.character);
this.speed = 1;
this.direction = 1;
this.radius = 1;
this.origin = new THREE.Vector3(0, 0, 0);
this.position = new THREE.Vector3(0, 1, 0);
this.forward = new THREE.Vector3(0, 0, 1);
this.previousForward = new THREE.Vector3(0, 0, 1);
this.rotateAxis = new THREE.Vector3(0, 1, 0);
this.extractVector = new THREE.Vector4(0, 0, 1, 0);
// 初期姿勢を未設定に
this.initializedPose = false;
},
/**
* キーボード操作をセットアップ
*/
initEvents: function () {
this.pressedKeyCode = {};
document.addEventListener('keydown', this.handleKeyDown.bind(this), false);
document.addEventListener('keyup', this.handleKeyUp.bind(this), false);
},
handleKeyUp: function (e) {
this.pressedKeyCode[e.keyCode] = false;
},
handleKeyDown: function (e) {
this.pressedKeyCode[e.keyCode] = true;
},
/**
* 前方ベクトルを取得する
*/
getForward: function () {
var forwardVec4 = this.extractVector.clone().applyMatrix4(this.object.matrix);
var forward = new THREE.Vector3(forwardVec4.x, forwardVec4.y, forwardVec4.z).normalize();
return forward;
},
/**
* 回転させる
*/
rotate: function (dir) {
var q = new THREE.Quaternion();
var rad = 5 * Math.PI / 180 * dir;
q.setFromAxisAngle(this.rotateAxis, rad);
var mat = new THREE.Matrix4();
var e = mat.elements;
e[0 * 4 + 0] = 1.0 - 2.0 * q.y * q.y - 2.0 * q.z * q.z;
e[0 * 4 + 1] = 2.0 * q.x * q.y + 2.0 * q.w * q.z;
e[0 * 4 + 2] = 2.0 * q.x * q.z - 2.0 * q.w * q.y;
e[0 * 4 + 3] = 0;
e[1 * 4 + 0] = 2.0 * q.x * q.y - 2.0 * q.w * q.z;
e[1 * 4 + 1] = 1.0 - 2.0 * q.x * q.x - 2.0 * q.z * q.z;
e[1 * 4 + 2] = 2.0 * q.y * q.z + 2.0 * q.w * q.x;
e[1 * 4 + 3] = 0;
e[2 * 4 + 0] = 2.0 * q.x * q.z + 2.0 * q.w * q.y;
e[2 * 4 + 1] = 2.0 * q.y * q.z - 2.0 * q.w * q.x;
e[2 * 4 + 2] = 1.0 - 2.0 * q.x * q.x - 2.0 * q.y * q.y;
e[2 * 4 + 3] = 0;
this.object.matrixAutoUpdate = false;
this.object.matrix.multiply(mat);
this.object.updateMatrixWorld();
this.previousForward = this.getForward();
},
/**
* 初期姿勢を初期化
*/
initPose: function () {
var direction = this.position.clone().sub(this.origin);
var len = direction.length();
if (len > this.radius) {
direction.normalize();
direction = direction.setLength(this.radius);
this.position = (new THREE.Vector3()).addVectors(this.origin, direction);
}
var z = this.forward.normalize();
var y = this.position.clone().sub(this.origin).normalize();
var x = y.clone().cross(z).normalize();
// 初期のZ軸(進行方向)を保持
this.previousForward = z;
var mat = new THREE.Matrix4();
var e = mat.elements;
e[0 * 4 + 0] = x.x;
e[0 * 4 + 1] = x.y;
e[0 * 4 + 2] = x.z;
e[0 * 4 + 3] = 0;
e[1 * 4 + 0] = y.x;
e[1 * 4 + 1] = y.y;
e[1 * 4 + 2] = y.z;
e[1 * 4 + 3] = 0;
e[2 * 4 + 0] = z.x;
e[2 * 4 + 1] = z.y;
e[2 * 4 + 2] = z.z;
e[2 * 4 + 3] = 0;
e[3 * 4 + 0] = this.position.x;
e[3 * 4 + 1] = this.position.y;
e[3 * 4 + 2] = this.position.z;
e[3 * 4 + 3] = 1;
this.object.matrixAutoUpdate = false;
this.object.matrix = mat;
this.object.updateMatrixWorld();
},
/**
* Upate moving
*/
update: function () {
if (!this.initializedPose) {
this.initPose();
this.initializedPose = true;
}
for (var key in this.pressedKeyCode) {
if (this.pressedKeyCode[key]) {
switch(key) {
case '38': // up
this.direction = 1;
this.move();
break;
case '40': // down
this.direction = -1;
this.move();
break;
case '37': // left
this.rotate(1);
break;
case '39': // right
this.rotate(-1);
break;
}
}
}
},
/**
* プレイヤーをカメラの向いている先に移動させる
*/
move: function () {
var forward = this.getForward().setLength(this.speed * this.direction);
// 進行方向に少しだけ距離を加算
this.position.sub(forward);
var direction = this.position.clone().sub(this.origin);
var len = direction.length();
if (len > this.radius) {
direction.normalize();
direction = direction.setLength(this.radius);
this.position = (new THREE.Vector3()).addVectors(this.origin, direction);
}
var y = this.position.clone().sub(this.origin).normalize();
var x = y.clone().cross(this.previousForward).normalize();
var z = x.clone().cross(y).normalize();
// 初期のZ軸(進行方向)を保持
this.previousForward = z;
var mat = new THREE.Matrix4();
var e = mat.elements;
e[0 * 4 + 0] = x.x;
e[0 * 4 + 1] = x.y;
e[0 * 4 + 2] = x.z;
e[0 * 4 + 3] = 0;
e[1 * 4 + 0] = y.x;
e[1 * 4 + 1] = y.y;
e[1 * 4 + 2] = y.z;
e[1 * 4 + 3] = 0;
e[2 * 4 + 0] = z.x;
e[2 * 4 + 1] = z.y;
e[2 * 4 + 2] = z.z;
e[2 * 4 + 3] = 0;
e[3 * 4 + 0] = this.position.x;
e[3 * 4 + 1] = this.position.y;
e[3 * 4 + 2] = this.position.z;
e[3 * 4 + 3] = 1;
this.object.matrixAutoUpdate = false;
this.object.matrix = mat;
this.object.updateMatrixWorld();
}
};
解説
ざっくりと処理の概要を解説すると、キーボードの上(下)矢印キーを押すと進行方向に少しだけ移動します。
移動後の軸を再計算し、進行方向自体をアップデートすることで、球体に沿って移動していく、という方法です。
イメージ的な話をすると、ものすごく角数の多い多角形を描いている感じになります。
限りなく円に近いんだけど、局所的に見れば直線部分があって多角形になっている、と。
軸のアップデート
さて多角形は分かりますが、その進行方向はどう求めているのか。
以前書いたローカルの軸をワールド空間上でのベクトルに合わせる回転行列という記事に、ビュー座標変換の回転行列の作り方と意味を記載していますが、以下の数値の指定は、まさにこれと同じものです。
/**
* 前方ベクトルを取得する
*/
getForward: function () {
var forwardVec4 = this.extractVector.clone().applyMatrix4(this.object.matrix);
var forward = new THREE.Vector3(forwardVec4.x, forwardVec4.y, forwardVec4.z).normalize();
return forward;
},
//////////////////////////////////////////////////
move: function () {
var forward = this.getForward().setLength(this.speed * this.direction);
// 進行方向に少しだけ距離を加算
this.position.sub(forward);
var direction = this.position.clone().sub(this.origin);
var len = direction.length();
if (len > this.radius) {
direction.normalize();
direction = direction.setLength(this.radius);
this.position = (new THREE.Vector3()).addVectors(this.origin, direction);
}
var y = this.position.clone().sub(this.origin).normalize();
var x = y.clone().cross(this.previousForward).normalize();
var z = x.clone().cross(y).normalize();
// 初期のZ軸(進行方向)を保持
this.previousForward = z;
var mat = new THREE.Matrix4();
var e = mat.elements;
e[0 * 4 + 0] = x.x;
e[0 * 4 + 1] = x.y;
e[0 * 4 + 2] = x.z;
e[0 * 4 + 3] = 0;
e[1 * 4 + 0] = y.x;
e[1 * 4 + 1] = y.y;
e[1 * 4 + 2] = y.z;
e[1 * 4 + 3] = 0;
e[2 * 4 + 0] = z.x;
e[2 * 4 + 1] = z.y;
e[2 * 4 + 2] = z.z;
e[2 * 4 + 3] = 0;
e[3 * 4 + 0] = this.position.x;
e[3 * 4 + 1] = this.position.y;
e[3 * 4 + 2] = this.position.z;
e[3 * 4 + 3] = 1;
this.object.matrixAutoUpdate = false;
this.object.matrix = mat;
this.object.updateMatrixWorld();
}
上記が各軸を更新している処理です。順を追って解説していきます。
オブジェクトの「前方」を取得する
forward
変数は、現在のオブジェクトの姿勢行列から前方、つまり Z軸の方向
を抜き出しています。
このベクトルに対して一定の長さを掛けることで、向いている方向に進ませることができます。
ただ、このままだと球体に沿わず、向いている方向に進み続けてしまうので、その下の部分で位置と軸をアップデートし、回転行列自体を書き換えることで、前方が常に球体に沿っているようにしています。
length
を取っている部分は、一定の長さ以上の距離離れないようにするための保険的な処理です。
本題はその次から。
球体に沿うように回転を適用する
まず、更新後の位置と球体の中心との差分ベクトルを取り、オブジェクトの上方(Y軸向きではなく、オブジェクトの頭の向き。地球のどこにいても頭上が空になるのと同じイメージ)を求めます。
これが、オブジェクト自体のY軸方向になります。
次に、前回の進行方向ベクトルと、たった今求めたY軸ベクトルの外積を取ります。
外積は、ふたつのベクトルのどちらとも垂直になるベクトルを求めることができます。
Y軸に垂直な軸はZ軸とX軸です。そして、利用した軸は前Z軸とY軸なので、必然的に求まるのはX軸となります。
さて、これで移動後の状態でのY軸とX軸が求まりました。
ですが、実はZ軸はまだ計算できてません。
おいおい、さっきの前方ベクトルはなんなんだ、となると思いますが、移動後
のZ軸方向はまだ求まっていません。
ただ求め方は、先ほどまで使っていた外積を利用します。
つまり、すでに求めたY軸とX軸それぞれに平行な軸はZ軸しかありません。
なので、その外積を取ってそれをZ軸とします。
そして計算で出てきたZ軸を、前回のZ軸として保持しておきます。
求まった各軸の値を行列に直に書き込んでいるのがその次の部分です。
e[0 * 4 + 0] = x.x;
e[0 * 4 + 1] = x.y;
e[0 * 4 + 2] = x.z;
e[0 * 4 + 3] = 0;
e[1 * 4 + 0] = y.x;
e[1 * 4 + 1] = y.y;
e[1 * 4 + 2] = y.z;
e[1 * 4 + 3] = 0;
e[2 * 4 + 0] = z.x;
e[2 * 4 + 1] = z.y;
e[2 * 4 + 2] = z.z;
e[2 * 4 + 3] = 0;
e[3 * 4 + 0] = this.position.x;
e[3 * 4 + 1] = this.position.y;
e[3 * 4 + 2] = this.position.z;
e[3 * 4 + 3] = 1;
XYZ軸
のそれぞれの x, y, z
を行列に格納しています。
相対的な回転ではなく、バシッと回転を決め打ちする場合は非常に分かりやすく行列が作れることが分かります。
よく聞く「回転行列」は、あくまで「今の姿勢から、任意の軸に対して回転を適用する」ための行列となるため、意味が少し異なります。
そして最後に、計算済みの位置情報( position
)を行列に書き込んだらそれをオブジェクトの行列として設定しupdateを行います。
これを実行すれば無事、オブジェクトが球体に沿って進むようになります。
オブジェクトを回転させる
さて、最後の解説はオブジェクトの回転です。
以下の処理ですね。
rotate: function (dir) {
var q = new THREE.Quaternion();
var rad = 5 * Math.PI / 180 * dir;
q.setFromAxisAngle(this.rotateAxis, rad);
var mat = new THREE.Matrix4();
var e = mat.elements;
e[0 * 4 + 0] = 1.0 - 2.0 * q.y * q.y - 2.0 * q.z * q.z;
e[0 * 4 + 1] = 2.0 * q.x * q.y + 2.0 * q.w * q.z;
e[0 * 4 + 2] = 2.0 * q.x * q.z - 2.0 * q.w * q.y;
e[0 * 4 + 3] = 0;
e[1 * 4 + 0] = 2.0 * q.x * q.y - 2.0 * q.w * q.z;
e[1 * 4 + 1] = 1.0 - 2.0 * q.x * q.x - 2.0 * q.z * q.z;
e[1 * 4 + 2] = 2.0 * q.y * q.z + 2.0 * q.w * q.x;
e[1 * 4 + 3] = 0;
e[2 * 4 + 0] = 2.0 * q.x * q.z + 2.0 * q.w * q.y;
e[2 * 4 + 1] = 2.0 * q.y * q.z - 2.0 * q.w * q.x;
e[2 * 4 + 2] = 1.0 - 2.0 * q.x * q.x - 2.0 * q.y * q.y;
e[2 * 4 + 3] = 0;
this.object.matrixAutoUpdate = false;
this.object.matrix.multiply(mat);
this.object.updateMatrixWorld();
this.previousForward = this.getForward();
},
これはクォータニオンから回転行列を作っています。
こちらの記事(その58 やっぱり欲しい回転行列⇔クォータニオン相互変換)を参考にさせていただきました。
また、記事内で紹介されている実例で学ぶ3D数学はとても参考になる書籍なので、興味がある方はぜひ読んでみてください。
上記書籍でも解説されていますが、こちらは三角関数の「倍角の公式」「三角恒等式」などを駆使して導き出したものです。
倍角の公式
倍角の公式は以下のようです。
$sin2θ = 2sinθcosθ$ と $cos2θ = cos^2θ - sin^2θ$ です。
三角恒等式
$sin^2θ = 1 - cos^2θ$
これら公式を駆使すると、任意軸の回転行列とクォータニオンとの関連性から、上記の計算が算出できます。
これを使うことで無事、クォータニオンから回転行列を創りだすことができました。
あとは行列同士の掛け算を行うことで、オブジェクトを回転させることができます。