2012年1月8日 星期日

使用 Unity 製作紙娃娃換裝的方法



遊戲內的角色,能夠像紙娃娃換裝那樣子讓玩家可以為自己的角色改變外觀,一直是相當受歡迎的功能;一般而言,我們建好的 3D 模型,如果要將其中一個部位換成另外一個形狀,最直接的就是將該物件部位的 Mesh 替換掉,那麼外觀就改變了,但這種方法如果運用在需要做動作的模型上,將發現被置換掉的部位不會正常動作,更糟的狀況可能連模型顯示的位置及方向都是錯誤的,所以,直接變更 Mesh 的方法只適用於靜態模型物件,為此,我們必須找出更深入的方法來做換裝的功能,幸好,此部份 Unity 官方已經有提供相關範例可以參考。


Unity 官方提供的人物換裝範例可以從官網下載 Character Customization,或是開啟 Unity 編輯器的 Window > Asset Store 在 Complete Projects > Tutorials 找到 Character Customization 下載並匯入到自己的專案中。這個範例提供相當完整的示範,而且考慮到實作於遊戲中時,不可能一次把全部的資源都載入,所以將模型、材質、紋理等資源都包成 Asset bundle,只在要使用到時才載入需要的部份;也因為如此,對於不瞭解 Asset bundle 的情況下,要透過這個範例來直接學習換裝也相對變得困難;另外,範例中也對資源規範了特定形式的命名規則,主要也是為了建立 Asset bundle 內容資料及從 Asset bundle 取出資源而設計,在不瞭解這些規則之前,想要透過此範例學習換裝,有一定程度的難度;當然,如果願意使用與範例中 characters 目錄中兩個人物的模型、材質、紋理完全相同的命名規則及檔案配置方式,幾乎可以直接套用到自己的遊戲中,而不太需要瞭解內部的運作方式。

在 Unity 開啟 Asset store
Asset store 中找出 Character Customization 範例

雖然,官方這個範例能夠直接套用,但製作遊戲常會有不同的客制化需求,如果不瞭解相關原理和流程的話,可能就無法自由靈活的運用,所以,以下將利用這個範例並排除掉 Asset bundle 的部份,直接在場景中完成紙娃娃換裝的方法。

首先,先來看看模型的結構,從 Projects 視窗將 CharacterCustomization > characters > Female > Female 拉到場景中,在 Hierarchy 視窗將物件展開來,會發現幾個名稱相同並使用數字區別的物件,它們分別代表人物各部位的模型,由此可知,整個人物模型檔包含多個相同部位的模型,而 Famale_Hips 則是整個人物的骨架結構,人物的動作則是設置在頂層物件(Female)的 Animation,所以這個模型檔是個模型資源,而不是實際上要放到在場景中的目標物件。

每個部位有多個模型物件

瞭解模型檔內容後,接下來先建立一個名為 TestChar 的 C# 程式檔用來控制換裝,為了方便測試,在 Projects 視窗將 CharacterCustomization > characters > Female > Female@walk 的 Animation Wrap Mode 改為 Loop,並在程式檔的 Start() 內加入 animation.Play("walk"),如此在執行狀態將會使人物不斷的做走路的動作。

選擇 Female@walk
Animation Wrap Mode 選擇 Loop
Unity 官方這個範例,說穿了就是將模型檔做為來源模型資源,然後再依照需求將各部位重新組合成一個新的目標模型,所以我們直接將人物模型 Female 拉兩個到場景中,分別為它們命名為 Source 及 Target,依照以下步驟做些準備動作:

  • 從 Projects 視窗將 CharacterCustomization > characters > Female > Per Texture Materials 依照名稱把適當的材質球(Material) 拉給 Source 的每個部位(不包含 Female_Hips 及其子物件)。
  • Source 物件是做為來源資源使用,實際在場景中不需要運作,所以直接點選 Source 物件並將 Inspector 視窗中 Source 名稱欄位前的方框取消勾選來將它關閉。
Source 前面的方框取消勾選
  • 取消勾選會彈出對話視窗詢問是否希望關閉全部的子物件,點擊 Deactivate Chidren。
點選 Deactivate Children
  • 把 Target 物件中除了 Famale_Hips 以外的子物件全部刪除。
  • 把 TestChar 程式檔拉給 Target 物件。
  • Source 中的各部位名稱應該都要有編號(例如 face-1 ),如果沒有的話,請加上編號。


完成以上的準備動作,接下來就要開始來寫程式了,程式主要工作是先將 Source 中每個物件的 SkinnedMeshRenderer 資料取出儲存在 data 變數中,data 的內容則是依照部位分類,接下來在 Target 加入 SkinnedMeshRenderer  ,然後每個部位取出一個指定的資料,利用 CombineInstance class 及 Mesh.CombineMeshes() 將各部位模型合併,同時也重新排列材質,然後依照取出的 SkinnedMeshRenderer 的 bone 的名稱,找到與 Target 的 Female_Hips 子物件內名稱相對應的物件重建骨架列表,最後將這些重新組合建立的資料給 Target 的 SkinnedMeshRenderer,如此就可完成換裝的動作,以下為程式說明...

