Skeletal Animation 理论与实践

今天要学习的是游戏开发中,特别是gameplay开发中非常重要的部分 – 骨骼动画。

首先会学习一些原理,还有动画制作的pipeline,实践方面会包括动画加载,GPU Skinning,animation blend, Addittive animation等等。

Tool: VS2015 + blender 2.78 + OpenGL + SDL2


Keyframe animation 和Skeletal animation

Keyframe定义:在关键的几个时间点定义主体的姿态,称为关键帧,

中间的部分用插值(Linear/Spline)得出

在游戏中,KeyFrame就是指的是一系列Mesh的顶点位置,Mesh的当前位置信息是通过上一个keyframe和下一个keyframe插值而来。在Uncharted4里面,有使用Keyframe animation来做人群动画的。

相对于Keyframe animation ,Skeletal animation的思想是将有动画的物体分为两个部分:用于渲染的Mesh 和 用与运动的骨骼(通常称为skeleton 或者 rig)。Mesh上的顶点和骨骼通常存在着一对多的对应关系,当骨骼发生transform的变化的时候,Mesh上的顶点会根据对应关系得出新的位置。

在游戏引擎架构这本书中,一个很有用的思想就是将skeletal animation视为一种数据压缩技术。选择动画技术的目的,是能够提供最佳压缩而又不会产生不能接受的视觉瑕疵,KeyFrameAnimation中的动画数据当然是相当巨大的,骨骼动画就能提供最佳的压缩,因为每个关节的移动会扩大至多个顶点的移动。

Character Creation Pipeline

在深入技术之前,我们先来了解Character Creation Pipeline。一下一个常规的Character制作的pipeline大概是这样

这些阶段有些是互相依赖的,有些又是可以并行的,每个阶段的详细说明可以参考这里

建模就不说了,和动画相关的主要是后面的三个个部分,第一个部分为Rigging,主要是根据模型还有角色可能要做的动作制作出一套对应的骨骼,这套骨骼包含了一些joints和bones,同时还定义了joints的自由度,约束,还包括了一些IK等等。通常在建模的时候,建模师会将角色摆成一个Binding Pos,也叫T pos,目的是方便做Rig的人,因为这样放joints和bones的时候会更方便。


之所以叫BingPos,是因为是在这个Pos下进行Mesh和骨骼的Binding。

Skinng就是就Mesh的顶点绑定到对应的骨骼的上,当骨骼运动的时候,Mesh会根据绑定的骨骼运动到相应的位置。关于Binding Pos更详细的解释可以参考:What Is a Binding Pose in Character Animation?

在完成Rigging和Skinning之后,动画师就可以去k动画了,也可能通过一些高级手段,比如动作捕捉来制作动画。


Md5格式说明

Md5是ID software所推出的一种动画格式。一个包含动画的md5资源包含了两个文件:

.md5mesh 文件:定义了mesh和材质还有骨骼信息.

.md5anim文件: 定义了一段对应于md5mesh文件的动画.

注意,两个文件中的骨骼信息必须一致。

对于这两个格式的详细ie

一个md5mesh的格式如下

MD5Version <int:version>
commandline <string:commandline>

numJoints <int:numJoints>
numMeshes <int:numMeshes>

joints {
<string:name> <int:parentIndex> ( <vec3:position> ) ( <vec3:orientation> )
...
}

mesh {
shader <string:texture>

numverts <int:numVerts>
vert <int:vertexIndex> ( <vec2:texCoords> ) <int:startWeight> <int:weightCount>
...

numtris <int:numTriangles>
tri <int:triangleIndex> <int:vertIndex0> <int:vertIndex1> <int:vertIndex2>
...

numweights <int:numWeights>
weight <int:weightIndex> <int:jointIndex> <float:weightBias> ( <vec3:weightPosition> )
...

}


有点像obj文件,但是还包含了joint的信息和每个顶点的权重信息。

一个md5anim文件如下

MD5Version <int:version>
commandline <string:commandline>
numFrames <int:numFrames>
numJoints <int:numJoints>
frameRate <int:frameRate>
numAnimatedComponents <int:numAnimatedComponents>
hierarchy {
<string:jointName> <int:parentIndex> <int:flags> <int:startIndex>
...
}
bounds {
( vec3:boundMin ) ( vec3:boundMax )
...
}
baseframe {
( vec3:position ) ( vec3:orientation )
...
}
frame <int:frameNum> {
<float:frameData> ...
}

