趁熱度來做個捏臉

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

捏臉有兩種做法,一種是利用骨骼變換,一種是利用Blend Shape。

骨骼法是在臉的內部正常建立骨骼並蒙皮,並利用骨骼的縮放係數,位移來改變臉的外形。但由於是數學計算的結果,設計時難以直接對最終效果進行調整。

BlendShape則是正常做多個種類的臉型並離線出差異數據,設計的時候比較簡單,但疊加的時候也不太好控制,而且數據量較大。

由於現在遊戲都需要利用BlendShape來實現人物的表情,重疊的BlendShape不易處理。而骨骼法只是製作上麻煩點,但可控性較高,而且數據量也小,所以大部分遊戲都是用骨骼來實現捏臉的。

骨骼的設計是唯一的難點

具體怎麼布置骨骼可參考上圖,其實並不需要太多的控制項,更細節的可動部分(比如口型和眼睛)是交給BlendShape完成的,骨骼僅僅用來布置捏臉需要變化的部分。

通常會在正常的骨骼上加上一個Position(0,0,0),Scale(1,1,1)的節點,然後讓它代替父節點進行蒙皮,並把它作為捏臉時的可變數據。這樣數值是單位化的,捏臉時容易處理。不希望縮放參數影響到子節點的時候也可以這樣做。

這樣直接改Bone的Transform數值就能看到變化了。

相比臉型,眼睛需要的節點數更多,因為需要針對眼球等多個物體處理,眼角長度和上挑都要可控,是捏臉骨骼最複雜的部分。

讓捏臉變得人性化

雖然提供全部可變骨骼就能做出任何一種臉型,但那樣只會讓玩家輕易捏出怪物來。

通常的做法是給出一組可調整值的數值,對應骨骼的一個或者多個單位數值,讓玩家在0-1之間選擇。

至於這些調整數值背後的邏輯——

我選擇讓一個調整項同時影響多個骨骼數據,每個骨骼數據隻影響某條骨骼的某個值,並且可以指定動畫曲線,以應對弧面上的位移。

通過組合就可以應對各種情況(眼睛這些地方必然需要影響多個骨骼)。

將Value值調整到某個極值,然後修改Min,Max查看效果,修改Curve曲線處理中間狀況,生成捏臉數據的過程還是比較直觀的。設計好之後,將序列化的數據存儲起來,以後要用的時候載入並只允許修改Value的值,就可以應用在遊戲的捏臉部分了。

而且這個編輯器的實現也是非常簡單的,利用Unity自己的數據序列化面板就可以了。

public enum ModifyType { ScaleX, ScaleY, ScaleZ, X, Y, Z } [System.Serializable] public class ModifyDataGroup { public string name; [Range(0,1)] public float value = 0.5f; public ModifyData[] modifys; } [System.Serializable] public class ModifyData { public string name; public ModifyType type; public float min; public float max; public AnimationCurve curve; } public ModifyDataGroup[] modifyDataGroup;

去掉捏臉用的無用數據

很顯然,我們為了實現捏臉加了許多沒用的骨骼數據,這些都會影響到遊戲運行時的效能。雖然量並不是很大,但要移除也並不困難。

如果不考慮BlendShape,其實只需要調用Unity的BakeMesh方法,替換Mesh並刪掉骨骼就可以了。

但如果有BlendShape,Bake後的Mesh數據應用BlendShape後,會和以前有微量的偏移。這是因為BlendShape是在骨骼變化前先應用的,而Bake後,相當於是在骨骼變化後應用。

兩者的差異和骨骼的變化強度有關,也和BlendShape的改變幅度有關,但確實在通常情況下差異不大(第三幅圖是前兩幅圖的疊加,可以看到在臉部表情影響到的部分產生了微量的偏移)

但是即使少也是存在的。

解決方法是先將每一個BlendShape分別應用到Mesh上,再進行骨骼變換,蒙皮生成網格數據,然後再減掉Bake後的Mesh,重新生成一個新的BlendShape。

這其實相當於重做了一次BlendShape的生成過程(兩個模型間求差值),數據就是完全準確的了。

下面的代碼除了修正BlendShape外,還有蒙皮網格烘焙的代碼實現。