//來源模型資源的物件
public Transform source;
//目標物件
public Transform target;

//模型資源資料
private Dictionary<string , Dictionary<string,SkinnedMeshRenderer>> data = new Dictionary<string, Dictionary<string,SkinnedMeshRenderer>>();

void Start () {

//從來源模型資源取出各部位的 SkinnedMeshRenderer
SkinnedMeshRenderer[] parts = source.GetComponentsInChildren<SkinnedMeshRenderer>(true);

foreach(SkinnedMeshRenderer part in parts){

//利用 - 字元分隔檔名做為資料結構的 key,檔名為 部位-編號 儲存為 [部位][編號]=SkinnedMeshRenderer資料
string[] partName = part.name.Split('-');

// 在 data 加入資料
if(!data.ContainsKey(partName[0])) data.Add(partName[0] , new Dictionary<string,SkinnedMeshRenderer>());
data[partName[0]].Add(partName[1],part);
}

//目標物件加入 SkinnedMeshRenderer 
SkinnedMeshRenderer targetSmr = target.gameObject.AddComponent<SkinnedMeshRenderer>();

//從目標物件取得骨架資料 (Female_Hips 的全部物件)
Transform[] hips = target.GetComponentsInChildren<Transform>();

/** 開始 重組模型 */
//初始化資料列表
List<CombineInstance> combineInstances = new List<CombineInstance>();
List<Material> materials = new List<Material>();
List<Transform> bones = new List<Transform>();

foreach(KeyValuePair<string , Dictionary<string,SkinnedMeshRenderer>> _part in data){

//從資料中取得各部位指定編號的 SkinnedMeshRenderer
SkinnedMeshRenderer smr = new SkinnedMeshRenderer();
switch(_part.Key){

case "eyes":
smr = _part.Value["1"];
break;
case "face":
smr = _part.Value["1"];
break;
case "hair":
smr = _part.Value["1"];
break;
case "pants":
smr = _part.Value["1"];
break;
case "shoes":
smr = _part.Value["1"];
break;
case "top":
smr = _part.Value["1"];
break;
}

//準備要組合的 Mesh
CombineInstance ci = new CombineInstance();
ci.mesh = smr.sharedMesh;
combineInstances.Add(ci);

//排列新的材質列表
materials.AddRange(smr.materials);

//取得相對應名稱的骨架物件來建立新的骨架列表
foreach(Transform bone in smr.bones){

foreach(Transform hip in hips){

if(hip.name != bone.name) continue;
bones.Add(hip);
break;
}
}
}

//合併 Mesh 並寫入至 Target 的 SkinnedMeshRenderer
targetSmr.sharedMesh = new Mesh();
targetSmr.sharedMesh.CombineMeshes(combineInstances.ToArray() , false , false);
// Target 的 SkinnedMeshRenderer 寫入新骨架列表
targetSmr.bones = bones.ToArray();
// Target 的 SkinnedMeshRenderer 寫入新材質列表
targetSmr.materials = materials.ToArray();

/** 重組模型 結束 */

//指定播放走路動作
animation.Play("walk");
}

寫完程式後,記得把場景中的 Source 及 Target 兩個物件分別拉給附屬在 Target 物件上的 TestChar script 的 source 及 target 欄位;
程式動作都在 Start() 內進行,是因為最初目標物件並沒有模型等資料,所以要先依照指定的各部位資料把人物建立出來並使它動作,而 smr = _part.Value["1"]; 的 "1" 則是表示指定此部位的 "1" 模型資料,所以只要改變各部位的這個值,就能為人物配置不同的造型,當然,前題是來源模型資源必須要有這個編號的物件才行;以上程式碼主要是測試及解說流程用,在實作上應該把標示 /** 重組模型 */ 這一段程式獨立出來,在需要換裝時,給予各部位指定編號來執行。



以上是 Unity 官方範例中處理換裝的方法,它把各部位模型、材質等資料重新組合合併成單一的模型並重建骨架列表,如此即使看起來人物身上有其中一個部位被置換了,仍能持續正常動作;當查看 Target 物件時會發現它的子物件仍然維持不變,只有 Target 物件本身在 Inspector 視窗中的 Component 多出了 Skinned Mesh Renderer 及各部位的 Material,如果查看 SkinnedMeshRenderer 的 Mesh 欄位 也會發現看不到任何的 Mesh。

Target 物件的內容
這種做法的來源模型與材質數量必須相對應,否則模型的貼圖將會變得不正常,也就是說如果褲子的 material 有兩個,其他部位的 materail 只有一個,那麼結果模型上的貼圖將與預期的不同;為了使各部位的 material 使用上更為彈性,前面的程式將做些修改,使它的各部位都是獨立的 GameObject,如下所示:

//來源模型資源的物件
public Transform source;
//目標物件
public Transform target;

