Skip to content

Latest commit

 

History

History
711 lines (578 loc) · 32.1 KB

File metadata and controls

711 lines (578 loc) · 32.1 KB

九、实现动画剪辑

动画剪辑是TransformTrack对象的集合。一个动画剪辑随着时间的推移动画化一组变换,动画化的一组变换称为一个姿势。把一个姿势想象成一个动画角色在特定时间点的骨架。姿势是变换的层次。每个转换的值都会影响其所有子转换。

让我们来看看为游戏角色动画的一帧生成姿势需要什么。对动画剪辑进行采样时,结果是一个姿势。动画剪辑由动画轨迹组成,每个动画轨迹由一个或多个帧组成。这种关系看起来像这样:

Figure 9.1: The dependencies of generating a pose.

图 9.1:生成姿势的依赖关系

到本章结束时,您应该能够从 glTF 文件中加载动画剪辑,并将这些剪辑采样成一个姿势。

实施姿势

要在变换之间存储父子层次结构,您需要维护两个并行的向量——一个用变换填充,一个用整数填充。整数数组包含每个关节的父变换的索引。不是所有的关节都有父母;如果关节没有父关节,则其父关节值为负。

当考虑骨骼或姿势时,很容易想到有一个根节点和多个从其分支的节点的层次结构。实际上,有两三个根节点并不罕见。有时,文件格式存储模型的方式是骨架的第一个节点是根节点,但也有一个根节点,所有蒙皮网格都是它的子节点。这些层次结构往往如下所示:

Figure 9.2: Multiple root nodes in one file

图 9.2:一个文件中的多个根节点

动画角色有三种常见的姿势——当前姿势、绑定姿势和 T2 姿势。其余姿势是所有骨骼的默认配置。动画描述了每块骨头随时间的变化。对动画进行时间采样会产生用于对角色进行蒙皮的当前姿势。绑定姿势将在下一章中介绍。

并非所有动画都会影响角色的每个骨骼或关节;这意味着某些动画可能不会改变关节的值。请记住,在这种情况下,关节被表示为Transform对象。如果动画 A 在索引1处动画化关节,但动画 B 没有动画化关节,会发生什么?以下列表显示了结果:

  • 如果只玩 A 或者 B ,一切都好。
  • 如果先玩 B 再玩 A ,一切都好。
  • 如果你先玩 A 然后玩 B ,事情就有点不稳定了。

在最后一个示例中,首先播放动画 A 其次播放动画 B ,索引1处的关节保持其从动画 A 最后修改的变换。因此,无论何时在动画之间切换,都必须重置当前姿势,使其与其余姿势相同。在下一节中,您将开始声明Pose类。

声明姿势类

Pose类需要跟踪您正在制作动画的角色骨架中每个关节的变换。它还需要跟踪每个关节的父关节。这些数据保存在两个平行的向量中。

在对新的动画剪辑进行采样之前,需要将当前角色的姿势重置为静止姿势。Pose类实现了一个复制构造函数和赋值操作符,使复制姿势尽可能快。按照以下步骤申报Pose类:

  1. 创建一个新的头文件,Pose.h。将Pose类的定义添加到该文件中,从关节变换的平行向量及其父向量开始:

    class Pose {
    protected:
        std::vector<Transform> mJoints;
        std::vector<int> mParents;
  2. 添加默认构造函数和复制构造函数,并重载赋值运算符。Pose类还有一个方便的构造器,它将姿势的关节数作为参数:

    public:
        Pose();
        Pose(const Pose& p);
        Pose& operator=(const Pose& p);
        Pose(unsigned int numJoints);
  3. 为姿势中的关节数量添加一个 getter 和 setter 函数。使用设置器功能时,mJointsmParents向量都需要调整大小:

        void Resize(unsigned int size);
        unsigned int Size();
  4. 为联合的父级添加 getter 和 setter 函数。这两个函数都需要以关节的指数为自变量:

        int GetParent(unsigned int index);
        void SetParent(unsigned int index, int parent);
  5. Pose类需要提供一种方法来获取和设置关节的局部变换,以及检索关节的全局变换。重载[] operator返回关节的全局变换:

        Transform GetLocalTransform(unsigned int index);
        void SetLocalTransform(unsigned int index, 
                               const Transform& transform);
        Transform GetGlobalTransform(unsigned int index);
        Transform operator[](unsigned int index);
  6. 对于要传递给 OpenGL 的Pose类,需要将其转换为矩阵的线性数组。GetMatrixPalette功能执行该转换。函数引用一个矩阵向量,并用姿势中每个关节的全局变换矩阵来填充它:

        void GetMatrixPalette(std::vector<mat4>& out);
  7. 通过重载等式和不等式运算符完成Pose类的设置:

        bool operator==(const Pose& other);
        bool operator!=(const Pose& other);
    };

