これはUnity Advent Calendar 2019の24日目の記事です。
3Dキャラクターをミラー処理するPlayableを作りたい
2D格闘ゲームや横スクロールアクションゲームでキャラクターや背景を3Dで表現した、いわゆる2.5Dゲームにおいて、キャラクターをミラーリング(左右反転)処理したいケースがあります。
なぜミラーリングが必要なのか?それはキャラクターは常に画面側を向いててほしいからです。
普通の3Dキャラクターでは右向きで画面に向くよう調整しても、左向きだと背中向けることになってしまい、こちら側を見てくれません。
ミラーリング処理をすることで、左向きでも右向きでも常にこちら向いてくれるようになるわけです。
UnityのMecanimには既にHumanoidに限りMirror機能がありますが、それを使わずにPlayables APIを使って自前で実装します。
結果
左右反転した動きをする3Dキャラクターを表示できました。
が、、よく見ると顔パーツの反転ができてません。
顔の中身をテクスチャで描いているためです。こちらの問題に対応するにXスケールを反転する(ポリゴンが反転するため、シェーダー側の対応も必要)するか、左右反転したテクスチャを用意しておいて、それに切り替えるかで対応する必要があります。
目をボーンで制御している昨今の3Dキャラクターであれば得に問題ないでしょう。
そもそも完全な左右対称の結果を得るにはシンメトリー(左右対称)なキャラクターに限定している話でもあります。
アシンメトリー(左右非対称)なキャラクターの場合、反転結果の差異をある程度許容してもよいですが、場合によっては難しいケース(腕の長さが違う等)もあります。
今記事ではそこまでは触れないでおきます。
今回作成したサンプルプロジェクトをGithubで公開してます。合わせてどうぞ。
https://github.com/you-ri/MirrorAnimationPlayable
なぜMecanim(AnimatorController)のMirrorを使わず自前で実装するのか?
Mecanimには既にミラー機能があります。なのになぜ自前で用意するのでしょうか?
自身のプロジェクトは当初普通にAnimatorControllerを使っていましたが、2018.2あたりでAnimatorControllerのPlayable版、AnimatorControllerPlayableに移行しました。
ところがUnity2019.2にバージョンアップした際、キャラクターのアニメーションが動かなくなるバグを踏んでしまいました。
AnimatorControllerOverrideを使ってランタイム中に書き換えるアプローチで大量のAnimationClipを処理していたのですが、これが機能しなくなったのです。
当然バグ報告しましたが、音沙汰なし。。。これは、、、仕様ということか?
しびれを切らして、自前で大量のAnimationClipを捌き、ミラーを再現する機能をPlayables APIを使って開発する必要がでてきたというわけです。
HumanoidのミラーをAnimationScriptPlayabeで再現する
まずググったところ以下のスレッドがヒットしました。
Playables API - Mirroring Clips and DirectorUpdateMode.Manual
残念なのことにUnityの中の人も途中で投げだしてしまって解決には至っていません。
Muscleの情報から顔や体のロール(RollLeftRight)とヨー(LeftRight)を反転、さらに左右の手、足、指、目などは交換するとで、ミラーした結果が得られるのは理解できるのですが。なぜうまく行かないのか。。。
少し悩んだ末、ルートの回転ミラー計算を見直したところ、左右反転後の姿勢を作り出せることに成功しました。
ついでにIKのミラーにも対応しておきました。
public static void MirrorPose (this AnimationHumanStream humanStream)
{
humanStream.bodyLocalPosition = Mirrored (humanStream.bodyLocalPosition);
humanStream.bodyLocalRotation = Mirrored (humanStream.bodyLocalRotation);
// mirror body
for (int i = 0; i < (int)BodyDof.LastBodyDof; i++) {
humanStream.MultMuscle (new MuscleHandle ((BodyDof)i), BodyDoFMirror[i]);
}
// mirror head
for (int i = 0; i < (int)HeadDof.LastHeadDof; i++) {
humanStream.MultMuscle (new MuscleHandle ((HeadDof)i), HeadDoFMirror[i]);
}
// swap arms
for (int i = 0; i < (int)ArmDof.LastArmDof; i++) {
humanStream.SwapMuscles (
new MuscleHandle (HumanPartDof.LeftArm, (ArmDof)i),
new MuscleHandle (HumanPartDof.RightArm, (ArmDof)i));
}
// swap legs
for (int i = 0; i < (int)LegDof.LastLegDof; i++) {
humanStream.SwapMuscles (
new MuscleHandle (HumanPartDof.LeftLeg, (LegDof)i),
new MuscleHandle (HumanPartDof.RightLeg, (LegDof)i));
}
// swap fingers
for (int i = 0; i < (int)FingerDof.LastFingerDof; i++) {
humanStream.SwapMuscles (
new MuscleHandle (HumanPartDof.LeftThumb, (FingerDof)i),
new MuscleHandle (HumanPartDof.RightThumb, (FingerDof)i));
humanStream.SwapMuscles (
new MuscleHandle (HumanPartDof.LeftIndex, (FingerDof)i),
new MuscleHandle (HumanPartDof.RightIndex, (FingerDof)i));
humanStream.SwapMuscles (
new MuscleHandle (HumanPartDof.LeftMiddle, (FingerDof)i),
new MuscleHandle (HumanPartDof.RightMiddle, (FingerDof)i));
humanStream.SwapMuscles (
new MuscleHandle (HumanPartDof.LeftRing, (FingerDof)i),
new MuscleHandle (HumanPartDof.RightRing, (FingerDof)i));
humanStream.SwapMuscles (
new MuscleHandle (HumanPartDof.LeftLittle, (FingerDof)i),
new MuscleHandle (HumanPartDof.RightLittle, (FingerDof)i));
}
// swap ik
Vector3[] goalPositions = new Vector3[4];
Quaternion[] goalRotations = new Quaternion[4];
float[] goalWeightPositons = new float[4];
float[] goalWeightRotations = new float[4];
Vector3[] hintPositions = new Vector3[4];
float[] hintWeightPositions = new float[4];
for (int i = 0; i < 4; i++) {
goalPositions[i] = humanStream.GetGoalLocalPosition (AvatarIKGoal.LeftFoot + i);
goalRotations[i] = humanStream.GetGoalLocalRotation (AvatarIKGoal.LeftFoot + i);
goalWeightPositons[i] = humanStream.GetGoalWeightPosition (AvatarIKGoal.LeftFoot + i);
goalWeightRotations[i] = humanStream.GetGoalWeightRotation (AvatarIKGoal.LeftFoot + i);
hintPositions[i] = humanStream.GetHintPosition (AvatarIKHint.LeftKnee + i);
hintWeightPositions[i] = humanStream.GetHintWeightPosition (AvatarIKHint.LeftKnee + i);
}
for (int i = 0; i < 4; i++) {
int j = (i + 1) % 2 + (i / 2) * 2; // make [1, 0, 3, 2]
humanStream.SetGoalLocalPosition (AvatarIKGoal.LeftFoot + i, Mirrored(goalPositions[j]));
humanStream.SetGoalLocalRotation (AvatarIKGoal.LeftFoot + i, Mirrored(goalRotations[j]));
humanStream.SetGoalWeightPosition (AvatarIKGoal.LeftFoot + i, goalWeightPositons[j]);
humanStream.SetGoalWeightRotation (AvatarIKGoal.LeftFoot + i, goalWeightRotations[j]);
humanStream.SetHintPosition (AvatarIKHint.LeftKnee + i, hintPositions[j]);
humanStream.SetHintWeightPosition (AvatarIKHint.LeftKnee + i, hintWeightPositions[j]);
}
}
public static Vector3 Mirrored (Vector3 value)
{
return new Vector3 (-value.x, value.y, value.z);
}
public static Quaternion Mirrored (Quaternion value)
{
return Quaternion.Euler (value.eulerAngles.x, -value.eulerAngles.y, -value.eulerAngles.z);
}
しかし、これだけでは不完全です。武器もミラーリングしないと実際のゲームでは使えません。
武器のミラーに対応する
Humanoidのミラー対応できました。次は武器、追加ボーンのミラー対応をします。
武器のミラーリングは複製した武器をもう片方の手にもたせて、表示、非表示で表現する方法が最も簡単ですが、武器オブジェクトが複製されているので、更新も2重に適応したりする必要があったり扱いが煩雑になりそうです。
今回はミラーリング時には片方のボーンに固定することで擬似的に親子関係が変わったように表現します。
武器の親ボーンをミラーリングした後に、武器をそのボーン下のトランスフォームに固定します。
ヒエラルキーのセットアップ
開発中のゲームのモデルを例に説明したいと思います。
まずミラーリング対応するためのヒエラルキーをセットアップします。
アニメーション制作時に向く方向は統一しておきます。今回weapon_R
にアニメーション情報が入っており、常にAnimationClipによって更新されます。
それの反転姿勢を保持するためのweapon_L
を作り、その子にミラー後の表示位置を確認するための武器としてjanis_weapon_mirror
をアタッチし、左右対称になるように調整しておきます。
janis_weapon_mirror
はミラー後の武器のアタリとして用意するだけで実際は表示しません。無効化しておきます。
追加トランスフォーム分をミラー処理する
武器の親トランスフォームをミラーリングしないといけません。
weapon_R
のミラーリング後した姿勢をweapon_L
へ反映します。
Humanoidのミラーリング処理する前に武器ボーンをミラーリングします。
ミラーリングしたいボーンをキャラクターの座標系(ルート座標系)に変換した後、XY平面でミラー化してその結果を片方のボーンに設定します。
public struct MirroringPlayableJob : IAnimationJob, IDisposable
{
public struct MirroringConstrant
{
public TransformStreamHandle driven;
public TransformStreamHandle source;
}
public struct MirroringPosture
{
public TransformStreamHandle source;
public TransformStreamHandle driven;
}
public bool debug;
public bool isMirror;
public TransformStreamHandle root;
public NativeArray<MirroringPosture> mirroringTransforms;
public NativeArray<MirroringConstrant> mirroringConstrants;
public void ProcessRootMotion (AnimationStream stream) { }
public void ProcessAnimation (AnimationStream stream)
{
Vector3 rootPosition;
Quaternion rootRotation;
root.GetGlobalTR (stream, out rootPosition, out rootRotation);
var rootTx = new AffineTransform (rootPosition, rootRotation);
var mirroredTransforms = new NativeArray<AffineTransform> (mirroringTransforms.Length, Allocator.Temp);
// 追加トランスフォームのミラーリング計算
if (isMirror) {
for (int i = 0; i < mirroringTransforms.Length; i++) {
if (!mirroringTransforms[i].source.IsValid (stream)) continue;
if (!mirroringTransforms[i].driven.IsValid (stream)) continue;
Vector3 position;
Quaternion rotation;
mirroringTransforms[i].source.GetGlobalTR (stream, out position, out rotation);
var drivenTx = new AffineTransform (position, rotation);
drivenTx = rootTx.Inverse() * drivenTx;
drivenTx = AnimationStreamMirrorExtensions.Mirrored (drivenTx);
drivenTx = rootTx * drivenTx;
mirroredTransforms[i] = drivenTx;
}
}
// Humanoid ミラーリング
~~ 省略 ~~
// 追加トランスフォームのミラーリング適用
if (isMirror) {
for (int i = 0; i < mirroringTransforms.Length; i++) {
if (!mirroringTransforms[i].source.IsValid (stream)) continue;
if (!mirroringTransforms[i].driven.IsValid (stream)) continue;
mirroringTransforms[i].driven.SetGlobalTR (stream, mirroredTransforms[i].position, mirroredTransforms[i].rotation, false);
}
}
// 追加トランスフォームのミラーリング拘束
~~ 省略 ~~
}
}
ミラー時にペアレントを切り替える
ミラー後のトランスフォームに武器を拘束して親子関係を切り替えたように見せます。
やり方は単純です。
この段階では janis_weapon_mirror
の親トランスフォームのweapon_L
にはミラー後の姿勢になっているのでjanis_weapon_mirror
の姿勢をjanis_weapon
にコピーするだけで、あたかも親トランスフォームがweapon_L
に切り替わったかのように見せることができます。
public void ProcessAnimation (AnimationStream stream)
{
// Humanoid のミラーリング
~~ 省略 ~~
// 追加トランスフォームのミラーリングの適用
~~ 省略 ~~
// ミラーリングしたトランスフォームへのコンストレント
if (isMirror) {
for (int i = 0; i < mirroringConstrants.Length; i++) {
if (!mirroringConstrants[i].source.IsValid (stream)) continue;
if (!mirroringConstrants[i].driven.IsValid (stream)) continue;
Vector3 position;
Quaternion rotation;
mirroringConstrants[i].source.GetGlobalTR (stream, out position, out rotation);
mirroringConstrants[i].driven.SetGlobalTR (stream, position, rotation, false);
}
}
}
あとはこれらのコードをPlayableGraphに登録するためのコンポーネントを作成すればおkです。
それの詳細についてはここでは言及しません。サンプルプロジェクトの
CustomAnimation.csを参照ください。
まとめ
これで3Dキャラクターを使って2Dゲーム的な表現ができるようになった!といいたいところですが、画角があるため立ち位置によって見え方が変化してしまう問題が残っています。
これを解決するにはキャラクターやそれに付随するエフェクトなどは画角を抑えて描画する必要があります。
それはまた別の機会があれば記事にしたいと思います。
3Dキャラクターを使うことで開発中もデザインを後から修正することが楽になりますし、ランタイムで入れ替えることも可能になります。
3Dくささを打ち消すのが大変だったりしますが、魅力的なキャラクターが活躍する2.5Dゲームが増えればいいな~と思っています。
コツコツとゲーム作ってます。
対戦格闘アクション http://extrival.com