この記事は「Cluster Script Advent Calendar 2024」22日目の記事です。
昨日は蛮樽むさしさんの「乗り物から飛翔体を撃つ方法」でした。いろんなサンプルを組み合わせて自分のつくりたい機能を実現するというのも、こういったスクリプトを覚えたりするうえで大事ですね。
こんにちは、滝竜三です。
今回はスクリプトを学習する中でも鬼門とされる「Quaternion」の使い方を解説していこうと思います。
オブジェクトの位置や回転を取得・操作することは動きのあるギミックでは必ずと言っていいほど必要になる基本的な機能ですが、スクリプトでの回転操作は直観的に理解しやすいXYZ軸の回転角度(オイラー角)ではなく「Quaternion(クォータニオン/四元数)」というものを使って計算するため、慣れないうちは少し難しい部分があります。
このQuaternionの使い方を理解して、オブジェクトの回転や向きを自在に操れるようにしていきましょう!
※実装する分にはおおよそ正しい内容になっていると思いますが、数学的な理解にはそれほど自信がないのでもし間違ったことを書いていたら教えてください。
Quaternionとは
Quaternionとは複素数の3次元への拡張であり、4次元のベクトルに回転軸と回転角度の情報を格納して…といった数学的に正しい解説はここではしません。ぶっちゃけ自分もこの方向でちゃんと解説できる自信がない。
もちろん数学的な中身まで理解しているに越したことはないですが、今回はあくまで「スクリプトで回転を扱えるようになる」ことに主眼を置いて、中の計算がどうなっているかを(できるだけ)気にせず使えることを目標にしていきたいと思います。
というわけで、ここではQuaternionとはざっくり「3D空間上での回転を表すデータ」とだけ考えることにしましょう。
「回転」というのは角度の変化量・差分のことなのはもちろん、「基準からどれだけ回転したか」と考えれば現在の向きや傾きを表すこともできます。
Quaternionは人間に分かりやすい形式の回転情報(UnityのTransformに入力するようなXYZ軸の角度など)と相互に変換することもできますが、基本的にはQuaternionの中身の値を人間が直接気にする必要はありません。
一度Quaternionの形式で回転を表現したら、以降はQuaternion同士の計算で求める回転を導き、その結果得られたQuaternionをsetRotationに入れたり、Vector3に適用して座標変換したりします。
Quaternion同士の計算というのは、「この回転をさせてから、次にこの回転をする」みたいな感じで順番に回転を適用していき、その最終的な回転の状態を求めるものです。
例えば「横に60°回転させてから縦に45°回転させたい」といった場合であれば、「横に60°の回転を表すQuaternion」と「縦に45°の回転を表すQuaternion」から「横に60°回転させてから縦に45°回転させた角度のQuaternion」を求め、最後にそれをsetRotationに渡します。
このように「Quaternionは回転を表すデータである(中身の数字は気にしない)」「回転の状態はQuaternion同士の計算で求める」という部分だけ考えてもらうといくらかスッキリするかなと思います。
Quaternionを使う
ここからは実際にQuaternionを使ってさまざまな「回転」を実装してみます。
比較的簡単なものからちょっと複雑なものまでいくつか紹介していくので、おそらくこの記事の読者にはおなじみであろうで各項目の難易度を示します(おなじみじゃない方は雰囲気だけ感じ取ってください)。
-
:書き方さえ分かればすぐに使える内容
-
:覚えておいた方が良い内容
-
:まあまあちゃんと理解しないと難しい内容
という感じです。
なお、この記事は基本的なClusterScriptの書き方は理解していて、特にVector3を使って座標(Position)を扱うことができる程度の習熟度を前提としています。
ある程度動くものを書けるようになったが回転の扱いがいまいち理解できていない、なんとなくで触っているという方が対象です。
指定の回転のQuaternionをつくる
「横に90°倒す」など、指定の角度でオブジェクトの向きを設定したり回転させたりしたい場合は、人間が理解しやすい形式の角度指定からQuaternionに変換することができます。
指定の角度のQuaternionをつくるには「XYZ軸それぞれの角度で指定」「回転軸ベクトルと角度で指定」のふたつの方法があります。
XYZ軸の角度で指定するQuaternion.setFromEulerAngles()
は、UnityのTransformで指定するのと同様のものです。
「回転軸」と「角度」を指定するQuaternion.setFromAxisAngle()
は、例えば「X軸中心に90°回転」であれば、X軸を表すベクトル(1, 0, 0)と、90°という角度を指定します。慣れてくると、特に斜めの回転などはこちらの方が直観的に表現しやすかったりします。
以下はインタラクトするたびに角度を変更するサンプルです。
「Y軸で指定の角度だけ回転する」ことを表すQuaternionを作成し、setRotation
します。
$.onStart(() => {
$.state.angle = 0;
});
$.onInteract((player) => {
// インタラクトするたびに30°回転
const angle = $.state.angle + 30;
$.state.angle = angle;
// 指定の角度のQuaternionをつくる(2通りの方法)
const newRotation = new Quaternion().setFromEulerAngles(new Vector3(0, angle, 0));
// const newRotation = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), angle);
// 回転を適用
$.setRotation(newRotation);
});
現在の向きから回転させる 
「アイテムの現在の向きを取得して、そこから指定の回転をした状態に変化させる」という処理をしてみます。例えば乗り物を旋回させる場合などに使う計算です。
「現在の向き」は「初期状態から現在の向きにする回転」で表されます。$.getRotation()
等で取得できるのがこの回転のQuaternionです。
「初期状態」と言っているのはTransformのRotationが(0,0,0)の状態のことですね。
それでは、「現在の向きからY軸に30°回転させる」処理を考えてみます。
「Y軸で30°回転」のQuaternionはsetFromEulerAngles(new Vector3(0, 30, 0))
でつくることができます。
QuaternionはQuaternion.multiply()
により別のQuaternionを「掛ける」ことで回転を重ね掛けすることができます。
ここで気を付けないといけないのが、Quaternionの掛け算は左右を入れ替えられないという点です。逆カプ地雷
Unity上で少し実験してみましょう。シーン上に適当なCubeを置き、回転ツールでCtrlを押しながら「X軸に45°→Y軸に30°」「Y軸に30°→X軸に45°」の順でそれぞれ回転させてみてください。どちらの回転を先にするかで、最終的な向きが違っているはずです。
Quaternionの掛け算は「ひとつ目の回転をしてからふたつ目の回転をする」ことをあらわすので、これと同じように順番が重要になってきます。
ちなみに余談ですが、実は数学の世界ではむしろ「順番を入れ替えられる掛け算」の方が特殊だったりします。
以下は左右方向の入力で回転する乗り物の例です。
「現在の回転」の左から「適用したい回転」を掛けることで回転させます。左からというのは係数として掛ける、と考えるのがイメージしやすいでしょうか。
※この辺の左から・右からに対する考え方はいろいろあるのですが、個人的に説明しやすいのでここではこういうことにします。
// 1秒あたり90°回転する
const angularVelocity = 90;
$.onStart(() => {
$.state.rotation = $.getRotation();
$.state.steerInput = new Vector2(0, 0);
});
$.onSteer((input, player) => {
$.state.steerInput = input;
});
$.onUpdate((deltaTime) => {
if ($.getRidingPlayer() != null) {
const steerInput = $.state.steerInput;
const currentRotation = $.state.rotation;
// ハンドル入力値*角速度*前のフレームからの時間で「このフレームで回転する角度」を求める
const degreeDifference = steerInput.x * angularVelocity * deltaTime;
const rotationDifference = new Quaternion().setFromEulerAngles(new Vector3(0, degreeDifference, 0));
// 現在の回転にハンドル操作による回転を加えた新しい回転を求める
// rotationDifference*currentRotation として、ハンドル操作による回転を左から掛ける
const newRotation = rotationDifference.clone().multiply(currentRotation);
$.setRotation(newRotation);
$.state.rotation = newRotation;
}
});
ここで、$.getRotation()
で取得できる回転は最後に$.setRotation
で設定した値と必ずしも同じにならずズレてしまうことがある(動きに補間が入るため)ことに注意してください。
毎回新規に現在の状態を取得するのではなく、別途Stateとして「最後に設定したRotation」の値を保存しておくのがオススメです。
「ローカル空間」で回転する 
上のスクリプトを、あらかじめ傾けた乗り物に適用してみてください。
そうすると、乗り物の向きによらずグローバル空間のY軸に対する回転になってしまいます。
つくりたい乗り物によってはこれで大丈夫ですが、例えば乗り物に乗っている人の一人称視点を基準に回転させたい場合はこれではうまくいきません。
こういった場合は掛ける順番を入れ替えて「現在の向きから回転する」ではなく「回転してから現在の向きを適用する」という風にしてみましょう。現在の向きになるより先にハンドル操作で回転していたことにする、というイメージです。
// 1秒あたり90°回転する
const angularVelocity = 90;
$.onStart(() => {
$.state.rotation = $.getRotation();
$.state.steerInput = new Vector2(0, 0);
});
$.onSteer((input, player) => {
$.state.steerInput = input;
});
$.onUpdate((deltaTime) => {
if ($.getRidingPlayer() != null) {
const steerInput = $.state.steerInput;
const currentRotation = $.state.rotation;
// ハンドル入力値*角速度*前のフレームからの時間で「このフレームで回転する角度」を求める
const degreeDifference = steerInput.x * angularVelocity * deltaTime;
const rotationDifference = new Quaternion().setFromEulerAngles(new Vector3(0, degreeDifference, 0));
// 変更箇所
// ハンドル操作による回転に現在の向きを適用した新しい回転を求める
// currentRotation*rotationDifference として、ハンドル操作の回転に現在の向きを左から適用する
const newRotation = currentRotation.clone().multiply(rotationDifference);
$.setRotation(newRotation);
$.state.rotation = newRotation;
}
});
このように、Quaternionの計算は適用する順番によって結果が変わってしまいます。
迷ったら「Rotation=(0,0,0)の状態からどういう順で回転したら求める結果になるか」の観点で整理しましょう。グローバル空間を基準に、最初に適用する回転がいちばん右で、そこから順番に左に掛けていきます。
(4つ目の回転)*(3つ目の回転)*(2つ目の回転)*(1つ目の回転)…
という形です。
「任意の座標系」で回転する 

