[OpenGL] 捏脸系统

        本文的主要内容是介绍了一下自己验证捏脸系统一个方案的小实验。

        很早之前便有捏脸系统的一个设想,但由于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 obj = activeObj.lock())
        {
            CAnimationComponent* aniComponent = obj->TryGetDefaultComponent();
            if(aniComponent) aniComponent->m_faceData.SetOffsetPositionX(m_strBone, data);
        }
        UpdatePosition(m_strBone);
    }, [this]()->float{
        auto activeObj = ObjectInfo::Inst()->GetActiveObject();
        if (shared_ptr obj = activeObj.lock())
        {
            CAnimationComponent* aniComponent = obj->TryGetDefaultComponent();
            if (aniComponent) return aniComponent->m_faceData.GetOffsetPosition(m_strBone).x();
        }
        return 0.0f;
    }); 
  

        在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或者本地路径,对捏脸数据也单独管理。

你可能感兴趣的:(OpenGL)