Hands-on C++ Game Animation Programming阅读笔记(八)
Chapter 11: Optimizing the Animation Pipeline
本章主要是优化之前写的动画相关代码,优化的几个思路如下:
- 用更好的方法来实现skinning
- 更高效的Sample Animation Clips
- 回顾生成matrix palette的方式
具体分为以下几个内容:
- Skin matrix的预处理
- 把skin pallete存到texture里
- 更快的Sampling
- The Pose palette generation
- 探讨Pose::GetGlobalTransform函数
优化一:Skin matrix的预处理
这一节可以把uniform占用的槽位数减半
前面的gpu蒙皮里的vs里有这么几行内容:
in vec4 weights;// 额外的顶点属性1
in ivec4 joints;// 额外的顶点属性2
// 两个Uniform数组
uniform mat4 pose[120]; // 代表parent joint的world trans
uniform mat4 invBindPose[120];//代表转换到parent joint的local space的offset矩阵
因为顶点属性里传入了顶点受影响的joints的id,而uniform数据是顶点之间共享的,但是每个顶点各自使用的id又不同,所以这里把整个数组都传进来了,这里应该是有120个Joints会影响顶点,也就是mat4类型的uniform一共有240个,而实际上一个mat4的uniform会占据4个uniform的槽位,所以这就是960个uniform slots,会造成很大的消耗。
仔细观察下面计算出的skin矩阵:
mat m0 = pose[joints.x] * invBindPose[joints.x] * weights.x;
mat m1 = pose[joints.y] * invBindPose[joints.y] * weights.y;
mat m2 = pose[joints.z] * invBindPose[joints.z] * weights.z;
mat m3 = pose[joints.w] * invBindPose[joints.w] * weights.w;
mat4 pallete = m0 + m1 + m2 + m3;
这里一个顶点确实会受到四个矩阵影响,这个是没办法处理的,如果要移到CPU这里就变成了CPU Skinning了,但是这里的pose和invBindPose俩矩阵的相乘,其内部都是一个joint的id,所以这块代码是可以放到CPU计算的,那么我可以在CPU里算出一个矩阵数组,这个数组size为120,第i个元素为pose[i]*invBindPose[i]。
这样就可以把原本的960个uniform slots减半,变为480个uniform slots,其实是把GPU的一部分计算负担交给了CPU,但是这样感觉计算分配更合理一些。
对于每个Joint,其WorldTrans乘以其invBindPose的矩阵的结果,这个矩阵,书里把它叫skin 矩阵,所以说skin矩阵跟之前提到的四个矩阵融合得到的matrix palette还不一样。
void Sample::Update(float deltaTime)
{
// Sample函数会把outPose存在mAnimatedPose里, 输入的时间是真实时间
// 返回的时间是处理后的时间, 比如取过模
mPlaybackTime = mAnimClip.Sample(mAnimatedPose, mPlaybackTime + deltaTime);
// 此函数会返回globalTrans的mat数组, 存在mPosePalette里
mAnimatedPose.GetMatrixPalette(mPosePalette);
// 对mPosePalette矩阵数组进行修改, 使其变成由skin矩阵组成的数组
vector<mat4>& invBindPose = mSkeleton.GetInvBindPose();
for (int i = 0; i < mPosePalette.size(); ++i)
{
mPosePalette[i] = mPosePalette[i] * invBindPose[i];
}
// If the mesh is CPU skinned, this is a good place to call the CPUSkin function.
// This function needs to be re-implemented to work with a combined skin matrix. I
if (mDoCPUSkinning)
mMesh.CPUSkin(mPosePalette);
// 如果想用GPU Skinning, 把前面的vs小改一下即可, 然后传uniform的代码也改一下, 就不多说了
}
使用预先计算的Skin矩阵数组实现第三种CPU Skin函数
可以先来看看老的CPU Skin函数,有俩版本:
#if 1
// pose应该是动起来的人物的pose
void Mesh::CPUSkin(const Skeleton& skeleton, const Pose& pose)
{
unsigned int numVerts = (unsigned int)mPosition.size();
if (numVerts == 0)
return;
// 设置size
mSkinnedPosition.resize(numVerts);
mSkinnedNormal.resize(numVerts);
// 这个函数会获取Pose里的每个Joint的WorldTransform, 存到mPosePalette这个mat4组成的vector数组里
pose.GetMatrixPalette(mPosePalette);
// 获取bindPose的数据
std::vector<mat4> invPosePalette = skeleton.GetInvBindPose();
// 遍历每个顶点
for (unsigned int i = 0; i < numVerts; ++i)
{
ivec4& j = mInfluences[i];// 点受影响的四块Bone的id
vec4& w = mWeights[i];
// 矩阵应该从右往左看, 先乘以invPosePalette, 转换到Bone的LocalSpace
// 再乘以Pose对应Joint的WorldTransform
mat4 m0 = (mPosePalette[j.x] * invPosePalette[j.x]) * w.x;
mat4 m1 = (mPosePalette[j.y] * invPosePalette[j.y]) * w.y;
mat4 m2 = (mPosePalette[j.z] * invPosePalette[j.z]) * w.z;
mat4 m3 = (mPosePalette[j.w] * invPosePalette[j.w]) * w.w;
mat4 skin = m0 + m1 + m2 + m3;
// 计算最终矩阵对Point和Normal的影响
mSkinnedPosition[i] = transformPoint(skin, mPosition[i]);
mSkinnedNormal[i] = transformVector(skin, mNormal[i]);
}
// 同步GPU端数据
mPosAttrib->Set(mSkinnedPosition);
mNormAttrib->Set(mSkinnedNormal);
}
#else
// 俩input, Pose应该是此刻动画的Pose, 俩应该是const&把
void Mesh::CPUSkin(const Skeleton& skeleton, const Pose& pose)
{
// 前面的部分没变
unsigned int numVerts = (unsigned int)mPosition.size();
if (numVerts == 0)
return;
// 设置size, 目的是填充mSkinnedPosition和mSkinnedNormal数组
mSkinnedPosition.resize(numVerts);
mSkinnedNormal.resize(numVerts);
// 之前这里是获取输入的Pose的WorldTrans的矩阵数组和BindPose里的InverseTrans矩阵数组
// 但这里直接获取BindPose就停了
const Pose& bindPose = skeleton.GetBindPose();
// 同样遍历每个顶点
for (unsigned int i = 0; i < numVerts; ++i)
{
ivec4& joint = mInfluences[i];
vec4& weight = mWeights[i];
// 之前是矩阵取Combine, 现在是算出来的点和向量, 再最后取Combine
// 虽然Pose里Joint存的都是LocalTrans, 但是重载的[]运算符会返回GlobalTrans
Transform skin0 = combine(pose[joint.x], inverse(bindPose[joint.x]));
vec3 p0 = transformPoint(skin0, mPosition[i]);
vec3 n0 = transformVector(skin0, mNormal[i]);
Transform skin1 = combine(pose[joint.y], inverse(bindPose[joint.y]));
vec3 p1 = transformPoint(skin1, mPosition[i]);
vec3 n1 = transformVector(skin1, mNormal[i]);
Transform skin2 = combine(pose[joint.z], inverse(bindPose[joint.z]));
vec3 p2 = transformPoint(skin2, mPosition[i]);
vec3 n2 = transformVector(skin2, mNormal[i]);
Transform skin3 = combine(pose[joint.w], inverse(bindPose[joint.w]));
vec3 p3 = transformPoint(skin3, mPosition[i]);
vec3 n3 = transformVector(skin3, mNormal[i]);
mSkinnedPosition[i] = p0 * weight.x + p1 * weight.y + p2 * weight.z + p3 * weight.w;
mSkinnedNormal[i] = n0 * weight.x + n1 * weight.y + n2 * weight.z + n3 * weight.w;
}
mPosAttrib->Set(mSkinnedPosition);
mNormAttrib->Set(mSkinnedNormal);
}
#endif
第三种方法其实很简单,就是把如下图所示的这一块提前算出来,存到数组里而已:
这里的mPosePalette是动态的Pose提取出来Joint的WorldTransform的矩阵数组,反正还是要不断更新的,代码如下:
void Mesh::CPUSkin(std::vector<mat4>& animatedPose)
{
unsigned int numVerts = (unsigned int)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);
}
三种方法其实大同小异,结果是一样的,效率也差不多,分别是:
- 算出带权重的融合矩阵,也就是最终四个Joint的融合影响矩阵,然后乘以position和normal
- 算出各自单独的矩阵,算出四个position和normal,然后各自乘以权重累加得到结果
- 算出各个Joint的单独Skin矩阵,然后算出四个position和normal,最后各自乘以权重累加得到结果,其实跟方法二很像
这么个原理写了三种函数,感觉作者在整花活。。。。
改变GPU skinning适配优化一的方案
还是这种方法,把Pose的每个Joint的WorldTransform和InversePosePalette预先乘起来,在这种情况下的VS应该怎么写。
之前是这么写的:
#version 330 core
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
in vec3 position;
in vec3 normal;
in vec2 texCoord;
in vec4 weights;// 额外的顶点属性1
in ivec4 joints;// 额外的顶点属性2
// 两个Uniform数组
uniform mat4 pose[120]; // 代表parent joint的world trans
uniform mat4 invBindPose[120];//代表转换到parent joint的local space的offset矩阵
// 这里是重点,对于Skinned Mesh,其ModelSpace下的顶点坐标和法向量都需要重新计算
// 因为这个Mesh变化了
out vec4 newModelPos;
out vec3 newNorm;
out vec2 uv; // 注意,uv是不需要变化的(为啥?)
void main()
{
mat m0 = pose[joints.x] * invBindPose[joints.x] * weights.x;
mat m1 = pose[joints.y] * invBindPose[joints.y] * weights.y;
mat m2 = pose[joints.z] * invBindPose[joints.z] * weights.z;
mat m3 = pose[joints.w] * invBindPose[joints.w] * weights.w;
mat4 pallete = m0 + m1 + m2 + m3;
gl_Position = projection * view * model * pallete * position;// 算出屏幕上的样子
// 计算真实的ModelSpace下的坐标,供后续进行着色和光照计算
newModelPos = (model * pallete * vec4(position, 1.0f)).xyz;
newNorm = (model * pallete * vec4(normal, 0.0f)).xyz;
// 注意,顶点即使进行了Deformation,但它对应贴图的TexCoord是不改变的
uv = texCoord;
}
改成这样就行了,很简单:
// 文件从skinned.vert改名为preskinned.vert
#version 330 core
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
in vec3 position;
in vec3 normal;
in vec2 texCoord;
in vec4 weights;// 额外的顶点属性1
in ivec4 joints;// 额外的顶点属性2
// 两个Uniform数组
uniform mat4 animatedCombinedPose[120]; // 代表parent joint的world trans
// 这里是重点,对于Skinned Mesh,其ModelSpace下的顶点坐标和法向量都需要重新计算
// 因为这个Mesh变化了
out vec4 newModelPos;
out vec3 newNorm;
out vec2 uv; // 注意,uv是不需要变化的(为啥?)
void main()
{
mat m0 = animatedCombinedPose[joints.x] * weights.x;
mat m1 = animatedCombinedPose[joints.y] * weights.y;
mat m2 = animatedCombinedPose[joints.z] * weights.z;
mat m3 = animatedCombinedPose[joints.w] * weights.w;
mat4 pallete = m0 + m1 + m2 + m3;
gl_Position = projection * view * model * pallete * position;// 算出屏幕上的样子
// 计算真实的ModelSpace下的坐标,供后续进行着色和光照计算
newModelPos = (model * pallete * vec4(position, 1.0f)).xyz;
newNorm = (model * pallete * vec4(normal, 0.0f)).xyz;
// 注意,顶点即使进行了Deformation,但它对应贴图的TexCoord是不改变的
uv = texCoord;
}
然后GPU方面设置uniform的opengl代码改一下:
// 现在是
// mPosePalette Generated in the Update method!
int animated = mSkinnedShader- >GetUniform("animated")
Uniform<mat4>::Set(animated, mPosePalette);
优化二:Storing the skin palette in a texture
这一节可以把uniform占用的槽位数变为1,其实就是用texture存储矩阵信息,只是介绍了思路,具体的实现后面章节会再提。
前面翻来覆去都是一些小把戏,这节感觉应该挺重要,看名字是把skin矩阵存到贴图里,我理解的应该是把上面这个动态的animatedCombinedPose,对应的mat4矩阵,用texture的方式用一个uniform通道传给vs,下面是具体的内容。
这种方法能把前面的480个uniform slots减少到一个,就是把相关信息存到Texture中,目前书里只提到了RGB24和RGBA32,这种贴图,每个分量都是8个bit,一共是256个值,这种贴图的精度是无法保存浮点数的。
而我们要用的矩阵里都是存的浮点数,所以这里需要用到一个特殊的,格式为FLOAT32的texture,FLOAT32的意思应该是,这种贴图的格式下,每个像素里的数据有32个bit,它表示的是一个浮点数。
这里的FLOAT32的贴图,可以认为是一个buffer,CPU可以对其进行写入,GPU可以从它读取数据。
the number of required uniform slots becomes just one—the uniform slot that is needed is the sampler for the FLOAT32 texture
这里用贴图的方式减少了Uniform的槽位个数,代价是降低了蒙皮算法的运行速度,对于每个Vertex来说,它都需要去Sample Texture,获取上面提到的四个矩阵,每个矩阵还不止Sample一次,因为一次只能返回一个float,这种方法比直接从uniform数组里获取矩阵数值要慢。
这里只是提出方法,具体的实现要放到第15章——Render Large Crowds with Instancing里。
优化三:Sample函数优化
Sample函数的回顾
可以看看目前的Sample函数,Sample函数由Clip类的成员函数提供,输入一个Input Time,返回一个Pose和矫正过的PlayTime:
// 这里的Sample函数还对输入的Pose有要求, 因为Clip里的Track如果没有涉及到每个Component的
// 动画, 则会按照输入Pose的值来播放, 所以感觉outPose输入的时候要为(rest Pose(T-Pose or A-Pose))
float Clip::Sample(Pose& outPose, float time)
{
if (GetDuration() == 0.0f)
return 0.0f;
time = AdjustTimeToFitRange(time);// 调用Clip自己实现的函数
unsigned int size = mTracks.size();
for (unsigned int i = 0; i < size; ++i)
{
unsigned int joint = mTracks[i].GetId();
Transform local = outPose.GetLocalTransform(joint);
// 本质是调用Track的Sample函数
Transform animated = mTracks[i].Sample(local, time, mLooping);
outPose.SetLocalTransform(joint, animated);
}
return time;
}
这里Clip的Sample函数,实际上会遍历每个Clip里的Track(相当于Property Curve),然后调用Track的Sample函数,输入的是Rest Pose的默认值,返回新的Transform值
// 各个Track的Sample, 如果有Track的话
// 由于不是所有的动画都有相同的Property对应的track, 比如说有的只有position, 没有rotation和scale
// 在Sample动画A时,如果要换为Sample动画B,要记得重置人物的pose
Transform TransformTrack::Sample(const Transform& ref, float time, bool looping)
{
// 每次Sample来播放动画时, 都要记录好这个result数据
Transform result = ref; // Assign default values
// 这样的ref, 代表原本角色的Transform, 这样即使对应的Track没动画数据, 也没关系
if (mPosition.Size() > 1)
{ // Only assign if animated
result.position = mPosition.Sample(time, looping);
}
if (mRotation.Size() > 1)
{ // Only assign if animated
result.rotation = mRotation.Sample(time, looping);
}
if (mScale.Size() > 1)
{ // Only assign if animated
result.scale = mScale.Sample(time, looping);
}
return result;
}
最后,其实Sample函数又细分到了具体的Track的Sample函数上,如下所示:
// Sample的时候根据插值类型来
template<typename T, int N>
T Track<T, N>::Sample(float time, bool looping)
{
if (mInterpolation == Interpolation::Constant)
return SampleConstant(time, looping);
else if (mInterpolation == Interpolation::Linear)
return SampleLinear(time, looping);
return SampleCubic(time, looping);
}
template<typename T, int N>
T Track<T, N>::SampleConstant(float time, bool looping)
{
// 获取时间对应的帧数, 取整
int frame = FrameIndex(time, looping);
if (frame < 0 || frame >= (int)mFrames.size())
return T();
// Constant曲线不需要插值, mFrames里应该只有关键帧的frame数据
return Cast(&mFrames[frame].mValue[0]);
// 为啥要转型? 因为mValue是float*类型的数组, 这里的操作是取从数组地址开始, Cast为T类型
}
Sample函数优化
只要当前播放的动画Clip的时长小于1s,那么它就很合适在现在的动画系统里播放。但是对于CutScene这种有多个时长很长的动画Clip同时播放的应用场景来说,就不太合适了,此时性能会比较差。
至于为什么现在的代码不适合播放时长较长的动画呢?原因出在下面的FrameIndex函数上,这个函数会逐帧遍历,寻找输入的time所在的区间,所以很耗时间:
// 根据时间获取对应的帧数, 其实是返回其左边的关键帧
// 注意这里的frames应该是按照关键帧来存的, 比如有frames里有三个元素, 可能分别对应的时间为
// 0, 4, 10, 那么我input time为5时, 返回的index为1, 代表从第二帧开始
// 这个函数返回值保证会在[0, size - 2]区间内
template<typename T, int N>
int Track<T, N>::FrameIndex(float time, bool looping)
{
unsigned int size = (unsigned int)mFrames.size();
if (size <= 1)
return -1;
if (looping)
{
float startTime = mFrames[0].mTime;
float endTime = 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 <= mFrames[0].mTime)
return 0;
// 注意, 只要大于倒数第二帧的时间, 就返回其帧数
// 也就是说, 这个函数返回值在[0, size - 2]区间内
if (time >= mFrames[size - 2].mTime)
return (int)size - 2;
}
// 就是在这, 造成了性能的dragging
for (int i = (int)size - 1; i >= 0; --i)
{
if (time >= mFrames[i].mTime)
return i;
}
return -1;
}
这里的线性查找并不合理,既然mFrames数组里的mTime是递增的,那么可以用binary search,不过二分法也不是最好的,它毕竟还要logn呢。这里有一个O1的方法,由于动画一般Sample是有固定的Sample Rate的,那么比如一秒有30帧,那么这30帧的时间是固定的,那么我可以预先把它们对应的前面的关键字的index记录下来,存起来,那么动画播放的时候就不必再去查找了。
代码如下,其实是创建了一个继承于Track的子类,给它加了些东西(其实也可创建一个Wrapper,把Track包起来):
template<typename T, int N>
class FastTrack : public Track<T, N>
{
protected:
// 用这玩意儿计算对应SampleRate的时间节点对应的左边Frame的Id
std::vector<unsigned int> mSampledFrames;
virtual int FrameIndex(float time, bool looping);// 这里要把原本的Track类的这个函数改为虚函数
// 没看到SampleRate啊? Track里也没有这个变量
// 看了下面后面的代码, 这里默认SampleRate就是一秒60帧了
public:
//This function needs to sample the animation at fixed time intervals and
// record the frame before the animation time for each interval.
void UpdateIndexLookupTable();
};
// 创建三个帮助使用的typedef
typedef FastTrack<float, 1> FastScalarTrack;// 类似与一个float对象的PropertyCurve
typedef FastTrack<vec3, 3> FastVectorTrack;
typedef FastTrack<quat, 4> FastQuaternionTrack;
// 一个全局的模板函数, 用于把Track优化为FastTrack类
template<typename T, int N>
FastTrack<T, N> OptimizeTrack(Track<T, N>& input);
对应的CPP文件如下:
// 基本之前没有见过这种写法, 注意, 这里不是模板特化, 而是让编译器生成这几个参数的对应函数而已
// 跟下面这种写法不一样(见附录)
// template<>
// FastTrack<float, 1> OptimizeTrack(Track<float, 1>& input);
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);
// 输入Track, 返回FastTrack, 设计这个函数主要也是为了不改动原本的代码吧
template<typename T, int N>
FastTrack<T, N> OptimizeTrack(Track<T, N>& input)
{
FastTrack<T, N> result;
// 1. 先复制原始数据
// 1.1 Copy插值类型
result.SetInterpolation(input.GetInterpolation());
// 1.2 Copy关键帧数组
// Track里有一个关键帧的数组
unsigned int size = input.Size();
result.Resize(size);
// Track类的下标运算符重载为返回第i个关键帧对象
for (unsigned int i = 0; i < size; ++i)
result[i] = input[i];
// 2. 基于复制过来的Track数据, 计算时间点对应的前面的关键帧的id
result.UpdateIndexLookupTable();
return result;
}
// 核心函数
template<typename T, int N>
void FastTrack<T, N>::UpdateIndexLookupTable()
{
// 检查关键帧数据
int numFrames = (int)this->mFrames.size();
if (numFrames <= 1)
return;
// 获取Track关键帧的时长(秒数)
float duration = this->GetEndTime() - this->GetStartTime();
// 这段在Github上的代码加了个60的offset, 不太清楚是为了啥, 这里就不加了
unsigned int numSamples = /*60 + */(unsigned int)(duration * 60.0f);
mSampledFrames.resize(numSamples);
// 按每秒60帧来遍历所有的帧
for (unsigned int i = 0; i < numSamples; ++i)
{
// 根据帧数算出对应的时间
float t = (float)i / (float)(numSamples - 1);
float time = t * duration + this->GetStartTime();
// 还是倒着遍历, 寻找对应时间的左边的关键帧ID
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;
}
}
// 这个FastTrack其实也就是比Track对象多了个mSampleFrames数组(是一个int数组)
mSampledFrames[i] = frameIndex;
}
}
// 虚函数重载
template<typename T, int N>
int FastTrack<T, N>::FrameIndex(float time, bool looping) override
{
std::vector<Frame<N>>& frames = this->mFrames;
unsigned int size = (unsigned int)frames.size();
if (size <= 1)
return -1;
if (looping)
{
float startTime = frames[0].mTime;
float endTime = frames[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;
}
// 区别就在这里
float duration = this->GetEndTime() - this->GetStartTime();
unsigned int numSamples = /* 60 + */(unsigned int)(duration * 60.0f);
float t = time / duration;
unsigned int index = (unsigned int)(t * (float)numSamples);
if (index >= mSampledFrames.size())
return -1;
return (int)mSampledFrames[index];
}
所以说,这里的重点其实就是预处理,把动画按照SampleRate进行分段,然后存储一个int数组作为lookup,这样我任何一个时间输入进来,都能快速定位到它位于哪些关键帧之间
调整原本的TransformTrack
这里为Track创建了子类FastTrack,Track对应的是一个Property的关键帧数据,别忘了之前为了方便,还写过一个TransformTrack,也就是三个PropertyCurve的集合,内部数据是这样
class TransformTrack
{
protected:
unsigned int mId;// 对应Bone的Id
// 这些玩意儿其实就是Track
VectorTrack mPosition; // typedef Track<vec3, 3> VectorTrack;
QuaternionTrack mRotation; // typedef Track<quat, 4> QuaternionTrack;
VectorTrack mScale;
typedef Track<quat, 4> QuaternionTrack;
public:
Transform Sample(const Transform& ref, float time, bool looping);
...
};
为了使用新的FastTrack,需要修改这个类的代码,由于Track和FastTrack的接口是相同的,所以目的是把这个TransformTrack类改成类模板(其实用虚函数也还行吧),新的类声明如下所示:
#ifndef _H_TRANSFORMTRACK_
#define _H_TRANSFORMTRACK_
#include "Track.h"
#include "Transform.h"
// 原本的Track用现在的模板表示
template <typename VTRACK, typename QTRACK>
class TTransformTrack
{
protected:
unsigned int mId; // 这条TransformTrack数据对应的Joint的id
VTRACK mPosition; // Position和Scale共享一个Track类型
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& ref, float time, bool looping);
};
// 然后加这俩typedef
typedef TTransformTrack<VectorTrack, QuaternionTrack> TransformTrack;
typedef TTransformTrack<FastVectorTrack, FastQuaternionTrack> FastTransformTrack;
// 额外声明了一个全局函数, 由于把TransformTrack改为FastTransformTrack, 其实就是把里面的三个Track都改成FastTrack
FastTransformTrack OptimizeTransformTrack(TransformTrack& input);
#endif
相关类实现代码如下:
#include "TransformTrack.h"
// 防止编译错误做的Template Instantiation
template TTransformTrack<VectorTrack, QuaternionTrack>;
template TTransformTrack<FastVectorTrack, FastQuaternionTrack>;
// 一些很普通的接口, mId是TransformTrack对应的joint的id
template <typename VTRACK, typename QTRACK>
TTransformTrack<VTRACK, QTRACK>::TTransformTrack()
{
mId = 0;
}
template <typename VTRACK, typename QTRACK>
unsigned int TTransformTrack<VTRACK, QTRACK>::GetId()
{
return mId;
}
template <typename VTRACK, typename QTRACK>
void TTransformTrack<VTRACK, QTRACK>::SetId(unsigned int id)
{
mId = id;
}
template <typename VTRACK, typename QTRACK>
VTRACK& TTransformTrack<VTRACK, QTRACK>::GetPositionTrack()
{
return mPosition;
}
template <typename VTRACK, typename QTRACK>
QTRACK& TTransformTrack<VTRACK, QTRACK>::GetRotationTrack()
{
return mRotation;
}
template <typename VTRACK, typename QTRACK>
VTRACK& TTransformTrack<VTRACK, QTRACK>::GetScaleTrack()
{
return mScale;
}
template <typename VTRACK, typename QTRACK>
bool TTransformTrack<VTRACK, QTRACK>::IsValid()
{
return mPosition.Size() > 1 || mRotation.Size() > 1 || mScale.Size() > 1;
}
// 基本没变
template <typename VTRACK, typename QTRACK>
float TTransformTrack<VTRACK, QTRACK>::GetStartTime()
{
float result = 0.0f;
bool isSet = false;
if (mPosition.Size() > 1)
{
result = mPosition.GetStartTime();
isSet = true;
}
if (mRotation.Size() > 1)
{
float rotationStart = mRotation.GetStartTime();
if (rotationStart < result || !isSet)
{
result = rotationStart;
isSet = true;
}
}
if (mScale.Size() > 1)
{
float scaleStart = mScale.GetStartTime();
if (scaleStart < result || !isSet)
{
result = scaleStart;
isSet = true;
}
}
return result;
}
// 基本没变
template <typename VTRACK, typename QTRACK>
float TTransformTrack<VTRACK, QTRACK>::GetEndTime()
{
float result = 0.0f;
bool isSet = false;
if (mPosition.Size() > 1)
{
result = mPosition.GetEndTime();
isSet = true;
}
if (mRotation.Size() > 1)
{
float rotationEnd = mRotation.GetEndTime();
if (rotationEnd > result || !isSet)
{
result = rotationEnd;
isSet = true;
}
}
if (mScale.Size() > 1)
{
float scaleEnd = mScale.GetEndTime();
if (scaleEnd > result || !isSet)
{
result = scaleEnd;
isSet = true;
}
}
return result;
}
// 基本没变, 就是从原来的成员函数变成了现在的模板成员函数
template <typename VTRACK, typename QTRACK>
Transform TTransformTrack<VTRACK, QTRACK>::Sample(const Transform& ref, float time, bool looping)
{
Transform result = ref; // Assign default values
// Only assign if animated
if (mPosition.Size() > 1)
result.position = mPosition.Sample(time, looping);
// Only assign if animated
if (mRotation.Size() > 1)
result.rotation = mRotation.Sample(time, looping);
if (mScale.Size() > 1)// Only assign if animated
result.scale = mScale.Sample(time, looping);
return result;
}
// 三个子Track各自转换
FastTransformTrack OptimizeTransformTrack(TransformTrack& input)
{
FastTransformTrack result;
result.SetId(input.GetId());
// copies the actual track data by value, it can be a little slow.
result.GetPositionTrack() = OptimizeTrack<vec3, 3>(input.GetPositionTrack());
result.GetRotationTrack() = OptimizeTrack<quat, 4>(input.GetRotationTrack());
result.GetScaleTrack() = OptimizeTrack<vec3, 3>(input.GetScaleTrack());
return result;
}
修改Clip类以适配
这是原本的Clip类,核心数据既然是TransformTrack数组,那么自然也要进行修改:
// 原本的代码
class Clip
{
protected:
// 本质就是TransformTracks
std::vector<TransformTrack> mTracks;
...
public:
float Sample(Pose& outPose, float inTime);
TransformTrack& operator[](unsigned int index);
...
}
其实就是TransformTrack改成TTransformTrack模板,我预想的是改成这样:
template <typename T, typename N>
class Clip
{
protected:
// 本质就是TransformTracks
std::vector<TTransformTrack<T, N>> mTracks;
...
public:
float Sample(Pose& outPose, float inTime);
TTransformTrack<T, N>& operator[](unsigned int index);
...
}
看了下书里的代码,感觉自己写的还是复杂了:
// 为了兼容TransformTrack和FastTransformTrack,这里使用了模板, TRACK只是个名字而已
template <typename TRACK>
class TClip
{
protected:
// 本质就是TransformTracks
std::vector<TRACK> mTracks;
...
public:
float Sample(Pose& outPose, float inTime);
TRACK& operator[](unsigned int index);
...
}
// 加了这俩typedef(其实目前只有第一个typedef), 就能让老的函数继续使用了, 比如
// std::vector<Clip> LoadAnimationClips(cgltf_data* data) 函数里用到了Clip
typedef TClip<TransformTrack> Clip;
typedef TClip<FastTransformTrack> FastClip;
// 全局函数
FastClip OptimizeClip(Clip&input);
// 同样为了保证编译正确
template TClip<TransformTrack>;
template TClip<FastTransformTrack>;
除了函数签名,具体的cpp要改的其实就是加个转换函数而已:
FastClip OptimizeClip(Clip& input)
{
// 还是先Copy数据
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);
// 在Clip的[]运算符重载里, 如果[id]找得到数据, 就直接返回其&
// 如果没有该数据, 就new一个TransformTrack, 加到数组里, 返回其&
result[joint] = OptimizeTransformTrack(input[joint]);
}
// 遍历所有的Joints的TransformTrack, 找到最早和最晚的关键帧的出现时间, 记录在mStartTime和mEndTime上
result.RecalculateDuration();
return result;
}
优化四:Pose类的成员函数GetMatrixPalette优化
这节属于算法层面的小优化
Pose里有这么一个函数,如下所示:
class Pose
{
protected:
// 本质数据就是两个vector, 一个代表Joints的hierarchy, 一个代表Joints的数据
std::vector<Transform> mJoints;
std::vector<int> mParents;
public:
// palette是调色板的意思, 这个函数是为了把Pose数据改成OpenGL支持的数据格式
// 由于OpenGL只接受linear array of matrices, 这里需要把Transform转换成矩阵
// 这个函数会根据Pose的Transform数组, 转化为一个mat4的数组
void GetMatrixPalette(std::vector<mat4>& out) const;
...
}
具体实现代码如下:
// vector<Transform> globalTrans 转化为mat4数组
void Pose::GetMatrixPalette(std::vector<mat4>& out) const
{
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);
}
}
// 计算特定Joint的GlobalTransform
Transform Pose::GetGlobalTransform(unsigned int index) const
{
Transform result = mJoints[index];
// 从底部往上Combine, 因为每个Joint的Parent Joint在vector里的id是可以获取到的
for (int parent = mParents[index]; parent >= 0; parent = mParents[parent])
// Combine这个函数是之前在mat4.cpp里实现的全局函数, 其实就是return matA*matB
result = combine(mJoints[parent], result);
return result;
}
这里没必要的性能消耗在于,每次算一个Joint的GlobalTransform时,都要从最Root开始算,然后最后转化为Mat4,我的想法是,其实可以按照BFS算法,按照Pose的Hierarchy来遍历,直接用Parent的Mat4矩阵,右乘以自己的Transform转换来的Mat4矩阵即可。
书里的思路是,默认认为,Pose里的Joints是不按顺序排列的,但是Joints对应的Id都满足一个条件,也就是Parent的id要小于Childrenm的id,也就是说id是按照BFS顺序排列的。
基于这个规则,可以按序号从小到大的顺序重排Pose里的mJoints数组,这样就能保证计算每个Joint的Transform时,其parent的Transform矩阵已经计算好了,代码如下:
// 书里创建了一个RearrangeBones文件, 这是头文件
#ifndef _H_REARRANGEBONES_
#define _H_REARRANGEBONES_
#include <map>
#include "Skeleton.h"
#include "Mesh.h"
#include "Clip.h"
std::map<int, int> RearrangeSkeleton(Skeleton& skeleton);
void RearrangeMesh(Mesh& mesh, std::map<int, int>& boneMap);
void RearrangeClip(Clip& clip, std::map<int, int>& boneMap);
void RearrangeFastclip(FastClip& clip, std::map<int, int>& boneMap);
#endif
// cpp文件
#include "RearrangeBones.h"
#include <list>
// 传入一个Skeleton, 里面有RestPose和BindPose, 对两个Pose里的Joints
// 以及Skeleton里的Names数组进行重新排序, 使其满足bfs的遍历要求, 这种遍历顺序下
// 计算每个节点的GlobalTransform信息时, 其Parent的GlobalTransform已经被预先计算好了
std::map<int, int> RearrangeSkeleton(Skeleton& outSkeleton)
{
Pose& restPose = outSkeleton.GetRestPose();
Pose& bindPose = outSkeleton.GetBindPose();
// size是skeleton里的Joints的个数
unsigned int size = restPose.Size();
if (size == 0)
return std::map<int, int>();
// 创建一个二维数组, 行数为joints的个数, 也就是每个joint对应一个int数组
// 每个Joint对应的int数组会存它所有子节点的id
std::vector<std::vector<int>> hierarchy(size);
std::list<int> process;
// 遍历RestPose里的每个Joint
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);//应该只有root节点会存到process对应的list链表里
}
// 本身每个Pose里会有一个joints数组, 这是老的数组
// 然后在这个函数执行之后, 会得到一个新的joints排序后的数组
// 所以这俩map就负责两个数组元素id间的映射
// mapForward记录了每个新的数组元素在老数组里的位置
// mapBackward记录了每个老的数组元素在新数组里的位置
std::map<int, int> mapForward;
std::map<int, int> mapBackward;
// index表示遍历顺序
int index = 0;
// 遍历链表, 感觉类似于处理队列一样处理list, 先入先出
// 这其实是一个bfs的遍历过程
while (process.size() > 0)
{
// 取head
int current = *process.begin();
// 出head
process.pop_front();
// 获取当前节点对应的children的id列表
std::vector<int>& children = hierarchy[current];
// 遍历children, 加入list模拟的队列里
unsigned int numChildren = (unsigned int)children.size();
for (unsigned int i = 0; i < numChildren; ++i)
process.push_back(children[i]);
// mapForward记录了每个新的数组元素在老数组里的位置, index是遍历顺序, 其实也就是新数组的元素排列顺序
// 其实是用这个mapForward记录了这个bfs的顺序, bfs遍历节点的顺序为: mapForward[0], mapForward[1]...
// mJoints[mapForward[0]]是第一个遍历的Joint
mapForward[index] = current;
// 作者在整花活, 一个简单的需求写这么复杂的代码...
// mapBackward记录了每个老的数组元素在新数组里的位置
// 反向存一个map, 可以知道任意一个节点在bfs遍历里遍历的顺序, 比如i号Joint, 会排在第mapBackward[i]个被遍历
// 其实是记录一个mapping关系, 第i号joint会变成新joints数组的第(mapBackward[i])个对象
mapBackward[current] = index;
index += 1;
}
mapForward[-1] = -1;
mapBackward[-1] = -1;
// 创建两个新Pose
Pose newRestPose(size);
Pose newBindPose(size);
std::vector<std::string> newNames(size);
// 按照bfs遍历的顺序, 遍历Skeleton里的RestPose和BindPose里的Joints
// 存到新的俩Pose里, 也就是说新的Pose相当于老的Pose按照BFS的顺序重排
for (unsigned int i = 0; i < size; ++i)
{
// Copy Joint数据
// 1. Copy Transform
int thisBone = mapForward[i];
newRestPose.SetLocalTransform(i, restPose.GetLocalTransform(thisBone));
newBindPose.SetLocalTransform(i, bindPose.GetLocalTransform(thisBone));
// 2. Copy Name
newNames[i] = outSkeleton.GetJointName(thisBone);
// 3. Copy Parent Id
int parent = mapBackward[bindPose.GetParent(thisBone)];
newRestPose.SetParent(i, parent);
newBindPose.SetParent(i, parent);
}
outSkeleton.Set(newRestPose, newBindPose, newNames);
return mapBackward;
}
// boneMap是RearrangeSkeleton函数返回的map<int, int>
// key是joint在原本的joints数组里的id, value是joint在新的Joints的数组里的id, 也其实就是遍历顺序
// 既然Skeleton里的俩Pose的Joints的顺序都改了, 这里Clip里的TransformTrack对应的
// Joint的id也应该换成新的
void RearrangeClip(Clip& outClip, std::map<int, int>& boneMap)
{
// Clip里的数据就是一个数组mTracks, 元素是TransformTrack
unsigned int size = outClip.Size();
// 遍历每个TransformTrack
for (unsigned int i = 0; i < size; ++i)
{
// 获取Track对应的joint的id
int joint = (int)outClip.GetIdAtIndex(i);
// 获取这个Joint的遍历顺序
unsigned int newJoint = (unsigned int)boneMap[joint];
// 改变outClip的mTracks数组的第i个track的对应的joint的id
outClip.SetIdAtIndex(i, newJoint);
}
}
// PS
// void Clip::SetIdAtIndex(unsigned int index, unsigned int id)
// {
// return mTracks[index].SetId(id);
// }
// 跟Clip一样的函数
void RearrangeFastclip(FastClip& clip, std::map<int, int>& 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);
}
}
// Mesh里有个std::vector<ivec4> mInfluences, 记录了joint的id, 既然新的id换了
// 里面的数据也要换
void RearrangeMesh(Mesh& mesh, std::map<int, int>& 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();
}
改变Pose::GetGlobalTransform函数
之前写了那么多东西,其实就是为了给涉及到Joints数组的东西重新排序而已,因为之前的Joints数组,如果顺序遍历数组,无法满足bfs遍历顺序,即数组元素的子节点都在其数组位置之后。
具体做了以下事情:
- 重新排列Skeleton,也就是里面的BindPose和RestPose里的mJoints的顺序,再调整Skeleton里代表joints的名字的mNames数组
- 重新排列Clip数据,因为里面有TransformTrack的数组数据,它是与mJoints的顺序一一对应的,所以也要重排
- 重新改变Mesh数据,其实主要是SkinnedMesh里的Skin数据,因为Mesh里的每个顶点数据里记录了受影响的Bone的id
有了这些玩意儿,代码改起来就很简单了,原本的代码是:
// 计算特定Joint的GlobalTransform
Transform Pose::GetGlobalTransform(unsigned int index) const
{
Transform result = mJoints[index];
// 从底部往上Combine, 因为每个Joint的Parent Joint在vector里的id是可以获取到的
for (int parent = mParents[index]; parent >= 0; parent = mParents[parent])
// Combine这个函数是之前在mat4.cpp里实现的全局函数, 其实就是return matA*matB
result = combine(mJoints[parent], result);
return result;
}
// 然后是调用的代码
for (unsigned int i = 0; i < size; ++i)
{
Transform t = GetGlobalTransform(i);//
out[i] = transformToMat4(t);
}
现在就没这么复杂了:
// 计算特定Joint的GlobalTransform
Transform Pose::GetGlobalTransform(unsigned int index) const
{
Transform result = mJoints[index];
// 去掉了之前的for循环
int parent = mParents[index];
if(parent >= 0)
result = combine(mJoints[parent], result);
return result;
}
// 调用的代码不变
for (unsigned int i = 0; i < size; ++i)
{
Transform t = GetGlobalTransform(i);//
out[i] = transformToMat4(t);
}
继续优化Pose::GetGlobalTransform函数
目前的Skeleton里的Joint数组是按bfs排序的,而且存的是LocalTransform,计算特定Joint时,该Joint的WorldTrans和其所有Parent的WorldTrans都会被计算一遍(在刚刚的优化之前,每个Parent的WorldTrans都可能会计算多变)
但是目前对于以下情况,仍然存在性能消耗:
- 如果我多次取同一个Joint,即使它的Transform没变过,仍然要重新计算
为了解决这个问题,我觉得可以弄一个Cache,作为缓存,也是一个mJoints的Transform数组,不过记录的不再是LocalTrans,而是GlobalTrans,每次存在Joint更新时,就更新该Joint以及所有Children的Transform信息,感觉这样是可以的。但我目前的Skeleton里,节点好像只存了其Parent的信息,没存Children的节点信息。
看了下书,作者的做法更好,他是这样的,除了加一个mJoints的Transform数组,记录GlobalTrans外,再额外加一个数组,这个数组元素为bool,与原本的mJoints数组的Joint一一对应,作为Dirty Flag,每次Set来改变Joint数据时,就改变Dirty Flag,此时不会马上更新Transform数据,而只有读取Joint的数据时,才会去检查Dirty Flag,比如Joint的id为5,那么就检查0到5区间的flag就行了(因为子节点的Transform改变了也不会影响该节点的Transform),这样就能最大程度上避免Joints的GlobalTransform数组里的数据进行无效更新了。
不过这俩方法,都是通过用空间复杂度来换取时间复杂度的方法,每一个Pose对象里面的joints数组都会从一个变成两个,这一章暂时不实现相关的优化算法。
ps: 除了IK算法,其实一般很少要使用Joint的GetGlobalTransform
函数,对于Skinning过程来说,主要还是使用的GetMatrixPalette
函数,而这个函数已经被彻底优化好了。
总结
这章优化动画的思路主要是:
- 减少蒙皮数据于CPU与GPU之间传递的uniform槽位
- 加速对基于关键帧的Curve进行采样的函数
- 蒙皮算法,动画更新的每帧需要更新每个Joint对应的蒙皮矩阵,优化了算法的计算过程
Github给了几个Sample:
- Sample00代表基本的代码
- Sample01展示了pre-skinned meshes的用法
- Sample02展示了how to use the
FastTrack
class for faster sampling - Sample03展示了how to rearrange bones for faster palette generation.
附录
template后面接<>与什么都不接的区别
参考:https://stackoverflow.com/questions/28354752/template-vs-template-without-brackets-whats-the-difference
比如说声明一个模板函数:
template <typename T> void foo(T& t);
然后分别是这两种写法,把T都被指定为int:
// 写法一
template <> void foo<int>(int& t);
// 写法二
template void foo<int>(int& t);
注意,二者区别在于,第一种写法是模板全特化,这是一行函数声明,还需要函数定义,而第二种不是模板特化,它要求编译器为这个类型生成对应的函数代码,因为C++的模板其实是在你用到它的时候,也就是在cpp里调用它的时候,才会生成相关的代码进行编译,这么写,能够在没有用到对应代码的cpp的情况下,为其生成代码,可以检查其编译情况。
template <> void foo<int>(int& t);
declares a specialization of the template, with potentially different body.
template void foo<int>(int& t);
causes an explicit instantiation of the template, but doesn’t introduce a specialization. It just forces the instantiation of the template for a specific type.
同理,对于类和struct这些的模板,也是一样的:
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本