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这些的模板,也是一样的:
在这里插入图片描述

posted @ 2022-12-03 13:07  弹吉他的小刘鸭  阅读(44)  评论(0编辑  收藏  举报