public static Mesh BakeMesh(SkinnedMeshRenderer source) { Mesh target = Object.Instantiate(source.sharedMesh); int vertexCount = source.sharedMesh.vertexCount; Bounds bounds = source.sharedMesh.bounds; BoneWeight[] boneWeights = source.sharedMesh.boneWeights; Vector3[] vertices = source.sharedMesh.vertices; Vector3[] normals = source.sharedMesh.normals; Vector4[] tangents = source.sharedMesh.tangents; Vector3[] newVertices = new Vector3[vertexCount]; Vector3[] newNormals = new Vector3[vertexCount]; Vector4[] newTangents = new Vector4[vertexCount]; Matrix4x4[] bindposes = source.sharedMesh.bindposes; Transform[] bones = source.bones; //Bake SkinMesh int count = bones.Length; Matrix4x4[] boneMatrixs = new Matrix4x4[count]; for (int i = 0; i < count; i++) { boneMatrixs[i] = source.rootBone.worldToLocalMatrix * bones[i].localToWorldMatrix * bindposes[i]; } for (int i = 0; i < vertexCount; i++) { ApplyBoneMatrix(boneWeights[i], boneMatrixs, vertices[i], normals[i], tangents[i], out newVertices[i], out newNormals[i], out newTangents[i]); } target.vertices = newVertices; target.normals = newNormals; target.tangents = newTangents; target.boneWeights = null; target.bounds = bounds; //修正BlendShape target.ClearBlendShapes(); count = source.sharedMesh.blendShapeCount; for (int i = 0; i < count; i++) { string name = source.sharedMesh.GetBlendShapeName(i); int frameCount = source.sharedMesh.GetBlendShapeFrameCount(i); Vector3[] deltaVertices = new Vector3[vertexCount]; Vector3[] deltaNormals = new Vector3[vertexCount]; Vector3[] deltaTangents = new Vector3[vertexCount]; for (int j = 0; j < frameCount; j++) { source.sharedMesh.GetBlendShapeFrameVertices(i, j, deltaVertices, deltaNormals, deltaTangents); for (int r = 0; r < vertexCount;r++) { Vector3 shapeVector; Vector3 shapeNormal; Vector4 shapeTangent; ApplyBoneMatrix(boneWeights[i], boneMatrixs, vertices[i] + deltaVertices[i], normals[i] + deltaNormals[i], tangents[i] + (Vector4)deltaTangents[i], out shapeVector, out shapeNormal, out shapeTangent); deltaVertices[i] = shapeVector - newVertices[i]; deltaNormals[i] = shapeNormal - newNormals[i]; deltaTangents[i] = shapeTangent - newTangents[i]; } float weight = source.sharedMesh.GetBlendShapeFrameWeight(i, j); target.AddBlendShapeFrame(name, weight, deltaVertices, deltaNormals, deltaTangents); } } return target; } //對每個頂點蒙皮 private static void ApplyBoneMatrix(BoneWeight bw, Matrix4x4[] boneMatrixs, Vector3 vector, Vector3 normal, Vector4 tangent, out Vector3 newVector, out Vector3 newNormal, out Vector4 newTangent) { Vector3 resultVector = new Vector3(); Vector3 resultNormal = new Vector3(); Vector3 resultTangent = new Vector3(); if (bw.weight0 > 0) { resultVector += boneMatrixs[bw.boneIndex0].MultiplyPoint3x4(vector) * bw.weight0; resultNormal += boneMatrixs[bw.boneIndex0].MultiplyVector(normal) * bw.weight0; resultTangent += boneMatrixs[bw.boneIndex0].MultiplyVector(tangent) * bw.weight0; } if (bw.weight1 > 0) { resultVector += boneMatrixs[bw.boneIndex1].MultiplyPoint3x4(vector) * bw.weight1; resultNormal += boneMatrixs[bw.boneIndex1].MultiplyVector(normal) * bw.weight1; resultTangent += boneMatrixs[bw.boneIndex1].MultiplyVector(tangent) * bw.weight1; } if (bw.weight2 > 0) { resultVector += boneMatrixs[bw.boneIndex2].MultiplyPoint3x4(vector) * bw.weight2; resultNormal += boneMatrixs[bw.boneIndex2].MultiplyVector(normal) * bw.weight2; resultTangent += boneMatrixs[bw.boneIndex2].MultiplyVector(tangent) * bw.weight2; } if (bw.weight3 > 0) { resultVector += boneMatrixs[bw.boneIndex3].MultiplyPoint3x4(vector) * bw.weight3; resultNormal += boneMatrixs[bw.boneIndex3].MultiplyVector(normal) * bw.weight3; resultTangent += boneMatrixs[bw.boneIndex3].MultiplyVector(tangent) * bw.weight3; } newVector = resultVector; newNormal = resultNormal; newTangent = new Vector4(resultTangent.x, resultTangent.y, resultTangent.z, tangent.w); }

只烘培部分骨骼

上面的是將整個頭部烘培成Mesh,但有時候我們也會想保留部分骨骼(例如頭髮)供捏臉之外的部分使用。而如果是體型部分的自定義,就更需要僅對部分骨骼烘培的功能。

雖然看上去骨骼的多層級和多權重難以下手,實際上也沒有多困難。

——只要把不需要烘培的骨骼重置回未經蒙皮變換時的狀態,也就是將那些骨骼的Transform設置為對應bindposes的逆,它們就不會對需要烘培的骨骼造成影響。

Matrix4x4 m = rootBone.worldToLocalMatrix * bindposes[i].inverse;SetTransformMatrix(bone, m);

bindposes本身就是Mesh空間相對於骨骼結點空間的變換矩陣……的逆,Mesh空間的頂點乘bindposes相當於將自己轉換到骨骼結點所在空間,所以再做一次「骨骼 -> 世界空間變換」就能完成蒙皮,這也是上面的蒙皮代碼實現的原理,實在不懂可自行查閱蒙皮相關資料。

我這裡再求一次bindposes的逆,就得到了骨骼結點相對於Mesh空間的矩陣,實際上就是骨骼最開始所在的位置。將骨骼移動回這個位置,就相當於這個骨骼沒有參與蒙皮,也就不會影響到其他需要蒙皮的骨骼。

設置Transform,而非直接跳過蒙皮階段,是為了讓需要烘培的結點的父結點位置正確。比起重新計算需要烘培的結點的Matrix4x4,這種方式會簡單一點(但是性能確實差一些)

之後走正常的BakeMesh流程就可以了。

完成後還需要單獨移除已經進行烘培,實際上已經無效的骨骼的boneWeight。所有boneWeight里涉及到那些骨骼的地方,都需要重置為骨骼的根節點RootBone的序號,也就是0。

for (int i = 0; i < vertexCount; i++){ BoneWeight bw = boneWeights[i]; bw.boneIndex0 = boneIndexFilter.Contains(bw.boneIndex0) ? 0 : bw.boneIndex0; bw.boneIndex1 = boneIndexFilter.Contains(bw.boneIndex1) ? 0 : bw.boneIndex1; bw.boneIndex2 = boneIndexFilter.Contains(bw.boneIndex2) ? 0 : bw.boneIndex2; bw.boneIndex3 = boneIndexFilter.Contains(bw.boneIndex3) ? 0 : bw.boneIndex3; boneWeights[i] = bw;}

對於SkinMesh來講,並不存在刪除骨骼結點一說,不管怎麼樣都會至少做一次相對於根結點的骨骼變換。把所有數據都重置為0是不行的,至少要有留下一個{boneIndex0 : 0, weight0 : 1f}。

烘培完後還需要將骨骼的狀態再次恢復。

不過如果是遊戲運行時,其實並不需要專門重置骨骼,因為骨骼剛載入出來的時候本來就是重置好的。只要先載入捏臉的骨骼數據,烘培,修改BoneWeight,再載入其他和骨骼相關的部分,烘培和非烘培就不會有衝突了。

然而模型從哪裡弄?

捏臉的技術難度其實就這些(烘培那段估計大部分人也不用),主要的難點還是骨骼和捏臉數據的設計。

我這個模型是從HoneySelect里扒的,它的模型數據全在abdata這個文件夾下以AssetBoundle形式存在,所以很簡單就能扒出來。

但UnityStudio這些破解工具導出的文件迄今為止依然會丟失BlendShape,所以需要用UnityAPI直接讀取ab文件並另存為。

HoneySelect其實也有捏臉參數的配置文件,有興趣可以自己扒過來以節約配置參數的時間(這個恐怕才是工作量的大頭)

zhuanlan.zhihu.com/p/28

想要節操當然也可以參考著自己做。

推薦閱讀:

TAG:Unity遊戲引擎 | 遊戲開發 | 計算機圖形學 |