本章主要是优化之前写的动画相关代码,优化的几个思路如下:
- 用更好的方法来实现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& 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 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& 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::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的时候根据插值类型来 templateT Track ::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 T Track ::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]区间内 templateint Track ::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包起来):
templateclass FastTrack : public Track { protected: // 用这玩意儿计算对应SampleRate的时间节点对应的左边frame的Id std::vector 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 FastScalarTrack;// 类似与一个float对象的PropertyCurve typedef FastTrack FastVectorTrack; typedef FastTrack FastQuaternionTrack; // 一个全局的模板函数, 用于把Track优化为FastTrack类 template FastTrack OptimizeTrack(Track & input);
对应的CPP文件如下:
// 基本之前没有见过这种写法, 注意, 这里不是模板特化, 而是让编译器生成这几个参数的对应函数而已 // 跟下面这种写法不一样(见附录) // template<> // FastTrackOptimizeTrack(Track & input); template FastTrack OptimizeTrack(Track & input); template FastTrack OptimizeTrack(Track & input); template FastTrack OptimizeTrack(Track & input); // 输入Track, 返回FastTrack, 设计这个函数主要也是为了不改动原本的代码吧 template FastTrack OptimizeTrack(Track & input) { FastTrack 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 void FastTrack ::UpdateIndexLookupTable() { // 检查关键帧数据 int numframes = (int)this->mframes.size(); if (numframes <= 1) return; // 获取Track关键帧的时长(秒数) float duration = this->GetEndTime() - this->GetStartTime(); // 这段在Github上的代码加了个60的offset, 不太清楚是为了啥, 这里就不加了 unsigned int numSamples = (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 int FastTrack ::frameIndex(float time, bool looping) override { std::vector>& 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 = (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 VectorTrack;
QuaternionTrack mRotation; // typedef Track QuaternionTrack;
VectorTrack mScale;
typedef Track 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用现在的模板表示 templateclass 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 TransformTrack; typedef TTransformTrack FastTransformTrack; // 额外声明了一个全局函数, 由于把TransformTrack改为FastTransformTrack, 其实就是把里面的三个Track都改成FastTrack FastTransformTrack OptimizeTransformTrack(TransformTrack& input); #endif
相关类实现代码如下:
#include "TransformTrack.h" // 防止编译错误做的Template Instantiation template TTransformTrack; template TTransformTrack ; // 一些很普通的接口, mId是TransformTrack对应的joint的id template TTransformTrack ::TTransformTrack() { mId = 0; } template unsigned int TTransformTrack ::GetId() { return mId; } template void TTransformTrack ::SetId(unsigned int id) { mId = id; } template VTRACK& TTransformTrack ::GetPositionTrack() { return mPosition; } template QTRACK& TTransformTrack ::GetRotationTrack() { return mRotation; } template VTRACK& TTransformTrack ::GetScaleTrack() { return mScale; } template bool TTransformTrack ::IsValid() { return mPosition.Size() > 1 || mRotation.Size() > 1 || mScale.Size() > 1; } // 基本没变 template float TTransformTrack ::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 float TTransformTrack ::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 Transform TTransformTrack ::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 (input.GetPositionTrack()); result.GetRotationTrack() = OptimizeTrack (input.GetRotationTrack()); result.GetScaleTrack() = OptimizeTrack (input.GetScaleTrack()); return result; }
修改Clip类以适配
这是原本的Clip类,核心数据既然是TransformTrack数组,那么自然也要进行修改:
// 原本的代码
class Clip
{
protected:
// 本质就是TransformTracks
std::vector mTracks;
...
public:
float Sample(Pose& outPose, float inTime);
TransformTrack& operator[](unsigned int index);
...
}
其实就是TransformTrack改成TTransformTrack模板,我预想的是改成这样:
templateclass Clip { protected: // 本质就是TransformTracks std::vector > mTracks; ... public: float Sample(Pose& outPose, float inTime); TTransformTrack & operator[](unsigned int index); ... }
看了下书里的代码,感觉自己写的还是复杂了:
// 为了兼容TransformTrack和FastTransformTrack,这里使用了模板, TRACK只是个名字而已 templateclass TClip { protected: // 本质就是TransformTracks std::vector
除了函数签名,具体的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 mJoints;
std::vector mParents;
public:
// palette是调色板的意思, 这个函数是为了把Pose数据改成OpenGL支持的数据格式
// 由于OpenGL只接受linear array of matrices, 这里需要把Transform转换成矩阵
// 这个函数会根据Pose的Transform数组, 转化为一个mat4的数组
void GetMatrixPalette(std::vector& out) const;
...
}
具体实现代码如下:
// vectorglobalTrans 转化为mat4数组 void Pose::GetMatrixPalette(std::vector & 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
改变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
比如说声明一个模板函数:
templatevoid foo(T& t);
然后分别是这两种写法,把T都被指定为int:
// 写法一 template <> void foo(int& t); // 写法二 template void foo (int& t);
注意,二者区别在于,第一种写法是模板全特化,这是一行函数声明,还需要函数定义,而第二种不是模板特化,它要求编译器为这个类型生成对应的函数代码,因为C++的模板其实是在你用到它的时候,也就是在cpp里调用它的时候,才会生成相关的代码进行编译,这么写,能够在没有用到对应代码的cpp的情况下,为其生成代码,可以检查其编译情况。
template <> void foo
(int& t); declares a specialization of the template, with potentially different body.
template void foo(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这些的模板,也是一样的:



