LOADING...

加载过慢请开启缓存(浏览器默认开启)

loading

游戏引擎架构精要(才怪)②

2022/3/3

《Game Engine Architecture》是一直以来我很有兴趣也很想阅读的书,书的译者是我很敬重的milo叶劲峰前辈(魔方引擎中心的大大)。

但是由于自己基础不牢,再加上这本厚厚的大书所带来的畏难情绪,同时自己过于功利总是把时间抽去做容易提升的事情,

以及n个自己给自己找的理由,让这个阅读计划一直搁置。

但这学期拿到了暑期实习的offer,刚巧也在魔方,对于就业的压力就减小了很多

再加上自己有意往引擎方向发展,阅读这本书就提上了日程。


第四章 三维数学

第四章基本上是三维数学,包括了点,矢量,矩阵,坐标系,以及他们的运算

这些数学内容不着重描写,可以在之前计算机图形学的博客温习。

这一章会主要谈谈四元数的特点,和其他旋转表示方式的比较。

4.4 四元数

简介

我们可以用3x3的矩阵来表示旋转,但旋转只有三个自由度(pitch,yaw,roll),用9个float来表示旋转很显然是冗余的

四元数可以表达为

image-20220303222511659

其中只有单位长度的四元数能表达三维函数(四元数算式太难打了不打了)

四元数可以理解成一个矢量一个标量,一个轴(矢量)和一个角度。


运算

四元数进行加法是不能表达旋转的,因为这样就不符合单位长度了。

给定两个四元数p和q表达旋转P和旋转Q,则pq就表示P和Q的组合旋转。

四元数求逆写为q-1(自动脑补上标-1),逆四元数和原四元数的乘积为标量1

共轭四元数q*相当于原四元数的矢量部分取负号,在单位长度的情况下,共轭四元数和逆四元数相等()

因此计算逆四元数比计算3x3逆矩阵快很多,可以利用这一特点优化引擎。

具体和矢量的旋转运算见原书p180,任何三维旋转都可以在3x3矩阵和四元数之间随意自由转换。


插值

四元数对比矩阵和欧拉角最大的优越在于插值方便

四元数的插值有线性插值(LERP)和球面线性插值(SLERP)两种。

SLERP对比LERP有更准确的结果,但开销更大更加昂贵,使用哪种插值还有待商榷。


4.5 比较各种旋转方式

欧拉角

欧拉角能表示旋转,由三个标量(pitch,yaw,roll)组成,会用一个三维矢量表示

优势:简单小巧(3个float),直观,便于理解,对于某个轴的插值只需要对对应的标量做插值

缺陷:对任意方向的旋转做不了插值;万向节死锁;领域没有通用的旋转次序,不能定义一个确定的旋转,旋转的先后次序对结果有影响;


3x3矩阵

3x3矩阵是有效表达旋转的方式,不受万向节死锁影响

优势:可以确切的独一无二的表达旋转,CPU和GPU有内建支持可以硬件加速运算,纯旋转的转置矩阵为逆矩阵

缺陷:旋转矩阵不够直观,不容易想象成对应的空间变换;旋转矩阵不容易插值;相对欧拉角(3个float)旋转矩阵需要9个float。


轴角

一个以单位矢量定义的旋转轴,再加上一个标量定义的旋转角

优势:直观,紧凑(4个float),确定了左右手就能确切表示旋转

缺陷:无法直接简单的进行插值,轴角形式的旋转也不能直接施加于矢量,必须转化为矩阵或四元数


四元数

形式与轴角相似,与轴角的区别是四元数的旋转轴矢量的长度为旋转角的一半的正弦,第四分量不是旋转角而是旋转半角的余弦。

优势:能串接旋转,可以轻易插值,只需要储存4个float,可以和矩阵自由转换,无所不能

缺陷:难学(确信)


第五章 游戏支持系统

5.1 子系统的启动和终止

游戏引擎是一个复杂工程,必须按照各个系统的依赖关系进行加载和卸载。


c++的静态初始化(行不通哒)

游戏引擎大部分为c++编写,原生的启动是否可以作为启动用?

众所周知全局和静态对象是在main函数之前初始化的,我们无法预知他们构造的次序,因此是行不通的。