//模型資源資料
private Dictionary<string , Dictionary<string,Transform>> data = new Dictionary<string, Dictionary<string,Transform>>();
//目標物件的骨架
private Transform[] hips;
//目標物件各部位的 SkinnedMeshRenderer 資料(參照)
private Dictionary<string , SkinnedMeshRenderer> targetSmr = new Dictionary<string, SkinnedMeshRenderer>();

void Start () {

//從來源模型資源取出各部位的 SkinnedMeshRenderer
SkinnedMeshRenderer[] parts = source.GetComponentsInChildren<SkinnedMeshRenderer>(true);

foreach(SkinnedMeshRenderer part in parts){

//利用 - 字元分隔檔名做為資料結構的 key,檔名為 部位-編號 儲存為 [部位][編號]=Transform資料
string[] partName = part.name.Split('-');

// 在 data 加入資料
if(!data.ContainsKey(partName[0])){

data.Add(partName[0] , new Dictionary<string,Transform>());

//建立新的 GameObject 並使用部位名稱來命名,指定為目標物件的子物件
GameObject partObj = new GameObject();
partObj.name = partName[0];
partObj.transform.parent = target;

//為新建立的 GameObject 加入 SkinnedMeshRenderer,並將此 SkinnedMeshRenderer 存入 targetSmr
targetSmr.Add(partName[0] , partObj.AddComponent<SkinnedMeshRenderer>());
}

data[partName[0]].Add(partName[1],part.transform);
}

//從目標物件取得骨架資料 (Female_Hips 的全部物件)
hips = target.GetComponentsInChildren<Transform>();

/** 開始 重組模型 */
foreach(KeyValuePair<string , Dictionary<string,Transform>> _part in data){

switch(_part.Key){

case "eyes":
ChangePart("eyes" , "1");
break;
case "face":
ChangePart("face" , "1");
break;
case "hair":
ChangePart("hair" , "1");
break;
case "pants":
ChangePart("pants" , "1");
break;
case "shoes":
ChangePart("shoes" , "1");
break;
case "top":
ChangePart("top" , "1");
break;
}
}
/** 重組模型 結束 */

//指定播放走路動作
target.animation.Play("walk");
}

private void ChangePart(string part , string item){

//從資料中取得各部位指定編號的 SkinnedMeshRenderer
SkinnedMeshRenderer smr = data[part][item].GetComponent<SkinnedMeshRenderer>();

//取得相對應名稱的骨架物件來建立新的骨架列表
List<Transform> bones = new List<Transform>();
foreach(Transform bone in smr.bones){

foreach(Transform hip in hips){

if(hip.name != bone.name) continue;
bones.Add(hip);
break;
}
}

// 更新指定部位 GameObject 的 SkinnedMeshRenderer 內容
targetSmr[part].sharedMesh = smr.sharedMesh;
targetSmr[part].bones = bones.ToArray();
targetSmr[part].materials = smr.materials;
}

在建立 data 變數內容時,同時為每個部位建立 GameObject,另外也把變更部位內容的程式碼獨立出來為 ChangePart 方法,如此在每次需要變更該部位時,只要指定部位名及編號就可以直接為該部位換裝,而不需要將每個部位都重建;因為每個部位都是 GameObject 實體,我們在 Hierarchy 或 Scene 視窗中點選該部位也可以清楚的從 Inspector 視窗中看到此部位內容,正因如此,每個部位就可以自由配置 Material 的數量了。

從以上程式中會發現換裝除了把 Mesh 和 Material 從來源取出給目標置換之外,有個關鍵的地方是重建骨架列表,為什麼要重建骨架列表呢?最主要是變更 Mesh 之後的 SkinnedMeshRenderer.bones 及 SkinnedMeshRenderer.sharedMesh.bindposes 數量有可能會不同而產生錯誤訊息 Number of bind poses doesn't match number of bones in skinned mesh,即使數量相同而沒有錯誤訊息,SkinnedMeshRenderer.sharedMesh.bindposes 內的 Matrix4x4[] 資料也會因為數值不正確而發生執行期模型扭曲成奇怪形狀的問題;這部份可以將 Female 模型檔匯入到 3DS Max 中查看,以鞋子為例,在 Modify 視窗中,可以很明顯看出 shoes-1 和 shoes-2 的 Bones 列表內容是不同的,所以在為模型物件變更 Mesh 的同時必須重建骨架列表。



以上的說明主要是用於瞭解換裝所需要的做法,實作時,不太可能把遊戲中的角色全身各部位的模型資料全部都載入做為來源資料,例如遊戲中的武器有100種,角色背包中有3種武器,但為了換裝卻把100種武器都載入到遊戲中,而實際上此角色最多也只能變換背包中的3種武器而已,這樣無疑是浪費了97種武器所佔用的資源;所以在瞭解如何換裝後,實作時應該儘量像官方範例那樣把來源資源包裝起來,只取出需要的資源來進行換裝。