在前置篇中,基本上梳理了一下換裝功能背后涉及到的美術工作流。但程序員嘛,功能終歸是要落到代碼上的。本文中會結合Unity提供的API及之前提到的內容來實現一個簡單的換裝功能。效果如下:
(圖1:最終效果展示)
資源導出規則
所有的換裝實現都是和導出規則相對應的。先說一下我這個小例子的導出規則。
1.角色的主干部分,包括頭,胳膊,大腿。整體導出作為一個基礎蒙皮。
2.其他部分的蒙皮,手套,下裝,衣服,頭發。每一種樣式都一個個單獨導出。
3.從MAX中導出FBX資源時,要注意導出蒙皮時候,骨骼也要選上,否則導出的就是普通mesh,而不是蒙皮了。
(圖2:角色基礎部分的導出內容,左側為主干部分,右側為一個頭發部件.都要帶上骨骼)
基本流程
如圖3,將max導出的所有fbx放入Unity后,每個部件都是單獨的,我們要做的就是把這些分散的部件攢在一起,讓他們正確的顯示並響應動畫。
(圖3:Unity中顯示的所有導出身體部件,Girl為主干模型)
寫具體代碼之前,我們先說一下幾個關鍵的Unity組件,Animator,SkinnedMeshRenderer.Animator會讀取動畫信息,我們在前置篇提到,max只制作動畫的關鍵幀,而游戲渲染是一幀一幀的,關鍵幀之間的動畫如何過渡,就是引擎自己負責的,也就是Animator做的事,Animator計算好當前幀的骨骼姿態后。會根據結果去改變Animator組件所在節點下的骨骼結構節點,只有我們在max里將骨骼正確導出,才會出現這些節點。SkinnedMeshRenderer則負責蒙皮計算,在每一幀中根據Animator計算出來后的骨骼位置,找到自己關聯了哪些骨骼及權重,然后進行變換和插值,計算出mesh頂點的正確位置。再將這些頂點信息傳入對應的材質球中進行渲染。
實現代碼
下面是一個簡單的實現代碼,我會對一些關鍵代碼進行說明。這個腳本是掛在角色主干部分的Prefab上。
1 public class SkinTest : MonoBehaviour 2 { 3 4 public GameObject[] Hairs; 5 public GameObject[] Clothes; 6 public GameObject[] Gloves; 7 public GameObject[] Unders; 8 9 private int hairIndex = 0; 10 private int clothesIndex = 0; 11 private int glovesIndex = 0; 12 private int underIndex = 0; 13 14 private List<Transform> bones; 15 private GameObject rootBone; 16 void Start () 17 { 18 rootBone = gameObject.transform.FindChild("Bip001").gameObject; 19 bones = new List<Transform>(); 20 21 BuildPlayer(); 22 } 23 24 public void BuildPlayer() 25 { 26 bones.Clear(); 27 List<CombineInstance> combineInstances = new List<CombineInstance>(); 28 List<Material> materials = new List<Material>(); 29 List<SkinnedMeshRenderer> smrList = new List<SkinnedMeshRenderer>(); 30 Transform[] transforms = rootBone.GetComponentsInChildren<Transform>(true); 31 32 if(Hairs!=null && Hairs.Length > hairIndex && Hairs[hairIndex]!=null) 33 { 34 SkinnedMeshRenderer smr = Hairs[hairIndex].GetComponentInChildren<SkinnedMeshRenderer>(); 35 if (smr != null) 36 { 37 smrList.Add(smr); 38 } 39 } 40 41 if (Clothes != null && Clothes.Length > clothesIndex && Clothes[clothesIndex] != null) 42 { 43 SkinnedMeshRenderer smr = Clothes[clothesIndex].GetComponentInChildren<SkinnedMeshRenderer>(); 44 if (smr != null) 45 { 46 smrList.Add(smr); 47 } 48 } 49 50 if (Gloves != null && Gloves.Length > glovesIndex && Gloves[glovesIndex] != null) 51 { 52 SkinnedMeshRenderer smr = Gloves[glovesIndex].GetComponentInChildren<SkinnedMeshRenderer>(); 53 if (smr != null) 54 { 55 smrList.Add(smr); 56 } 57 } 58 59 if (Unders != null && Unders.Length > underIndex && Unders[underIndex] != null) 60 { 61 SkinnedMeshRenderer smr = Unders[underIndex].GetComponentInChildren<SkinnedMeshRenderer>(); 62 if (smr != null) 63 { 64 smrList.Add(smr); 65 } 66 } 67 68 for(int i =0;i<smrList.Count;i++) 69 { 70 SkinnedMeshRenderer smr = smrList[i]; 71 if(smr) 72 { 73 for(int sub =0;sub<smr.sharedMesh.subMeshCount;sub++) 74 { 75 for (int j = 0; j < smr.bones.Length; j++) 76 { 77 for (int index = 0; index < transforms.Length; index++) 78 { 79 if (smr.bones[j].name.Equals(transforms[index].name)) 80 { 81 bones.Add(transforms[index]); 82 break; 83 } 84 } 85 } 86 87 CombineInstance ci = new CombineInstance(); 88 89 ci.mesh = smr.sharedMesh; 90 ci.subMeshIndex = sub; 91 combineInstances.Add(ci); 92 } 93 materials.AddRange(smr.sharedMaterials); 94 95 } 96 } 97 98 SkinnedMeshRenderer oldSkin = GetComponent<SkinnedMeshRenderer>(); 99 if(oldSkin!=null) 100 { 101 GameObject.DestroyImmediate(oldSkin); 102 } 103 104 SkinnedMeshRenderer newSmr = gameObject.AddComponent<SkinnedMeshRenderer>(); 105 newSmr.sharedMesh = new Mesh(); 106 newSmr.sharedMesh.CombineMeshes(combineInstances.ToArray(), false, false); 107 newSmr.bones = bones.ToArray(); 108 newSmr.rootBone = rootBone.transform; 109 newSmr.materials = materials.ToArray(); 110 } 111 }
4~15行,一些基本變量,存放用於換裝的Prefab的引用,以及索引下標,bones用來存儲Skin合並后的骨骼引用,rootBone用來存儲根骨骼。
18行,找到根骨的節點,此處的Bip001是3Dmax中Bip結構的默認根節點。主干部分的蒙皮導出時帶有骨骼,所以可以在Prefab的子節點上找到。
27~30行,建立一些List用來存儲SkinMeshRenderer合並過程中所用到的一些中間內容。這里再提一下SkinMeshRenderer,我們會發現一個SkinMeshRenderer一般都只包含一個Material,但它是可以包含多個的。當我們的SkinMeshRenderer里對應的Mesh是包含多個subMesh的時候,那么他們需要多個材質球來對應每個SubMesh。我們導出的各個部件里都有自己的SkinMeshRenderer,我們要做的是把他們合為一個整體,這樣做會對計算性能上有提升,邏輯處理上也更統一。后面我們再細說。
32~66行,這部分是根據各個部位的索引號去配置好的Prefab數組中查找到對應配件,只獲取SkinMeshRenderer組件就夠了,因為他里面包含了我們所需的蒙皮的所有信息。把他們放到List中后面統一處理。
68~96行,循環遍歷處理我們前面獲取的各個部件的SkinMeshRenderer.這里要說一下關於SkinMeshRenderer的Bones變量,它返回的是這個Skin綁定了哪些骨骼,Unity是以Transform引用數組的形式返回的,引用的是原來每個部件Prefab下自己的Bip下的骨骼節點,當我們把這些SkinMeshRenderer整合成一個的時候,就需要把引用重新指定成主體模型上的相應骨骼節點,這正是73~85行做的事。注意我這里根據部位里是否有多個subMesh來重復添加多次骨骼,這是必須的,而且順序也是一定要保證的。在FBX上的Optimize Mesh選項可以解決這個問題,不過會引入其它問題,這里不展開了。每個部件Skin對應的材質球也都按順序放到List中。89~91行的CombineInstance是Unity用來進行Mesh合並的一個數據結構,我們最終是需要把每個部件Skin對應的Mesh合並到一起,這里注意,合並到一起,並不一定是真的變成了一個Mesh,因為部件和部件之間的材質不一定完全一致,這時候的Mesh合並實際上只是一種邏輯上的合並,真正渲染時各個部件的Mesh頂點數據還是各走一個DrawCall。即使是這樣,邏輯上的這種整合對於Unity的性能也是有好處的,這涉及到渲染層面節省頂點Buffer的問題,也涉及到提高Unity引擎一些自身邏輯效率的問題,這里不展開。subMeshIndex這個變量,對於普通的部件Skin里只包含一個subMesh,所以一般指定0,但有時候會包含多個,如果在max里部件本身就由多個材質構成,那么每個材質負責的Mesh部分到了Unity里就變成一個SubMesh了。93行我們把材質球也按順序(順序很重要),放到了List里,你也許會問為什么不合並呢?理論上如果所有部件用的都是統一材質,或者材質基本相似的話是可以通過合並貼圖,重新賦值UV來讓所有部件正真的合並在一起,只用一個Mesh。
104~106行,我們最終要把所有分散的SkinMeshRenderer合並到一起,添加一個SkinnedMeshRenderer組件,但是這個組件的所有變量都是默認空的。所以105行我們給這個Renderer新建一個空的Mesh。106行通過CombineMesh來利用我們前面創建的CombineInstance數據把Mesh合並。這里說明一下后兩個參數,第一個參數如果為true,則表示會把所有Mesh真的合並到一起,也就是合並之后subMeshCount為1。這一搬是與我前面提到的材質合並配合使用的。第三個參數為true的話我們需要給每個CombineInstance提供一個變換矩陣,在它們被合並之前,它們會先利用這個矩陣進行一次空間變換。
107行,將前面骨骼節點集合傳遞給前面新建的SkinMeshRenderer,必須保證順序。
108行,rootBone習慣性的賦值為骨骼結構的根節點,這里設為空也沒問題。
109行,同骨骼節點一樣集合一樣,材質球集合傳遞給SkinMeshRenderer,保證順序與部件合並的順序相同。
總結
換裝功能的實現代碼並沒有統一規范,這跟部件的設計規則有很大關系,所以本文只提供一種最簡單基本的思路。還可以在這個基礎上繼續展開,深入優化。有些地方我沒有深入去剖析,一筆帶過。一方面是有些內容我也並不深入了解,另一方面是怕大家過於糾結細節,迷失方向。對於一些更深入的內容,我計划有時間再寫一篇來分享。上面的實現在實際項目中也有很多問題,比如每個部件的Fbx導出都需要帶全套骨骼。這造成一些數據上的冗余,如果要是在資源打包上依然沒有辦法去掉冗余的話,就會造成運行時內存的浪費,希望大家來一起討論。