本文的主要内容是介绍了一下自己验证捏脸系统一个方案的小实验。
很早之前便有捏脸系统的一个设想,但由于demo中没有引入动画系统所以一直没有来得及验证。捏脸系统一个美术工作量比较小的方案是基于骨骼动画的,我一开始的想法是,对于骨骼动画而言,既然绑定姿态(bindPose)能够反映不同的体型,那么它同样也能够反映不同的脸型。也就是说,我们需要实现一套运行时修改角色绑定姿态(脸部骨骼平移、旋转等)并能实时反馈修改,支持记录并读入修改数据,支持场景中同一体型中不同角色加载各自的捏脸这样一套系统。
为了确保捏脸系统的多样性、美观性和易用性,实际上我们需要对脸部骨骼做精心设计,提供符合大众审美的基本脸型,并对滑杆的数值进行合理的限制,当然这一部分内容需要一个团队的共同努力,而我在此会更加关注捏脸本身的实现。
为了详细展开对捏脸系统设计的描述,开篇依然要从动画系统开始说起。角色动画本身会在运行时占据大量的内存空间,所以一般而言我们需要做以下操作:
(1) 动画数据的曲线压缩和实时解析、四元数等数据的压缩存储
(2) 多角色同体型引用同一份骨骼数据和动画数据
(3) 同一人形骨架下不同体型的同一动作,支持映射,在内存中仅存一份动画数据
(4) 动画数据在第一次调用后,在后台流式加载,未调用的动画数据则不进行加载
(5) 长时间未使用的动画数据/仅调用一次的动画数据进行动态卸载
……
对于最终蒙皮矩阵的计算,在比较复杂的系统中,我们应该要经历:对于每一骨骼,从压缩数据解析出localTransform -> 一些后处理计算 -> 换算成globalTransform -> 一些后处理计算 -> 乘以offset矩阵(绑定姿态的逆矩阵)得到蒙皮矩阵 -> 传递给GPU(或直接在CPU中)做蒙皮运算。
那么现在,我们面临的一个问题就是,捏脸功能应该如何集成到上述这样一个流程中?
实际上我们可能已经发现了一些问题:同一体型的绑定姿态是一致的,所以理论上我们应该只存储一份offset矩阵,但是捏脸需要对不同角色应用不同offset矩阵,如此看来我们则不得不多存储许多运行时数据。
但是仍然有一部分数据是共享的——我们只可能修改脸部骨骼,剩余的身体数据依然有着同样的绑定姿态,而且用户可能只修改了少量的骨骼数据。因此在这里我们可以考虑引入一个类似于蒙版的机制,也就是说,仅在数据发生了修改的时候,才记录对应的改动。那么我们最终得到的捏脸数据就是“发生了改动的骨骼的offset矩阵”。
在运行时解算动画时,我们对每根骨骼检查是否存在本地修改,如果有,则读取捏脸数据,否则读取公共的骨骼绑定数据。
对于角色而言,它会关联到许多系统,比如为了支持不同部位换装与穿搭,我们需要把mesh分为多个部分,如脸部、头发、上身、下身等;对于部分角色,它可能有对应的骨架、材质、动画、特效、布料、破碎体等,对于这种可选的模块,我们可以将其设计为组件。我们只加载物体所需的组件。
那么对Object而言,我们可以写出一个简单的组件系统,支持从Component继承而来的各种组件:
class Object
{
privarte:
// ...
unordered_map>> m_mapComponent;
// ...
public:
void AddComponent(Component* component, const string& name);
template
T* CreateComponent(const string& name, Args args...)
{
string typeName = GetTypeName();
m_mapComponent[typeName][name] = make_unique(args);
return static_cast(m_mapComponent[typeName][name].get());
}
template
T* CreateComponent(const string& name)
{
string typeName = GetTypeName();
m_mapComponent[typeName][name] = make_unique();
return static_cast(m_mapComponent[typeName][name].get());
}
template
string GetTypeName()
{
string typeName = typeid(T).name();
const string& prefix = "class ";
if(typeName.find(prefix) != string::npos)
{
typeName = typeName.substr(prefix.size());
}
return typeName;
}
template
T* TryGetDefaultComponent()
{
string typeName = GetTypeName();
if (m_mapComponent.find(typeName) == m_mapComponent.end()) return nullptr;
return static_cast(m_mapComponent[typeName].begin()->second.get());
}
template
T* TryGetComponent(const string& name)
{
string typeName = GetTypeName();
if (m_mapComponent.find(typeName) == m_mapComponent.end()) return nullptr;
if (m_mapComponent[typeName].find(name) == m_mapComponent[typeName].end()) return nullptr;
return static_cast(m_mapComponent[typeName][name].get());
}
// ...
};
对所有组件支持序列化后,我们便得到了一个预制件(prefab),之后读取这一预制件,就会自动创建对应的组件。
由于不同数据之间可能存在共享,所以我们的数据将由一个专门的资源管理类进行控制,而在组件中存储对应的链接/索引。我们在创建组件的时候,如果对应的资源没有加载,我们申请创建;如果已经存在,则直接链接过去。
对于捏脸数据这样的非共享数据而言,我们就可以将其直接存储在组件中:
class CAnimationComponent : public Component
{
public:
CAnimationComponent(const string& name)
: m_skeletonName(name) { }
CAnimationComponent() { }
QMatrix4x4 GetInvBindPose(const string& boneName);
QMatrix4x4 GetInvBindPose(int boneIndex);
SFaceData m_faceData;
vector m_vecSkinMatrix;
string m_skeletonName;
SInvBindPoseType m_mapInvBindPose;
};
如上,我们首先在动画组件中存储了骨架名,以便链接到对应的骨骼数据(包含获取对应的offset矩阵);我们同时缓存了一个skinMatrix的矩阵,这是为了将动画数据更新和渲染的逻辑分离,更新每个角色的动画数据时,将最终运算结果存储在skinMatrix矩阵中,渲染时读取使用这一矩阵。
接下来是捏脸数据,我们这里存储了两份捏脸数据,一份是SFaceData,它存储了骨骼的偏移旋转和缩放,这一数据主要是为了方便我们进行实时的捏脸操作,里面的数据直接对应滑杆上的数值。在不进行捏脸操作时,这一数据可以卸载,在进入捏脸模式后才从最终的数据中一次性反推出所有偏移值。
struct SFaceData
{
unordered_map m_offsetRotation;
unordered_map m_offsetPosition;
// ...
};
另一份数据为SInvBindPoseType,它主要应用于最终骨骼动画的渲染,它存储了进行捏脸变换后的offset矩阵:
struct SInvBindPoseType
{
unordered_map data;
// ...
};
在动画更新时,我们封装一个GetInvBindPose的方法(即获取offset矩阵),判断读取本地修改的矩阵,而是公共链接的矩阵数据。
REGISTER_COMPONENT(CAnimationComponent)
QMatrix4x4 CAnimationComponent::GetInvBindPose(const string& boneName)
{
CAnimationCharacter& animCharacter = CAnimationEngine::Inst()->AccessSkeleton(m_skeletonName);
shared_ptr bone = animCharacter.GetBone(boneName);
if (m_mapInvBindPose.data.find(boneName) != m_mapInvBindPose.data.end())
{
return m_mapInvBindPose.data[boneName];
}
else
{
return bone->m_invBindPose;
}
}
QMatrix4x4 CAnimationComponent::GetInvBindPose(int boneIndex)
{
CAnimationCharacter& animCharacter = CAnimationEngine::Inst()->AccessSkeleton(m_skeletonName);
const string& boneName = animCharacter.GetBone(boneIndex)->m_name;
return GetInvBindPose(boneName);
}
此时动画更新时的操作如下:
{
// ...
const QMatrix4x4& invBindPose = aniComponent->GetInvBindPose(i);
QMatrix4x4 mat;
mat.translate(trans);
mat.rotate(quat);
mat = mat * invBindPose;
final.emplace_back(mat);
}
以上UI依然是通过反射机制生成的,这不是本文的重点,我们来关注操作滑竿将会绑定一个回调函数,内部会做对应的数据更新。
如下所示,显示了写入和读取数据的操作。如对写入而言,我们获取当前偏移数据,然后在faceData中记录偏移数据。然后通过调用一个UpdatePosition的函数,将偏移数据换算成offset矩阵。
faceTransXInit("transX", -1.0f, 1.0f, [this](float data)
{
auto activeObj = ObjectInfo::Inst()->GetActiveObject();
if (shared_ptr
在UpdatePosition中,我们从角色链接的骨骼中得到原始bindPose数据,然后累加偏移的位移、旋转,计算得到新的offset矩阵,存储到动画组件的m_mapInvBindPose中。
void CFaceWidget::UpdatePosition(const string& boneName)
{
auto activeObj = ObjectInfo::Inst()->GetActiveObject();
if (shared_ptr obj = activeObj.lock())
{
CAnimationComponent* aniComponent = obj->TryGetDefaultComponent();
if (!aniComponent) return;
auto bone = CAnimationEngine::Inst()->AccessSkeleton(aniComponent->m_skeletonName).GetBone(boneName);
if (!bone) return;
const STransform& transform = bone->m_bindPose;
QMatrix4x4 mat;
mat.translate(transform.position + aniComponent->m_faceData.GetOffsetPosition(boneName));
QVector3D rot = transform.rotation.toEulerAngles();
rot += aniComponent->m_faceData.GetOffsetRotation(boneName) / 180 * PI;
mat.rotate(QQuaternion::fromEulerAngles(rot));
aniComponent->m_mapInvBindPose.data[boneName] = mat.inverted();
}
}
由此可见,m_faceData是我们执行捏脸操作时的一个辅助数据,我们最终存储的数据是m_mapInvBindPose,这可以随着动画组件一起序列化,直接存储在组件中。我们在编辑过程中实时计算m_mapInvBindPose,动画更新便会自动读取更新后的数据并应用到角色身上。至此,我们就支持了不同角色的捏脸数据存储、读入、修改和更新。
如果想要做进一步优化,在多份捏脸数据中也实现共享,我们可对每份捏脸数据,记录一个uuid/guid或者本地路径,对捏脸数据也单独管理。