游戏世界的加载与流送
Loading & Streaming Game Worlds
世界加载系统负责两件事:把世界块和所需资源从磁盘读进内存的文件 I/O,以及这些资源的内存分配与释放;另外还要管理游戏对象的生成与销毁。GEA 按演进顺序介绍:一次只载一个关卡的栈式加载(LSR 资源压栈底),用小块气闸 (air lock) 区域实现基本无缝的过渡,再到把所有资产切成等大块、用块池分配器按关卡加载区域流送的细粒度方案(Naughty Dog);对象生成的内存问题有"不许运行时分配"、按尺寸分池、小内存分配器和内存重定位四种对策;存档则分检查点和随处存档(GEA4 卷 II 17.4)。
简单关卡加载
最早的游戏一次只允许载入一个世界块(关卡),开始游戏和关卡之间显示静态或简单动画的二维加载画面(卷 II 17.4.1)。内存管理很简单,非常适合栈分配器(卷 I 7.2.2.7,见 自定义内存分配器):
flowchart LR A["载入 LSR 数据<br/>记下栈标记"] --> B["载入关卡 A 的资源"] B --> C["卸载关卡 A:<br/>栈指针退回标记"] C --> D["载入关卡 B 的资源"]
上图对应 GEA 图 17.11:游戏启动时把所有关卡都要用的载入并常驻 (load and stay resident, LSR) 资源载入栈底并记录栈指针;每个世界块及其网格、纹理、音频、动画等资源压在 LSR 之上;关卡完成后把栈指针重置到 LSR 顶部即释放全部关卡内存。缺点:玩家只能看到离散的块,无法做巨大连续的无缝世界;加载期间内存里没有游戏世界,只能看加载画面。
走向无缝加载:气闸
最好的办法是让玩家在下一个世界块加载时继续玩(卷 II 17.4.2)。
- 把世界内存平分两块,玩 A 时在另一块里用流式文件 I/O(在单独线程中加载)载入 B:每个关卡大小因此减半。
- 改为不等分:一块能装"完整"世界块,一块只够装很小的块,即气闸。开始时载入一个完整块和一个气闸块;玩家穿过完整块进入气闸后,某种门或障碍保证他既看不到也回不去之前的区域,于是卸载完整块、载入新的完整块;加载期间玩家在气闸里做点事,可以只是走过一段走廊,也可以是解谜或打敌人。
- 这依赖异步文件 I/O(卷 I 7.1.3,见 引擎文件系统与异步文件读写)。开新游戏时仍需要加载画面,因为初次加载时内存里没有可玩的世界;之后玩家就不必再看加载画面。
- 实例:Xbox 上的《光环》大区域之间总有较小较封闭、防止回头的区域,大约每 5–10 分钟遇到一个;PS2 上的《杰克 2》以主城为中心,各分支区域通过狭小的气闸区与主城相连。
游戏世界流送
很多设计要求玩家感到身处巨大、连续、无缝的世界,最好不要周期性地被关进气闸。现代引擎用流送 (streaming) 实现,目标总是:(a) 在玩家正常游玩时加载数据;(b) 管理内存以消除碎片,同时允许按需加载卸载(卷 II 17.4.3)。
- 粗粒度:内存分成例如三个等大缓冲,先载入 A、B、C,玩家进入 B 且看不到 A 后卸载 A、载入 D,依此循环。问题是所有块必须大致等大:足够填满一个缓冲的大部分,但绝不能更大。
- 细粒度:把所有资产(世界块、前景网格、纹理、动画库……)都切成等大的数据块,用块式、基于池的分配系统(卷 I 7.2.2.7)按需加载卸载,不必担心碎片。GEA 说这基本就是 Naughty Dog《最后生还者》引擎的做法,另外还有利用未填满块末尾空间的精巧技术。
决定载入哪些资源:关卡加载区域
Naughty Dog 引擎用相对简单的关卡加载区域 (level load region) 系统(卷 II 17.4.3.1)。神秘海域和最后生还者系列都发生在多个地理上分离的连续世界里(例如《神秘海域 4》的巴拿马、新奥尔良、苏格兰、马达加斯加和海盗乌托邦利伯塔利亚),每个世界有自己的世界空间,又划分为许多相邻的块。
- 每个块被一个简单的凸体区域包围,区域之间略有重叠;每个区域列出玩家在其中时应在内存中的世界块。
- 任意时刻玩家位于一个或多个区域内,取包围主角 Nathan Drake 的所有区域的块列表并集作为主块列表。
- 加载系统定期比较主列表与内存中的块:从列表中消失的块被卸载,释放其占用的所有分配块;新出现的块被载入到能找到的空闲分配块里。
- 区域与块的设计保证玩家永远看不到块消失,并且从开始加载到内容第一次可见之间有足够时间完成流送(GEA 图 17.12)。
PlayGo:PS4 和 PS5 的 PlayGo 只下载玩第一段游戏所需的最小数据子集,其余内容在后台下载、玩家不受打断;前提是游戏支持上面这种无缝关卡流送(卷 II 17.4.3.2)。
与 UE5 的对照:世界分区 把世界划分为网格单元,按流送源(通常是玩家)周围的加载范围自动加载卸载,取代了手工划分子关卡的做法。GEA 的关卡加载区域是由设计者显式放置的凸体,世界分区则是基于网格和距离的自动划分。两者都要求"开始加载到可见之间有足够时间",这是对比归纳。
对象生成的内存管理
世界载入后还要管理动态对象的生成。动态分配可能很慢;对象尺寸各异,动态分配还会造成碎片、提前耗尽内存(卷 II 17.4.4)。
离线分配:有的引擎干脆禁止游戏过程中动态分配:载入世界块时立即生成所有动态对象,此后不再创建或销毁对象,可以理解为"游戏对象守恒定律"(卷 II 17.4.4.1)。
- 好处:每个块的对象内存需求事先已知且有界,可以由世界编辑器离线分配、放进世界块数据里,和其他资源一样不易碎片化;内存使用高度可预测,不会突然冒出一大群对象撑爆内存。
- 坏处:限制策划。可以在编辑器里预先放一个隐身、休眠的对象,"生成"时激活并显示,但策划必须预估每种对象的总数;想要无限供应的血包、武器或敌人就得设法回收对象,否则无解。
动态分配(卷 II 17.4.4.2):策划更喜欢真正的动态生成。主要问题仍是碎片:对象大小不同(同类对象的实例有时也不同),不能直接用无碎片的池分配器;销毁顺序与生成顺序不同,也不能用栈分配器,只剩容易碎片化的堆分配器。对策:
- 每种对象类型一个内存池:前提是同类型实例大小相同;其实只需每种尺寸一个池。可以完全避免碎片,但池很多,还要猜每种对象需要多少个:多了浪费,少了生成失败。
- 小内存分配器 (small memory allocator):允许从元素比对象大的池里分配,大大减少池的数量,代价是每个池里有些浪费。例如一组池的元素大小依次翻倍:8、16、32、64、128、256、512 字节,也可以用其他合适的序列或根据游戏运行时收集的分配统计来定。分配时找元素大小不小于对象的最小池;超过最大池的请求交给通用堆分配器,因为大块碎片远没有小块碎片严重。它能消除能放进池的那些分配的碎片,也能显著加速小块分配:池分配只需两次指针操作,把元素从空闲链表中取下。
- 内存重定位:直接对付碎片,把已分配的块移到相邻空洞里。移动内存容易,难在要修正所有指向被移动块的指针(卷 I 6.2.2.2)。
存档
存档系统与世界块加载系统相似,都从磁盘或存储卡载入世界状态,但需求不同,所以通常是两个独立(或只部分重叠)的系统(卷 II 17.4.5)。
- 世界块描述所有动态对象的初始状态和所有静态元素的完整描述;背景网格、碰撞数据等很占空间,所以一个世界块有时由多个文件组成,总量通常很大。
- 存档只需存对象的当前状态,且不必重复存能从世界块数据得到的信息(比如静态几何);对玩法无影响的对象可以完全省略,其他对象可能只需部分状态。只要玩家分辨不出存读档前后的差别(或差别无关紧要),存档系统就是成功的。所以存档比世界块小得多,更强调压缩和省略。旧主机的小存储卡要求这样;即使现在主机有大硬盘和云存档,存档也应尽量小。
检查点 (checkpoint):只允许在特定点存档。好处是每个检查点附近的状态大多已在当前世界块里,与是谁在玩无关,所以存档可以极小:可能只要存最后到达的检查点名,或许再加玩家生命、剩余命数、背包物品、武器和各自弹药;有的游戏连这些都不存,每个检查点都从已知状态开始。坏处是检查点稀疏时容易让玩家沮丧(卷 II 17.4.5.1)。
随处存档 (save anywhere):任意时刻都能保存。存档会大很多:每个状态影响玩法的对象的当前位置和内部状态都要保存再恢复。这时存档基本等于世界块减去静态部分,理论上两者可以用同一种格式,但可能不可行,例如世界块格式为灵活性设计,而存档格式为减小体积而压缩。减小存档的方法仍是省略无关对象和无关细节,例如不必记住每个动画的精确时间索引或每个刚体的精确动量和速度,可以依赖人类玩家不完美的记忆,只存近似状态(卷 II 17.4.5.2)。
UE 的对应:存档系统 让开发者从
USaveGame派生、只把需要跨会话保存的数据拷进去再写入槽位,这与 GEA 说的"只存影响玩法的部分状态"一致;UE 文档没有区分检查点与随处存档,具体策略由游戏决定(对比归纳)。
出处
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.4 节