比預想的更複雜——動態斷肢實現

為求參數錄入方便(不想做編輯器)直接用骨骼作為分割的依據,指定一個骨骼節點以及子節點,只要權重中包含這些節點的頂點都是需要被「切斷」的頂點。

var count = bones.Length;//收集需要切掉的骨骼HashSet<int> cutBoneIndexSet = new HashSet<int>();for (int i = 0; i < count; i++){ Transform b = bones[i]; if (IsParentBone(b, cutBone)) cutBoneIndexSet.Add(i);}//根據骨骼獲得需要切掉的頂點HashSet<int> cutIndexs = new HashSet<int>();count = boneWeights.Length;for (int i = 0; i < count; i++){ BoneWeight bw = boneWeights[i]; if (bw.weight0 > 0 && cutBoneIndexSet.Contains(bw.boneIndex0) || bw.weight1 > 0 && cutBoneIndexSet.Contains(bw.boneIndex1) || bw.weight2 > 0 && cutBoneIndexSet.Contains(bw.boneIndex2) || bw.weight3 > 0 && cutBoneIndexSet.Contains(bw.boneIndex3)) cutIndexs.Add(i);}

然後再遍歷Mesh的三角索引數組,只要有一個點被Mark就認為是切斷的部分,將三角數據拆開成兩個數組,就能將一部分肢體分離顯示了