其中Hierarchy定义了骨骼的父子结构。Bounds定义了每一帧mesh的aabb。Baseframe定义了骨骼的初始位置。Frame定义了每一帧的骨骼信息。

文件的加载可以自己去写paser,这里为了方便起见就直接用assimp来处理。但是assimp加载还是有很多隐晦的默认规则,比如

1) 如果load(”soldier.md5mesh “),默认会把加载一个soldier.md5anim的文件,所以如果要加载多个anim文件,就要手动去指定;

2) 如果shader那一行制定的贴图没有扩展名,则默认为name_d.tga为diffuse贴图。


骨骼和矩阵数学

一个带骨骼的mesh如下图


骨骼可理解为一个坐标空间,关节可理解为骨骼坐标空间的原点。关节的位置由它在父骨骼坐标空间中的位置描述。

一个bone可以定义如下

class Bone
{
	public:
	Bone(const SkeletonNodeData& node);

	string m_name;
	unsigned int m_parentID;
	vector<unsigned int> m_childID;
	glm::mat4 m_transform;
};


骨骼就是坐标空间,骨骼层次就是嵌套的坐标空间。关节只是描述骨骼的位置即骨骼自己的坐标空间原点在其父空间中的位置,绕关节旋转是指骨骼坐标空间(包括所有子空间)自身的旋转。

直接看一下Gpu skinning 的 vertex shader

#version 330 core
//skinningShader.vert

  const int MAX_BONES = 100;

  uniform mat4 modelMatrix;
  uniform mat4 projModelViewMatrix;	// (projection x view x model) matrix
  uniform mat3 normalMatrix;
  uniform mat4 lightProjModelViewMatrix;

  uniform mat4 boneMatrix[MAX_BONES];


  in vec3 in_position;
  in vec3 in_normal;
  in vec2 in_textCoord;

  in ivec4 in_boneIDs;
  in vec4  in_weights;

  out Data
  {
	vec2 textCoord;
	vec3 position;
	vec3 normal;
	vec4 lightVertexPosition; //position of vertex in light space.
  } 
  DataOut; 


void main() 
{            

  mat4 boneTransform = boneMatrix[in_boneIDs[0]] * in_weights[0];
  boneTransform += boneMatrix[in_boneIDs[1]] * in_weights[1];
  boneTransform += boneMatrix[in_boneIDs[2]] * in_weights[2];
  boneTransform += boneMatrix[in_boneIDs[3]] * in_weights[3];
//转换到bone 空间下
  vec4 vertex4 = boneTransform * vec4(in_position, 1.0);  
   
  DataOut.position = vec3(boneTransform * modelMatrix * vertex4);
  DataOut.normal = normalize( mat3(boneTransform) * normalMatrix * in_normal);
  DataOut.textCoord = in_textCoord;
//转换到世界空间下
  DataOut.lightVertexPosition = lightProjModelViewMatrix * vertex4;
  gl_Position = projModelViewMatrix * vertex4;
}


简直超级简单!就是

最终位置 = 原始位置 * 求和(矩阵* 权重)

这个过程就是GPU Skinning。这里注意到GPU Skinning的一个小小的限制,就是顶点的权重数不超过4,这对于一般的游戏来说已经足够了。(或者传两个vec4来处理多于4个权重数的情况)

空间变化应当是:

Model space -> Bone Space ->World Space

Shader中boneMatrix 就是经插值之后得到的matrix矩阵了。

再看下keyframe,每一个keyframe其实就是一堆骨骼的position和rotation的组合,动画中的每一秒都有24个这样的keyframe

class KeyFrame
{
	public:
	KeyFrame(double time,
			 const vector<glm::vec3>& boneTranslation, 
			 const vector<glm::quat>& boneRotation);

	inline double getTime() const { return m_time; }
	inline unsigned int getBoneCount() const { return m_boneTranslation.size(); }

	inline const glm::vec3& getBoneTranslation(unsigned int boneID) const 
	{
		return m_boneTranslation[boneID];
	}

	inline const glm::quat& getBoneRotation(unsigned int boneID) const 
	{
		return m_boneRotation[boneID];
	}


	private:
	double m_time;
	vector<glm::vec3> m_boneTranslation;
	vector<glm::quat> m_boneRotation;
};


