作者:admin 来源:找剑灵 发表时间:2022-07-21 09:06:34
捏脸的方法有两种,一种是使用骨骼变换,另一种是使用Blend Shape。

骨架法是在面部内部正常构建骨骼和皮肤,并使用骨骼的缩放因子和位移来改变面部的形状。但由于是数学计算的结果,设计时很难直接调整最终效果。
BlendShape 通常会制作多种类型的人脸形状并离线生成差异数据。设计起来比较简单,但是叠加的时候不容易控制,数据量大。
由于游戏现在需要使用 BlendShape 来实现角色的表情,重叠的 BlendShape 不好处理。骨架法只是在制作上比较麻烦,但是可控性高,数据量少,所以大部分游戏都是用骨头来实现捏脸的。
骨骼的设计是唯一的难点
有关如何排列骨骼的详细信息,请参阅上图。事实上,您不需要太多的控制项。更详细的可移动部分(例如嘴形和眼睛)交给 BlendShape。骨骼只用于排列捏脸时需要改变的部分。.
通常在普通骨骼上添加一个Position(0,0,0),Scale(1,1,1)的节点,然后代替父节点进行蒙皮,使用as 捏脸时的变量数据,这样捏脸的时候值是统一的,容易处理,当你不希望缩放参数影响子节点时也可以这样做。

这样直接改变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,则在应用 BlendShape 后,Bake 后的 Mesh 数据会与之前的略有偏移。这是因为 BlendShape 在骨骼变化之前应用,而在 Bake 之后,它在骨骼变化之后应用。

两者的差异与骨骼的变化强度有关,也与BlendShape的变化幅度有关,但一般情况下差别不大(第三张图是前两张图的叠加,可以看到面部表情影响部分产生轻微偏移)
但更少。
解决方法是先将每个BlendShape应用到Mesh上,然后进行骨骼变换、蒙皮生成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设置为对应绑定姿势的逆,它们就不会影响需要烘焙的骨骼烤。
Matrix4x4 m = rootBone.worldToLocalMatrix * bindposes[i].inverse;
SetTransformMatrix(bone, m);
Bindposes本身就是Mesh空间相对于骨架节点空间的变换矩阵的逆……将Mesh空间的顶点乘以bindposes相当于将自身转换到骨架节点所在的空间,所以“Bone -> 世界空间变换”再次。可以完成换肤,这也是上述换肤代码实现的原理。
在这里又找到了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其实有一个捏人脸参数的配置文件。有兴趣的可以自己去拿剑灵捏人,省去配置参数的时间(这大概是工作量的大头)
/p/28471808
当然,如果你想自律,也可以参考自己。