読者です 読者をやめる 読者になる 読者になる

Blender×Unityワークフロー:ミラーモディファイアとシェイプキーを共存させる(+左右に分離)

はじめに

 キャラクターの顔をモデリングする際、Mirror modifierを使って左右対称にモデリングする方は多いと思います。更に表情もBoneではなく、ShapeKey(unityではBlendShape、一般的にはMorph:モーフ)で作成するのも、分かり易くて良いですよね。

落とし穴

しかしこのやり方には致命的な問題があります。

Mirror modifierが適用されたObjectはShapeKeyを書き出せない。

のです。せっかくモデリングして、ShapeKeyも設定したのに、UnityにインポートしてもBlendShapeには何も出てきません。
 Mirror modifierは頂点の数を変更してしまうため、頂点ごと動かすShapeKeyは正しく書き出せないためです。
(Mirror modifier以外でも、Subdivision Surfaceなど頂点数を変更するModifierが入っているとNGな模様)

他の方法

 まず、Mirror modifierを使わずに、ToolShelf内OptionsのX Mirrorや、Topology MirrorをOnにしてモデリングする方法があります。ただしこの方法は、作業してるうちにいつの間にか左右非対称になってしまったりすることがあり、不安があります。
 またBlender上のスクリプトで無理やりModifierを適用させるものもありますが、Unityへ書き出す際に毎回やるのも手間です。

解決策

 そこで、最も手間が少ないと思われる手順を考えました。

前提として、

  • Mirror modifierが適用されていなければ、ShapeKeyをエクスポート出来る
  • Blenderでリンク複製(Alt+D)したオブジェクトには、オリジナルとは異なるモディファイアを設定できる
  • Unity上ではBlendShapeの変更、追加が自由に行える

この3点がキモになります。

フロー

流れを説明します。

  1. Mirror modifierを使って普通にモデリング(オブジェクト名をHeadとする)
  2. ShapeKeyで表情を作る
  3. リンク複製(Alt+D)をする(オブジェクト名をHead_SHAPEとする)
  4. Head_SHAPEのMirror modifierを外す(これでこのオブジェクトにはShapeKeyが付く)
  5. Unityのプロジェクトフォルダに保存


ここからはUnity側の作業になります。

  1. (自動)HeadImporterのOnPostprocessModel関数内で
  2. (自動)末尾が_SHAPEが入ったObjectから、入ってないオリジナルのObjectへBlendShapeをコピー
  3. (自動)末尾が_SHAPEのObjectを削除する
  4. 完成!

利点

 今まで通りMirror modifierが使えるので、常に左右対称なのが保証されます。また1度リンク複製するだけ(あとは非表示にでもしておく)なので、手間はほぼ0です。

欠点

この方法には、左右対称のShapeKeyしか作れないという欠点があります。BlendShape自体は左右に分けて追加するため、目や眉毛の動きは問題ありません。問題は左右が繋がっている口で、片方の口角を上げる(ニヤッとするなど)表情などはBlender上では作れません。ただし口角を上げる、下げるシェイプキーをあらかじめ用意しておき、Unity側でBlendShapeの適用量を左右で調整することで、間接的に作ることは出来ます。

ソースコード

 以下のコードをHeadImporter.csという名前で、Unityのプロジェクトフォルダ(Assets)以下ならどこでもいいので、"Editor"という名前のついたフォルダを作って保存してください。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

/// <summary>
/// 末尾に"_Head"で終わるファイルに対して
/// 名前の末尾に"_SHAPE"が入ったオブジェクトから、ShapeKeyを左右にコピーする
/// </summary>
public class HeadImporter : AssetPostprocessor
{
	public const string filenameSuffix = "_Head";

	// インポート後の処理
	void OnPostprocessModel(GameObject g)
	{
		// ファイル名(ルートオブジェクト名)チェック
		if (!g.name.EndsWith(filenameSuffix)) return;

		var smr = g.GetComponentsInChildren<SkinnedMeshRenderer>();

		for (int i = 0; i < smr.Length; i++)
		{
			var sm = smr[i];
			// 末尾に_SHAPEが付いたオブジェクトがあった場合、_SHAPEを取り除いたオリジナルオブジェクトを探し
			// BlendShapeをコピーする
			if (sm.gameObject.name.EndsWith("_SHAPE"))
			{
				var originalName = sm.gameObject.name.Replace("_SHAPE", "");
				for (int j = 0; j < smr.Length; j++)
				{
					if (smr[j].gameObject.name == originalName)
					{
						CopyBlendShape(sm, smr[j]);
					}
				}
			}
		}
		DeleteSHAPE(g.transform);
	}