在读取的时候MD5anim文件的的时候,每一帧中每个骨骼对应的是六个数字,

( vec3:position ) ( vec3:orientation )

对应的分别是vector3的位置,还有一个四元素表示旋转,其中旋转只给了三个分量,最后一个需要再加载的时候计算出来。

void ComputeQuatW( glm::quat& quat )
{
    float t = 1.0f - ( quat.x * quat.x ) - ( quat.y * quat.y ) - ( quat.z * quat.z );
    if ( t < 0.0f )
    {
        quat.w = 0.0f;
    }
    else
    {
        quat.w = -sqrtf(t);
    }
}


看一下插值计算的过程,注意,这里的计算一般是放在Cpu处理的.为了计算出某个时刻骨骼最终的变换矩阵,我们需要根据当前时间对关键帧之间三个变换数组进行插值,并将这些变换组合成一个矩阵。这些完成之后我们需要在骨骼树种找到对应的骨骼节点并遍历其父节点,之后我们对它的每个父节点都做同样的插值处理,并将这些变换矩阵乘起来即可。对应的函数实现如下

void AnimatedMeshGL::getBoneTransformation(double timeEllasped, const Skeleton& skeleton, AnimationComponent& animComponent, vector<glm::mat4>& finalTransformList)
{
	//获取当前的动画序列
	auto& anim = skeleton.getAnimation(animComponent.m_animationIndex);
	unsigned int keyFrameCount = anim.getKeyFrameCount();
	double ticksPerSecond = anim.getTicksPerSecond() != 0 ? anim.getTicksPerSecond() : 25.0f;
	//总共运行的帧数
	double timeInTicks = timeEllasped * ticksPerSecond;
	//取余操作
	double animationTime = fmod(timeInTicks, anim.getDuration());

	//算出当前动画的上一帧和下一帧,后面用
	for (unsigned int i = animComponent.m_startFrameIndex; i < keyFrameCount - 1; i++)
	{
		if (animationTime < anim.getKeyFrame(i + 1).getTime()) {
			animComponent.m_startFrameIndex = i;
			break;
		}
	}
	animComponent.m_endFrameIndex = (animComponent.m_startFrameIndex + 1) % keyFrameCount;

	//计算要插值的时间点
	double deltaTime = (anim.getKeyFrame(animComponent.m_endFrameIndex).getTime() - anim.getKeyFrame(animComponent.m_startFrameIndex).getTime());
	animComponent.m_remainingTime = (animationTime - anim.getKeyFrame(animComponent.m_startFrameIndex).getTime()) / deltaTime;

	glm::mat4 identity(1.0f);
	transformBone(0, identity, skeleton, animComponent);

	//将计算的结果保存一下
	for (unsigned int i = 0, maxBones = m_boneFinalTransform.size(); i < maxBones; i++)
		finalTransformList[i] = m_boneFinalTransform[i];

	//最后一帧和第一帧重合
	if (animComponent.m_startFrameIndex == keyFrameCount - 2)
		animComponent.m_startFrameIndex = 0;
}


关键函数transformBone

void AnimatedMeshGL::transformBone(unsigned int boneID, const glm::mat4& parentTransform, const Skeleton& skeleton, const AnimationComponent& animComponent)
{
	//从0号bone开始递归计算,初始parentTransform为单位阵
	const Bone& node = skeleton.getBoneNode(boneID);
	//取出上一帧和下一帧的信息
	const Animation& animation = skeleton.getAnimation(animComponent.m_animationIndex);
	const KeyFrame& startKeyframe = animation.getKeyFrame(animComponent.m_startFrameIndex);
	const KeyFrame& endKeyframe = animation.getKeyFrame(animComponent.m_endFrameIndex);
	//取出根骨的offset matrix
	const glm::mat4& inverseTransform = skeleton.getInverseTransform();
	//骨骼的当前位置
	glm::mat4 nodeTransform = node.m_transform;

	unsigned int boneIndex = animation.getBoneIndex(node.m_name);
	if (boneIndex != ~0)
	{
		//m_remainingTime作为插值的参数
		float factor = float(animComponent.m_remainingTime);

		//四元素的slerp对旋转进行插值
		auto rotQuat = glm::slerp(startKeyframe.getBoneRotation(boneIndex),
			endKeyframe.getBoneRotation(boneIndex), factor);
		//Vector3的插值得到位置
		auto posVec = BlendVec3(startKeyframe.getBoneTranslation(boneIndex),
			endKeyframe.getBoneTranslation(boneIndex), factor);

		//组合出boone矩阵
		glm::mat4 translation = glm::translate(glm::mat4(), posVec);
		nodeTransform = translation * glm::toMat4(rotQuat);
	}

	//和父骨骼的矩阵相乘
	glm::mat4 localFinalTransform = parentTransform * nodeTransform;

	//计算最终的transform
	auto iter2 = m_boneNameToIndex.find(node.m_name);
	if (iter2 != m_boneNameToIndex.end())
	{
		//boneOffset是每个骨骼的offetMatrix 后面有说明
		glm::mat4 boneOffset = glm::make_mat4(m_bone[iter2->second].m_offsetMatrix.m_m16);
		m_boneFinalTransform[iter2->second] = inverseTransform * localFinalTransform * boneOffset;
	}

	//递归处理child bone
	for (unsigned int i = 0; i < node.m_childID.size(); i++)
		transformBone(node.m_childID[i], localFinalTransform, skeleton, animComponent);
}


