到目前为止,您已经编写了一个完整的动画系统,可以加载标准文件格式 gLTF,并在 CPU 或 GPU 上执行蒙皮。动画系统对于大多数简单的动画来说表现得足够好。
在本章中,您将探索优化动画系统的方法,以使其更快、更少占用资源。这包括探索执行蒙皮的替代方法,提高示例动画剪辑的速度,以及重新研究矩阵调色板的生成方式。
这些主题中的每一个都是单独探讨的,您可以选择实现尽可能少或尽可能多的优化。所有这些都很简单,可以用来轻松替换不太理想的管道版本。
本章将涵盖以下主题:
- 预生成皮肤矩阵
- 将皮肤调色板存储在纹理中
- 更快的采样
- 姿势调色板生成
- 探索
Pose::GetGlobalTransform
顶点着色器蒙皮的一个更大的问题是系统占用的制服数量。一个mat4
对象占据四个均匀的槽,蒙皮顶点着色器目前有两个矩阵数组,每个数组有 120 个元素。总共有 960 个统一插槽,太多了。
顶点着色器中的这两个矩阵数组会发生什么?它们相乘,如下所示:
mat4 skin=(pose[joints.x]*invBindPose[joints.x])*weights.x;
skin += (pose[joints.y]*invBindPose[joints.y])*weights.y;
skin += (pose[joints.z]*invBindPose[joints.z])*weights.z;
skin += (pose[joints.w]*invBindPose[joints.w])*weights.w;
这里一个简单的优化是组合pose * invBindPose
乘法,这样着色器只需要一个数组。这确实意味着一些换肤过程被移回中央处理器,但这一变化清除了 480 个统一插槽。
生成皮肤矩阵不需要调用应用编程接口——这很简单。使用Pose
类的GetMatrixPalette
功能从当前动画姿态生成矩阵调色板。然后,将调色板中的每个矩阵乘以同一索引的反向绑定姿势矩阵。
显示网格的代码负责计算这些矩阵。例如,一个简单的更新循环可能如下所示:
void Sample::Update(float deltaTime) {
mPlaybackTime = mAnimClip.Sample(mAnimatedPose,
mPlaybackTime + deltaTime);
mAnimatedPose.GetMatrixPalette(mPosePalette);
vector<mat4>& invBindPose = mSkeleton.GetInvBindPose();
for (int i = 0; i < mPosePalette.size(); ++ i) {
mPosePalette[i] = mPosePalette[i] * invBindPose[i];
}
if (mDoCPUSkinning) {
mMesh.CPUSkin(mPosePalette);
}
}
在前面的代码示例中,一个动画剪辑被采样成一个姿势。姿势被转换成矩阵向量。然后将该向量中的每个矩阵乘以相同索引的反向绑定姿态矩阵。矩阵的结果向量是组合的皮肤矩阵。
如果网格是 CPU 蒙皮的,这是调用CPUSkin
函数的好地方。该功能需要重新实现,以便与组合皮肤矩阵一起工作。如果网格是 GPU 蒙皮的,则需要对着色器进行编辑,使其仅使用一个矩阵数组,渲染代码需要更新为仅传递一个统一数组。
在下面的部分,您将探索如何重新实现CPUSkin
功能,使其与组合的皮肤矩阵一起工作。这样会稍微加快一点 CPU 结皮的过程。
你需要一个新的蒙皮方法,尊重预先相乘的蒙皮矩阵。该函数引用一个矩阵向量。每个位置都由影响它的所有四个骨骼的组合皮肤矩阵转换。这四个结果然后被缩放并相加在一起。
在Mesh.cpp
增加以下 CPU 蒙皮功能。别忘了给Mesh.h
添加函数声明:
-
确保网格有效,开始执行
CPUSkin
功能。一个有效的网格至少有一个顶点。确保mSkinnedPosition
和mSkinnedNormal
向量足够大,可以容纳所有顶点:void Mesh::CPUSkin(std::vector<mat4>& animatedPose) { unsigned int numVerts = mPosition.size(); if (numVerts == 0) { return; } mSkinnedPosition.resize(numVerts); mSkinnedNormal.resize(numVerts);
-
接下来,循环遍历网格中的每个顶点:
for (unsigned int i = 0; i < numVerts; ++ i) { ivec4& j = mInfluences[i]; vec4& w = mWeights[i];
-
用动画姿势变换每个顶点四次,对影响顶点的每个关节变换一次。要找到蒙皮顶点,按适当的权重缩放每个变换的顶点,并将结果相加:
vec3 p0 = transformPoint(animatedPose[j.x], mPosition[i]); vec3 p1 = transformPoint(animatedPose[j.y], mPosition[i]); vec3 p2 = transformPoint(animatedPose[j.z], mPosition[i]); vec3 p3 = transformPoint(animatedPose[j.w], mPosition[i]); mSkinnedPosition[i] = p0 * w.x + p1 * w.y + p2 * w.z + p3 * w.w;
-
用同样的方法找到顶点的蒙皮法线:
vec3 n0 = transformVector(animatedPose[j.x], mNormal[i]); vec3 n1 = transformVector(animatedPose[j.y], mNormal[i]); vec3 n2 = transformVector(animatedPose[j.z], mNormal[i]); vec3 n3 = transformVector(animatedPose[j.w], mNormal[i]); mSkinnedNormal[i] = n0 * w.x + n1 * w.y + n2 * w.z + n3 * w.w; }
-
通过将蒙皮顶点位置和蒙皮顶点法线上传到位置和法线属性
mPosAttrib->Set(mSkinnedPosition); mNormAttrib->Set(mSkinnedNormal); }
,完成功能
核心蒙皮算法保持不变;唯一改变的是变换后的位置是如何生成的。这个函数现在可以只使用已经组合好的矩阵,而不必组合动画姿势和反向绑定姿势。
在下一节中,您将探索如何将此蒙皮函数移动到顶点着色器中。组合动画和反向绑定姿势仍然是在中央处理器上完成的,但是实际顶点的蒙皮可以在顶点着色器中实现。
在顶点着色器中实现预乘皮肤矩阵蒙皮很简单。用新的预倍增皮肤姿势替换姿势和反向绑定姿势的输入制服。使用这个新的统一数组生成皮肤矩阵。这就是它的全部——皮肤管道的其余部分保持不变。
创建一个新文件preskinned.vert
,在中实现新的预蒙皮顶点着色器。将skinned.vert
的内容复制到这个新文件中。按照以下步骤修改新着色器:
-
旧的蒙皮顶点着色器有统一的姿势和反向绑定姿势。两种制服都是矩阵阵列。脱下这些制服:
uniform mat4 pose[120]; uniform mat4 invBindPose[120];
-
用新的
animated
制服代替制服。这是一个矩阵的单个数组,数组中的每个元素包含animated
姿态和反向绑定姿态矩阵相乘:uniform mat4 animated[120];
-
接下来,找到皮肤矩阵的生成位置。生成皮肤矩阵的代码如下所示:
mat4 skin = (pose[joints.x] * invBindPose[joints.x]) * weights.x; skin += (pose[joints.y] * invBindPose[joints.y]) * weights.y; skin += (pose[joints.z] * invBindPose[joints.z]) * weights.z; skin += (pose[joints.w] * invBindPose[joints.w]) * weights.w;
-
换上新的
animated
制服。对于影响顶点的每个关节,按适当的权重缩放animated
均匀矩阵,并将结果相加:mat4 skin = animated[joints.x] * weights.x + animated[joints.y] * weights.y + animated[joints.z] * weights.z + animated[joints.w] * weights.w;
着色器的其余部分保持不变。唯一需要更新的是着色器采用的制服以及skin
矩阵是如何生成的。渲染时animated
矩阵可以设置如下:
// mPosePalette Generated in the Update method!
int animated = mSkinnedShader->GetUniform("animated")
Uniform<mat4>::Set(animated, mPosePalette);
您可能已经注意到,CPU 蒙皮实现和 GPU 蒙皮实现是不同的。CPU 实现将顶点变换四次,然后缩放并对结果求和。GPU 实现缩放和求和矩阵,并且只变换顶点一次。两种实现都是有效的,并且它们都产生相同的结果。
在下一节中,您将探讨如何避免使用统一矩阵数组进行蒙皮。
预生成蒙皮矩阵会将蒙皮着色器所需的统一槽的数量减半,但是可以将所需的统一槽的数量减少到一个。这可以通过在纹理中编码预先生成的皮肤矩阵并在顶点着色器中读取该纹理来完成,而不是在统一的数组中。
到目前为止,在这本书里,你只处理了RGB24
和RGBA32
纹理。在这些格式中,像素的三个或四个分量使用每个分量 8 位进行编码。这只能保存 256 个唯一值。这些纹理不能提供存储浮点数所需的精度。
这里还有另一种有用的纹理格式——纹理。使用这种纹理格式,向量的每个分量都有一个完整的 32 位浮点数来支持它,给你完全的精度。这个纹理可以用一个特殊的采样函数来采样,这个函数不会对数据进行归一化。FLOAT32
纹理可以被视为一个缓冲区,中央处理器可以写入,图形处理器可以读取。
这种方法的好处是所需的均匀槽的数量变成了一个——所需的均匀槽是FLOAT32
纹理的采样器。缺点是速度。必须为每个顶点采样纹理比快速统一数组查找更昂贵。请记住,这些示例查找中的每一个都需要返回几个 32 位浮点数。要传输的数据非常多。
我们在这里不涉及纹理存储皮肤矩阵的实现,因为在第 15 章、中有一大部分专门讨论这个主题,包括完整的代码实现。
目前的动画剪辑采样代码表现不错,只要每个动画持续 1 秒以下。随着多分钟长的动画剪辑,如过场动画,动画系统的性能开始受到影响。为什么动画越长,性能越差?罪魁祸首是Track::FrameIndex
函数中的以下代码:
for (int i = (int)size - 1; i >= 0; --i) {
if (time >= mFrames[i].mTime) {
return i;
}
}
呈现的循环会遍历轨道中的每一帧。如果一个动画有很多帧,性能就会开始变差。请记住,这段代码是为动画剪辑中每个动画骨骼的每个动画组件执行的。
该功能目前做线性搜索,但可以用更高效的搜索进行优化。由于时间只会增加,在这里使用二分搜索法是一种自然的优化。然而,二分搜索法不是最好的优化。有可能把这个循环变成一个持续的查找。
无论长度如何,采样动画的回放成本都是一样的。它们以已知的采样间隔对每一帧进行计时,找到正确的帧索引只需对提供的时间进行归一化,并将其移动到采样间隔范围内即可。不幸的是,对这样的动画进行采样会占用大量内存。
如果您仍然以给定的时间间隔对动画轨迹进行采样,但是每个时间间隔都指向其左侧和右侧的关键帧,而不是包含完整的姿势,会怎么样?使用这种方法,额外的内存开销最小,并且找到正确的帧是恒定的。
有两种方法来处理优化Track
类。您可以创建一个新的类,该类具有类的大部分功能并维护已知采样时间的查找表,或者扩展Track
类。本节采用后一种方法——我们将扩展Track
类。
FastTrack
子类包含一个无符号整数向量。Track
类以统一的时间间隔采样。对于每个时间间隔,播放头左侧的帧(时间之前的帧)被记录到该向量中。
所有新代码都被添加到现有的Track.h
和Track.cpp
文件中。按照以下步骤实施FastTrack
课程:
-
找到
Track
类的FrameIndex
成员函数,标记为virtual
。这一变化允许新子类重新实现FrameIndex
功能。更新后的申报应该是这样的:template<typename T, int N> class Track { // ... virtual int FrameIndex(float time, bool looping); // ...
-
创建一个继承自
Track
的新类FastTrack
。FastTrack
类包含一个无符号整数向量——重载的FrameIndex
函数和一个填充无符号整数向量的函数:template<typename T, int N> class FastTrack : public Track<T, N> { protected: std::vector<unsigned int> mSampledFrames; virtual int FrameIndex(float time, bool looping); public: void UpdateIndexLookupTable(); };
-
为了使
FastTrack
类更容易使用,使用 typedef 为标量、向量和四元数类型创建别名:typedef FastTrack<float, 1> FastScalarTrack; typedef FastTrack<vec3, 3> FastVectorTrack; typedef FastTrack<quat, 4> FastQuaternionTrack;
-
在。
cpp
文件,为标量、向量和四元数快速轨迹添加模板声明:template FastTrack<float, 1>; template FastTrack<vec3, 3>; template FastTrack<quat, 4>;
由于FastTrack
类是Track
的子类,所以现有的 API 都不变。当所讨论的动画具有更多帧时,通过这种方式实现轨迹采样的性能增益更大。在下一节中,您将学习如何构建索引查找表。
UpdateIndexLookupTable
函数负责填充mSampledFrames
向量。该功能需要以固定的时间间隔对动画进行采样,并记录每个时间间隔的动画时间之前的帧。
FastTrack
类应该包含多少样本?这个问题非常依赖于上下文,因为不同的游戏有不同的要求。对于本书的上下文,每秒 60 个样本应该足够了:
-
确保轨迹有效,开始执行
UpdateIndexLookupTable
功能。一个有效的轨道至少有两帧:template<typename T, int N> void FastTrack<T, N>::UpdateIndexLookupTable() { int numFrames = (int)this->mFrames.size(); if (numFrames <= 1) { return; }
-
接下来,找到需要的样本数量。由于该类每一秒都有
60
个动画样本,因此将持续时间乘以60
:float duration = this->GetEndTime() - this->GetStartTime(); unsigned int numSamples = duration * 60.0f; mSampledFrames.resize(numSamples);
-
对于每个样本,找出样本沿轨道的时间。要找到时间,将规范化的迭代器乘以动画持续时间,并将动画的开始时间加入其中:
for (unsigned int i = 0; i < numSamples; ++ i) { float t = (float)i / (float)(numSamples - 1); float time = t*duration+this->GetStartTime();
-
最后,是时候找到每个给定时间的帧索引了。找到本次迭代采样时间之前的帧,并记录在
mSampledFrames
向量中。如果采样帧是最后一帧,则在最后一个索引之前返回索引。请记住,FrameIndex
函数永远不会返回最后一帧:unsigned int frameIndex = 0; for (int j = numFrames - 1; j >= 0; --j) { if (time >= this->mFrames[j].mTime) { frameIndex = (unsigned int)j; if ((int)frameIndex >= numFrames - 2) { frameIndex = numFrames - 2; } break; } } mSampledFrames[i] = frameIndex; } }
UpdateIndexLookupTable
函数旨在加载时调用。可以通过记住内部j
循环的最后使用的索引来优化使其更快,因为在每次i
迭代中,帧索引仅增加。在下一节中,您将学习如何实现FrameIndex
来使用mSampledFrames
向量。
FrameIndex
功能是负责在给定时间之前找到帧。优化的FastTrack
类使用查找数组,而不是循环遍历轨迹的每一帧。所有输入时间都有非常相似的性能成本。按照以下步骤覆盖FastTrack
类中的FrameIndex
功能:
-
确保轨迹有效,开始执行
FrameIndex
功能。有效轨道必须至少有两帧或更多帧:template<typename T, int N> int FastTrack<T,N>::FrameIndex(float time,bool loop){ std::vector<Frame<N>>& frames = this->mFrames; unsigned int size = (unsigned int)frames.size(); if (size <= 1) { return -1; }
-
接下来,确保请求的采样时间在轨道的开始和结束时间之间。如果轨道正在循环,使用
fmodf
将其保持在有效范围内:if (loop) { float startTime = this->mFrames[0].mTime; float endTime = this->mFrames[size - 1].mTime; float duration = endTime - startTime; time = fmodf(time - startTime, endTime - startTime); if (time < 0.0f) { time += endTime - startTime; } time = time + startTime; }
-
如果轨道没有循环,夹紧到第一帧或下一帧:
else { if (time <= frames[0].mTime) { return 0; } if (time >= frames[size - 2].mTime) { return (int)size - 2; } }
-
找到归一化样本时间和帧索引。帧索引是标准化的采样时间乘以采样数。如果索引无效,返回
-1
;否则,返回索引指向的帧:float duration = this->GetEndTime() - this->GetStartTime(); float t = time / duration; unsigned int numSamples = (duration * 60.0f); unsigned int index = (t * (float)numSamples); if (index >= mSampledFrames.size()) { return -1; } return (int)mSampledFrames[index]; }
FrameIndex
函数几乎总是在有效时间内被调用,因为它是一个受保护的辅助函数。这意味着找到帧索引所需的时间是一致的,与轨道中的帧数无关。在下一节中,您将学习如何将未优化的Track
类转换为优化的FastTrack
类。
既然FastTrack
存在,你是如何创造的?您可以创建一个新的加载函数来加载一个FastTrack
类,而不是Track
。或者,您可以创建一个函数,将现有的Track
类转换为FastTrack
类。本章采用后一种方法。按照以下步骤创建功能,将Track
对象转换为FastTrack
对象:
-
在
FastTrack.h
中声明OptimizeTrack
功能。该函数是模板化的。采用与Track
相同的模板类型:template<typename T, int N> FastTrack<T, N> OptimizeTrack(Track<T, N>& input);
-
为追踪到
FastTrack.cpp
的所有三种类型声明OptimizeTrack
函数的模板专门化。这意味着声明使用标量、向量 3 和四元数轨迹的专门化:template FastTrack<float, 1> OptimizeTrack(Track<float, 1>& input); template FastTrack<vec3, 3> OptimizeTrack(Track<vec3, 3>& input); template FastTrack<quat, 4> OptimizeTrack(Track<quat, 4>& input);
-
要实现
OptimizeTrack
功能,请调整结果轨迹的大小,使其与输入轨迹的大小相同,并与插值匹配。重载的[]
运算符功能可用于复制每帧数据:template<typename T, int N> FastTrack<T, N> OptimizeTrack(Track<T, N>& input) { FastTrack<T, N> result; result.SetInterpolation(input.GetInterpolation()); unsigned int size = input.Size(); result.Resize(size); for (unsigned int i = 0; i < size; ++ i) { result[i] = input[i]; } result.UpdateIndexLookupTable(); return result; }
仅仅把Track
类优化成FastTrack
还不够。TransformTrack
班也需要改变。它需要包含新的,优化的FastTrack
类。在下一节中,您将更改TransformTrack
类,使其模板化,并且可以包含Track
或FastTrack
。
使用Track
类的高级结构,如TransformTrack
,需要适应新的FastTrack
子类的。FastTrack
班和Track
班有相同的签名。因为类的签名是相同的,所以很容易模板化TransformTrack
类,以便它可以使用这两个类中的任何一个。
在本节中,您将把TransformTrack
类重命名为TTransformTrack
,并对该类进行模板化。然后,您将typedef
模板专门化为TransformTrack
和FastTransformTrack
。这样,TransformTrack
类保持不变,优化的变换轨迹使用相同的代码:
-
将
TransformTrack
类的名称更改为TTransformTrack
,并模板化该类。该模板采用两个参数——要使用的向量轨迹类型和四元数轨迹类型。更新mPosition
、mRotation
和mScale
轨道以使用新的模板化类型:template <typename VTRACK, typename QTRACK> class TTransformTrack { protected: unsigned int mId; VTRACK mPosition; QTRACK mRotation; VTRACK mScale; public: TTransformTrack(); unsigned int GetId(); void SetId(unsigned int id); VTRACK& GetPositionTrack(); QTRACK& GetRotationTrack(); VTRACK& GetScaleTrack(); float GetStartTime(); float GetEndTime(); bool IsValid(); Transform Sample(const Transform& r,float t,bool l); };
-
将此类定义为
TransformTrack
,参数为VectorTrack
和QuaternionTrack
。再次输入FastTransformTrack
,以FastVectorTrack
和FastQuaternionTrack
为模板参数:typedef TTransformTrack<VectorTrack, QuaternionTrack> TransformTrack; typedef TTransformTrack<FastVectorTrack, FastQuaternionTrack> FastTransformTrack;
-
声明将
TransformTrack
转换为FastTransformTrack
的优化函数:FastTransformTrack OptimizeTransformTrack( TransformTrack& input);
-
在
TransformTrack.cpp
中添加两个typedef
功能的模板规格:template TTransformTrack<VectorTrack, QuaternionTrack>; template TTransformTrack<FastVectorTrack, FastQuaternionTrack>;
-
实现
OptimizeTransformTrack
功能。复制轨道标识,然后按值复制单个轨道:FastTransformTrack OptimizeTransformTrack( TransformTrack& input) { FastTransformTrack result; result.SetId(input.GetId()); result.GetPositionTrack()= OptimizeTrack<vec3, 3> ( input.GetPositionTrack()); result.GetRotationTrack() = OptimizeTrack<quat, 4>( input.GetRotationTrack()); result.GetScaleTrack() = OptimizeTrack<vec3, 3> ( input.GetScaleTrack()); return result; }
因为OptimizeTransformTrack
是按值复制实际的轨迹数据,所以可能会有点慢。该函数旨在初始化时被调用。在下一节中,您将对Clip
类进行模板化,类似于您对Transform
类的模板化,以创建FastClip
。
这个动画系统的用户与Clip
对象交互。为了适应新的FastTrack
类,Clip
类同样被模板化,并分为Clip
和FastClip
。您将实现一个功能,将Clip
对象转换为FastClip
对象。按照这些步骤来模板化Clip
类:
-
将
Clip
类的名称更改为TClip
,并模板化该类。模板只采用一种类型,即TClip
类包含的变换轨迹的类型。更改mTracks
的类型和[] operator
的返回类型,使其为模板类型:template <typename TRACK> class TClip { protected: std::vector<TRACK> mTracks; std::string mName; float mStartTime; float mEndTime; bool mLooping; public: TClip(); TRACK& operator[](unsigned int index); // ...
-
将
TransformTrack
类型定义为Clip
。将FastTransformTrack
型定义为FastClip
。这样,Clip
类不会改变,FastClip
类可以重用所有已有的代码:typedef TClip<TransformTrack> Clip; typedef TClip<FastTransformTrack> FastClip;
-
声明一个函数,将
Clip
对象转换为FastClip
对象:FastClip OptimizeClip(Clip& input);
-
在
Clip.cpp
:template TClip<TransformTrack>; template TClip<FastTransformTrack>;
中声明这些类型化类的模板专门化
-
要实现
OptimizeClip
功能,复制输入片段的名称和循环值。对于片段中的每个关节,调用其轨道上的OptimizeTransformTrack
功能。在返回新的FastClip
对象的副本之前,不要忘记计算它的持续时间:FastClip OptimizeClip(Clip& input) { FastClip result; result.SetName(input.GetName()); result.SetLooping(input.GetLooping()); unsigned int size = input.Size(); for (unsigned int i = 0; i < size; ++ i) { unsigned int joint = input.GetIdAtIndex(i); result[joint] = OptimizeTransformTrack(input[joint]); } result.RecalculateDuration(); return result; }
与其他转换函数一样,OptimizeClip
仅用于在初始化时调用。在下一节中,您将探索如何优化Pose
调色板的生成。
你应该考虑的最后一个优化是从Pose
生成矩阵调色板的过程。如果你看一下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);
}
}
就其本身而言,这个函数还不算太坏,但是GetGlobalTransform
函数循环遍历指定关节变换链上的每个关节,直到根关节。这意味着该函数浪费了大量的时间来寻找变换的矩阵,而它在之前的迭代中已经找到了变换的矩阵。
要解决这个问题,您需要确保Pose
类中关节的顺序是升序的。也就是说,在mJoints
数组中,所有父关节的索引必须低于其子关节的索引。
一旦设置了这个顺序,就可以遍历所有关节,知道当前索引处关节的父矩阵已经找到。这是因为所有父元素的索引都低于其子元素。要将此关节的局部矩阵与其父关节的全局矩阵相结合,只需将先前找到的世界矩阵和局部矩阵相乘即可。
不能保证输入数据可以被信任以这种特定顺序列出关节。要解决这个问题,你需要写一些代码来重新安排一个Pose
类的关节。在下一节中,您将学习如何改进GetMatrixPalette
函数,以便它在可能的情况下使用优化的方法,而在不能的情况下返回到未优化的方法。
在本节中,如果当前关节的父索引低于该关节,您将修改GetMatrixPalette
函数以预缓存全局矩阵。如果这个假设被打破,函数需要回到较慢的计算模式。
GetMatrixPalette
功能会有两个循环。第一个循环查找并存储变换的全局矩阵。如果关节父项的索引小于关节,则使用优化方法。如果关节的父关节不是更小,第一个循环就会爆发,给第二个循环一个运行的机会。
在第二个循环中,每个关节返回调用缓慢的GetWorldTransform
函数来找到它的世界变换。此循环是优化循环失败时使用的回退代码。如果优化循环一直执行,则第二个循环不会执行:
void Pose::GetMatrixPalette(std::vector<mat4>& out) {
int size = (int)Size();
if ((int)out.size() != size) { out.resize(size); }
int i = 0;
for (; i < size; ++ i) {
int parent = mParents[i];
if (parent > i) { break; }
mat4 global = transformToMat4(mJoints[i]);
if (parent >= 0) {
global = out[parent] * global;
}
out[i] = global;
}
for (; i < size; ++ i) {
Transform t = GetGlobalTransform(i);
out[i] = transformToMat4(t);
}
}
这一变化给GetMatrixPalette
函数增加了非常少的开销,但很快就弥补了这一点。它使矩阵调色板计算运行得很快,如果可能的话,但如果不可能的话,仍然执行。在下一节中,您将学习如何重新排列加载模型的关节,以使GetMatrixPalette
功能始终采用快速路径。
不是所有的模型都会被很好地格式化;正因为如此,他们不会都能够利用的优化GetMatrixPalette
功能。在本节中,您将学习如何重新排列模型的骨骼,以便它可以利用优化的GetMatrixPalette
功能。
创建新文件,RearrangeBones.h
。使用键值对是骨骼索引的字典来重新映射骨骼索引。RearrangeSkeleton
功能生成本词典并重新排列骨骼中的绑定、反向绑定和静止姿势。
一旦RearrangeSkeleton
功能生成了BoneMap
,就可以使用它来处理任何影响当前骨骼的网格或动画剪辑。按照以下步骤对关节进行重新排序,以便骨骼可以始终利用优化的GetMatrixPalette
路径:
-
将以下函数声明添加到
RearrangeBones.h
文件中:typedef std::map<int, int> BoneMap; BoneMap RearrangeSkeleton(Skeleton& skeleton); void RearrangeMesh(Mesh& mesh, BoneMap& boneMap); void RearrangeClip(Clip& clip, BoneMap& boneMap); void RearrangeFastclip(FastClip& clip, BoneMap& boneMap);
-
在新文件
ReearrangeBones.cpp
中开始执行RearrangeSkeleton
功能。首先,创建对其余部分的引用并绑定姿势,然后确保您正在重新排列的骨架不是空的。如果是空的,就返回一个空字典:BoneMap RearrangeSkeleton(Skeleton& skeleton) { Pose& restPose = skeleton.GetRestPose(); Pose& bindPose = skeleton.GetBindPose(); unsigned int size = restPose.Size(); if (size == 0) { return BoneMap(); }
-
接下来,创建一个二维整数数组(整数向量的向量)。外部向量的每个元素代表一个骨骼,该向量的索引和绑定或静止姿势中的
mJoints
数组是平行的。内向量表示外向量索引处的关节包含的所有子向量。循环通过静止姿势中的每个关节:std::vector<std::vector<int>> hierarchy(size); std::list<int> process; for (unsigned int i = 0; i < size; ++ i) { int parent = restPose.GetParent(i);
-
如果关节有父节点,则将关节的索引添加到父节点的子节点向量中。如果节点是根节点(因此它没有父节点),请将其直接添加到进程列表中。该列表稍后将用于遍历地图深度:
if (parent >= 0) { hierarchy[parent].push_back((int)i); } else { process.push_back((int)i); } }
-
为了弄清楚如何对骨骼重新排序,您需要保留两个映射——一个从旧配置映射到新配置,另一个从新配置映射回旧配置:
BoneMap mapForward; BoneMap mapBackward;
-
对于每个元素,如果它包含子元素,则将这些子元素添加到进程列表中。这样,所有的关节都被处理,并且变换层次中更高的关节首先被处理:
int index = 0; while (process.size() > 0) { int current = *process.begin(); process.pop_front(); std::vector<int>& children = hierarchy[current]; unsigned int numChildren = children.size(); for (unsigned int i = 0; i < numChildren; ++ i) { process.push_back(children[i]); }
-
将正向映射的当前索引设置为正在处理的关节的索引。正向映射的当前索引是一个原子计数器。对反向映射做同样的事情,但是切换键值对。不要忘记将空节点(
-1
)添加到两个映射:mapForward[index] = current; mapBackward[current] = index; index += 1; } mapForward[-1] = -1; mapBackward[-1] = -1;
-
现在地图已经填好了,你需要建立新的休息和绑定姿势,这些姿势的骨骼顺序是正确的。循环遍历原始静止和绑定姿势中的每个关节,并将它们的局部变换复制到新姿势。对联名做同样的事情:
Pose newRestPose(size); Pose newBindPose(size); std::vector<std::string> newNames(size); for (unsigned int i = 0; i < size; ++ i) { int thisBone = mapForward[i]; newRestPose.SetLocalTransform(i, restPose.GetLocalTransform(thisBone)); newBindPose.SetLocalTransform(i, bindPose.GetLocalTransform(thisBone)); newNames[i] = skeleton.GetJointName(thisBone);
-
为每个关节找到新的父关节标识需要两个映射步骤。首先,将当前索引映射到原始骨骼中的骨骼。这将返回原始骨架的父骨架。将该父索引映射回新骨架。这就是为什么有两个字典,使这个映射快速:
int parent = mapBackward[bindPose.GetParent( thisBone)]; newRestPose.SetParent(i, parent); newBindPose.SetParent(i, parent); }
-
一旦找到新的休息和绑定姿势,并且关节名称已经相应地重新排列,通过调用公共
Set
方法将这些数据写回到骨架。骨架的Set
方法也计算反向绑定姿态矩阵调色板:
```cpp
skeleton.Set(newRestPose, newBindPose, newNames);
return mapBackward;
} // End of RearrangeSkeleton function
```
RearrangeSkeleton
功能重新排列骨骼中的骨骼,以便骨骼可以利用优化版本的GetMatrixPalette
。重新排列骨架是不够的。由于关节索引已移动,任何引用此骨架的剪辑或网格现在都已断开。在下一节中,您将实现辅助函数来重新排列剪辑中的关节。
要重新排列动画剪辑,循环播放剪辑中的所有轨道。对于每个轨迹,找到关节标识,然后使用RearrangeSkeleton
函数返回的(向后)骨骼图转换该关节标识。将修改后的接头标识写回到大头钉中:
void RearrangeClip(Clip& clip, BoneMap& boneMap) {
unsigned int size = clip.Size();
for (unsigned int i = 0; i < size; ++ i) {
int joint = (int)clip.GetIdAtIndex(i);
unsigned int newJoint = (unsigned int)boneMap[joint];
clip.SetIdAtIndex(i, newJoint);
}
}
如果您已经实现了本章前面的FastClip
优化,RearrangeClip
功能应该仍然有效,因为它是Clip
的子类。在下一节中,你将学习如何在网格中重新排列关节,这将是使用该优化所需的最后一步。
要重新排列影响网格蒙皮的关节,循环遍历网格的每个顶点,并重新映射存储在该顶点的“影响”属性中的所有四个关节索引。关节的权重不需要编辑,因为关节本身没有变化;只有它在数组中的索引发生了变化。
以这种方式更改网格只会编辑网格的中央处理器副本。调用UpdateOpenGLBuffers
将新属性也上传到 GPU:
void RearrangeMesh(Mesh& mesh, BoneMap& boneMap) {
std::vector<ivec4>& influences = mesh.GetInfluences();
unsigned int size = (unsigned int)influences.size();
for (unsigned int i = 0; i < size; ++ i) {
influences[i].x = boneMap[influences[i].x];
influences[i].y = boneMap[influences[i].y];
influences[i].z = boneMap[influences[i].z];
influences[i].w = boneMap[influences[i].w];
}
mesh.UpdateOpenGLBuffers();
}
实现RearrangeMesh
函数后,可以加载一个骨骼,然后调用RearrangeSkeleton
函数并存储它返回的骨骼图。使用此骨骼贴图,您还可以使用RearrangeClip
和RearrangeMesh
功能修复任何引用骨骼的网格或动画剪辑。以这种方式处理资产后,GetMatrixPalette
总是采用优化的路径。在下一节中,您将探索在层次结构中缓存转换。
使Pose
类的GetGlobalTransform
函数的一个特点是它总是计算世界变换。考虑这样一种情况,您请求节点的世界变换,然后紧接着请求其父节点的世界变换。原始请求计算并使用父节点的世界变换,但是一旦发出下一个请求,就会再次计算相同的变换。
对此的解决方案是向Pose
类添加两个新数组。一个是世界空间变换的向量,另一个包含脏标志。只要设置了关节的局部变换,关节的脏标志就需要设置为true
。
当一个世界变换被请求时,变换及其所有父变换的脏标志被检查。如果链中有脏变换,世界变换将被重新计算。如果未设置脏标志,则返回缓存的世界转换。
在本章中,您不会实现这种优化。这种优化为Pose
类的每个实例增加了大量内存。除了逆运动学的情况外,GetGlobalTransform
功能很少使用。对于蒙皮,GetMatrixPalette
函数用于检索世界空间矩阵,并且该函数已经过优化。
在本章中,您探讨了如何针对几个场景优化动画系统。这些优化减少了顶点蒙皮着色器所需的制服数量,加快了具有许多关键帧的动画的采样速度,并更快地生成姿势的矩阵调色板。
请记住,没有放之四海而皆准的解决方案。如果游戏中的所有动画都有几个关键帧,则使用查找表优化动画采样所增加的开销可能不值得额外的内存。然而,更改采样函数以使用二分搜索法可能是值得的。每个优化策略都有相似的优缺点;您必须选择对您的特定用例有意义的东西。
查看本章的示例代码时,Chapter11/Sample00
包含本章的全部代码。Chapter11/Sample01
展示了如何使用预蒙皮网格,Chapter11/Sample02
展示了如何使用FastTrack
类来实现更快的采样,Chapter11/Sample03
展示了如何重新排列骨骼来实现更快的调色板生成。
在下一章中,您将探索如何混合动画以在两个动画之间平滑切换。本章还将探索通过添加混合来修改现有动画的混合技术。