	void CopyBlendShape(SkinnedMeshRenderer smFrom, SkinnedMeshRenderer smTo)
	{
		var orig_Verts = smFrom.sharedMesh.vertices;
		var dest_Verts = smTo.sharedMesh.vertices;

		// 頂点の対応関係を求める	   
		Dictionary<int, int> MirrorIndex = new Dictionary<int, int>();
		for (int i = 0; i < dest_Verts.Length; i++)
		{
			var vd = dest_Verts[i];

			for (int j = 0; j < orig_Verts.Length; j++)
			{
				var vo = orig_Verts[j];

				if ((vd-vo).magnitude < 0.00001f)
				{ 
					MirrorIndex.Add(i, j);
					break;
				}
				vo.x *= -1;
				if ((vd - vo).magnitude < 0.00001f)
				{
					MirrorIndex.Add(i, j);
					break;
				}
			}
		}

		// オリジナルのBlendShapeデータ
		Vector3[] vOffset = new Vector3[orig_Verts.Length];
		Vector3[] nOffset = new Vector3[orig_Verts.Length];
		Vector3[] tOffset = new Vector3[orig_Verts.Length];

		// 左側用
		Vector3[] vOffsetL = new Vector3[dest_Verts.Length];
		Vector3[] nOffsetL = new Vector3[dest_Verts.Length];
		Vector3[] tOffsetL = new Vector3[dest_Verts.Length];

		// 右側用
		Vector3[] vOffsetR = new Vector3[dest_Verts.Length];
		Vector3[] nOffsetR = new Vector3[dest_Verts.Length];
		Vector3[] tOffsetR = new Vector3[dest_Verts.Length];

		for (int i = 0; i < smFrom.sharedMesh.blendShapeCount; i++)
		{
			smFrom.sharedMesh.GetBlendShapeFrameVertices(i, 0, vOffset, nOffset, tOffset);

			for (int j = 0; j < dest_Verts.Length; j++)
			{
				int orig = MirrorIndex[j];
				vOffsetL[j] = vOffset[orig];
				vOffsetR[j] = vOffset[orig];

				tOffsetL[j] = tOffset[orig];
				tOffsetR[j] = tOffset[orig];

				nOffsetL[j] = nOffset[orig];
				nOffsetR[j] = nOffset[orig];

				// オリジナルと符号が異なる場合反転
				if (dest_Verts[j].x * orig_Verts[orig].x < 0)
				{
					vOffsetL[j].x *= -1;
					vOffsetR[j].x *= -1;

					tOffsetL[j].x *= -1;
					tOffsetR[j].x *= -1;

					nOffsetL[j].x *= -1;
					nOffsetR[j].x *= -1;
				}
			}

			// ブレンドシェイプ左右両方
			smTo.sharedMesh.AddBlendShapeFrame(smFrom.sharedMesh.GetBlendShapeName(i), 100, vOffsetL, nOffsetL, tOffsetL);

			// 左右それぞれ片方のBlendShapeを無効にする
			for (int j = 0;j < dest_Verts.Length;j++)
			{
				if (dest_Verts[j].x < 0)
				{
					vOffsetL[j] = Vector3.zero;
					tOffsetL[j] = Vector3.zero;
					nOffsetL[j] = Vector3.zero;
				}
				else
				{
					vOffsetR[j] = Vector3.zero;
					tOffsetR[j] = Vector3.zero;
					nOffsetR[j] = Vector3.zero;
				}
			}
			
			// ブレンドシェイプ左
			smTo.sharedMesh.AddBlendShapeFrame(smFrom.sharedMesh.GetBlendShapeName(i) + "_L", 100, vOffsetL, nOffsetL, tOffsetL);
			// ブレンドシェイプ右
			smTo.sharedMesh.AddBlendShapeFrame(smFrom.sharedMesh.GetBlendShapeName(i) + "_R", 100, vOffsetR, nOffsetR, tOffsetR);
		}
	}				
	
	// _SHAPEが末尾についたオブジェクトを削除する
	void DeleteSHAPE(Transform t)
	{
		if (t.name.EndsWith("_SHAPE"))
		{
			GameObject.DestroyImmediate(t.gameObject);
		}
		else
		{
			foreach (Transform child in t)
				DeleteSHAPE(child);
		}
	}