本篇知识点来源于《游戏引擎架构》第五章,此章节主要讨论多数游戏引擎中都会出现的底层支持系统。
系统的启动和终止次序控制
C++静态初始化次序是无法使用的,原因是我们无法预引擎子系统的构造函数调用次序和析构函数调用次序。比如我要启动引擎的A,B,C系统,无法保证这些系统是按照规定次序进行创建的。所以这对含有互相依赖关系的引擎系统都不适合。
虽然我们可以通过全局静态单例构造来实现控制构建次序,但是依然没办法控制析构次序,而且我们无法确定什么时候这个单例会被创建(即显式调用:get())
简单有效的方法就是不在构造函数和析构函数里做事,而是定义一个启动和终止函数,再在一个主管理器里去显式调用这些函数即可达到效果。
更高端的方式即是让各个管理器登记在一个全局的优先队列里,之后就可以按照恰当的顺序逐一启动所有管理器,在结束之前就可以逐一弹出并调用相应的终止函数,以此来达到自动管理。
内存管理
实际上C++的全局new/delete运算符,或者malloc()/free()来动态分配内存(又称为堆分配)是很低效的,原因一个是必须处理任何大小的分配请求,这需要大量的管理开销,二是分配内存需要从用户模式转换至内核模式,上下文切换可能会耗费非常多的时间。因此我们需要做到:维持最低限度的堆分配,并且永不在紧凑循环中使用堆分配。
当然多数游戏引擎会实现一个或多个定制分配器。
基于堆栈的分配器
分配一大块连续内存,再安排一个指针指向堆栈的顶端,指针以下的内存是已分配的,以上的内存是未分配的,对于分配需求,只是移动指针位置即可。注意,这个模式下必须是以请求内存时的相反次序来释放内存。
当然有个高阶分配技巧,即双端堆栈分配器,由两个指针进行管理,一个从内存底端向上分配,一个从内存顶端向下分配。有个使用案例即是底堆栈用来加载和卸载游戏关卡,顶堆栈用来分配临时内存块,这些临时内存块会频繁被分配/释放,此举可保证不会产生内存碎片问题。
池分配器
预分配一大块内存,大小刚好是分配元素的大小的倍数。池内每个元素被加到一个存放自由元素的单链中,分配的时候从链表中取出一个来用,释放的时候再返回链表即可。分配和释放都是O(1)。
含对齐功能的分配器
分配内存时,分配比请求多一点的内存,再向上调整其内存地址至适当的对齐,最后传回调整后的地址。大多数实现里,额外分配的字节等于对齐字节。
单帧和双缓冲分配器
单帧分配器即是预留一块内存用作简单堆栈分配管理器,每段重复地做着以下工作:每帧开始时,把堆栈的顶端指针重置到内存块底端地址,用于这一帧的分配需求。指针只在目前的帧里使用,分配器每帧会自动清除所有内存。
双缓冲分配器即是两个单帧分配器循环交替使用,用以在第i+2帧之前仍能处理第i帧的结果,防止数据被重写。
内存碎片
动态堆分配会产生另一个问题,即是会随时间产生内存碎片。分配次序和释放次序不一样、尺寸不一样的情况随着时间愈演愈烈,会发现自由区域被切成无数个相对很小的“洞”,此为内存碎片。会导致就算有足够的自由内存,分配请求也仍然可能会失败。
可以使用虚拟内存技术,即把不连续的物理内存块映射至虚拟地址空间,使它看上去是连续的。
当然可以使用上述说的堆栈分配器和池分配器解决问题,前者分配和释放后剩下的内存块总是连续的,而后者因为每片内存都是完全一样大的,所以释放和使用都不会因为缺乏足够大的连续内存块。
我们也可以对堆定期进行碎片整理,也就是把“洞”从低地址移向高地址,所有可用堆内存空间沉到底端。但那些原先指向已分配的内存块的指针就会失效,于是引出一个新概念:指针重定位,即碎片整理后指针重新指向正确的位置。可以使用智能指针或者句柄来完成这项工作。
智能指针是一个类,可以编写代码正确处理内存重定位,其中一个方法即是把自身加进全局链表中,移动内存后通知全局链表扫描并找到那些指向相关内存的智能指针去更新他们的指向。
句柄通常实现为索引,这些索引指向表内的元素,元素存储指针。句柄表本身不能被重定位。移动内存块时,扫描整个句柄表并更新指针。
重定位另一个问题就是有些内存不能被重定位,有一个方法是让这些内存置于特别缓冲区中,这缓冲区在可重定位内存的范围之外。另一个方法是这些内存块直接不能被重定位。
碎片整理通常很慢,我们可以把碎片整理成本分摊至多个帧,每帧进行N(很小)次内存块移动。只要分配及释放的次数低于碎片整理的移动次数,那么堆就会经常保持接近完全整理的状态。如果是重定位非常大的内存块,可以把它切分成两个或更多的小块进行重定位。
容器
动态数组
数组大小不能编译时期决定时,常用链表或者动态数组来代替。动态数组的简单使用技巧即是在开始时分配n个元素缓冲区,当已含有n个元素并想再次扩大时,则创建一个更大的缓冲区(可以翻倍当前大小,也可以只加n个元素的大小),将原缓冲区内的元素移动到新缓冲区并释放原缓冲区。
链表
链表的每个元素都有指针指向下一个节点,在双向链表中,每个元素还有指针指向上一个节点。分为两种数据结构形式。
外露式表是一种链表,其节点数据结构完全和元素的数据结构分离,每个节点含指针指向元素(类似于编程课上那种最简单形式的,数据结构内部维护一个指针)。
侵入式表是另一种链表,数据结构被嵌进目标元素本身。
循环链表里,真正的首个节点的prev指针和真正的最后节点的next指针可指向同一个root节点。有时候还会包括头尾指针这两个属性变量用来存储头部和尾部节点。这样设计可以非常快检测节点是否属于链表(任何一个节点在循环链表里都有prev指针指向和next指针指向)。
在大学里学到的链表概念默认是单向链表,单向链表的劣势是移除某个特定节点时因为没有存储prev节点,所以删除时要遍历整个链表并找到前一个节点才能移除,相当于O(n)操作,而双向链表仅需O(1)。它的最大缺点还在于不能高效的逆向遍历。如果仅是栈或者队列需求,那么可以采用单向链表来节省一些内存。
字典和散列表
字典是由键值对组成的表,通过字典能快速用键找到对应的值。通常实现方式是二叉查找树或者散列,只不过前者所需复杂度是O(logn)而后者是O(1)。
散列表的实现就是所有值存在固定大小的表里,表的每个位置表示1个或多个键。插入键值对时把键转换为整数(不同类型有不同映射整数的方式,比如str可以映射ascii码,浮点数可以按照32位模式转换为32位整数),再把整数除表的大小就能获得表的索引。如果两个或以上的键会占用散列表的同一位置,即索引是一样的,这种情况称之为碰撞,对此有两种解决方式:
开放式散列里,多个相同的键会以链表形式存储,容易实现,但是每次在相同的索引中插入键值对都需要动态分配内存。
闭合式散列里,会进行探查过程,在附近或者通过其他巡查规律来找到下一个键值对位置。比较难实现,而且必须要设定表的键值对数目上限,好处是建立之后不用再动态分配内存。