#概要
ハンドコントローラの動きに合わせてIKでアバターを動かそうとするとき、ランタイムでアバターを切り替える場合は特に、各アバターによって手のボーンの初期姿勢が異なるために両手があらぬ方向を向くという問題がある気がします。
今回は、VRoomというHMDを被りながら作業通話やゲームをするツールを作っている過程で、上記の問題にどのように対処したのか、書きながら整理しようと思います。
かなり手探りで進めているので、いろいろ教えていただければ幸いです…
HMDを被ったままビデオ通話に参加するための環境ができつつある(嬉しい)
— Ytomi (@kanmichun) April 26, 2020
・HMDを被ったままでのモニター操作
・コントローラーとマウスの持ち換え
・内部でのカメラスイッチ
・楽しい室内移動…などが、とりあえず欲しかった pic.twitter.com/nVjCXWbuQq
GitHub : Ytomi4/VRoom
####環境
- Unity 2019.3.10
- FinalIK ver1.9
####目指すところ
アプリ内でアバターを自由に切り替えることを前提に、プレイヤーの手にアバターの手が重なるよう、ボーンの初期姿勢が異なる様々なアバターに対応可能な調整方法を考える。
#解決したい問題の特定
便宜的に、アバターによって手首のボーンの初期姿勢が異なることから起こる問題と表現しましたが、具体的には下画像のような状況を指しています。
画像上段は、各アバターの左手にあたるボーン(VRIK.referencesのLeftHandに格納されるGameObject)の姿勢をGizmoで表示したものです。
左手ボーンのローカル空間において、赤が右方向(1,0,0)、緑が上方向(0,1,0)、青が前方向(0,0,1)であることを示しています。
上の例が示す通り、アバターの左手(掌)が左手ボーンのローカル空間でどの方向を向いているかは様々です(モデラーの方の出力による)。
Unitychan(画像左上)1では、左手が左手ボーンのローカル空間における左方向(-1,0,0)を向いており、Cygnet(画像中央上)2では左手ボーンのローカル空間における上方向(0,1,0)を向いています。Unitychan(画像左上)とVita(画像右上)3は、手がそれぞれのボーンのローカル空間で左方向(-1,0,0)を向いているという点では同じですが、x軸(赤)を中心に90°回転させたようになっており、たとえば手のひらに対する親指の方向の表現が異なります。
これにより、読み込んだアバターに単純にVRIKコンポーネントをアタッチするだけでは手首があらぬ方向を向きます(画像下段)。
Weightを1にして左手のボーンの回転RotationをTarget(画像ではVIVEコントローラーのモデルの先端)に完全に追従させると、「左手ボーンのローカル空間における右・上・前方向」と「コントローラーのローカル空間における右・上・前方向」とがぴったり重なるように左手ボーンが回転するため、アバターの手があらぬ方向を向いてしまう、と理解しました。
上記の理由からアバターによって種々の方向を向く手首を、なんとかコントローラーの前方向を向くようにしたいというのが今回の問題となります。
#解決の指針
たとえば、VRIKの公式チュートリアル動画(1:39~)では、コントローラーの位置と回転を反映させたシーン内のGameObjectの子オブジェクトをTargetに設定し、Transformを直接Inspectorからいじるという形で調整しています。
ただし、今回のケースでは、プレイ中のアバターの切り替えを前提としているのでInspectorから直接調整するという手法はとれず、アバター読み込み時にアバターの手のボーンの状態から適当な回転を計算し、親(コントローラー)に対してTargetを回転させる必要があります。
ここでかなり役に立ったのがGuessHandOrientations()でした(リファレンス)。これによって、回転を計算するのに必要な情報である、手首から手のひら方向と手のひらから親指方向とみなせる手のボーンのローカル空間における軸を求められます。内容は決して複雑ではないのですが、言及している記事を自分が見つけられなかった、かつ公式のリファレンスがスカスカだったので、次の話に入る前に少しまとめておきます。
###VRIK.GuessHandOrientations()
public void GuessHandOrientations() {
solver.GuessHandOrientations(references, false);
}
まず一次的な処理としては、IKSolverVRクラスのインスタンスsolverからGuessHandOrientations (VRIK.References references, bool onlyIfZero)
を呼び出し、次にsolver.rightHandとsolver.leftHandのwristToPalmAxis
とpalmToThumbAxis
に代入する値(Vector3)を計算しています。それぞれ、手首から手のひら方向・手のひらから親指方向とみなせる手のボーンのローカル空間における軸を意味します。
上の画像の例では、wristToPalmAxis
が(0,1,0)、palmToThumbAxis
が(0,0,1)です。
もう少し詳しく見ると、wristToPalmAxis
は手のボーンから肘にあたる場所のボーンへのベクトルtoForearmと、手のボーンのローカル空間におけるXYZ軸それぞれとの内積を取り、内積が最も小さくなる軸をVector3で返した後で、肘とは反対側を指すようひっくり返した(-1倍した)ものであると説明できます。Final IKのスクリプトから一部抜粋して引用します。
leftArm.wristToPalmAxis = GuessWristToPalmAxis(references.leftHand, references.leftForearm);
private Vector3 GuessWristToPalmAxis(Transform hand, Transform forearm) {
Vector3 toForearm = forearm.position -hand.position;
Vector3 axis = AxisTools.ToVector3(AxisTools.GetAxisToDirection(hand, toForearm));
if (Vector3.Dot(toForearm, hand.rotation * axis) > 0f) axis = -axis;
return axis;
}
余談ですが、先の説明と引用したコードが示す通り、wristToPalmAxis
は肘と手首の関係のみから求められ、手のひらと手首の位置関係は関係ないため、たとえば手首が小さく丸まっているようなアバターを想定した場合でも、wristToPalmAxis
がコントローラーの前方向を向くように回転させれば、手首の丸まりを保持したままコントローラーに沿った手のかたちになるといえそうです。
モデラ―の方が出力してくれた肘から手にかけてのニュアンスを(極力)維持したまま、向きをコントローラーにあわせてそれらしく調整できるようになっているかというのはかなり重要な部分だと思います。
#具体的な解決方法
VRoomのCharacterControl.csでは、アバター読み込み時に以下のような処理を行うことにしました。
- 読み込んだアバターにVRIKコンポーネントをアタッチする
- AutoDetectReferences()でリファレンスを埋める
-
AdjustIKTargetsToAvatarsHands()でアバターに合わせてIKTargetの位置・回転を変える
-
GuessHandOrientations()
で、solver.rightHand
とsolver.leftHand
それぞれのwristToPalmAxis
とpalmToThumbAxis
を求める -
wristToPalmAxis
がコントローラーの前方向(0,0,1)を向くような回転leftWristRot
を求め、IKTargetを回転させる- このとき、あくまで回転させるのはコントローラーの子オブジェクトであるIKTargetのlocalRotationであるため、
wristToPalmAxis
(あるいは回転後のpalmToThumbAxis
)と比較するコントローラーの前方向は常に(0.0,1)と表現できる
- このとき、あくまで回転させるのはコントローラーの子オブジェクトであるIKTargetのlocalRotationであるため、
- コントローラーの前方向(0,0,1)を軸として、
leftWristRot
で回転したpalmToThumbAxis
が、コントローラーの右方向(1,0,0)となす角だけ回る回転leftPalmRotAngle
を求め、IKTargetを回転させる(左手の場合) - さらに回転した
palmToThumbAxis
が左方向(-1,0,0)を向いている場合は、前方向(0,0,1)を軸に180°回転(左手の場合)- Vector3.Angleの返す
leftPalmRotAngle
はあくまで角度差であるため、90°反対に回転してpalmToThumbAxis
と向くべき方向との間で180°の開きが生まれるパターンがある
- Vector3.Angleの返す
- 手の位置にオフセットを加える
- オフセットを加えない場合、コントローラーの先端がアバターの手首の位置にあり、不自然に感じる
- オフセットを加える方向はアバターによって異なるので、毎回TargetのlocalPositionはVector3.zeroに戻しておく必要がある
-
- IKTargetの代入やstretchCurveの初期化など…
private void AdjustIKTargetsToAvatarsHands()
{
_vrik.GuessHandOrientations();
//Targetのローカルポジションを(0,0,0)に戻しておく
_leftHandIKTarget.transform.localPosition = Vector3.zero;
_rightHandIKTarget.transform.localPosition = Vector3.zero;
//左手
//wristToPalmAxisがコントローラーの前方向(0,0,1)を向くよう回転
Quaternion leftWristRot = Quaternion.FromToRotation(_vrik.solver.leftArm.wristToPalmAxis, Vector3.forward);
_leftHandIKTarget.transform.localRotation = leftWristRot * Quaternion.identity;
//palmToThumbAxisがコントローラーの右方向(1,0,0)となす角だけ前方向(0,0,1)を軸に回転
float leftPalmRotAngle = Vector3.Angle(leftWristRot * _vrik.solver.leftArm.palmToThumbAxis, Vector3.right);
Quaternion leftPalmRot = Quaternion.AngleAxis(leftPalmRotAngle, Vector3.forward);
_leftHandIKTarget.transform.localRotation = leftPalmRot * _leftHandIKTarget.transform.localRotation;
//palmToThumbAxisが左方向(-1,0,0)を向いている場合は、前方向(0,0,1)を軸に180°回転
Vector3 leftThumbDirRotated = leftPalmRot * (leftWristRot * _vrik.solver.leftArm.palmToThumbAxis);
if (Vector3.Dot(leftThumbDirRotated, Vector3.right) < 0)
{
_leftHandIKTarget.transform.localRotation = Quaternion.AngleAxis(180, Vector3.forward) * _leftHandIKTarget.transform.localRotation;
}
//手の位置にオフセットを加える
_leftHandIKTarget.transform.localPosition -= _handOffset * Vector3.forward;
//右手
//wristToPalmAxisがコントローラーの前方向(0,0,1)を向くよう回転
Quaternion rightWristRot = Quaternion.FromToRotation(_vrik.solver.rightArm.wristToPalmAxis, Vector3.forward);
_rightHandIKTarget.transform.localRotation = rightWristRot * Quaternion.identity;
//palmToThumbAxisがコントローラーの左方向(1,0,0)となす角だけ前方向(0,0,1)を軸に回転
float rightPalmAngle = Vector3.Angle(rightWristRot * _vrik.solver.rightArm.palmToThumbAxis, Vector3.left);
Quaternion rightPalmRot = Quaternion.AngleAxis(rightPalmAngle, Vector3.forward);
_rightHandIKTarget.transform.localRotation = rightPalmRot * _rightHandIKTarget.transform.localRotation;
//palmToThumbAxisが右方向(1,0,0)を向いている場合は、前方向(0,0,1)を軸に180°回転
Vector3 rightThumbDirRoted = rightPalmRot * (rightWristRot * _vrik.solver.rightArm.palmToThumbAxis);
if (Vector3.Dot(rightThumbDirRoted, Vector3.left) < 0)
{
_rightHandIKTarget.transform.localRotation = Quaternion.AngleAxis(180, Vector3.forward) * _rightHandIKTarget.transform.localRotation;
}
//手の位置にオフセットを加える
_rightHandIKTarget.transform.localPosition -= _handOffset * Vector3.forward;
}
例に挙げた3体のアバターで確認したところ、プレイヤー側に操作を強いることなく、どのアバターの手もプレイヤーの手と違和感なく重なるようになっていました。
#課題
- 確実にベストプラクティスがどこかにあるはずなので、調べる&読む
- 例外をつくらない
leftPalmRot
- 他のボーンとの位置関係から、特定の部位の向いている方向をとるという考え方は応用が利きそう(たとえば指先前方向にRayを飛ばすなど)
- そもそものアバター間の違いが、作られたモデリングソフトの違いによって発生していると思っているが、どうなのだろう
- Quaternionの理解を深めたい