先ほどは「自身の座標系」での回転を考えましたが、今度は「任意の座標系」での回転を考えます。
基本的には「基準となる座標系の回転をいったん打ち消して、必要な回転をしてからもとの座標系に戻す」という感覚が分かりやすいと思います。
先ほどの「ローカル座標系での回転」も、「先にローカル回転をしてから元の向きの回転を適用する」ではなく、「元の向きの逆回転をさせて初期位置に戻してから、ローカル回転させて、元の向きの回転を再度適用する」という風に考えることもできます(結果的に初期状態に戻すだけなので逆回転の計算を省略している)。
例として「プレイヤーの向きを基準に回転するアイテム」をつくってみます。
あるQuaternionの逆回転を表すQuaternionはQuaternion.invert()
で取得できます。
const angularVelocity = 90;
$.onStart(() => {
$.state.player = null;
$.state.currentRotation = $.getRotation();
});
$.onInteract((player) => {
$.state.player = player;
});
$.onUpdate((deltaTime) => {
const player = $.state.player;
if (player == null) return;
if (player.exists() === false) return;
const currentRotation = $.state.currentRotation;
// プレイヤーの向きを基準に回転する
const referenceRotation = player.getRotation();
const rotateAngle = angularVelocity * deltaTime;
const rotationDifference = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), rotateAngle);
// 基準の向きを打ち消す回転
const referenceRotationInverse = referenceRotation.clone().invert();
// 基準の向きを打ち消した現在のアイテムの向き=基準座標系でのアイテムの向き
const localCurrentRotation = referenceRotationInverse.clone().multiply(currentRotation);
// 回転
const localNewRotation = rotationDifference.clone().multiply(localCurrentRotation);
// 基準の向きを再適用してグローバル座標系での向きにする
const globalRotation = referenceRotation.clone().multiply(localNewRotation);
$.setRotation(globalRotation);
$.state.currentRotation = globalRotation;
});
このように複雑になってくると、数式で整理した方が分かりやすいかもしれません。
現在のグローバル空間での向きを表すQuaternionを$R$、基準となる向きを$R_{ref}$とすると、「ローカル回転に基準座標系の回転を適用するとグローバル回転になる」ことから、ローカル回転を表す$R_{local}$は
\displaylines{
R_{ref}R_{local}&=&R \\
R_{ref}^{-1}R_{ref}R_{local}&=&R_{ref}^{-1}R \\
R_{local}&=&R_{ref}^{-1}R
}
となります。ここで、ある回転を表すQuaternionを$Q$としたとき、その逆回転を$Q^{-1}$と表記し、$QQ^{-1}$および$Q^{-1}Q$は打ち消し合って消えます。
式変形では普通の方程式と同様に「両辺に同じものを掛ける」操作をしていますが、掛ける向きが決まっているため「左右どちらから掛けるか」に注意してください。
ローカル回転$R_{local}$に回転$A$を適用し、基準の回転$R_{ref}$を適用して座標系を戻した、最終的なグローバル空間での向き$R_{new}$は
R_{new}=R_{ref}AR_{local}=R_{ref}AR_{ref}^{-1}R
となります。
適用する回転を「基準の逆回転」と「基準の回転」で挟む形になっていて、「最初に基準を打ち消して最後に戻す」という説明と一致しますね。
ベクトルを回転させる
Quaternionは単にオブジェクトの向きを変えるだけでなく、ベクトルを回転させることもできます。
例えば回転するオブジェクトを「そのオブジェクトから見た正面方向」に移動させたい場合などには、「正面方向のベクトル」を「現在のオブジェクトの向き」で回転させて、その方向に移動することで実現できます。
「『ローカル空間』の回転を考える」でつくった向きを変えられる乗り物を、現在の向きに対する正面方向へ前進させるようにしてみます。
// 1秒あたり90°回転する
const angularVelocity = 90;
// 追加
// 最大2m/sの速度でZ軸方向に前進する
const velocity = new Vector3(0, 0, 2);
$.onStart(() => {
$.state.rotation = $.getRotation();
$.state.steerInput = new Vector2(0, 0);
// 追加
$.state.position = $.getPosition();
$.state.additionalInput = 0;
});
$.onSteer((input, player) => {
$.state.steerInput = input;
});
$.onUpdate((deltaTime) => {
if ($.getRidingPlayer() != null) {
const steerInput = $.state.steerInput;
const currentRotation = $.state.rotation;
// ハンドル入力値*角速度*前のフレームからの時間で「このフレームで回転する角度」を求める
const degreeDifference = steerInput.x * angularVelocity * deltaTime;
const rotationDifference = new Quaternion().setFromEulerAngles(new Vector3(0, degreeDifference, 0));
const newRotation = currentRotation.clone().multiply(rotationDifference);
$.setRotation(newRotation);
$.state.rotation = newRotation;
// 追加
const currentPosition = $.state.position;
// 速度ベクトルに前後入力値*前のフレームからの時間を掛けて「このフレームでの移動量」を求める
const positionDifference = velocity.clone().multiplyScalar(steerInput.y * deltaTime);
// このフレームでの移動量に現在の向きを適用し、「現在の向きを基準としたこのフレームでの移動量」を求める
const globalPositionDifference = positionDifference.applyQuaternion(currentRotation);
const newPosition = currentPosition.clone().add(globalPositionDifference);
$.setPosition(newPosition);
$.state.position = newPosition;
}
});
slerp
Quaternionのslerpという機能を利用すると、ふたつの回転の中間の回転を計算することができます。
ある向きから別の向きになるようにゆっくり回転させたい、とかスコアの差によってメーターを回転させたい、といったような場合はこの機能を使います。
インタラクトすると、ある向きから別の向きに向かってゆっくり回転するサンプルです。
// 5秒かけてstartRotationからendRotationまで回転する
const startRotation = new Quaternion().setFromEulerAngles(new Vector3(0, 90, 0));
const endRotation = new Quaternion().setFromEulerAngles(new Vector3(45, 0, 45));
const moveTime = 5;
$.onStart(() => {
$.state.time = moveTime;
});
$.onInteract((player) => {
$.state.time = 0;
});
$.onUpdate((deltaTime) => {
const time = $.state.time + deltaTime;
const timeRate = Math.min(time / moveTime, 1);
// startとendの間の、timeRate:(1-timeRate)の比率になる中間地点を求める
const rotation = startRotation.clone().slerp(endRotation, timeRate);
$.setRotation(rotation);
$.state.time = time;
});
というわけで、ClusterScriptにおけるQuaternionの基本的な機能や考え方をざっと紹介しました。
座標系の変換あたりなどは少し難しいかもしれませんが、回転の適用順を意識しながら落ち着いて考えてみてください。
回転が使いこなせるとさまざまな動きを自在につくることができます。基本的な機能の割に直観的に捉えるのが難しいQuaternionですが、ぜひ頑張ってみてください!
明日はニンニン猫さんの「動的に色や光が変わるクラフトアイテムに必要なマテリアルプロパティ操作APIで遊んでみよう」です。アイテムの色などを変えられるマテリアルプロパティ操作APIを使うと、演出などにさまざまな工夫ができますね。