按需构建(还是行不通哒)

在c++中,类中声明的静态变量只会在第一次调用时构造,我们创建静态变量就能控制全局单例的构造次序。

但此方法无法控制析构次序,


在单例管理器中定义启动和终止函数(盘他)

放弃使用构造函数和析构函数,这两个函数让他们去摸鱼

我们自己定义并按所需的明确顺序调动各启动和终止函数。

class RenderManager
{
  public:
   RenderManager()
   {
      //摸鱼
   }
   ~RenderManager()
   {
      //摸鱼
   }
   
   void startUP()
   {
      //启动管理器
   }
   
      void ShutDown()
   {
      //终止管理器
   }
}

.....

class AnimationManager{....}
//巴拉巴拉...

RenderManager gRenderManager;
AnimationManager gAnimationManager;
//巴拉巴拉...

int main()
{
    gRenderManager.startUP();
    gAnimationManager.startUP();
    
    //运行游戏
    
    gAnimationManager.ShutDown();
    gRenderManager.ShutDown();   
    
//以相反的次序终止各系统

}

5.2 内存管理

通过malloc和free或者new/delete来动态申请内存(堆分配),是非常慢的操作

原因是堆分配器是一个通用的设施,必须能处理任何大小的请求,需要大量管理开销(众所周知越通用的东西就越不高效越不强大)

在多数操作系统中,动态分配内存会使得从用户态切换到核心态(好耶刚刚学)

ue在内存方面是怎么处理的则在另一篇博客新坑有比较详细的说明


基于堆的分配器

分配一大段连续内存,安排一个指向堆顶部的指针,用来标识已分配的和未分配的空间

进行分配时,只需要把指针往上移动请求所需的字节数即可。释放空间时,记得以进行分配时相反的次序进行释放

可以编写一个函数,把堆顶指针回滚到上一次标记的位置,即释放回滚点之后到堆顶的所有内存

伪代码可以在原书p220查看


双端堆分配器

将一块内存给两个分配器使用,一个从底端向上分配,另一个从顶端向下分配。

这个方案很实用,因为允许权衡两个堆栈的使用,因此能更有效的运用内存。


池分配器

在分配大量同等大小的小块内存时(矩阵,迭代器,可渲染的网格实例),池分配器则是不二之选。

做法是预先分配一大块内存,大小刚好是元素分配内存大小的倍数。

例如4x4矩阵池就是16个元素乘以每个元素4字节(32位float)或8字节(64位double)。

(ps:原书的意思应该是这个池本身是一个4x4矩阵的形状,用来存放16个元素,而非储存一个4x4矩阵)

收到分配请求时只需要取出元素,释放则只需要回收回池中,分配和释放都是O(1)的操作。


含对齐功能的分配器

所有内存分配器都传回对齐的内存块(具体实现伪代码在原书p223有详解)

对齐的内存块能让CPU更加高效的读写。


单帧和双缓冲内存分配器

几乎引擎都会在游戏循环中分配一些临时用的数据,要么在这次循环中丢弃,要么在下一次迭代丢弃。

单帧分配器的做法是先预留一块内存,并以前文所述的基于堆的分配器分配,在每一帧开始时将顶端指针重新定向到内存块的底端地址。

益处是分配的这块内存永远会在循环中重置,不需要我们手动释放,也极其高效

但我们必须注意决不能将指向单帧内存块的指针跨帧使用(nullptr警告)

双缓冲分配器允许在第i帧分配的内存块用于i+1帧,实现则是简历两个相同尺寸的单帧堆分配器,每帧交替使用

在缓存非同步处理的结果时,这类分配器极其有用。双缓冲模式在渲染里因为可以解决画面撕裂的问题得到了广泛应用

我们在当帧完成前将结果写入缓存,在下一帧时缓冲处于不活跃状态,我们依然可以安心使用数据。


今天看到内存管理的部分就没怎么看了,原因是和糕糕老师聊ue的内存管理这部分聊了很多有意思的东西,一不注意时间就过去了。

不过感觉今天收获还是不少滴,吸收的也还可以,日后想动手自己实现一个内存管理方案了。2022/3/3