Pose类用于保存动画层次中每个骨骼的变换。把它想象成动画中的一帧;Pose类表示动画在给定时间的状态。在下一节中,您将实现Pose类。

实现姿势类

创建新的文件,Pose.cpp。您将在该文件中实现Pose类。采取以下步骤实施Pose班:

  1. 默认构造函数不需要做任何事情。复制构造函数调用赋值运算符。便利构造器调用Resize方法:

    Pose::Pose() { }
    Pose::Pose(unsigned int numJoints) {
        Resize(numJoints);
    }
    Pose::Pose(const Pose& p) {
        *this = p;
    }
  2. 分配操作者需要尽可能快地复制姿势。您需要确保姿势没有分配给它自己。接下来,确保姿势有正确的关节和父母数量。然后,执行记忆复制,快速复制所有父数据和姿势数据:

    Pose& Pose::operator=(const Pose& p) {
        if (&p == this) {
            return *this;
        }
        if (mParents.size() != p.mParents.size()) {
            mParents.resize(p.mParents.size());
        }
        if (mJoints.size() != p.mJoints.size()) {
            mJoints.resize(p.mJoints.size());
        }
        if (mParents.size() != 0) {
            memcpy(&mParents[0], &p.mParents[0], 
                   sizeof(int) * mParents.size());
        }
        if (mJoints.size() != 0) {
            memcpy(&mJoints[0], &p.mJoints[0], 
                   sizeof(Transform) * mJoints.size());
        }
        return *this;
    }
  3. 由于父向量和关节向量是平行的,Resize函数需要设置两者的大小。size getter 函数可以返回任一向量的大小:

    void Pose::Resize(unsigned int size) {
        mParents.resize(size);
        mJoints.resize(size);
    }
    unsigned int Pose::Size() {
        return mJoints.size();
    }
  4. 局部转换的 getter 和 setter 方法很简单:

    Transform Pose::GetLocalTransform(unsigned int index) {
        return mJoints[index];
    }
    void Pose::SetLocalTransform(unsigned int index, const Transform& transform) {
        mJoints[index] = transform;
    }
  5. 从当前变换开始,GetGlobalTransform方法需要组合父链上的所有变换,直到它到达根骨骼。请记住,变换串联是从右向左进行的。过载的[] operator应该被当作GetGlobalTransform的别名:

    Transform Pose::GetGlobalTransform(unsigned int i) {
        Transform result = mJoints[i];
        for (int p = mParents[i]; p >= 0; p = mParents[p]) {
            result = combine(mJoints[p], result);
        }
        return result;
    }
    Transform Pose::operator[](unsigned int index) {
        return GetGlobalTransform(index);
    }
  6. 要将一个Pose类转换成一个矩阵向量,循环遍历姿态中的每个变换。对于每个变换,找到全局变换,将其转换为矩阵,并将结果存储在矩阵向量中。该功能尚未优化;您将在后面的章节中对其进行优化:

    void Pose::GetMatrixPalette(std::vector<mat4>& out) {
        unsigned int size = Size();
        if (out.size() != size) {
            out.resize(size);
        }
        for (unsigned int i = 0; i < size; ++ i) {
            Transform t = GetGlobalTransform(i);
            out[i] = transformToMat4(t);
        }
    }
  7. 父联合索引的 getter 和 setter 方法很简单:

    int Pose::GetParent(unsigned int index) {
        return mParents[index];
    }
    void Pose::SetParent(unsigned int index, int parent) {
        mParents[index] = parent;
    }
  8. 比较两个姿势时,您需要确保两个姿势中的所有关节变换和父索引都相同:

    bool Pose::operator==(const Pose& other) {
        if (mJoints.size() != other.mJoints.size()) {
            return false;
        }
        if (mParents.size() != other.mParents.size()) {
            return false;
        }
        unsigned int size = (unsigned int)mJoints.size();
        for (unsigned int i = 0; i < size; ++ i) {
            Transform thisLocal = mJoints[i];
            Transform otherLocal = other.mJoints[i];
            int thisParent = mParents[i];
            int otherParent = other.mParents[i];
            if (thisParent != otherParent) { return false; }
            if (thisLocal.position != otherLocal.position) {
            return false; }
            if (thisLocal.rotation != otherLocal.rotation {
            return false; }
            if (thisLocal.scale != otherLocal.scale { 
            return false; } 
        }
        return true;
    }
    bool Pose::operator!=(const Pose& other) {
        return !(*this == other);
    }

一个动画角色有多个活动姿势并不罕见。考虑一个角色同时奔跑和开枪的情况。可能会播放两个动画——一个影响下半身,运行动画,一个影响上半身,拍摄动画。这些姿势混合在一起形成最终姿势,用于显示动画角色。这种类型的动画混合在 第 12 章动画之间的混合中有所介绍。

在下一节中,您将实现动画剪辑。动画剪辑包含一个姿势中所有动画关节随时间变化的动画。Clip类用于采样动画和生成要显示的姿势。

实现剪辑

动画剪辑是动画轨迹的集合;每个轨迹描述一个关节随时间的运动,所有合并的轨迹描述动画模型随时间的运动。如果对动画剪辑进行采样,您将获得一个姿势,该姿势描述了动画剪辑中每个关节在指定时间的配置。

对于一个基本剪辑类,你只需要一个变换轨迹的向量。因为变换轨迹包含它们影响的关节的标识,所以每个片段可以有最少数量的轨迹。Clip类还应该跟踪元数据,例如剪辑的名称、剪辑是否正在循环以及关于剪辑的时间或持续时间的信息。

声明剪辑类

Clip类需要来维护变换轨迹的向量。这是剪辑包含的最重要的数据。除了轨道,片段还有名称、开始时间和结束时间,片段应该知道它是否在循环。

Clip类的循环属性可以被卸载到管道更下游的构造中(例如动画组件或类似的东西)。然而,当实现一个裸机动画系统时,这是一个放置循环属性的好地方:

  1. 新建一个文件Clip.h,开始Clip类的申报:

    class Clip {
    protected:
        std::vector<TransformTrack> mTracks;
        std::string mName;
        float mStartTime;
        float mEndTime;
        bool mLooping;
  2. 片段的采样方式与轨道的采样方式相同。提供的采样时间可能超出了剪辑的范围。为了解决这个问题,您需要实现一个助手函数来调整提供的采样时间,使其在当前动画剪辑的范围内:

    protected:
        float AdjustTimeToFitRange(float inTime);
  3. Clip类需要一个默认构造函数来为它的一些成员分配默认值。编译器生成的析构函数、复制构造函数和赋值运算符在这里应该没问题:

    public:
        Clip();
  4. Clip类应该提供一种方法来获取片段包含的关节数量,以及特定轨道索引的关节标识。您还需要一个基于片段中关节索引的关节标识设置器:

        unsigned int GetIdAtIndex(unsigned int index);
        void SetIdAtIndex(unsigned int idx, unsigned int id);
        unsigned int Size();
  5. 从剪辑中检索数据有两种方法。[] operator返回指定关节的变换轨迹。如果指定关节不存在轨迹,则会创建并返回一个轨迹。Sample函数采用一个Pose参考和一个时间,并返回一个也是一个时间的float值。该功能在提供的时间将动画剪辑采样到Pose参考:

        float Sample(Pose& outPose, float inTime);
        TransformTrack& operator[](unsigned int index);
  6. 我们需要一个公共助手函数来计算动画剪辑的开始和结束时间。RecalculateDuration功能循环遍历所有TransformTrack对象,并根据构成剪辑的轨迹设置动画剪辑的开始/结束时间。该函数旨在由从文件格式加载动画剪辑的代码调用。

        void RecalculateDuration();
  7. 最后,Clip类接受简单的 getter 和 setter 函数:

        std::string& GetName();
        void SetName(const std::string& inNewName);
        float GetDuration();
        float GetStartTime();
        float GetEndTime();
        bool GetLooping();
        void SetLooping(bool inLooping);
    };

这里实现的Clip类可以用来动画任何东西;不要觉得自己局限于人类和人形动画。在下一节中,您将实现Clip类。

实现剪辑类

创建一个新文件,Clip.cpp。您将在这个新文件中实现Clip类。按照以下步骤实施Clip课程:

  1. 默认构造函数需要给Clip类的成员分配一些默认值:

    Clip::Clip() {
        mName = "No name given";
        mStartTime = 0.0f;
        mEndTime = 0.0f;
        mLooping = true;
    }
  2. 要实现Sample功能,请确保剪辑有效,并且时间在剪辑范围内。然后,循环遍历所有的轨迹。获取轨迹的关节 ID,对轨迹进行采样,并将采样值赋回Pose参考。如果变换的某个组件没有设置动画,则参考组件将用于提供默认值。然后,该函数返回调整后的时间:

    float Clip::Sample(Pose& outPose, float time) {
        if (GetDuration() == 0.0f) {
            return 0.0f;
        }
        time= AdjustTimeToFitRange(time);
        unsigned int size = mTracks.size();
        for (unsigned int i = 0; i < size; ++ i) {
            unsigned int j = mTracks[i].GetId(); // Joint
            Transform local = outPose.GetLocalTransform(j);
            Transform animated = mTracks[i].Sample(
                                 local, time, mLooping);
            outPose.SetLocalTransform(j, animated);
        }
        return time;
    }
  3. 应该循环的AdjustTimeToFitRange函数与您为模板化的Track类实现的AdjustTimeToFitTrack函数具有相同的逻辑:

    float Clip::AdjustTimeToFitRange(float inTime) {
        if (mLooping) {
            float duration = mEndTime - mStartTime;
            if (duration <= 0) { 0.0f; }
            inTime = fmodf(inTime - mStartTime, 
                           mEndTime - mStartTime);
            if (inTime < 0.0f) {
                inTime += mEndTime - mStartTime;
            }
            inTime = inTime + mStartTime;
        }
        else {
            if (inTime < mStartTime) {
                inTime = mStartTime;
            }
            if (inTime > mEndTime) {
                inTime = mEndTime;
            }
        }
        return inTime;
    }
  4. RecalculateDuration功能将mStartTimemEndTime设置为0的默认值。接下来,这些函数循环遍历动画剪辑中的每个TransformTrack对象。如果轨道有效,将检索轨道的开始和结束时间。存储最小开始时间和最大结束时间。剪辑的开始时间可能不是0;有可能在任意时间点开始剪辑:

    void Clip::RecalculateDuration() {
        mStartTime = 0.0f;
        mEndTime = 0.0f;
        bool startSet = false;
        bool endSet = false;
        unsigned int tracksSize = mTracks.size();
        for (unsigned int i = 0; i < tracksSize; ++ i) {
            if (mTracks[i].IsValid()) {
                float startTime = mTracks[i].GetStartTime();
                float endTime = mTracks[i].GetEndTime();
                if (startTime < mStartTime || !startSet) {
                    mStartTime = startTime;
                    startSet = true;
                }
                if (endTime > mEndTime || !endSet) {
                    mEndTime = endTime;
                    endSet = true;
                }
            }
        }
    }
  5. [] operator用于检索片段中特定关节的TransformTrack对象。这个函数主要由从文件加载动画剪辑的任何代码使用。该函数对所有轨迹执行线性搜索,以查看是否有任何轨迹以指定关节为目标。如果找到一个合格的赛道,将返回对它的引用。如果没有找到合格的赛道,则创建并返回一个新的赛道:

    TransformTrack& Clip::operator[](unsigned int joint) {
        for (int i = 0, s = mTracks.size(); i < s; ++ i) {
            if (mTracks[i].GetId() == joint) {
                return mTracks[i];
            }
        }
        mTracks.push_back(TransformTrack());
        mTracks[mTracks.size() - 1].SetId(joint);
        return mTracks[mTracks.size() - 1];
    }
  6. Clip类剩下的 getter 函数很简单:

    std::string& Clip::GetName() {
        return mName;
    }
    unsigned int Clip::GetIdAtIndex(unsigned int index) {
        return mTracks[index].GetId();
    }
    unsigned int Clip::Size() {
        return (unsigned int)mTracks.size();
    }
    float Clip::GetDuration() {
        return mEndTime - mStartTime;
    }
    float Clip::GetStartTime() {
        return mStartTime;
    }
    float Clip::GetEndTime() {
        return mEndTime;
    }
    bool Clip::GetLooping() {
        return mLooping;
    }
  7. 同样的,Clip类剩下的 setter 函数也很简单:

    void Clip::SetName(const std::string& inNewName) {
        mName = inNewName;
    }
    void Clip::SetIdAtIndex(unsigned int index, unsigned int id) {
        return mTracks[index].SetId(id);
    }
    void Clip::SetLooping(bool inLooping) {
        mLooping = inLooping;
    }

动画剪辑总是修改相同的关节。不需要重新设置采样到中的姿态,这样每一帧都是绑定姿态。但是,在切换动画时,不能保证两个剪辑将动画相同的轨道。重置采样到的姿势是个好主意,这样每当我们切换动画剪辑时,它就是绑定姿势!

在下一节中,您将学习如何从 glTF 文件中加载角色的剩余姿势。剩下的姿势很重要;这是一个角色在没有动画时的姿势。

glTF–加载剩余姿势

在本书中,我们将假设一个 glTF 文件只包含一个动画角色。可以安全地假设 glTF 文件的整个层次结构可以被视为模型的骨架。这使得加载静止姿势变得容易,因为静止姿势成为其初始配置中的层次。

在加载休息姿势之前,您需要创建几个助手函数。这些函数是 glTF 加载程序内部的,不应该在头文件中公开。在GLTFLoader.cpp中创建新的命名空间,并将其称为GLTFHelpers。所有的辅助函数都是在这个命名空间中创建的。

按照以下步骤实现从 glTF 文件加载静止姿势所需的辅助函数:

  1. 首先,实现一个辅助函数,得到cgltf_node的局部变换。节点可以将其变换存储为矩阵或单独的位置、旋转和缩放组件。如果节点将其变换存储为矩阵,则使用mat4ToTransform分解函数;否则,根据需要创建组件:

    // Inside the GLTFHelpers namespace
    Transform GLTFHelpers::GetLocalTransform(cgltf_node& n){
        Transform result;
        if (n.has_matrix) {
            mat4 mat(&n.matrix[0]);
            result = mat4ToTransform(mat);
        }
        if (n.has_translation) {
            result.position = vec3(n.translation[0], 
                 n.translation[1], n.translation[2]);
        }
        if (n.has_rotation) {
            result.rotation = quat(n.rotation[0], 
              n.rotation[1], n.rotation[2], n.rotation[3]);
        }
        if (n.has_scale) {
            result.scale = vec3(n.scale[0], n.scale[1], 
                                n.scale[2]);
        }
        return result;
    }
  2. 接下来,实现一个辅助函数,从数组中获取cgltf_node的索引。GLTFNodeIndex函数可以通过循环遍历.gltf文件中的所有节点并返回您正在搜索的节点的索引来执行简单的线性查找。如果没有找到索引,返回-1表示无效索引:

    // Inside the GLTFHelpers namespace
    int GLTFHelpers::GetNodeIndex(cgltf_node* target, 
        cgltf_node* allNodes, unsigned int numNodes) {
        if (target == 0) {
            return -1;
        }
        for (unsigned int i = 0; i < numNodes; ++ i) {
            if (target == &allNodes[i]) {
                return (int)i;
            }
        }
        return -1;
    }
  3. 有了这些辅助函数,加载休息姿势只需要很少的工作。遍历当前 glTF 文件中的所有节点。对于每个节点,将本地变换分配给将返回的姿势。您可以使用GetNodeIndex辅助函数找到节点的父节点,如果节点没有父节点,该函数将返回【T1:

    Pose LoadRestPose(cgltf_data* data) {
        unsigned int boneCount = data->nodes_count;
        Pose result(boneCount);
        for (unsigned int i = 0; i < boneCount; ++ i) {
            cgltf_node* node = &(data->nodes[i]);
            Transform transform = 
            GLTFHelpers::GetLocalTransform(data->nodes[i]);
            result.SetLocalTransform(i, transform);
            int parent = GLTFHelpers::GetNodeIndex(
                         node->parent, data->nodes, 
                         boneCount);
            result.SetParent(i, parent);
        }
        return result;
    }

在下一节中,您将学习如何从 glTF 文件中加载关节名称。这些关节名称以与其余姿势关节相同的顺序出现。知道关节名称有助于调试骨骼的样子。联合名称也可以通过索引以外的方式来检索联合。您将在本书中构建的动画系统不支持按名称联合查找,仅支持索引。

GLTF–装载接头名称

在某些情况下,您可能想要知道指定给加载的每个关节的名称。这有助于使调试或构建工具更加容易。要以加载其余姿势的关节的相同顺序加载每个关节的名称,请遍历关节并使用名称访问器。

GLTFLoader.cpp中实现LoadJointNames功能。别忘了给GLTFLoader.h添加功能声明:

std::vector<std::string> LoadJointNames(cgltf_data* data) {
    unsigned int boneCount = (unsigned int)data->nodes_count;
    std::vector<std::string> result(boneCount, "Not Set");
    for (unsigned int i = 0; i < boneCount; ++ i) {
        cgltf_node* node = &(data->nodes[i]);
        if (node->name == 0) {
            result[i] = "EMPTY NODE";
        }
        else {
            result[i] = node->name;
        }
    }
    return result;
}

联合名称对于调试非常有用。他们让你把一个关节的索引和一个名字联系起来,所以你知道数据代表什么。在下一节中,您将学习如何从 glTF 文件加载动画剪辑。

GLTF–加载动画剪辑

要在运行时生成姿势数据,您需要能够加载动画剪辑。和其他姿势一样,这个需要一些辅助功能。

您需要实现的第一个助手函数GetScalarValues读取gltf访问器的浮点值。这可以通过cgltf_accessor_read_float助手功能来完成。

下一个助手函数TrackFromChannel完成大部分繁重的工作。它将一个 glTF 动画频道转换成一个VectorTrack或一个QuaternionTrack。glTF 动画频道记录在https://github . com/KhronosGroup/glTF-tutories/blob/master/glTF tutorial/glTF tutorial _ 007 _ animations . MD

LoadAnimationClips函数应该返回剪辑对象的向量。这不是最优的;这样做是为了让加载 API 更容易使用。如果性能是一个问题,考虑传递结果向量作为参考。

按照以下步骤从 glTF 文件加载动画:

  1. GLTFHelpers命名空间

    // Inside the GLTFHelpers namespace
    void GLTFHelpers::GetScalarValues( vector<float>& out, 
                      unsigned int compCount, 
                      const cgltf_accessor& inAccessor) {
        out.resize(inAccessor.count * compCount);
        for (cgltf_size i = 0; i < inAccessor.count; ++ i) {
            cgltf_accessor_read_float(&inAccessor, i, 
                                      &out[i * compCount], 
                                      compCount);
        }
    }

    GLTFLoader.cpp中实现GetScalarValues助手函数

  2. GLTFLoader.cpp中实现TrackFromChannel助手功能。通过设置Track插值启动功能实现。为此,请确保轨道的Interpolation类型与采样器的cgltf_interpolation_type类型相匹配:

    // Inside the GLTFHelpers namespace
    template<typename T, int N>
    void GLTFHelpers::TrackFromChannel(Track<T, N>& result,
                  const cgltf_animation_channel& channel) {
        cgltf_animation_sampler& sampler = *channel.sampler;
        Interpolation interpolation = 
                      Interpolation::Constant;
        if (sampler.interpolation ==
            cgltf_interpolation_type_linear) {
            interpolation = Interpolation::Linear;
        }
        else if (sampler.interpolation ==
                 cgltf_interpolation_type_cubic_spline) {
            interpolation = Interpolation::Cubic;
        }
        bool isSamplerCubic = interpolation == 
                              Interpolation::Cubic;
        result.SetInterpolation(interpolation);
  3. 采样器输入是动画时间线的访问器。采样器输出是动画值的访问器。使用GetScalarValues将这些访问器转换成浮点数的线性数组。采样输入中的帧数和元素数。每帧的组件数量(vec3quat)是值元素的数量除以时间线元素的数量。调整轨道大小,以便有足够的空间存储所有帧:

        std::vector<float> time; // times
        GetScalarValues(time, 1, *sampler.input);
        std::vector<float> val; // values
        GetScalarValues(val, N, *sampler.output);
        unsigned int numFrames = sampler.input->count; 
        unsigned int compCount = val.size() / time.size();
        result.Resize(numFrames);
  4. 要将timevalue数组解析为帧结构,循环遍历采样器中的每一帧。对于每一帧,设置时间,然后读取输入正切值,然后读取输出正切值。输入和输出切线仅在采样器为立方时可用;如果不是,这些应该默认为0。需要使用局部offset变量来处理立方体轨迹,因为输入和输出切线与组件数量一样多:

        for (unsigned int i = 0; i < numFrames; ++ i) {
            int baseIndex = i * compCount;
            Frame<N>& frame = result[i];
            int offset = 0;
            frame.mTime = time[i];
            for (int comp = 0; comp < N; ++ comp) {
                frame.mIn[comp] = isSamplerCubic ? 
                      val[baseIndex + offset++ ] : 0.0f;
            }
            for (int comp = 0; comp < N; ++ comp) {
                frame.mValue[comp] = val[baseIndex + 
                                     offset++ ];
            }
            for (int comp = 0; comp < N; ++ comp) {
                frame.mOut[comp] = isSamplerCubic ? 
                      val[baseIndex + offset++ ] : 0.0f;
            }
        }
    } // End of TrackFromChannel function
  5. GLTFLoader.cpp中实现LoadAnimationClips功能;不要忘记将函数的声明添加到GLTFLoader.h中。循环通过提供的gltf_data中的所有夹子。为每个片段设置其名称。循环播放片段中的所有通道,找到当前通道影响的节点的索引:

    std::vector<Clip> LoadAnimationClips(cgltf_data* data) {
        unsigned int numClips = data->animations_count;
        unsigned int numNodes = data->nodes_count;
        std::vector<Clip> result;
        result.resize(numClips);
        for (unsigned int i = 0; i < numClips; ++ i) {
            result[i].SetName(data->animations[i].name);
            unsigned int numChannels = 
                     data->animations[i].channels_count;
            for (unsigned int j = 0; j < numChannels; ++ j){
                cgltf_animation_channel& channel = 
                          data->animations[i].channels[j];
                cgltf_node* target = channel.target_node;
                int nodeId = GLTFHelpers::GetNodeIndex(
                             target, data->nodes, numNodes);
  6. glTF 文件的每个通道都是一个动画轨道。一些节点可能只制作其位置的动画,而其他节点可能制作位置、旋转和缩放的动画。检查解析的通道类型,调用TrackFromChannel辅助函数将其转换为动画轨迹。Track类的[] operator要么检索当前轨道,要么创建一个新轨道。这意味着您正在解析的节点的TransformTrack函数始终有效:

                if (channel.target_path == 
                     cgltf_animation_path_type_translation){
                   VectorTrack& track = 
                     result[i][nodeId].GetPositionTrack();
                   GLTFHelpers::TrackFromChannel<vec3, 3>
                                (track, channel);
                }
                else if (channel.target_path == 
                         cgltf_animation_path_type_scale) {
                    VectorTrack& track = 
                          result[i][nodeId].GetScaleTrack();
                    GLTFHelpers::TrackFromChannel<vec3, 3>
                                (track, channel);
                }
                else if (channel.target_path == 
                       cgltf_animation_path_type_rotation) {
                    QuaternionTrack& track = 
                       result[i][nodeId].GetRotationTrack();
                    GLTFHelpers::TrackFromChannel<quat, 4>
                                 (track, channel);
                }
            } // End num channels loop
  7. 剪辑中的所有轨道都已填充后,调用剪辑的ReclaculateDuration功能。这确保回放发生在适当的时间范围内:

            result[i].RecalculateDuration();
        } // End num clips loop
        return result;
    } // End of LoadAnimationClips function

能够加载动画片段并将其采样成姿势大约是动画编程所涉及工作的一半。您可以加载动画剪辑,在应用更新时对其进行采样,并使用调试线来绘制姿势。结果是一个动画骨架。在下一章中,您将学习如何使用这个动画骨架来变形网格。

总结

在本章中,您实现了PoseClip类。您学习了如何从 glTF 文件中加载其余的姿势,以及如何加载动画剪辑。您还学习了如何对动画剪辑进行采样以生成姿势。

这本书的可下载内容可以在网站上找到。Chapter09/Sample01中的示例加载一个 glTF 文件,并使用DebugDraw功能绘制静止姿势和当前动画姿势。要使用调试线绘制骨骼,请从关节位置到其父关节位置绘制一条线。

请记住,并不是所有的剪辑都会为姿势的每个关节设置动画。只要您正在采样的动画剪辑发生变化,它被采样到的帖子就需要重置。重置一个姿势很容易——给它指定其余姿势的值。本章的代码示例演示了这一点。

在下一章中,您将学习如何对动画网格进行蒙皮。一旦你知道如何给网格蒙皮,你就能显示一个动画模型。