注意其中的插值是分别对position,rotation进行插值。

由于骨骼的 Transform Matrix (作用是将顶点从骨骼空间变换到上层空间)是基于其父骨骼空间的,只有根骨骼的 Transform 是基于世界空间的,所以要通过自下而上一层层 Transform 变换(如果使用行向量右乘矩阵,这个 Transform 的累积过程就是C=Mbone * Mfather * Mgrandpar *... *Mroot ) , 得到该骨骼在世界空间上的变换矩阵 - Combined Transform Matrix ,即通过这个矩阵可将顶点从骨骼空间变换到世界空间。那么这个矩阵的逆矩阵就可以将世界空间中的顶点变换到某块骨骼的骨骼空间。由于 Mesh 实际上就是定义在世界空间了,所以这个逆矩阵就是Bone Offset Matrix 。即Bone OffsetMatrix 就是骨骼在初始位置(没有经过任何动画改变)时将 bone 变换到世界空间的矩阵( CombinedTransformMatrix )的逆矩阵。

从结构上看, skeletal Animation的输入主要有:动画数据,骨骼数据,包含 Skin info 的 Mesh 数据,以及 Bone Offset Matrix 。

从过程上看,载入阶段:载入并建立骨骼层次结构,计算或载入 Bone Offset Matrix ,载入 Mesh 数据和 Skin info (具体的实现 不同的引擎中可能都不一样)。运行阶段:根据时间从动画数据中获取骨骼当前时刻的 Transform Matrix ,调用 UpdateBoneMatrix 计算出各骨骼的 CombinedMatrix ,对于每个顶点根据 Skin info 进行 Skinning 计算出顶点的世界坐标,最终进行模型的渲染。

最终效果(假装在动)


调整播放速度

每个动画片段都有一个局部时间线,里面有动画开始播放的时间,时间缩放比例R。通过调整时间比例R,就可以达到控制动画播放缩率的效果。

具体来说,对于每个Animation Clip都有一个Animation Component用于记录播放的信息

struct AnimationComponent
{
	AnimationComponent(unsigned int animationIndex, unsigned int startFrameIndex);
	unsigned int m_animationIndex;
	unsigned int m_startFrameIndex;
	unsigned int m_endFrameIndex;
	double m_StartTimeMark;
	double m_PlaySpeed;
	double m_remainingTime;
};


再采样动画的时候,将全局的时间映射到局部的时间

double ticksPerSecond = anim.getTicksPerSecond() != 0 ? anim.getTicksPerSecond() : 25.0f;
double timeInTicks = timeEllasped * ticksPerSecond* animComponent.m_PlaySpeed;


以0.25倍的速度播放相同的动画效果如下

Animation blending

动画混合是把两个或更多的输入姿势结合,产生骨骼的输出姿势,这样就可以再不添加新动画的情况下产生一些新的动画,所需要付出的只是CPU上一些消耗。原理就是插值。对于两个输入姿势Pa和Pb的情况,最终姿势

P = (1-b)Pa + bPb

其中b为混合百分比,取值为(0,1).这里所说的姿势插值主要是一个4*4的矩阵进行插值,矩阵当然不能直接插值,所以要对位移和旋转分别进行插值(有些引擎还会有scale插值),位置的插值用的Vector3::Lerp,旋转就用四元素的SLerp。

