游戏引擎原理与实践 卷2 高级技术

978-7-115-56040-7
作者: 程东哲
译者:
编辑: 谢晓芳

图书目录:

详情

本书共14章,主要讲解游戏引擎中的动画、渲染、多线程等高级技术。书中的主要内容包括骨骼蒙皮模型与动画基础,动画播放和插槽,动画混合,变形动画混合,逆向动力学(IK)与角色,光照渲染的发展史,渲染器接口,材质,流程渲染架构,光照与材质,后期效果,阴影,多线程,动态缓冲区和性能分析器。 本书适合游戏开发人员阅读。

图书摘要

版权信息

书名:游戏引擎原理与实践 卷2:高级技术

ISBN:978-7-115-56040-7

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。


著    程东哲

责任编辑 谢晓芳

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


本书共14章,主要讲解游戏引擎中的动画、渲染、多线程等高级技术。书中的主要内容包括骨骼蒙皮模型与动画基础,动画播放和插槽,动画混合,变形动画混合,逆向动力学(IK)与角色,光照渲染的发展史,渲染器接口,材质,流程渲染架构,光照与材质,后期效果,阴影,多线程,动态缓冲区和性能分析器。

本书适合游戏开发人员阅读。


游戏引擎技术在国外的发展十分迅速,根本原因在于国外的从业者有很扎实的基础,每一代游戏引擎都是根据游戏迭代而来的。国内游戏行业起步相对较晚,再加上从业者有时急于求成,游戏引擎方面的人才积累远远不如国外,因此国内的自研游戏引擎企业寥寥无几。因为学习游戏引擎开发的门槛很高,所以很多人觉得游戏引擎开发遥不可及。

本书主要讲解游戏引擎的开发,通过详细的代码示例来剖析游戏引擎内部的技术。书中有些内容基于目前市面上比较成熟的解决方案,有些内容基于作者优化并改进的新的架构,这些架构对读者开发游戏引擎有很大帮助。如果读者想使用商业游戏引擎,那么本书也会有很多帮助。

通过本书,读者可以清晰地了解企业开发游戏引擎的基本思路。一个好的游戏引擎应该做什么、不应该做什么,本书都会详细介绍。企业以高效地开发游戏引擎为最终目标。以渲染为例,本书强调灵活并且开发效率高的渲染接口以及工具比渲染效果更重要,这一观念会改变很多游戏引擎开发爱好者的传统观念。

本书中的很多概念来自Unreal Engine,但本书不会基于 Unreal Engine讲解。本书配套的代码只有不到10%的地方用了Unreal Engine,大部分内容是通过其他的架构来实现的。这套书分为卷1和卷2。卷1介绍的是游戏引擎的基础架构,卷2着重讲解游戏引擎中的动画、渲染、多线程等高级技术。读者在深入阅读本书时,就会发现卷1中的基础知识给可见的高级效果提供了良好的保障。卷2旨在讲述游戏制作流程和可扩展性,对于游戏中的某个效果,例如高动态范围(High Dynamic Range,HDR)、屏幕空间环境遮挡(Screen Space Ambient Occlusion,SSAO)等,卷2并没有详细介绍,因为市面上已经有很多专门讲解这些内容的图书,而游戏引擎的重点是如何能够很好地集成这些效果。

本书配套的自研游戏引擎叫作VSEngine,作者在实际工作中的一些技术思路和尝试都以它为基础。这个游戏引擎先后重构过多次。

本书提供了大量示例。其中,标题带*号的节都没有给出示例具体的实现方式,只介绍了详细做法。读者如果想真正掌握本书的知识,就应该自己去实现它们。同时,部分章的末尾有一些练习。有一些是作者准备实现但没有实现的;有一些练习很难,即使是经验丰富的游戏引擎开发者,也需要认真思考一番。

本书共14章。本书主要内容如下。

第1章介绍VSEngine游戏引擎中的骨骼蒙皮模型和动画的架构。

第2章介绍如何播放动画,并讲解如何使用插槽(socket)让物体跟随动画运动。

第3章介绍如何采用动画树(animation tree)实现各种动画效果。

第4章介绍采用变形动画树(morph animation tree)实现各种变形动画。

第5章首先讲解逆向动力学(Inverse Kinematics,IK),然后介绍角色换装、角色高矮胖瘦变换等机制。

第6章简单介绍前向渲染(forward shading)、延迟渲染(deferred shading)、延迟光照(deferred lighting)、基于块的延迟渲染和前向增强渲染。

第 7 章介绍渲染器接口,游戏引擎层通过一套接口可以兼容多种底层渲染应用程序接口(Application Programming Interface,API)。

第8章主要讲解材质与着色器(shader)的关系以及如何实现材质树(material tree)。

第 9 章介绍场景(scene)里面的模型如何通过特定的渲染方式呈现在指定的渲染目标(render target)上。

第10章介绍光照和材质在VSEngine游戏引擎中是如何集成的。

第 11 章介绍如何通过一种链式结构把后期效果集成到VSEngine游戏引擎的渲染流程中。

第12章介绍常用阴影方法在VSEngine游戏引擎中是如何集成的。

第13章介绍多线程更新、多线程渲染、纹理流式加载、着色器缓存、编辑器资源热更新等。

第14章首先介绍如何利用动态缓冲区(dynamic buffer),然后讨论如何解决游戏引擎开发中的性能瓶颈。

不同游戏引擎的内部架构千差万别,而且游戏引擎涉及的知识点很多,很少有人能全面把握每一个知识点。由于游戏引擎属于实践性的工程,因此游戏引擎图书必须有足够令人信服的演示示例以及代码支持。本书旨在介绍一个商业游戏引擎应该具备什么,以及应该如何开发商业游戏引擎。

目前国内有游戏引擎研发能力的公司很少,本书揭开了游戏引擎神秘的面纱。读者只要详细阅读本书并了解每个知识点,就能够快速地提升游戏引擎开发能力。通过阅读本书,读者可以更好地理解游戏引擎机制,在使用相应的游戏引擎的时候更加得心应手,同时还将具备修改商业游戏引擎的能力。

本书要求读者熟练掌握C++、数据结构、基本3D知识、常用的设计模式,并具备多线程的基础知识。如果读者有一定的3D游戏开发经验,或者想要尝试3D游戏引擎开发,那么本书值得阅读。

本书会详细讲解大部分比较难的知识点。而对和游戏引擎开发相关性较低的一些基础知识,本书会列出相关的图书和资源,推荐读者去阅读。

通过阅读和学习本书,读者能够:

要下载本书配套的资源,请在GitHub网站上搜索“79134054/VSEngine2”。

感谢我的妻子对我写作本书的支持。为了写作本书,我牺牲了很多陪伴家人的时间,没有他们的理解和支持,我不可能完成本书的写作。感谢人民邮电出版社的陈冀康编辑,本书是在他的一再推动和鼓励下完成的。

感谢叶劲峰、沙鹰、王杨军、王琛、付强等人对本书的大力推荐,能够得到他们的肯定,我感到万分荣幸。

感谢唐强、周秦、Houwb在百忙之中抽出时间帮助我校验本书并修改本书配套代码,没有他们的努力,本书是无法顺利出版的。感谢王学强对本书第6章提供的技术支持。

感谢本书的所有读者。选择了本书,意味着您对我的支持和信任。由于水平有限,书中难免存在一些不足之处,还望您在阅读过程中不吝指出。您可以通过79134054@qq.com联系我。