List<int> newTriangles = new List<int>();List<int> partTriangles = new List<int>();count = triangles.Length;for (int i = 0; i < count; i += 3){ if (cutIndexs.Contains(triangles[i]) || cutIndexs.Contains(triangles[i + 1]) || cutIndexs.Contains(triangles[i + 2])) { //切下的部分 partTriangles.Add(triangles[i]); partTriangles.Add(triangles[i + 1]); partTriangles.Add(triangles[i + 2]); } else { //留下的部分 newTriangles.Add(triangles[i]); newTriangles.Add(triangles[i + 1]); newTriangles.Add(triangles[i + 2]); }}

但這樣模型就會出現一個破洞

這個實現的主體就是這個破洞的縫補過程了。

過程如下:

1.尋找兩部分的模型的交界線

交界線上,一邊的三角形必定三個頂點都沒有被Mark的,而交界線上的三角形一定有兩個頂點是重合的,所以另一邊的三角形肯定有兩個頂點是未被Mark的,它又必須有一個點被Mark,那就只有下面這一種情況。

判斷依舊就是有且只有兩個頂點未Mark的三角形是邊緣三角形,而且那兩個未Mark的頂點就是邊緣線段。

上面拆分頂點的代碼改成:

count = triangles.Length;for (int i = 0; i < count; i += 3){ linkIndexInOneTriangles.Clear(); if (!cutIndexs.Contains(triangles[i])) linkIndexInOneTriangles.Add(triangles[i]); if (!cutIndexs.Contains(triangles[i + 1])) linkIndexInOneTriangles.Add(triangles[i + 1]); if (!cutIndexs.Contains(triangles[i + 2])) linkIndexInOneTriangles.Add(triangles[i + 2]); if (linkIndexInOneTriangles.Count < 3) { partTriangles.Add(triangles[i]); partTriangles.Add(triangles[i + 1]); partTriangles.Add(triangles[i + 2]); if (linkIndexInOneTriangles.Count == 2) { //記錄邊緣線段 findEdge.AddSegment(linkIndexInOneTriangles[0], linkIndexInOneTriangles[1]); } } else { newTriangles.Add(triangles[i]); newTriangles.Add(triangles[i + 1]); newTriangles.Add(triangles[i + 2]); }}

2.獲得由這些線段首尾相連的最大迴環

其實就是一個遍歷。首先記錄每個點與另一個點的相關性和距離,生成關係圖linkDict。

class Item{ public int index; public float distance; public Item(int index, float distance) { this.index = index; this.distance = distance; }}Vector3[] vertices;//傳入原來的頂點數據用於距離計算Dictionary<int, List<Item>> linkDict;//關係圖public FindEdge(Vector3[] vertices){ this.vertices = vertices; this.linkDict = new Dictionary<int, List<Item>>();}public void AddSegment(int p1, int p2){ float distance = Vector3.Distance(vertices[p1], vertices[p2]); if (linkDict.ContainsKey(p1)) { linkDict[p1].Add(new Item(p2, distance)); } else { linkDict.Add(p1, new List<Item>() { new Item(p2, distance) }); } if (linkDict.ContainsKey(p2)) { linkDict[p2].Add(new Item(p1, distance)); } else { linkDict.Add(p2, new List<Item>() { new Item(p1, distance) }); }}

然後隨便遍歷下整個關係圖,只要保證路徑不重複即可。就可以找出周長最長的頂點索引數組。

(並不是所有的線段都要加入結果中,孤立線段和hole是應該過濾掉的,這樣處理它們自然也就不存在了)

(正常情況下,遍歷都是單線沒有分支的,時間複雜度並不高)

List<int> path;//當前路徑HashSet<int> pathSet;//當前路徑的HashSet(方便判重)HashSet<int> pathSetHistory;//記錄全部遍歷過的頂點List<int> result;float resultLength;float curLength;int startIndex;public List<int> GetMaxLoop(){ path = new List<int>(); pathSet = new HashSet<int>(); pathSetHistory = new HashSet<int>(); result = new List<int>(); curLength = 0; resultLength = 0; foreach (var pair in linkDict) { if (!pathSetHistory.Contains(pair.Key))//凡是遍歷過的頂點都不會再成為起點 { startIndex = pair.Key; Find(pair.Key);//選擇一個起點開始遍歷 } } return result;}void Find(int v){ path.Add(v); pathSet.Add(v); pathSetHistory.Add(v);//遍歷過的頂點只增加不減少 foreach (Item next in linkDict[v]) { curLength += next.distance; if (next.index == startIndex) { if (curLength > resultLength) //形成迴環後,記錄一個周長更長的結果 { result.Clear(); result.AddRange(path); resultLength = curLength; } } else if (!pathSet.Contains(next.index)) { Find(next.index);//遞歸遍歷下一個節點 } curLength -= next.distance; } path.RemoveAt(path.Count - 1); pathSet.Remove(v);}

但光這樣還不行,因為通常模型都存在重複頂點,手臂之類的筒狀物體接縫處其實是重合但沒有任何相關性的點,這會導致迴環無法完成。

所以需要刪掉並轉移和它相關的聯繫,修正linkDict數據,再進行上面的操作。

public void RemoveRepeatIndex(){ List<int> linkIndexs = new List<int>(); foreach (var pair in linkDict) { linkIndexs.Add(pair.Key); } //記錄重複頂點的對應關係 Dictionary<int, int> repeatIndexDict = new Dictionary<int, int>(); int count = linkIndexs.Count; for (int i = 0; i < count - 1; i++) { for (int j = i + 1; j < count; j++) { int v1 = linkIndexs[i]; int v2 = linkIndexs[j]; if (vertices[v1] == vertices[v2]) { if (!repeatIndexDict.ContainsKey(v2)) repeatIndexDict.Add(v2, v1); } } } foreach (var pair in linkDict) { //通過之前記錄的關係進行替換linkDict的內容 foreach (Item v in pair.Value) { if (repeatIndexDict.ContainsKey(v.index)) { v.index = repeatIndexDict[v.index]; } } //如果鍵重複則合併 if (repeatIndexDict.ContainsKey(pair.Key)) { linkDict[repeatIndexDict[pair.Key]].AddRange(pair.Value); pair.Value.Clear(); } }}

3.根據獲得的迴環生成三角數據

首先要注意的是,我們的原數據不是離散頂點,而是Edge。而數據中出現凹多邊形,當做離散點處理會出現下面的錯誤:

紅色的錯誤連線

所以一般的Delaunay其實是不適用的,而且我們其實也並不在乎出現狹長的三角形。

這裡用的是Unity Wiki上的一個演算法(我稍微修正了下三角形正反朝向的問題)

Triangulator - Unify Community Wiki

原理就是不斷將相鄰的三個頂點連成三角形並移除,同時保證生成的三角形內部沒有其他的頂點(所以這個演算法的先決條件就是形成迴環而且沒有hole)

但這個演算法是2D的,所以要先把3D頂點映射到2D平面上。這裡我直接用了斷開處骨骼的切線平面——只要將頂點換算到骨骼空間就可以了,也就是直接乘以bindposes。

Matrix4x4 m = skinMeshRenderer.sharedMesh.bindposes[cutBoneStartIndex];foreach (int index in linkIndexs){ Vector3 boneSpaceVertice = m.MultiplyPoint3x4(vertices[index]); linkBoneSpaceVertices.Add(boneSpaceVertice);}

切面的紋理Uv也可以從這個2D平面數據里獲得,記錄最大值和最小值,將x,y歸一化到0,1就是Uv。

Dictionary<int, Vector2> NormalUv(Dictionary<int, Vector2> uvs){ Vector4 uvBounds = new Vector4(float.MaxValue, float.MaxValue, float.MinValue, float.MinValue); foreach (var pair in uvs) { if (pair.Value.x < uvBounds.x) uvBounds.x = pair.Value.x; if (pair.Value.x > uvBounds.z) uvBounds.z = pair.Value.x; if (pair.Value.y < uvBounds.y) uvBounds.y = pair.Value.y; if (pair.Value.y > uvBounds.w) uvBounds.w = pair.Value.y; } uvBounds.z = uvBounds.z - uvBounds.x; uvBounds.w = uvBounds.w - uvBounds.y; Dictionary<int, Vector2> result = new Dictionary<int, Vector2>(); foreach (var pair in uvs) { result.Add(pair.Key, new Vector2((pair.Value.x - uvBounds.x) / uvBounds.z, (pair.Value.y - uvBounds.y) / uvBounds.w)); } return result;}

4.將切面網格和原網格合成

這裡用了subMesh和多重材質,可以少複製些數據。該多重紋理的時候還是該多重紋理。

切面三角形的正反面問題在生成三角形的時候就已經處理了,方法是判斷相鄰線段的叉乘方向。如果方向不對,輸出時就Reverse一次。

這個做法大部分時候都是正確的,但因為坐標系轉換的依據是Bone的方向而非切面的平均方向,假如關聯骨骼比較雜亂,又或者蒙皮刷得比較奇葩,導致Bone方向和切面方向差得太遠,在這裡就會出錯。

所以也可以直接使用雙面材質。

切面圖隨便找的一張,演示用。可以看到紋理能正常呈現。

切面其實並不僅限於簡單形狀,這樣的也是可以的,所以切面迴環必須重新生成。

不過在「Bone方向和切面方向過於不統一」,「切面投影到平面是多層的」,「本來窟窿就有多個」的時候,三角形還是會生成失敗。但只要切面比較規整,一般是沒有問題的。

然而最後還存在一個問題

上面拆分頂點只拆了三角形索引,兩個模型都有完整的頂點數據,斷手的bakeMesh也是全模型執行的。效率問題是其次,更嚴重的問題是,如果想讓斷手有正常的物理表現就需要MeshCollider,而它的數據是根據頂點生成的(和三角形索引無關),會生成這樣一個碰撞箱(按原模型生成)

所以必須過濾掉不在三角形索引內的頂點。

方法還是重映射,把三角形索引這樣複製一下去重,數組下標就是新索引,數組內容是舊索引。

List<int> triConvertDict = new List<int>(new HashSet<int>(triangles));

然後重新生成所有頂點數據

//反向索引Dictionary<int, int> triConvertInvDict = new Dictionary<int, int>();//重排頂點int count = triConvertDict.Count;for (int i = 0; i < count;i++){ int index = triConvertDict[i]; vertices.Add(oldVertices[index]); uvs.Add(oldUvs[index]); colors.Add(oldColors[index]); normals.Add(oldNormals[index]); tangents.Add(oldTangents[index]); boneWeights.Add(oldBoneWeights[index]); triConvertInvDict.Add(index, i);}//重排三角索引List<int> newTriangles = new List<int>(triangles.Count);count = triangles.Count;for (int i = 0;i < count;i++){ newTriangles.Add(triConvertInvDict[triangles[i]]);}

便能得到正常的物理效果

Package和完整代碼:

https://pan.baidu.com/s/1mi25W7Ypan.baidu.com


推薦閱讀:

Lua性能優化(一):Lua內存優化
Unity/C++混合編程全攻略!——基礎準備
UWA 兩周年慶活動第一彈!四場技術直播領跑六月充電季!
基於 Unity 引擎的遊戲開發進階之 全局光照
在Unity中復刻《超級馬里奧》

TAG:Unity游戏引擎 | 游戏开发 | 计算机图形学 |