下面以两个Animation blending简单的应用来实践一下。

Animation Crossfade

动画的淡入淡出通常会应用在两个动画的切换,

关键函数贴一下

void AnimatedMeshGL::transformBoneBlend(unsigned int boneID, const glm::mat4& parentTransform, const Skeleton& skeleton, const AnimationComponent& animComponent1, const AnimationComponent& animComponent2, float blendFactor)
{
	const Bone& node = skeleton.getBoneNode(boneID);

	const Animation& animation1 = skeleton.getAnimation(animComponent1.m_animationIndex);
	const KeyFrame& startKeyframe1 = animation1.getKeyFrame(animComponent1.m_startFrameIndex);
	const KeyFrame& endKeyframe1 = animation1.getKeyFrame(animComponent1.m_endFrameIndex);

	const Animation& animation2 = skeleton.getAnimation(animComponent2.m_animationIndex);
	const KeyFrame& startKeyframe2 = animation2.getKeyFrame(animComponent2.m_startFrameIndex);
	const KeyFrame& endKeyframe2 = animation2.getKeyFrame(animComponent2.m_endFrameIndex);

	const glm::mat4& inverseTransform = skeleton.getInverseTransform();
	glm::mat4 nodeTransform = node.m_transform;

	unsigned int boneIndex = animation1.getBoneIndex(node.m_name);
	if (boneIndex != ~0)
	{
		float factor1 = float(animComponent1.m_remainingTime);

		auto rotQuat1 = glm::slerp(startKeyframe1.getBoneRotation(boneIndex),
			endKeyframe1.getBoneRotation(boneIndex), factor1);

		auto posVec1 = BlendVec3(startKeyframe1.getBoneTranslation(boneIndex),
			endKeyframe1.getBoneTranslation(boneIndex), factor1);

		float factor2 = float(animComponent2.m_remainingTime);
		auto rotQuat2 = glm::slerp(startKeyframe2.getBoneRotation(boneIndex),
			endKeyframe2.getBoneRotation(boneIndex), factor2);

		auto posVec2 = BlendVec3(startKeyframe2.getBoneTranslation(boneIndex),
			endKeyframe2.getBoneTranslation(boneIndex), factor2);

		auto posVec = BlendVec3(posVec1, posVec2, blendFactor);
		auto rotQuat = glm::slerp(rotQuat1, rotQuat2, blendFactor);

		glm::mat4 translation = glm::translate(glm::mat4(), posVec);
		nodeTransform = translation * glm::toMat4(rotQuat);
	}

	glm::mat4 localFinalTransform = parentTransform * nodeTransform;

	auto iter2 = m_boneNameToIndex.find(node.m_name);
	if (iter2 != m_boneNameToIndex.end())
	{
		glm::mat4 boneOffset = glm::make_mat4(m_bone[iter2->second].m_offsetMatrix.m_m16);
		m_boneFinalTransform[iter2->second] = inverseTransform * localFinalTransform * boneOffset;
	}

	for (unsigned int i = 0; i < node.m_childID.size(); i++)
		transformBoneBlend(node.m_childID[i], localFinalTransform, skeleton, animComponent1, animComponent2, blendFactor);
}

相比于之前单个动画播放的函数只是再对应的地方添加了插值处理。看一下结果。

现在要实现行走到下蹲的blend,再没有blend处理的情况下,效果是这样的

可以看在动画切换的时候,右脚被直接掰回来了,显得很不自然。加了blend的动作会通过插值生成中间的动画,比如下面的第二帧图片

在第三人称角色控制中经常会遇到的一个问题就是走路和跑步的动作blend,假设走路的动画是一个3s的循环,跑步的是一个2s的循环,那么他们两个之间的blend就没那么简单了,需要考虑的问题有1)走路的过程中需要随时可以切换到run的动画 2)blend的时候,要保证出的脚是一致的。要做到这两点就要用到归一化时间的概念。

对于一个Animation Clip,无论它的时常T是多长,u = 0代表动画开始,u=1代表动画结束。在blend的时候,将walk的归一化时间和run的归一化时间匹配上,就可以做到完美的匹配。


Additive animation

首先看下定义,对于两个输入片段S(SourceClip)和参考片段R(ReferenceClip),可以通过减法得到区别片段D(DifferenceClip),有D = S-R.

得出差别动画之后,就可以将D按一定百分比混合到任意的不相干的动画片段上,而不仅限于原来的参考片段。比如参考片段是角色征程跑步,而来源片段是疲惫下跑步,那么区别片段只含有角色在疲惫的动画,若将此片段应用至步行,结果会是一个疲惫下步行的结果。

下图是神海2中将两个动画通过Additive blend 的方式生成新的Idle动画。

在blend中随便制做个前俯后仰的动画,

这里我们用一个最简单的方法来处理S和R – 导出的动画就是S,R就是动画的第一帧。

Unity貌似也是这样的处理方法

Additive animations in Unity are always relative to the first frame of the animation. This means that you will sometimes need to use more animations, or do a little more scripting than you would otherwise have done, but you should always be able to obtain the same results in the end as if you could have specified any frame as the reference.

实现上其实非常简单,

float factor1 = float(currentComponent.m_remainingTime);

auto rotQuat1 = glm::slerp(startKeyframe1.getBoneRotation(boneIndex),
	endKeyframe1.getBoneRotation(boneIndex), factor1);

auto posVec1 = BlendVec3(startKeyframe1.getBoneTranslation(boneIndex),
	endKeyframe1.getBoneTranslation(boneIndex), factor1);

glm::vec3 sourceVec = sourceFrame.getBoneTranslation(boneIndex);
glm::quat sourceRot = sourceFrame.getBoneRotation(boneIndex);

float factor2 = float(addComponent.m_remainingTime);
glm::quat rotQuat2 = glm::slerp(startKeyframe2.getBoneRotation(boneIndex),
	endKeyframe2.getBoneRotation(boneIndex), factor2);

auto posVec2 = BlendVec3(startKeyframe2.getBoneTranslation(boneIndex),
	endKeyframe2.getBoneTranslation(boneIndex), factor2);

glm::quat rotDiff = rotQuat2 * glm::inverse(sourceRot);
glm::vec3 posDiff = posVec2 - sourceVec;
auto rotQuat = rotQuat1* rotDiff;
auto posVec = posVec1 + posDiff;

效果

同样还有使用additive animation的应用有tps游戏里的瞄准混合等等。

和additive blend类似有一个混合方式是Partial blend,指的是身体的不同部位通过mask标记播放不同的动画,比如各种情况下的挥手动作。Unity中对应的是Animation 中 Layermask 的使用。Partial blend 有两个比较明显的缺点:

1)两个部分的动画因为混合因子改变剧烈(0,1)动画会看上去很突兀。

2)现实中人体的动作并不是完全独立的,即晃动手臂的同时,其他的骨骼也会有相应的动画。

所以通常会混合使用多种blend来处理角色动画。

下面是uncharted2中是动画层

小结

动画作为GamePlay的基石,在国内的游戏开发中通常得不到太大的重视,然后在3A游戏的制作中,通常都会有一个专门的动画团队,甚至有专门负责动画的TA,所以如果想把gameplay这块的东西做好,细节做到位,建议还是认真了解下动画的原理,制作的pipeline。

写这篇东西花了很长的时间,一方面是最近事情比较多,另一方面,动画这块的东西实在非常之多,上面所写的内容只是动画里面非常小的一些东西,很多内容,比如数据压缩,Procedual Animation 都没有提及,对于想深入了解引擎内部Animation实现的同学,非常建议认真读一下Game Engine Architecture 中关于骨骼动画的内容,书的作者就是做动画系统出身的。最后放个彩蛋


参考

Fast Skinning March 21st 2005 J.M.P. van Waveren ? 2005, Id Software, Inc.ipeLibne

Fast Skinning March 21st 2005 J.M.P. van Waveren ? 2005, Id Software, Inc.ipeLibne

Game Engine Architecture

Animations in lwjgl \\

MD5模型的格式、导入与顶点蒙皮式骨骼动画II \\

zwqxin.com/archives/opeGraphics for Games \\

MAKING DOOM 3 MODS : THE CODE \\

Doom md5 Model Loader \\

Loading and Animating MD5 Models with OpenGL

Keyframe Animation

Skeletal AnimationWhat is Rigging?Optimizing the Rendering Pipeline of Animated Models Using the Intel Streaming SIMD Extensions

open source project

julienr/scalamd5
编辑于 2017-05-25

文章被以下专栏收录