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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Program 3D Games in C++: The #1 Language at Top Game Studios Worldwide C++ remains the key language at many leading game development studios. Since it’s used throughout their enormous code bases, studios use it to maintain and improve their games, and look for it constantly when hiring new developers. Game Programming in C++ is a practical, hands-on approach to programming 3D video games in C++. Modeled on Sanjay Madhav’s game programming courses at USC, it’s fun, easy, practical, hands-on, and complete. Step by step, you’ll learn to use C++ in all facets of real-world game programming, including 2D and 3D graphics, physics, AI, audio, user interfaces, and much more. You’ll hone real-world skills through practical exercises, and deepen your expertise through start-to-finish projects that grow in complexity as you build your skills. Throughout, Madhav pays special attention to demystifying the math that all professional game developers need to know. Set up your C++ development tools quickly, and get started Implement basic 2D graphics, game updates, vectors, and game physics Build more intelligent games with widely used AI algorithms Implement 3D graphics with OpenGL, shaders, matrices, and transformations Integrate and mix audio, including 3D positional audio Detect collisions of objects in a 3D environment Efficiently respond to player input Build user interfaces, including Head-Up Displays (HUDs) Improve graphics quality with anisotropic filtering and deferred shading Load and save levels and binary game data Whether you’re a working developer or a student with prior knowledge of C++ and data structures, Game Programming in C++ will prepare you to solve real problems with C++ in roles throughout the game development lifecycle. You’ll master the language that top studios are hiring for—and that’s a proven route to success. Table of Contents Chapter 1: Game Programming Overview Chapter 2: Game Objects and 2D Graphics Chapter 3: Vectors and Basic Physics Chapter 4: A

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值