本书由异步社区出品,社区(https://www.epubit.com/)为您提供后续服务。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,单击“提交勘误”,输入勘误信息,单击“提交”按钮即可,如下图所示。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区投稿(直接访问www.epubit.com/ contribute即可)。

如果您所在的学校、培训机构或企业想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接通过邮件发送给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、人工智能、测试、前端、网络技术等。

异步社区

微信服务号


动画系统是游戏引擎的重要组成部分之一,而且动画控制和游戏逻辑控制的关系十分紧密,游戏中的动画质量决定游戏细节和操作手感。对于动作类游戏来说,动作手感对游戏品质的影响往往超过渲染。

20世纪末《反恐精英》(Counter-Strike,CS)游戏曾风靡一时,如图1.1所示。喜欢玩第一人称射击(First Person Shooting,FPS)类游戏的读者对此绝对不会感到陌生。在这款游戏中,人物的行走分成两种:一种是普通模式的行走,走的时候会有声音;另一种是按住Shift键缓慢行走,走的时候没有声音。在这两种行走方式中,拿着不同武器的人物行走的速度也不一样。如今再看这款游戏,我们还是会被它惊艳的效果折服。对于这款游戏里任何情况下人物的行走方式,玩家都不会觉得怪异,因为人物移动和动画播放的速度相当匹配,会让玩家感觉是“一个正常的人在行走”,同时也给人一种很厚重的感觉。而在国内某些FPS类游戏中,人物移动的时候给人的感觉很“飘”,好像所有角色都在“滑步”一样。出现这种问题的根本原因是动画控制和游戏逻辑控制没有匹配,只有3个方面——动画师制作的动画、游戏引擎中动画的融合控制与游戏逻辑中对动画的驱动高度契合,人物移动才能达到视觉上的协调。

图1.1 CS游戏

上面提到的这一点看似对FPS类游戏的核心玩法无关紧要,但没有一定技术积累的团队是很难做好的。好的游戏要注重各方面的细节,很多成功的小细节的累积会使游戏质量发生飞跃。

在国内,随着自研游戏引擎的没落,商业游戏引擎的使用已经非常普遍,蒙皮模型和动画实现细节被封装到了商业游戏引擎的“黑盒”里。因此问题一旦出现,便让人无从下手。本节将会详细介绍蒙皮模型与动画原理的相关知识点,再配合相关代码,让读者一个更全面的认识。

在游戏引擎中,骨骼可以表示成一个4×4矩阵,由缩放(scale)、旋转(rotation)和平移(translation)分量组成,这本质上和卷1中介绍的空间位置没什么区别,所以它具有空间节点的任何性质。但这种“节点”存在蒙皮的概念,它和我们生活中认识的骨骼类似,所以起名为“骨骼”。需要注意的是,我们在现实中所说的骨骼是一节一节的,有长度,大小可见,而游戏引擎中的骨骼是虚拟的,只是作用和真正的骨骼类似。

图1.2所示为左手及左手臂骨架,短线段代表每个骨骼节点的轴向,这个轴向表示的是模型在所在世界空间(world space,参见卷1)下的轴向,轴向的原点就是骨骼所在的位置。

图1.2 左手及左手臂骨架

骨骼的父子层级关系和卷1中介绍的空间位置关系是一样的,所以每根骨骼都有自己的本地空间(local space,参见卷1)位置和世界空间位置。整个层级结构称为骨架,如图1.3所示。

图1.3 左手及左手臂的层级结构

一个骨架默认带有所有骨骼的空间位置,每根骨骼的空间位置存放的都是相对其父骨骼的信息,然后通过一层层的空间变换就可以变换到根骨(root bone)所在的空间。

蒙皮的原理类似于人体的皮肤附着在骨头上,皮肤随着骨骼运动。本质是模型顶点依附于骨骼所在空间,骨骼一旦运动,就会带动模型顶点运动。

模型师在做完模型后,会把模型交给动画师。这个时候模型是没有任何骨骼的,所有模型的顶点数据都在模型空间下。然后,动画师根据模型的形态创建骨架,分配骨骼节点。

不过,这时的骨架和模型并没有绑定,还需要把模型顶点绑定到对应的骨骼上,这样骨骼运动时才会带着模型顶点运动,这个绑定过程称为蒙皮。在一般情况下,模型顶点绑定到对应的骨骼后是完全跟随这根骨骼运动的(如图1.4所示),不过有些靠近骨骼原点的模型顶点,除了受当前骨骼的影响外,还受到其父骨骼的影响,如人体关节处的皮肤。

图1.4所示矩形区域表示模型顶点,它受到两个骨骼节点的影响。读者可以自己动动肘部关节,因为肘部的皮肤是受小臂关节和上臂影响的,所以在转动肘部的时候皮肤会被拉扯。

图1.4 顶点受骨骼的控制

动画师在绑定模型顶点的时候,在关节处按照每根骨骼对模型顶点影响的大小为模型顶点分配权重。这个过程必须时刻绷紧神经,防止某个模型顶点被遗漏。

现在游戏中所有的动画技术基本上是关键帧技术,通过关键帧技术把所有的关键帧插值按时间顺序来播放就形成了动画。动画数据是多个关键帧按时间排列的集合,每个关键帧除有对应的时间以外,还有对应的数据。对于3D骨骼动画来说,数据就是每根骨骼的缩放、旋转和平移分量。播放骨骼的动画实际就是计算当前时刻这根骨骼的空间位置。

举一个例子,在没有任何压缩的情况下,如果有一个包含帧、根骨骼的动画,那么它包含的数据如图1.5所示。

图1.5 动画数据存放

那么这些数据位于哪个空间下呢?作者曾在面试中多次向面试者提到这个问题,但很多面试者无法准确回答。大部分的程序员在使用游戏引擎时,依靠一个函数、一个动画名就可以播放动画师导出的动画文件,至于数据究竟是什么样的,很少有人关心。

这些数据在其对应的父骨骼的空间下,而非模型空间下,和默认骨架下每根骨骼的空间位置数据所在空间是一样的。

结合上面提到的知识点,下面介绍一下模型顶点跟随骨骼运动的原理。一个模型顶点受到根骨骼的影响,权重分别为Weight1~Weight。顶点默认在模型空间下,先要把变换到受其控制的骨骼空间下,这个变换的空间位置矩阵称为蒙皮矩阵。

一般情况下,模型空间和根骨所在的空间是同一个空间,并且美术师在进行3ds Max蒙皮的时候模型和骨架都是对应的。下式用于计算将顶点变换到Bone_i所在的空间下的矩阵Bone_i_SkinMatrix

Bone_i_SkinMatrix = RootBoneInverseMatrix *…* Bone_i_Parent Parent 
    InverseMatrix *Bone_i_Parent InverseMatrix * Bone_i_InverseMatrix

计算出V * Bone_i_SkinMatrix之后,就变换到了Bone_i空间下,再通过V * Bone_i_SkinMatrix * Bone_i_Matrix * Bone_i_Parent Matrix * Bone_i_

ParentParentMatrix ** RootBoneMatrix把这个过程还原回去。

这样顶点又还原到对应的模型空间下。

这里要做一个规定:整个骨架只有一个根(root)节点,也就是一根根骨。这也是美术界约定俗成的。因此,任何骨骼的根节点必定只有一个,这为制作根骨动画提供了很好的保证。

 

注意:

一般骨架和模型是对齐的,不过有时美术师添加其他控制器可能会导致骨架和模型未必对齐,上面的计算过程没有考虑骨架和模型之间的偏差,这样计算出的模型和骨架是分离的。如果只渲染模型,不渲染骨架,可能没什么问题。但如果要考虑一些挂点和物理碰撞,就不可行了。模型和骨架的偏移位置在3ds Max导出插件中也是可以获取的。

 

如果要加入动画,那么将动画里每根骨骼的空间位置直接替换为对应骨骼的空间位置即可。

那么,实际上,顶点最后的位置为Sum(V * Bone_i_SkinMatrix * Bone_i_Matrix * Bone_i_Parent Matrix * Bone_i_Parent Parent Matrix ** RootBone Matrix * Weight_i)

举个简单的例子,现在有一个长度为的动画,共帧,顶点受到3根骨骼影响,骨骼分别为,权重分别为,其中 + + = 100%。

现在播放时刻刚好是,根据时间找到对应的前后两帧,如图1.6所示。

图1.6 动画插值

根据如下公式,计算插值因子。

k = (t – time_f) / (time_f+1 – time_f)

根据以下公式,计算出所有骨骼当前时间下的空间位置。

Bone_i_scale = Bone_scale_f + 
     k *(Bone_scale_f+1 – Bone_scale_f)
Bone_i_translation = Bone_translation_f + 
     k *(Bone_translation_i+f– A_translation_f)
Bone_i_roator = Bone_roator_f + 
     k *(Bone_roator_f+1 – Bone_roator_f)
Bone_i_Transform  = Bone_i_scale * Bone_i_roator  
     + Bone_i_translation
NewV = V * Bone_A_SkinMatrix * Bone_A_Transform * 
Bone_A_Parent_Transform * Bone_A_ParentParent_Transform 
*…* RootBone_Transform * a + 
  V * Bone_B_SkinMatrix * Bone_B_Transform * 
Bone_A_Parent_Transform * Bone_B_ParentParent_Transform 
*…* RootBone_Transform * b + 
  V * Bone_B_SkinMatrix * Bone_C_Transform * 
Bone_C_Parent_Transform * Bone_C_ParentParent_Transform 
*…* RootBone_Transform * c

本节介绍的是骨骼蒙皮模型以及动画基本的原理,后文将要介绍的模型导出、播放动画及动画混合是以这些原理为基础的。

图1.7所示是蒙皮模型类VSSkeletonMeshNode类和VSSkeletonMeshComponent类的结构,它们与静态模型结构(卷1中曾介绍)的继承方式类似。

图1.7 VSSkeletonMeshNodeVSSkeletonMeshComponent的结构

图1.8所示是骨骼类(VSBoneNode类)和骨架类(VSSkeleton类)的关系。骨骼可以有自己的子骨骼,所以VSBoneNode类自然而然就继承自VSNode(参见卷1)。规定VSSkeleton类的子节点为根骨,默认是可以支持多根根骨的,但为了方便制作根骨动画,所以规定仅有一根根骨,也就是只有一个子节点。

图1.8 VSBoneNode类与VSSkeleton类的关系

图1.9所示是蒙皮模型的架构,VSSkeletonMeshComponent类VSMeshNode(参见卷1)指向VSSkeletonMeshNode类,VSSkeletonMeshNode类中包含模型数据和骨架信息。

图1.9 蒙皮模型的架构

VSSkeletonMeshNode类是实际渲染的蒙皮模型,也可以保存成资源,代码如下。

class VSGRAPHIC_API VSSkeletonMeshNode : public VSModelMeshNode
{
protected:
    //骨架
    VSSkeletonPtr m_pSkeleton;
public:
     void SetSkeleton(VSSkeleton *pSkeleton);
     inline VSSkeleton *GetSkeleton()const
     {
          return m_pSkeleton;
     }
protected:
     //默认骨骼模型
     static VSPointer<VSSkeletonMeshNode> Default;
public:
     static const VSSkeletonMeshNode *GetDefault()
     {
          return Default;
     }
};

static VSPointer<VSSkeletonMeshNode> Default是默认的蒙皮模型,当创建VSSkeletonMeshComponent类而没有指定资源时就用这个默认的模型。

VSSkeletonMeshComponent类包含VSSkeletonMeshNode类的实例对象,代码如下。

class VSGRAPHIC_API VSSkeletonMeshComponent : public VSMeshComponent
{
public:
     VSSkeletonMeshComponent();
     virtual ~VSSkeletonMeshComponent();
     void SetSkeletonMeshResource(VSSkeletonMeshNodeR *pSkeletonMeshResource);
     VSSkeletonMeshNode *GetSkeletonMeshNode();
     virtual void LoadedEvent(VSResourceProxyBase *pResourceProxy);
     virtual void PostCreate();
protected:
     VSSkeletonMeshNodeRPtr m_pSkeletonMeshResource;
};

VSSkeletonMeshComponent类和卷1中的VSStaticMeshComponent类差别不大,这里不再过多介绍。

VSSkeleton类里面包含了所有骨骼,分别用数组和树形方式存储,代码如下。

class VSGRAPHIC_API VSSkeleton : public VSNode
{
protected:
     //除了childNode存放所有骨骼以外,这个数组也存放了所有骨骼,目的是方便查找某一根骨骼
     VSArray<VSBoneNode *> m_pBoneArray;
public:
     //获得骨骼数量
     unsigned int GetBoneNum()const;
     //获得对应的骨骼
     VSBoneNode *GetBoneNode(const VSUsedName & Name)const;
     //根据m_pBoneArray的下标获得骨骼
     VSBoneNode *GetBoneNode(unsigned int i)const;
     //根据骨骼名字获得m_pBoneArray的下标
     int GetBoneIndex(const VSUsedName &Name)const;
};

m_pBoneArray以数组形式存储所有骨骼;VSSkeleton类是从VSNode类继承的,它只有一个直接子节点(称为根骨),根骨和根骨的所有子节点按树形方式存储了所有骨骼。

VSBoneNode类继承自VSNode类,所以它也是树形结构,代码如下。

class VSGRAPHIC_API VSBoneNode : public VSNode
{
public:
     //骨骼名字
     VSUsedName m_cName;
     //蒙皮矩阵
     VSMatrix3X3W m_OffSetMatrix;
public:
     inline const VSMatrix3X3W & GetBoneOffsetMatrix()const
     {
          return m_OffSetMatrix;
     }
     //获得对应名字的子骨骼
     VSBoneNode *GetBoneNodeFromLevel(const VSUsedName & BoneName);
     //一共有多少根子骨骼
     unsigned int GetAllBoneNum()const;
     //获得所有子骨骼
     void GetAllBoneArray(VSArray<VSBoneNode *> & AllNodeArray);
};

如果不是根骨,那么它的父节点就是它的父骨骼;否则,它的父节点是VSSkeleton类。

现在又回到了VSFBXConverter类,其基本架构在卷1中已经介绍过,这里不再重复。本节的重点在于把FBX蒙皮模型导入形成游戏引擎格式文件。“-d”为导出骨骼模型的命令行参数。

首先,要判断是否导出带骨骼的蒙皮模型,代码如下。

else if (m_CurExportType == ET_SKELETON_MESH)
{
     m_pNode = VS_NEW VSSkeletonMeshNode();
     m_pSkeleton = VS_NEW VSSkeleton();
     //获得骨架
     GetSkeleton(m_pFbxScene->GetRootNode());
     m_pSkeleton->SetLocalScale(VSVector3(1.0f,1.0f,-1.0f));
     m_pSkeleton->CreateBoneArray();
     m_pGeoNode = VS_NEW VSGeometryNode();
     m_pNode->AddChild(m_pGeoNode);
     //获得蒙皮模型
     if (GetMeshNode(m_pFbxScene->GetRootNode()) == false)
     {
          bIsError = true;
     }
}

导出蒙皮模型和导出静态模型过程差不多,唯一的区别是要导出骨架和蒙皮信息。先看看获得骨架信息的函数GetSkeletonGetSkeleton函数比较简单,是一个递归函数。FBX中存放骨骼信息的结构与游戏引擎里的类似,代码如下。

void VSFBXConverter::GetSkeleton(FbxNode* pNode,VSBoneNode * pParentBoneNode)
{
    VSBoneNodePtr pBoneNode = NULL;
    if(pNode->GetNodeAttribute())  
    {  
         switch(pNode->GetNodeAttribute()->GetAttributeType())  
         {  
              case FbxNodeAttribute::eSkeleton:
              {
                  //创建骨骼
                  pBoneNode = VS_NEW VSBoneNode();
                  //获得当前骨骼的空间位置信息
                  FbxTimeSpan timeSpan;
                  m_pFbxScene->GetGlobalSettings().
                           GetTimelineDefaultTimeSpan(timeSpan);
                  FbxTime start = timeSpan.GetStart();
                  FbxTime end = timeSpan.GetStop();
                  pBoneNode->m_cName = pNode->GetName();
                  FbxAMatrix Combine = pNode->EvaluateLocalTransform(start);
                  VSMatrix3X3W VSMat;
                  //把3ds Max坐标系转换成游戏引擎坐标系
                  MaxMatToVSMat(Combine,VSMat);
                  pBoneNode->SetLocalMat(VSMat);
                  if(pParentBoneNode)
                  {
                      pParentBoneNode->AddChild(pBoneNode);
                  }
                  else
                  {
                      m_pSkeleton->AddChild(pBoneNode);
                  }
              } 
              break;  
         }  
    }  
    for(int i = 0 ; i < pNode->GetChildCount() ; ++i)  
    {  
         GetSkeleton(pNode->GetChild(i),pBoneNode);  
    } 
}

把3ds Max坐标系转换成游戏引擎坐标系的原因在卷1中已经讲过,此处不再重复。唯一要解释的是获取骨骼的空间位置信息。这里直接获取了动画的第1帧信息,EvaluateLocalTransform函数获取骨骼相对于其父节点的空间位置(就是前文所说的骨骼的动画数据都是相对于它的父骨骼的原因),我们可以指定时间作为参数。

GetMeshNode函数的作用是导出蒙皮模型,代码如下。

bool VSFBXConverter::GetMeshNode(FbxNode* pNode)  
{  
     if(pNode->GetNodeAttribute())  
     {  
          switch(pNode->GetNodeAttribute()->GetAttributeType())  
          {  
          case FbxNodeAttribute::eMesh :
              VSOutPutDebugString("Process Fbx Mesh Node\n");
              printf("Process Fbx Mesh Node\n");
              if (ProcessMesh(pNode) == false)
              {
                   return false;
              }
              break;  
          }  
     }  
     for(int i = 0 ; i < pNode->GetChildCount() ; ++i)  
     {  
          if (GetMeshNode(pNode->GetChild(i)) == false)
          {
               return false;
          }
     }  
     return true;
}

3ds Max的结构是树形的,几何(geometry)数据信息作为一个子节点挂在树上,它和游戏引擎里面的VSMeshNode类其实差不多。递归遍历整个树形结构,如果遇到FbxNodeAttribute::eMesh,就获取几何数据信息;如果遇到FbxNodeAttribute::eSkeleton,就获取骨架信息。

卷1曾经介绍过用ProcessMesh函数导入静态模型,不过当时没有全部介绍,把蒙皮相关的代码去掉了,下文会详细介绍。卷1介绍过的代码此处会省略,不再重复介绍,读者可以对照源码查看。

3ds Max可以为网格添加多种变形器,蒙皮变形器为其中一种,下面的代码访问所有的变形器,直到访问到蒙皮变形器。

if (m_CurExportType == ET_SKELETON_MESH)
{
     int nCountDeformer = pMesh->GetDeformerCount();
     for(int i = 0 ; i < nCountDeformer ; ++i)  
     {  
          FbxDeformer*  pFBXDeformer = pMesh->GetDeformer(i);  
          if(pFBXDeformer == NULL)  
          {  
               continue;  
          }  
          //只考虑eSKIN的管理方式  
          if(pFBXDeformer->GetDeformerType() != FbxDeformer::eSkin)  
          {  
               continue;  
          }  
          //只用第1个蒙皮变形器
          pFBXSkin = (FbxSkin*)(pFBXDeformer);  
          break;
     }  
     if (!pFBXSkin)
     {
           return false;
     }
     GetOffSetMatrix(pFBXSkin);
}

GetOffSetMatrix函数用于获得蒙皮矩阵信息,代码如下。

void VSFBXConverter::GetOffSetMatrix(FbxSkin *pSkin)
{
     int iClusterCount = pSkin->GetClusterCount();
     for (int iCluster = 0; iCluster < iClusterCount; iCluster++)
     {
          FbxCluster *pCluster = pSkin->GetCluster(iCluster);
          FbxNode *pFbxBone = pCluster->GetLink();
          VSBoneNode *pNode = m_pSkeleton->GetBoneNode(
                     pFbxBone->GetName());
          VSMAC_ASSERT(pNode);
          FbxAMatrix FbxMat,FbxMatLink;
          pCluster->GetTransformLinkMatrix(FbxMatLink);
          pCluster->GetTransformMatrix(FbxMat);
          FbxAMatrix Combine = FbxMatLink.Inverse() * FbxMat;
          VSMatrix3X3W VSMat;
          MaxMatToVSMat(Combine,VSMat);
          pNode->m_OffSetMatrix = VSMat;
     }
}

FbxCluster 包含骨骼及其控制的对应几何数据顶点,GetLink函数用于得到骨骼,蒙皮矩阵实际是把几何数据从模型空间变换到对应骨骼空间的变换矩阵。如果模型和骨架都在模型空间的原点位置,那么计算蒙皮矩阵很容易;但如果位置不同,就要计算它们之间的偏差。不过FBX里面已经有函数把偏差都算进去了,所以我们直接使用即可。

实际上,GetTransformMatrix函数用于得到对应的几何数据从模型空间变换到世界空间的变换矩阵,GetTransformLinkMatrix函数用于得到骨骼从自己的空间变换到世界空间的变换矩阵,FbxMat为将几何数据变换到世界空间的变换矩阵。Combine = FbxMatLink.Inverse() *FbxMat 就是把模型变换到对应骨骼空间的变换矩阵,然后把这个矩阵转换成游戏引擎格式。具体信息读者可以查看FBX的函数说明。

 

注意:

3ds Max中的几何数据和模型的关系与游戏引擎中VSGeometry类(卷1中介绍过此类)和VSMeshNode类的关系类似。但3ds Max中的几何数据和模型并不一定在同一空间,变换矩阵也不一定为单位矩阵。在3ds Max中存在3种变换到世界空间的矩阵——骨骼到世界空间的变换矩阵、几何数据到世界空间的变换矩阵、模型到世界空间的变换矩阵。3ds Max最后导出顶点所有信息时会把几何数据和模型变换到世界空间中。所以在游戏引擎中VSGeometry类和VSMeshNode类在同一个空间,变换矩阵为单位矩阵。

 

在图形处理单元(Graphics Processing Unit,GPU)的蒙皮实现中,如果不用双四元数方式来存放骨骼,那么每根骨骼至少要占用3个寄存器,很多游戏引擎默认支持70根骨骼,这样算下来将占用210个寄存器。如果当前几何数据中用的骨骼数量超出70,则会把这个几何数据拆解成多个几何数据,代码如下。

for (int j = 2; j >= 0; j--)
{
      int ctrlPointIndex = pMesh->GetPolygonVertex(i, j);
      if (!IsBoneNumAccept(pFBXSkin, m_MeshBoneNode, ctrlPointIndex))
      {
             VSString Name = pNode->GetName();
             if (MaterialCount > 0)
             {
            Name = Name + _T("_") + pNode->GetMaterial(k)->GetName() + +_T("_SubMesh");
      }
      VSMatrix3X3W Mat;
      if (!CreateMesh(Name, Mat, TexCoordNum, (pFBXSkin != NULL)))
      {
            ClearAllVertexInfo();
            break;
      }
}

IsBoneNumAccept函数用来检验添加当前三角形会不会导致几何数据超出70根骨骼。如果超出,立即把之前记录的顶点信息导出为一个几何数据,代码如下。

bool VSFBXConverter::IsBoneNumAccept(FbxSkin *pSkin,VSArray<VSUsedName> & Bone,
unsigned int VertexIndex)
{
    //临时存放骨骼
    VSArray<VSString> TempBone;
    for (unsigned int i = 0 ; i < Bone.GetNum() ; i++)
    {
         TempBone.AddElement(Bone[i].GetString());
    }
    unsigned int BoneNum = TempBone.GetNum();
    //找到控制索引为VertexIndex的顶点的所有骨骼,并判断它是否在当前的TempBone里,若不在,则添加
    int iClusterCount = pSkin->GetClusterCount();
    for (int iCluster = 0; iCluster < iClusterCount; iCluster++)
    {
         FbxCluster *pCluster = pSkin->GetCluster(iCluster);
         FbxNode *pFbxBone = pCluster->GetLink();
         int *iControlPointIndex = pCluster->GetControlPointIndices();
         int iControlPointCount = pCluster->GetControlPointIndicesCount();
         for (int i_Index = 0; i_Index < iControlPointCount; i_Index++)
         {
               if (iControlPointIndex[i_Index] == VertexIndex)
               {
                     unsigned int j = 0;
                     for(j = 0 ; j < TempBone.GetNum() ; j++)
                     {
                           if(pFbxBone->GetName() == TempBone[j])
                           {
                                break;
                           }
                     }
                     if(j == TempBone.GetNum())
                     {
                           VSString BoneName = pFbxBone->GetName();
                           TempBone.AddElement(BoneName);    
                     }
               }
         }
    }
    //判断当前所有骨骼数量是否超过规定数量
    if (TempBone.GetNum() > VSResourceManager::GetGpuSkinBoneNum())
    {
         return false;
    }
    return true;
}

VSResourceManager::GetGpuSkinBoneNum()这个变量不可以随意修改。如果修改后的值小于当前值,则所有导出过的模型都要重新导出;否则,游戏引擎渲染时不支持;如果修改后的值大于当前值,则没什么影响。

 

注意:

Unreal Engine 3支持70根骨骼,用寄存器的方式将骨头传递给GPU;Unreal Engine 4在移动端使用的方法和Unreal Engine 3一样,只不过它支持75根骨骼,而在个人计算机(PC)端支持256根骨骼,用顶点数据缓存的方式将骨头传递给GPU。

 

最后要处理的就是权重信息。一个顶点可以受到多根骨骼的控制,每根骨骼对这个顶点的控制程度是不一样的,其原理在前文已经介绍过,这里不再重复,代码如下。

if (pFBXSkin)
{
     //获得影响当前顶点的所有骨骼和权重
     VSArray<VSString> BoneTemp;
     VSArray<VSREAL>  Weight;
     BoneSkin(pFBXSkin,BoneTemp,Weight,ctrlPointIndex);
     //没有骨骼影响该顶点
     if(BoneTemp.GetNum() == 0)
     {
          return false;
     }
     //如果影响整个顶点的骨骼数量大于4,则把权重小的骨骼去掉,减少到4根骨骼
     while(BoneTemp.GetNum() > 4)
     {
      VSREAL MinWeight = Weight[0];
      unsigned int MinWeightIndex = 0;
      for(unsigned int uiBoneTemp = 1 ; uiBoneTemp < BoneTemp.GetNum() ; uiBoneTemp++)
          {
               if(Weight[uiBoneTemp] < MinWeight)
               {
                    MinWeight = Weight[uiBoneTemp];
                    MinWeightIndex = uiBoneTemp;
               }
          }
          BoneTemp.Erase(MinWeightIndex);
          Weight.Erase(MinWeightIndex);                         
     }
     //再一次过滤权重过小的骨骼。这次过滤后,不能保证影响顶点的骨骼数为4
     for(unsigned int uiBoneTemp = 0 ; uiBoneTemp < BoneTemp.GetNum() ; uiBoneTemp++)
     {
          if(Weight[uiBoneTemp] < EPSILON_E4)
          {
               BoneTemp.Erase(uiBoneTemp);
               Weight.Erase(uiBoneTemp);    
               uiBoneTemp--;
          }
     }
     //重新计算权重,保证权重在0~1
     VSREAL TotleWeight = 0;
     for(unsigned int uiBoneTemp = 0 ; uiBoneTemp < BoneTemp.GetNum() ; uiBoneTemp++)
     {
          TotleWeight += Weight[uiBoneTemp];
     }
     for(unsigned int uiBoneTemp = 0 ; uiBoneTemp < BoneTemp.GetNum() ; uiBoneTemp++)
     {
          if(TotleWeight > EPSILON_E4)
               Weight[uiBoneTemp] = Weight[uiBoneTemp] /TotleWeight;
     }
     //把骨骼放入网格骨骼列表,并加入这个顶点的索引和权重
     //一个顶点最多支持4根骨骼,所以用VSVector3W
     VSVector3W BoneIndexTemp(0.0f,0.0f,0.0f,0.0f);
     VSVector3W BoneWeightTemp(0.0f,0.0f,0.0f,0.0f);
     for (unsigned int uiBoneTemp = 0 ; uiBoneTemp < BoneTemp.GetNum() ; uiBoneTemp++)
     {
          VSBoneNode *pBoneNode = 
                     m_pSkeleton->GetBoneNode(BoneTemp[uiBoneTemp]);
          VSMAC_ASSERT(pBoneNode);
          //没有找到对应的骨骼
          if(!pBoneNode)
          {
               return false;
          }
          unsigned int uiBoneIndex = 0;
          for(uiBoneIndex = 0 ; uiBoneIndex < m_MeshBoneNode.GetNum() ; uiBoneIndex++)
          {
               if(m_MeshBoneNode[uiBoneIndex] == BoneTemp[uiBoneTemp])
               {
                   break;
               }
          }
          //把影响这个网格的所有骨骼都添加到骨骼列表中
          if(uiBoneIndex == m_MeshBoneNode.GetNum())
          {
               m_MeshBoneNode.AddElement(BoneTemp[uiBoneTemp]);
          }
          //记录影响整个顶点的骨骼所在骨骼列表中的索引和权重
          BoneIndexTemp.m[uiBoneTemp] = uiBoneIndex * 1.0f;
          BoneWeightTemp.m[uiBoneTemp] = Weight[uiBoneTemp];
     }
     //加入权重和骨骼索引
     m_BoneIndex.AddElement(BoneIndexTemp);
     m_BoneWeight.AddElement(BoneWeightTemp);
}

首先,获取这个顶点对应的骨骼索引和权重,游戏引擎里支持一个顶点最多受4根骨骼影响,对应4个骨骼索引和4个骨骼权重。所以把权重小的骨骼去掉,可以保证骨骼数小于或等于4。

 

注意:

Unreal Engine 3支持一个顶点最多受4根骨骼影响;Unreal Engine 4支持一个顶点最多受8根骨骼影响。

 

BoneSkin函数从FBX中获取顶点的权重和索引信息,代码如下。

void VSFBXConverter::BoneSkin(FbxSkin *pSkin,VSArray<VSString> & Bone,
VSArray<VSREAL>& Weight,unsigned int VertexIndex)
{
    //遍历所有骨骼,再遍历骨骼控制点。如果控制VertexIndex对应的顶点,则收集对应的权重信息和骨骼索引
    int iClusterCount = pSkin->GetClusterCount();
    for (int iCluster = 0; iCluster < iClusterCount; iCluster++)
    {
          FbxCluster *pCluster = pSkin->GetCluster(iCluster);
          FbxNode* pFbxBone = pCluster->GetLink();
          int *iControlPointIndex = pCluster->GetControlPointIndices();
          int iControlPointCount = pCluster->GetControlPointIndicesCount();
          double *pWeights = pCluster->GetControlPointWeights();
          for (int i_Index = 0; i_Index < iControlPointCount; i_Index++)
          {
                if (iControlPointIndex[i_Index] == VertexIndex)
                {
                      unsigned int j = 0;
                      for(j = 0 ; j < Bone.GetNum() ; j++)
                      {
                            if(pFbxBone->GetName() == Bone[j])
                            {
                                 Weight[j] += (VSREAL)pWeights[i_Index];
                                 break;
                            }
                      }
                      if(j == Bone.GetNum())
                      {
                            VSString BoneName = pFbxBone->GetName();
                            Bone.AddElement(BoneName);    
                            Weight.AddElement((VSREAL)pWeights[i_Index]);
                      }
                }
          }
    }
}

然后,把权重信息加入pBoneWeight,代码如下。

VSDataBufferPtr pBoneWeight = NULL;
if(HasSkin)
{
     pBoneWeight = VS_NEW VSDataBuffer;
     if(!pBoneWeight)
          return 0;
     if (m_CurExportPara & EP_SKIN_COMPRESS)
     {
          VSArray<DWORD> CompressData;
          CompressData.SetBufferNum(m_BoneWeight.GetNum());
          for (unsigned int i = 0 ; i < m_BoneWeight.GetNum() ;i++)
          {
               CompressData[i] = m_BoneWeight[i].GetDWABGR();
          }
          pBoneWeight->SetData
               (&CompressData[0],CompressData.GetNum(),VSDataBuffer::DT_UBYTE4N);
     }
     else
     {
          pBoneWeight->SetData(&m_BoneWeight[0],(unsigned int)
                      m_BoneWeight.GetNum(),VSDataBuffer::DT_FLOAT32_4);
     }
}

权重是0~1的浮点数,这里可以将其压缩成UCHAR类型(0~255的整数)。DT_UBYTE4N是4个UCHAR,DirectX在顶点着色器里默认会把它转成0~1的浮点数。

接着,添加骨骼索引信息到pBoneIndex 中,代码如下。

//添加骨骼索引
VSDataBufferPtr pBoneIndex = NULL;
if(HasSkin)
{
     pBoneIndex = VS_NEW VSDataBuffer;
     if(!pBoneIndex)
          return 0;
     if (m_CurExportPara & EP_SKIN_COMPRESS)
     {
          VSArray<DWORD> CompressData;
          CompressData.SetBufferNum(m_BoneIndex.GetNum());
          for (unsigned int i = 0 ; i < m_BoneIndex.GetNum() ;i++)
          {
               unsigned char R = (unsigned char)m_BoneIndex[i].r;
               unsigned char G = (unsigned char)m_BoneIndex[i].g;
               unsigned char B = (unsigned char)m_BoneIndex[i].b;
               unsigned char A = (unsigned char)m_BoneIndex[i].a;
               CompressData[i] = VSDWCOLORABGR(A,R,G,B);
          }
          pBoneIndex->SetData(&CompressData[0],
                      CompressDate.GetNum(),VSDataBuffer::DT_UBYTE4N);
     }
     else
     {
          pBoneIndex->SetData(&m_BoneIndex[0],
               (unsigned int)m_BoneIndex.GetNum(),VSDataBuffer::DT_FLOAT32_4);
     }
}

由于游戏引擎最多支持70根骨骼,因此用浮点数来存放骨骼索引实在有些浪费,用UCHAR即可。UCHAR可以表示数量介于0~255的骨骼,最多有256根。

最后把权重信息和索引信息添加到pVertexBuffer中,代码如下。

if(HasSkin)
{
    pVertexBuffer->SetData
                  (pBoneIndex,VSVertexFormat::VF_BLENDINDICES);
    pVertexBuffer->SetData
                  (pBoneWeight,VSVertexFormat::VF_BLENDWEIGHT);
}

前文已经算出了每根骨骼的蒙皮矩阵和相对于顶点的权重信息,按照顶点计算公式,得到的是在顶点在模型空间下的位置。

Sum(V * Bone_i_SkinMatrix * Bone_i_ Matrix * Bone_i_Parent Matrix * Bone_i_ParentParent Matrix *…* RootBone Matrix * Weight_i)

其中,

首先,我们要知道要渲染的顶点集合受到哪些骨骼的影响。在pGeometry->SetAffect- BoneArray(m_MeshBoneNode)中,m_MeshBoneNode记录着对VSGeometry类产生影响的骨骼的名字。

通过VSGeometry类的SetAffectBoneArray函数,可以根据骨骼名字重新创建需要对VSGeometry类产生影响的骨骼数组,代码如下。

class VSGRAPHIC_API VSGeometry : public VSSpatial
{
         void SetAffectBoneArray(const VSArray<VSUsedName> & BoneNodeArray);
         //计算最终骨骼矩阵
         virtual void UpdateOther(double dAppTime);
         //影响VSGeometry的骨骼
         VSArray<VSBoneNode *> m_pBoneNode;
         //影响VSGeometry的骨骼的名字
         VSArray<VSUsedName> m_BoneName;
         //根据骨骼名字创建m_pBoneNode
         void LinkBoneNode();
         //存放最终的骨骼矩阵
         VSArray<VSVector3W> m_SkinWeightBuffer;
}
void VSGeometry::SetAffectBoneArray(const VSArray<VSUsedName> & BoneNodeArray)
{
     if(BoneNodeArray.GetNum())
     {
          m_pBoneNode.Clear();
          m_BoneName.Clear();
          //得到影响VSGeometry的骨骼的名字
          m_BoneName = BoneNodeArray;
          //根据骨骼名字创建m_pBoneNode
          LinkBoneNode();
     }
}

根据骨骼名字创建m_pBoneNode,代码如下。

void VSGeometry::LinkBoneNode()
{
     m_pBoneNode.Clear();
     if (m_BoneName.GetNum())
     {
          //得到骨架
          VSSkeleton *pSke = GetAffectSkeleton();
          if (pSke)
          {
               m_pBoneNode.Clear();
               //根据骨骼名字找到对应的骨骼,加入m_pBoneNode数组
               for (unsigned int i = 0 ; i < m_BoneName.GetNum() ; i++)
               {
                    VSBoneNode *pBoneNode = 
                                    pSke->GetBoneNode(m_BoneName[i]);
                    if (!pBoneNode)
                    {
                          return ;
                    }
                    m_pBoneNode.AddElement(pBoneNode);
               }
          }
     }
     //初始化最终骨骼矩阵的Buffer大小
     m_SkinWeightBuffer.SetBufferNum(GetAffectBoneNum() * 3);
}

m_BoneName会存放到文件资源中,代码如下。

REGISTER_PROPERTY(m_BoneName, BoneName, 
VSProperty::F_SAVE_LOAD_CLONE | VSProperty::F_REFLECT_NAME)

无论是加载还是复制VSGeometry类,都会调用LinkBoneNode函数。这个函数根据自己当前的骨架,重新创建影响VSGeometry类的所有骨骼数组(m_pBoneNode)。

UpdateOther函数会计算最终的骨骼矩阵,代码如下。

void VSGeometry::UpdateOther(double dAppTime)
{
    if (!m_pMeshData)
    {
          return;
    }
    VSVertexBuffer *pVBuffer = GetMeshData()->GetVertexBuffer();
    if(GetAffectBoneNum() && pVBuffer->GetBlendWeightData() && 
               pVBuffer->GetBlendIndicesData())
    {
         VSTransform World  = m_pParent->GetWorldTransform();
         for (unsigned int i = 0 ; i < GetAffectBoneNum() ; i++)
         {
              VSBoneNode *pBone = GetAffectBone(i);
              if(pBone)
              {
                   VSTransform BoneWorld = pBone->GetWorldTransform();
                   VSMatrix3X3W TempBone = pBone->GetBoneOffsetMatrix()
                                    *BoneWorld.GetCombine()
                                  *World.GetCombineInverse();
                   VSVector3W ColumnVector[4];
                   TempBone.GetColumnVector(ColumnVector);
                   m_SkinWeightBuffer[i * 3] = ColumnVector[0];
                   m_SkinWeightBuffer[i * 3 + 1] = ColumnVector[1];
                   m_SkinWeightBuffer[i * 3 + 2] = ColumnVector[2];
              }
              else
              {
                   m_SkinWeightBuffer[i * 3].Set(1.0f,0.0f,0.0f,0.0f);
                   m_SkinWeightBuffer[i * 3 + 1].Set(0.0f,1.0f,0.0f,0.0f);
                   m_SkinWeightBuffer[i * 3 + 2].Set(0.0f,0.0f,1.0f,0.0f);
              }
         }
    }
}

在更新骨架时会更新所有的骨骼层级,得到每根骨骼在世界空间中的位置。但在实际公式中我们要得到模型空间下的位置,常通过BoneWorld.GetCombine()* World.GetCombineInverse()以变换到模型空间。最后,m_SkinWeightBuffer的数据会传递到顶点着色器,根据蒙皮权重计算顶点位置,这在后文介绍着色器的时候再详细说明。

计算骨骼模型的包围盒(bound,参见卷1)时要比静态模型复杂一些。因为模型跟随着骨架,骨架随着动作变换而变化,所以骨骼模型的包围盒要根据所有动作来计算。

首先,根据骨架的所有动作计算出骨架的最大包围盒。然后,把骨架的最大包围盒和骨骼模型的包围盒整合到一起。

本节先介绍不带动作的骨架包围盒和骨骼模型包围盒(如图1.10所示)。

图1.10 包围盒

下面是计算骨骼模型包围盒的代码,这里考虑了骨骼蒙皮带来的影响。其中AABB(Axis-Aligned Bounding Box)的解释参见卷1。

void VSGeometry::CreateLocalAABB()
{
    if (m_pMeshData && m_pMeshData->GetVertexBuffer())
    {
    VSAABB3 NewAABB;
    VSVertexBuffer *pVerBuffer = m_pMeshData->GetVertexBuffer();
    if (!pVerBuffer->GetPositionData(0))
    {
          return;
    }
    VSVector3 *pVer = (VSVector3*)pVerBuffer->GetPositionData(0)->GetData();
    if (!pVer)
    {
          return;
    }
    unsigned int uiVextexNum = pVerBuffer->GetPositionData(0)->GetNum();
    VSTransform World  = m_pParent->GetWorldTransform();
    if (GetAffectSkeleton()) //如果模型受到骨骼影响
    {
       VSDataBuffer *pBlendIndex = pVerBuffer->GetBlendIndicesData();
       VSDataBuffer *pBoneWeight = pVerBuffer->GetBlendWeightData();
       //取出索引和权重
       if (!pBlendIndex || !pBoneWeight)
       {
           return ;
       }
       VSArray<VSVector3>TempBuffer; //用来保存蒙皮后的顶点
       TempBuffer.SetBufferNum(uiVextexNum);
       //如果索引是压缩后的格式
       if (pBlendIndex->GetDT() == VSDataBuffer::DT_UBYTE4N)
       {
          DWORD *pBlendIndexData = (DWORD *)pBlendIndex->GetData();
          DWORD *pBoneWeightData = (DWORD *)pBoneWeight->GetData();    
          for (unsigned int i = 0; i < uiVextexNum ;i++)
          {
                 VSVector3W BoneWeight; //解压缩索引
                 BoneWeight.CreateFormABGR(pBoneWeightData[i]);
                 unsigned char BlendIndex[4];
                        VSDWCOLORGetABGR(pBlendIndexData[i],BlendIndex[0],
                        BlendIndex[1],BlendIndex[2],BlendIndex[3]);
                 TempBuffer[i].Set(0.0f,0.0f,0.0f);
                 for (unsigned int k = 0 ; k < 4 ; k++)
                 {
                      //分别计算4根骨骼的影响
                      VSBoneNode *pBone = GetAffectBone(BlendIndex[k]);
                      if(pBone)
                      {
                          VSTransform BoneWorld = pBone->GetWorldTransform();
                          //消除模型在世界空间的影响
                          World.GetCombineInverse()
                          VSMatrix3X3W TempBone = pBone->GetBoneOffsetMatrix()
                                *BoneWorld.GetCombine() *World.GetCombineInverse();
                          TempBuffer[i] += 
                          pVer[i] *TempBone *BoneWeight.m[k];
                      }
                 }
            for (unsigned int k = 0 ; k < 4 ; k++)
          }
       }
       else
       {
          VSVector3W *pBlendIndexData = 
                      (VSVector3W *)pBlendIndex->GetDate();
          VSVector3W *pBoneWeightData = 
                      (VSVector3W *)pBoneWeight->GetDate();    
          for (unsigned int i = 0; i < uiVextexNum ;i++)
          {
               TempBuffer[i].Set(0.0f,0.0f,0.0f);
               for (unsigned int k = 0 ; k < 4 ; k++)
               {
                   unsigned int BlendIndex = (unsigned int) 
                   pBlendIndexData[i].m[k];
                   VSBoneNode *pBone = GetAffectBone(BlendIndex);
                   if(pBone)
                   {
                      VSTransform BoneWorld = pBone->GetWorldTransform();
                      VSMatrix3X3W TempBone = pBone->GetBoneOffsetMatrix() 
                            *BoneWorld.GetCombine() *World.GetCombineInverse();
                      TempBuffer[i] +=
                                   pVer[i] *TempBone *pBoneWeightData[i].m[k];
                   }
                 }
            }
          }
          NewAABB.CreateAABB(TempBuffer.GetBuffer(),uiVextexNum);
      }
      else
      {
          NewAABB.CreateAABB(pVer,uiVextexNum);
      }
       m_LocalBV = NewAABB;
    }
}

骨架包围盒的计算也比较简单,就是把每根骨骼的位置都考虑进来,代码如下。

void VSSkeleton::CreateLocalAABB()
{
     VSVector3 MinPos(VSMAX_REAL, VSMAX_REAL, VSMAX_REAL);
     VSVector3 MaxPos(VSMIN_REAL, VSMIN_REAL, VSMIN_REAL);
     VSTransform SkeletonLocalT = GetLocalTransform();
     for (unsigned int j = 0; j < GetBoneNum(); j++)
     {
          VSBoneNode *pBone = GetBoneNode(j);
          if (pBone)
          {
               VSVector3 Pos = pBone->GetWorldTranslate() * 
                  SkeletonLocalT.GetCombineInverse();
               for (int t = 0; t < 3; t++)
               {
                    if (MinPos.m[t] > Pos.m[t])
                    {
                          MinPos.m[t] = Pos.m[t];
                    }
                    if (MaxPos.m[t] < Pos.m[t])
                    {
                          MaxPos.m[t] = Pos.m[t];
                    }
               }
          }
     }
     m_LocalBV.Set(MaxPos, MinPos);
     m_OriginLocalBV.Set(MaxPos, MinPos);
}

以上计算都在骨架的本地空间下进行,因为两段计算代码都是在FBX插件中调用的,所以这个时候骨骼模型还在自己的本地空间下,它在世界空间中的旋转和位置分量都为0。这里的 SkeletonLocalT= GetLocalTransform()和前文计算骨骼模型包围盒的代码中用GetWorldTransform()是等价的。

下面再计算骨骼模型包围盒。这里只进行了粗略的计算,即在骨架包围盒的基础上再扩展了骨骼模型包围盒,代码如下。

void VSSkeletonMeshNode::UpdateWorldBound(double dAppTime)
{
    bool bFoundFirstBound = false; 
    //计算子模型的包围盒
    for (unsigned int i = 0; i < m_pChild.GetNum(); i++) 
    {     
         if(m_pChild[i])
         {
             if(!bFoundFirstBound)
             {
                   m_WorldBV = m_pChild[i]->m_WorldBV;
                   bFoundFirstBound = true; 
             }
             else
             {
                   m_WorldBV = 
                         m_WorldBV.MergAABB(m_pChild[i]->m_WorldBV);
             }
         }
    }
    //用子模型的包围盒扩展骨架包围盒,最后得到骨骼模型包围盒
    if (m_pSkeleton)
    {
         if(!bFoundFirstBound)
         {
              m_WorldBV = m_pSkeleton->m_WorldBV;
              bFoundFirstBound = true; 
         }
         else
         {
              VSVector3 MaxPos = m_pSkeleton->m_WorldBV.GetMaxPoint();
              VSVector3 MinPos = m_pSkeleton->m_WorldBV.GetMinPoint();
              VSREAL fA[3];
              m_WorldBV.GetfA(fA);
              MaxPos = MaxPos + VSVector3(fA[0], fA[1], fA[2]);
              MinPos = MinPos - VSVector3(fA[0], fA[1], fA[2]);
              m_WorldBV.Set(MaxPos, MinPos);
         }
    }
    if (m_pParent)
    {
         m_pParent->m_bIsChanged = true;
    }
}

骨骼模型下的网格可见性就这样被粗略计算出来了。一旦整个骨骼模型包围盒可见,将不再进行下一步判断,代码如下。

void VSSkeletonMeshNode::ComputeNodeVisibleSet(VSCuller & Culler,bool bNoCull,
double dAppTime)
{
    if (!Culler.CullConditionNode(this))
    {
          UpDataView(Culler,dAppTime);
          for(unsigned int i = 0 ; i < m_pChild.GetNum() ; i++)
          {
              if(m_pChild[i])
              {
                  m_pChild[i]->ComputeVisibleSet(Culler,true,dAppTime);
              }
          }        
    }
}

最后,将模型数据保存到文件中。

else if (m_CurExportType == ET_SKELETON_MESH)
{
     if (m_pSkeleton && m_pNode)
     {
          VSMeshNode * m_pSkeletonMesh = m_pNode;
          ((VSSkeletonMeshNode *)m_pSkeletonMesh)->SetSkeleton(
                       m_pSkeleton);
          m_pNode->CreateLocalAABB();
          m_pNode->UpdateAll(0.0f);
          m_pSkeleton->CreateLocalAABB();
                      VSResourceManager::NewSaveSkeletonMeshNode(
               StaticCast<VSSkeletonMeshNode>(m_pNode), pDestFile);
     }
}

前文已经提到了骨骼动画的原理和骨骼动画存放的数据,我们需要把每个动画的关键帧数据都保存成游戏引擎格式,然后在游戏引擎中根据动画的关键帧数据计算当前时刻每根骨骼的空间位置。

先给出整个动画数据架构,如图1.11所示。

图1.11 动画数据架构

首先要用游戏引擎的格式来保存这些动画数据。关键帧数据包括时间和空间位置,而空间位置由缩放、旋转和平移分量组成,代码如下。

class VSGRAPHIC_API VSKeyTimeInfo
{
      VSREAL m_dKeyTime;
};
class VSGRAPHIC_API VSKeyTimeVector : public VSKeyTimeInfo
{
      VSVector3 m_Vector;
};
class VSGRAPHIC_API VSKeyTimeQuaternion : public VSKeyTimeInfo
{
      VSQuat m_Quat;
};

每一个VSBoneKey类都对应一根骨骼的所有关键帧数据,m_cName为骨骼的名字,代码如下。

class VSGRAPHIC_API VSBoneKey : public VSObject
{
      VSArray<VSKeyTimeVector>         m_TranslationArray;
      VSArray<VSKeyTimeVector>         m_ScaleArray;
      VSArray<VSKeyTimeQuaternion>     m_RotatorArray;
      VSUsedName                       m_cName;
};

VSAnim类为动画类,它包含了所有关键帧数据,继承自VSResource类,所以它就是动画数据资源,它有默认的资源,代码如下。

class VSGRAPHIC_API VSAnim : public VSObject,public VSResource
{
    //关键帧数据
    VSArray<VSBoneKeyPtr> m_pBoneKeyArray;
    //动画时间长度
    VSREAL m_fLength;
    //添加骨骼关键帧
    void AddBoneKey(VSBoneKey *pBoneKey);
    //根据名字得到骨骼的关键帧
    VSBoneKey *GetBoneKey(const VSUsedName & AnimName)const;
    VSBoneKey *GetBoneKey(unsigned int uiIndex)const;
    //动画名字
    VSUsedName m_cName;
    static VSPointer<VSAnim> Default;
};

VSAnimSet类为动画数据集合,它包含这个角色对应的所有动画,代码如下。

class VSGRAPHIC_API VSAnimSet : public VSObject
{
    VSMapOrder<VSUsedName,VSAnimRPtr> m_pAnimArray;
    void AddAnim(VSUsedName AnimName,VSAnimR *pAnim);
    VSAnimR *GetAnim(const VSUsedName & AnimName)const;
    VSAnimR *GetAnim(unsigned int i)const;
};

现在又回到VSFBXConverter类,-a为导出动画数据的命令行参数,代码如下。

else if(m_CurExportType == ET_ACTION)
{
    m_pAnim = VS_NEW VSAnim();
    VSString PathNameAndSuffix = pDestFile;
    VSString NameAndSuffix;
    //分别根据'/'和'\\'去掉路径,找到对应的文件名
    if (NameAndSuffix.GetString(PathNameAndSuffix, _T('/'), -1, false) == false)
    {
         NameAndSuffix.GetString(PathNameAndSuffix, _T('\\'), -1, false);
    }
    //去掉扩展名
    VSString AnimName;
    AnimName.GetString(NameAndSuffix,'.',-1);
    //作为动画名字
    m_pAnim->m_cName = AnimName;
    GetAnim(m_pFbxScene->GetRootNode());
}

GetAnim函数的作用是得到动画数据,并放在m_pAnim里。下面的代码把动画数据存储成游戏引擎格式。

else if (m_CurExportType == ET_ACTION)
{
    if (m_pAnim)
    {
         //计算动画时间长度
         m_pAnim->ComputeAnimLength();
         if (m_CurExportPara & EP_ACTION_COMPRESS)
         {
              //压缩动画数据
              m_pAnim->Compress();
         }
         VSResourceManager::NewSaveAction(m_pAnim, pDestFile);
    }
    else
    {
         return false;
    }
}

现在把注意力集中在GetAnim函数上。

void VSFBXConverter::GetAnim(FbxNode *pNode)
{
    //按照每秒30帧进行动画采样的时间间隔
    const FbxLongLong ANIMATION_STEP_30 = 1539538600; 
    //按照每秒15帧进行动画采样的时间间隔
    const FbxLongLong ANIMATION_STEP_15 = 3079077200; 
    //不压缩的情况下,按照每秒30帧进行动画采样
    FbxTime step = ANIMATION_STEP_30;
    //压缩的情况下,按照每秒15帧进行动画采样
    if (m_CurExportPara & EP_ACTION_COMPRESS)
    {
         step = ANIMATION_STEP_15;
    }
    VSBoneNodePtr pBoneNode = NULL;
    if(pNode->GetNodeAttribute())  
    {  
         switch(pNode->GetNodeAttribute()->GetAttributeType())  
         {  
         case FbxNodeAttribute::eSkeleton:  
             {
             VSBoneKeyPtr pBoneKey = VS_NEW VSBoneKey();
             pBoneKey->m_cName = pNode->GetName();
             FbxTimeSpan timeSpan;
             //得到动画时间长度
             m_pFbxScene->GetGlobalSettings().
                           GetTimelineDefaultTimeSpan(timeSpan);
             FbxTime start = timeSpan.GetStart();
             FbxTime end = timeSpan.GetStop();
             //从第1帧开始遍历
             for (FbxTime i = start ; i < end ; i = i + step)
             {
                  //得到对应时间的骨骼矩阵
                  FbxAMatrix Combine = pNode->EvaluateLocalTransform(i);
                  VSMatrix3X3W VSMat;
                  MaxMatToVSMat(Combine,VSMat);
                  //得到毫秒级的时间
                  FbxLongLong MS = i.GetMilliSeconds() – 
                      start.GetMilliSeconds();
                  VSKeyTimeVector KeyTimeTran;
                  KeyTimeTran.m_dKeyTime = MS * 1.0f;
                  VSKeyTimeQuaternion KeyTimeQuat;
                  KeyTimeQuat.m_dKeyTime = MS * 1.0f;
                  VSKeyTimeVector KeyTimeScale;
                  KeyTimeScale.m_dKeyTime = MS * 1.0f;
                  //分别得到平移、旋转、缩放分量
                  VSMatrix3X3 ScaleAndRotator;
                  VSMat.Get3X3(ScaleAndRotator);
                  VSVector3 Scale;
                  ScaleAndRotator.GetScaleAndRotated(Scale);
                  VSVector3 Tran = VSMat.GetTranslation();
                  KeyTimeTran.m_Vector = Tran;
                  KeyTimeQuat.m_Quat = ScaleAndRotator.GetQuat();
                  KeyTimeScale.m_Vector = Scale;
                  pBoneKey->m_TranslationArray.AddElement(KeyTimeTran);
                  pBoneKey->m_RotatorArray.AddElement(KeyTimeQuat);
                  pBoneKey->m_ScaleArray.AddElement(KeyTimeScale);
             }
             //把最后一帧数据也放进去
             FbxAMatrix Combine = pNode->EvaluateLocalTransform(end);
             VSMatrix3X3W VSMat;
             MaxMatToVSMat(Combine,VSMat);
             FbxLongLong MS = end.GetMilliSeconds() - start.GetMilliSeconds();
             VSKeyTimeVector KeyTimeTran;
             KeyTimeTran.m_dKeyTime = MS * 1.0f;
             VSKeyTimeQuaternion KeyTimeQuat;
             KeyTimeQuat.m_dKeyTime = MS * 1.0f;
             VSKeyTimeVector KeyTimeScale;
             KeyTimeScale.m_dKeyTime = MS * 1.0f;
             VSMatrix3X3 ScaleAndRotator;
             VSMat.Get3X3(ScaleAndRotator);
             VSVector3 Scale;
             ScaleAndRotator.GetScaleAndRotated(Scale);
             VSVector3 Tran = VSMat.GetTranslation();
             KeyTimeTran.m_Vector = Tran;
             KeyTimeQuat.m_Quat = ScaleAndRotator.GetQuat();
             KeyTimeScale.m_Vector = Scale;
             pBoneKey->m_TranslationArray.AddElement(KeyTimeTran);
             pBoneKey->m_RotatorArray.AddElement(KeyTimeQuat);
             pBoneKey->m_ScaleArray.AddElement(KeyTimeScale);
             m_pAnim->AddBoneKey(pBoneKey);                
             } 
             break;  
         }  
    }  
    for(int i = 0 ; i < pNode->GetChildCount() ; ++i)  
    { 
         GetAnim(pNode->GetChild(i));  
    } 
}

整体代码十分简单,主要获取对应时间的骨骼矩阵。EvaluateLocalTransform函数获取骨骼相对于其父空间的矩阵,也就是本地(local)矩阵。

在FBX里面,使用的时间要转换为毫秒。

inline FbxLongLong GetMilliSeconds() const { return mTime / FBXSDK_TC_MILLISECOND; }

可以看到,FBX中的时间要除以FBXSDK_TC_MILLISECOND

#define FBXSDK_TC_MILLISECOND            FBXSDK_LONGLONG(46186158)

如果动画的播放速度为每秒30帧,每帧的播放时间约为33.33ms,再转换成FBX中的时间,const FbxLongLong ANIMATION_STEP_30 = 1539538600。

ComputeAnimLength函数计算出最大动画帧的时间,起始帧的时间为0ms。

可以每秒导入15帧以减少动画数据。不过采样频率变低后,有些数据就会被跳过,动画数据不能精确地导入,导致播放动画时发生错误。

1.1.3节介绍了动画播放原理,如图1.12所示,如果按照这种方式播放动画,采样点之间的信息就会丢失。

图1.12 动画采样频率过低导致信息丢失

一般情况下,游戏中重要的角色还要按每秒30帧进行采样。对于不重要的角色,每秒15帧即可。不过读者自己也可以尝试以每秒24帧的频率来采样。

实际上导出的动画数据对3ds Max动画进行采样,采样频率分别为每秒30帧和每秒15帧。在采样过程中,有很多冗余的数据。如果相邻两帧的数据相等,则完全可以将其删除。

如图1.13所示,第1帧、第2帧、第3帧的数据是一样的,第2帧的数据完全可以通过第1帧和第3帧插值出来,所以第2帧就可以删除。同理,第16帧、第17帧、第18帧、第19帧、第20帧、第21帧、第22帧、第23帧的数据也是一样的,因此只保留第16帧和第23帧即可。

图1.13 动画采样相同帧

另外,通过压缩每根骨骼的空间位置信息,把缩放、旋转和平移这3个分量的32位float类型用16位来存储,这样可以压缩掉一半数据。

存储旋转信息用四元数,而且存储的都是单位四元数,也就是说,每个分量的范围都为−1~1。一般对精度要求不高的情况下,−1~1的数据可以用UCHAR类型转换到0~255。但实践证明,用UCHAR类型存储旋转信息,如果信息丢失会导致动画错误,所以非主要角色的旋转信息可以压缩为UCHAR类型,主要角色还用USHORT类型存储。

至于平移和缩放信息,一般存储的数据没有明确的范围。如果要用16位USHORT类型来存储数据,就必须知道当前数据的集合的范围。所以算出当前动画数据的平移和缩放分量的最大值和最小值是必不可少的。

下面是表示动画压缩的数据结构。

class VSGRAPHIC_API VSKeyTimeVectorCompress : public VSKeyTimeInfo
{
    unsigned short m_X;
    unsigned short m_Y;
    unsigned short m_Z;
};
class VSGRAPHIC_API VSKeyTimeQuaternionCompress : public VSKeyTimeInfo
{
    unsigned short m_X;
    unsigned short m_Y;
    unsigned short m_Z;
    unsigned short m_W;
};
class VSGRAPHIC_API VSBoneKeyCompress : public VSObject
{
    VSArray<VSKeyTimeVectorCompress>          m_TranslationArray;
    VSArray<VSKeyTimeVectorCompress>          m_ScaleArray;
    VSArray<VSKeyTimeQuaternionCompress>      m_RotatorArray;
    VSUsedName                       m_cName;
};

下面是动画数据压缩的整个过程。

void VSAnim::Compress()
{
    if (!m_pBoneKeyArray.GetNum())
    {
          return ;
    }
    //表明数据压缩过
    m_bCompress = true;
    //用来存储压缩动画帧
    m_pBoneKeyCompressArray.Clear();
    //平移和缩放分量的最大值
    m_MaxCompressScale = 
           VSVector3(-VSMAX_REAL,-VSMAX_REAL,-VSMAX_REAL);
    m_MaxCompressTranslation = 
           VSVector3(-VSMAX_REAL,-VSMAX_REAL,-VSMAX_REAL);
    //平移和缩放分量的最小值
    m_MinCompressScale = 
        VSVector3(VSMAX_REAL,VSMAX_REAL,VSMAX_REAL);
    m_MinCompressTranslation = 
        VSVector3(VSMAX_REAL,VSMAX_REAL,VSMAX_REAL);
    //计算平移和缩放分量的最小值和最大值
    for (unsigned int i = 0 ; i < m_pBoneKeyArray.GetNum() ;i++)
    {
         for (unsigned int j = 0 ; j < m_pBoneKeyArray[i]->m_ScaleArray.GetNum() ; 
              j++)
         {
              for (unsigned int k = 0 ; k < 3 ; k++)
              {
                   if (m_MaxCompressScale.m[k] < 
                              m_pBoneKeyArray[i]->m_ScaleArray[j].m_Vector.m[k])
                   {
                        m_MaxCompressScale.m[k] = m_pBoneKeyArray[i]->
                                           m_ScaleArray[j].m_Vector.m[k];
                   }
                   if (m_MinCompressScale.m[k] > 
                              m_pBoneKeyArray[i]->m_ScaleArray[j].m_Vector.m[k])
                   {
                        m_MinCompressScale.m[k] = m_pBoneKeyArray[i]->
                                           m_ScaleArray[j].m_Vector.m[k];
                   }
              }

         }

         for (unsigned int j = 0 ; 
                       j < m_pBoneKeyArray[i]->m_TranslationArray.GetNum() ; j++)
         {
              for (unsigned int k = 0 ; k < 3 ; k++)
              {
                   if (m_MaxCompressTranslation.m[k] < 
                              m_pBoneKeyArray[i]->m_TranslationArray[j].m_Vector.m[k])
                   {
                       m_MaxCompressTranslation.m[k] = 
                              m_pBoneKeyArray[i]->m_TranslationArray[j].m_Vector.m[k];
                   }

                   if (m_MinCompressTranslation.m[k] > 
                              m_pBoneKeyArray[i]->m_TranslationArray[j].m_Vector.m[k])
                   {
                       m_MinCompressTranslation.m[k] = 
                         m_pBoneKeyArray[i]->m_TranslationArray[j].m_Vector.m[k];
                   }
              }
         }
    }
    //合并相同数据帧
    for (unsigned int i = 0 ; i < m_pBoneKeyArray.GetNum() ; i++)
    {
         m_pBoneKeyArray[i]->CompressSameFrame();
    }
    //压缩动画数据
    for (unsigned int i = 0 ; i < m_pBoneKeyArray.GetNum() ; i++)
    {
         VSBoneKeyCompress *pBoneKeyCompress =
                   VS_NEW VSBoneKeyCompress();
         m_pBoneKeyArray[i]->Get(pBoneKeyCompress,
                   m_MaxCompressTranslation,m_MinCompressTranslation,
                   m_MaxCompressScale,m_MinCompressScale);
         m_pBoneKeyCompressArray.AddElement(pBoneKeyCompress);
    }
    m_pBoneKeyArray.Destroy();
}
//删除相同帧数据
void VSBoneKey::CompressSameFrame()
{
    //处理平移,数据量必须大于两帧
    if (m_TranslationArray.GetNum() >= 2)
    {
         VSArray<VSKeyTimeVector> NewTranslationArray;
         VSKeyTimeVector Fisrt = m_TranslationArray[0];
         NewTranslationArray.AddElement(Fisrt);
         unsigned int Index = 0;
         for (unsigned int i = 1 ; i < m_TranslationArray.GetNum() ; i++)
         {
              if (i != m_TranslationArray.GetNum() - 1)
              {            
                     if (Fisrt.m_Vector == m_TranslationArray[i].m_Vector)
                     {
                           continue;
                     }
              }
              if (Index != i - 1)
              {
                    NewTranslationArray.AddElement(m_TranslationArray[i - 1]);
              }
              NewTranslationArray.AddElement(m_TranslationArray[i]);
              Fisrt = m_TranslationArray[i];
              Index = i;
         }
         m_TranslationArray = NewTranslationArray;
    }
    //处理缩放
    …
    //处理旋转
  …
}

旋转、缩放的处理和平移一样,这里不给出代码。

下面是压缩平移数据的核心代码。

void VSBoneKey::Get(VSBoneKeyCompress *pBoneKeyCompress,
         const VSVector3 & MaxTranslation , const VSVector3 & MinTranslation ,
         const VSVector3 MaxScale,const VSVector3 MinScale)
{
     pBoneKeyCompress->m_cName = m_cName;
     for (unsigned int i = 0 ; i < m_ScaleArray.GetNum() ; i++)
     {
          VSKeyTimeVectorCompress Compress;
          Compress.m_dKeyTime = m_ScaleArray[i].m_dKeyTime;
          Compress.m_X =
               CompressFloat(m_ScaleArray[i].m_Vector.x,MaxScale.x,MinScale.x);
          Compress.m_Y =
               CompressFloat(m_ScaleArray[i].m_Vector.y,MaxScale.y,MinScale.y);
          Compress.m_Z =
               CompressFloat(m_ScaleArray[i].m_Vector.z,MaxScale.z,MinScale.z);
          pBoneKeyCompress->m_ScaleArray.AddElement(Compress);
     }
     for (unsigned int i = 0 ; i < m_TranslationArray.GetNum() ; i++)
     {
          VSKeyTimeVectorCompress Compress;
          Compress.m_dKeyTime = m_TranslationArray[i].m_dKeyTime;
          Compress.m_X = CompressFloat(m_TranslationArray[i].m_Vector.x,
                     MaxTranslation.x,MinTranslation.x);
          Compress.m_Y = CompressFloat(m_TranslationArray[i].m_Vector.y,
                     MaxTranslation.y,MinTranslation.y);
          Compress.m_Z = CompressFloat(m_TranslationArray[i].m_Vector.z,
                     MaxTranslation.z,MinTranslation.z);
          pBoneKeyCompress->m_TranslationArray.AddElement(Compress);
     }
     for (unsigned int i = 0 ; i < m_RotatorArray.GetNum() ; i++)
     {
          VSKeyTimeQuaternionCompress Compress;
          Compress.m_dKeyTime = m_RotatorArray[i].m_dKeyTime;
          Compress.m_X = CompressFloat(m_RotatorArray[i].m_Quat.x,1.0f,-1.0f);
          Compress.m_Y = CompressFloat(m_RotatorArray[i].m_Quat.y,1.0f,-1.0f);
          Compress.m_Z = CompressFloat(m_RotatorArray[i].m_Quat.z,1.0f,-1.0f);
          Compress.m_W = CompressFloat(m_RotatorArray[i].m_Quat.w,1.0f,-1.0f);
          pBoneKeyCompress->m_RotatorArray.AddElement(Compress);
     }
}

压缩平移数据的代码也很简单,就是将每个分量根据大小压缩为USHORT类型。

CompressUnitFloat函数的核心功能是把对应的变量根据范围大小先转换到[0,1]区间,然后把这个变量压缩到对应的Bit里面。CompressUnitFloat函数考虑到了浮点数四舍五入后带来的精度问题,方法稍微复杂一些。

inline unsigned int CompressUnitFloat(VSREAL f, unsigned int Bit = 16)
{
     unsigned int nIntervals = 1 << Bit;
     VSREAL scaled = f * (nIntervals - 1);
     unsigned int rounded = (unsigned int)(scaled + 0.5f);
     if (rounded > nIntervals - 1)
     {
           rounded = nIntervals - 1;
     }
     return rounded;
}
inline unsigned int CompressFloat(VSREAL f, VSREAL Max , VSREAL Min ,
unsigned int Bit = 16)
{
     VSREAL Unitf = (f - Min)/(Max - Min);
     return CompressUnitFloat(Unitf,Bit);
}

解压过程实际就是CompressUnitFloat函数的逆过程,代码如下。

inline VSREAL DecompressUnitFloat(unsigned int quantized,unsigned int Bit = 16)
{
     unsigned int nIntervals = 1 << Bit;
     VSREAL IntervalSize = 1.0f / (nIntervals - 1);
     return quantized * IntervalSize;
}
inline VSREAL DecompressFloat(unsigned int quantized,VSREAL Max , VSREAL Min ,
unsigned int Bit = 16)
{
     VSREAL Unitf = DecompressUnitFloat(quantized,Bit);
     return (Min + Unitf * (Max - Min));
}

在加载动画文件后解压动画数据,代码如下。

bool VSAnim::PostLoad(void *pData)
{
     VSObject::PostLoad(pData);
     if (m_bCompress)
     {
          if (m_pBoneKeyCompressArray.GetNum() ==0 ||
                       m_pBoneKeyArray.GetNum() > 0)
          {
               VSMAC_ASSERT(0);
               return false;
          }
          for (unsigned int i = 0 ; i < m_pBoneKeyCompressArray.GetNum() ; i++)
          {
               VSBoneKey *pBoneKey = VS_NEW VSBoneKey();
               m_pBoneKeyCompressArray[i]->Get(pBoneKey,
                      m_MaxCompressTranslation,m_MinCompressTranslation,
                           m_MaxCompressScale,m_MinCompressScale);
               m_pBoneKeyArray.AddElement(pBoneKey);
          }
          m_pBoneKeyCompressArray.Destroy();
     }
     return true;
}

导出的动画最好都加入角色动画集合中,将模型与这个角色动画集合关联。这样,给出动画名字,从角色动画集合中找到对应的动画就可以播放,代码如下。

//创建角色动画集合
pAnimSet = VS_NEW VSAnimSet();
//加载动画
VSAnimRPtr  pAnim0 = VSResourceManager::LoadASYNAction(_T("Idle"), false);
VSAnimRPtr  pAnim1 = VSResourceManager::LoadASYNAction("Walk", false);
VSAnimRPtr  pAnim2 = VSResourceManager::LoadASYNAction("Attack", false);
VSAnimRPtr  pAnim3 = VSResourceManager::LoadASYNAction("RootMotion", false);
//把动画添加到角色动画集合中
pAnimSet->AddAnim(_T("Idle"), pAnim0);
pAnimSet->AddAnim(_T("Walk"), pAnim1);
pAnimSet->AddAnim(_T("Attack"), pAnim2);
pAnimSet->AddAnim(_T("RootMotion"), pAnim3);
//使骨骼模型关联角色动画集合
pSkeletonMeshNode->SetAnimSet(pAnimSet);
//重新保存模型
VSResourceManager::NewSaveSkeletonMeshNode(pSkeletonMeshNode, 
_T("NewMonsterWithAnim"), true);

示例1.1

展示给导出的蒙皮模型添加材质的过程。

示例1.2

加载示例1.1导出的模型并渲染,如图1.14所示。

pSkActor->GetTypeNode()->SetIsDrawSkeleton(true);//开启渲染骨架

图1.14 加载并渲染模型

[1] GitHub中有每个示例的详细代码。——编者注


相关图书

Python面向对象编程:构建游戏和GUI
Python面向对象编程:构建游戏和GUI
精通游戏测试(第3版)
精通游戏测试(第3版)
罗布乐思开发官方指南 从入门到实践
罗布乐思开发官方指南 从入门到实践
游戏数值设计
游戏数值设计
游戏引擎原理与实践 卷1 基础框架
游戏引擎原理与实践 卷1 基础框架
终极探索:魔兽世界(修订版)
终极探索:魔兽世界(修订版)

相关文章

相关课程