|
写在前面
虽然不论在任何引擎里平时不需要手撕自己的蒙皮,但是了解蒙皮的算法仍然用处巨大。比如,刚接触蒙皮问题的同学有可能搞不懂“Candy Wrapping”问题的来源,即蒙皮的关节处扭转180°会在中间扭出一个很细的结,如同糖纸包装方式。它的解决方式通常是加twist骨骼,为什么加上以后就能掩盖这个问题,也需要从蒙皮的算法中寻找答案。
另外,线性混合蒙皮算法可以成为一种强有力的思维模式,帮助我们解决蒙皮之外的问题。举例如,在重定向表情动画的时候,在现有骨骼和蒙皮结构上算出一个框架结构称之为“笼子”,每个面部骨骼在“笼子”的范围内确定自己的运动,可以避免上下眼皮穿插的问题,此时表情骨骼的位置就是“笼子”确定的边界位置的线性混合结果。另外,经典Motion Warping算法中的Simple Warping可能会造成运动轨迹圆滑的地方出现尖锐的拐角,但如果把运动轨迹看成Mesh,Warping Point看成运动轨迹蒙皮的骨骼,由此得到的新的算法则只要“蒙皮权重”的变化是平滑的,就不会在Warping Point处出现不必要的拐角。
参考
[1] Gregory J , 叶劲峰. 游戏引擎架构[M]. 电子工业出版社, 2014.
狂推的唯一参考。其11.5部分标题为《蒙皮矩阵及生成矩阵调色板》,讲解得平白易懂,就算没有任何图形和动画基础的同学(比如公司里的法务、市场和文案)看完这一章也能完全明白最经典的蒙皮算法的原理。其中做为示例的数据结构和Unity的Mesh中的数据结构不一样,这主要因为Unity没有把一个顶点上的位置、法线、切线和蒙皮权重信息存成一个struct,而是存了好几个数组,每个索引对应一个顶点的信息。这很容易互相转化。
如果有同学对矩阵和变换比较糊涂,翻前边甚至还可以就近补矩阵相关知识。
线性混合蒙皮的本质
蒙皮的诀窍:顶点只有一个关节(即常说的骨骼,但是《游戏引擎架构》喜欢把它叫关节)影响时,顶点在关节空间中始终不变。
在被多个关节影响时,对于每个关节空间都会有这么一个不变的顶点位置。把这些顶点位置转化到模型空间,然后按权重对它们进行加权平均(线性混合),就可以得到模型空间下的顶点。于是,蒙皮效果就出现了。
这一步也可以让它直接转换到世界空间以节省一次矩阵乘法。但是如果场景中需要大量的实例,先转换到模型空间是更优的选择。
对每个关节而言,把顶点从bindpose变化到当前姿势的矩阵为蒙皮矩阵。每个顶点对应多个蒙皮矩阵,他们组成了蒙皮矩阵调色板。
在Unity中验证算法
提取信息
首先,写一个自定义Component,在Component里写上一个提取SkinnedMeshRenderer中所有必要信息的函数,序列化到Component上。
编辑器里右键菜单,一键提取所有相关信息,包括顶点、法线、切线、三角面、bindpose、骨骼等。如果想用这信息完全复原之前的Mesh,还可以加上UV信息。
提取信息之后,SkinnedMeshRenderer就可以删掉了。等会可以从这些信息里面把蒙皮后的Mesh的效果复原出来。
[HideInInspector]
[SerializeField]
public Matrix4x4[] bindPoses;
[HideInInspector]
public Vector3[] vertices;
[HideInInspector]
public Vector4[] tangents;
[HideInInspector]
public Vector3[] normals;
[HideInInspector]
public BoneWeight[] boneWeights;
[HideInInspector]
public int[] triangles;
[HideInInspector]
public Transform[] bones;
public SkinnedMeshRenderer target;
[ContextMenu("Extract Bind Pose")]
void ExtractBindPose() {
if (target && target.sharedMesh) {
bindPoses = target.sharedMesh.bindposes;
vertices = target.sharedMesh.vertices;
boneWeights = target.sharedMesh.boneWeights;
normals = target.sharedMesh.normals;
tangents = target.sharedMesh.tangents;
triangles = target.sharedMesh.triangles;
bones = target.bones;
}
}
一键提取蒙皮网格信息
生成蒙皮矩阵
Unity中的Mesh会有一个bindPose成员,是一个Matrix4x4类型的数组,每个参与蒙皮的骨骼按顺序对应其中的一个矩阵。官方文档里只说它是bindPose,没有解释得非常清楚(也许是觉得这东西属于先验知识不需要解释)。实际上,它是蒙皮姿势下所有的骨骼从Mesh Space转换到Local Space的变换矩阵组成的数组。Local Space也可以说成是Bone Space以帮助理解。
我打算跳过转换到模型空间的一步,直接让所有顶点转换到世界空间。因此对每根骨骼而言,实现蒙皮矩阵是,当前骨骼的LocalToWorldMatrix * bindPose。
/// <summary>
/// 蒙皮矩阵:每根骨骼对应一个蒙皮矩阵,把其影响的顶点从bindpose转换到目前的pose
/// </summary>
/// <returns></returns>
Matrix4x4[] SkinningMatrices() {
Matrix4x4[] skinningMatrices = new Matrix4x4[bindPoses.Length];
for (int i = 0; i < bindPoses.Length; i++) {
Transform bone = bones;
Matrix4x4 currentBoneWorldTransformationMatrix;
if (bone)
{
currentBoneWorldTransformationMatrix = bone.localToWorldMatrix;
}
else {
currentBoneWorldTransformationMatrix = target.transform.localToWorldMatrix * bindPoses.inverse;
}
skinningMatrices = currentBoneWorldTransformationMatrix * bindPoses;
}
return skinningMatrices;
}
烘焙现有姿势
需要变换的是顶点位置、法线和切线,其他的信息如三角面、UV等完全不会变。因此只Bake这三个数组即可。
用上面的函数求出的蒙皮矩阵,乘上这些信息即可得到世界空间下的位置、旋转和缩放。
有以下几个注意点。
位置信息需要转换为齐次坐标再做乘法,否则变换时会丢失位移。
法线不转化为齐次坐标,因为它只需要变换旋转。
切线的默认类型是Vector4,最后一位是标记位,作用是在模型存在负缩放的部分情形(x × y × z < 0)下翻转副切线。我暂时用不到它,因此前三位当成方向处理,最后一位照抄。
实现的时候复原的是Unity默认自带的4bone效果。
void BakeOnCurrentPose(out Vector3[] poseVerts, out Vector3[] poseNormals, out Vector4[] poseTangents) {
int numVerts = vertices.Length;
poseVerts = new Vector3[numVerts];
poseNormals = new Vector3[numVerts];
poseTangents = new Vector4[numVerts];
Matrix4x4[] skinningMatrices = SkinningMatrices();
for (int i = 0; i < numVerts; i++) {
BoneWeight boneWeight = boneWeights;
Vector4 vert = vertices;
vert.w = 1;
Matrix4x4 skinningMatrix0 = skinningMatrices[boneWeight.boneIndex0];
Matrix4x4 skinningMatrix1 = skinningMatrices[boneWeight.boneIndex1];
Matrix4x4 skinningMatrix2 = skinningMatrices[boneWeight.boneIndex2];
Matrix4x4 skinningMatrix3 = skinningMatrices[boneWeight.boneIndex3];
float weight0 = boneWeight.weight0;
float weight1 = boneWeight.weight1;
float weight2 = boneWeight.weight2;
float weight3 = boneWeight.weight3;
Vector3 pos0 = skinningMatrix0 * vert;
Vector3 pos1 = skinningMatrix1 * vert;
Vector3 pos2 = skinningMatrix2 * vert;
Vector3 pos3 = skinningMatrix3 * vert;
Vector3 pos = pos0 * weight0 + pos1 * weight1 + pos2 * weight2 + pos3 * weight3;
Vector3 norm = normals;
Vector3 normal0 = skinningMatrix0 * norm;
Vector3 normal1 = skinningMatrix1 * norm;
Vector3 normal2 = skinningMatrix2 * norm;
Vector3 normal3 = skinningMatrix3 * norm;
Vector3 normal = normal0 * weight0 + normal1 * weight1 + normal2 * weight2 + normal3 * weight3;
Vector4 tan = tangents;
Vector3 tangent0 = skinningMatrix0 * tan;
Vector3 tangent1 = skinningMatrix1 * tan;
Vector3 tangent2 = skinningMatrix2 * tan;
Vector3 tangent3 = skinningMatrix3 * tan;
Vector4 tangent = tangent0 * weight0 + tangent1 * weight1 + tangent2 * weight2 + tangent3 * weight3;
tangent.w = tan.w;
poseVerts = pos;
poseNormals = normal;
poseTangents = tangent;
}
}
效果
已经删掉了SkinnedMeshRenderer,但是从之前序列化存储的信息里面还可以看到Mesh的样子。
在OnDrawGizmo里面写了一些用来可视化验证成果的脚本。此脚本每帧运算极端消耗形能,不建议使用。
private void OnDrawGizmos()
{
Vector3[] bakedVerts;
Vector3[] bakedNormals;
Vector4[] bakedTangents;
BakeOnCurrentPose(out bakedVerts, out bakedNormals, out bakedTangents);
if (drawEdges)
{
Gizmos.color = Color.gray;
for (int i = 0; i < triangles.Length; i += 3)
{
int vertIndex0 = triangles;
int vertIndex1 = triangles[i + 1];
int vertIndex2 = triangles[i + 2];
Gizmos.DrawLine(bakedVerts[vertIndex0], bakedVerts[vertIndex1]);
Gizmos.DrawLine(bakedVerts[vertIndex1], bakedVerts[vertIndex2]);
Gizmos.DrawLine(bakedVerts[vertIndex0], bakedVerts[vertIndex2]);
}
}
if (drawTangents) {
Gizmos.color = Color.blue;
for (int i = 0; i < bakedVerts.Length; i++) {
Gizmos.DrawRay(bakedVerts, bakedTangents * 0.1f);
}
}
if (drawNormals) {
Gizmos.color = Color.green;
for (int i = 0; i < bakedVerts.Length; i++)
{
Gizmos.DrawRay(bakedVerts, bakedNormals * 0.1f);
}
}
}
可以分别开启画边、画法线、画切线三个选项,查看CPU做此次蒙皮运算的结果。
画边
动作用的是从ALSV4里导出的蹲伏动作。每一个顶点没一条边都在预期的位置上,因此这个bake初步是成功的。
法线的炸毛形状很明显,切线也在贴着模型表面走,因此虽然场景里没有任何Mesh和MeshRenderer,但它仍然可以还原Mesh的形状。 |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|