场景树是“假相”,服务器才是本质。
概况
前文提到Godot通过场景树来组织游戏逻辑。这种结构符合一般人的直觉,降低了设计游戏的难度。但是场景由大量异构的节点组成,直接处理计算机并不擅长,程序性能低下。当前计算机硬件架构决定,批量处理大量同类数据才能实现高效率。为了迎合这个特点近年来游戏领域流行所谓的面向数据编程,ECS。
Godot为了维持易用性没有在用户层面采用ECS,但在更下层也采用了面向数据编程的思想,提出了服务器的概念。所谓服务器是指一个运行在独立线程上的程序,专注某种单一数据类型的处理。常见的服务器类型有:
- 渲染服务器:负责管理渲染相关数据,如模型、贴图、材质、渲染器等等。同时按需进行渲染计算,生成画面。
- 物理服务器:负责管理物理仿真相关数据,主要分为物体和区域。同时按需进行物理仿真计算。
- 寻路服务器:负责管理寻路计算相关数据,如地图、障碍物。同时按需搜索路径。
- 音效服务器:负责管理音乐、音效相关数据,如声音片段,混响器等。同时按需播放声音。
逻辑树实际上是建立在服务器之上的抽象层。逻辑树中节点、资源的实际功能是通过调用相应的服务器来实现。这种双层设计一方面向最终用户提供了逻辑树这种友好的界面,另一方面通过服务器保证了运行效率。
工作机制
主进程负责场景树的处理,主要是节点上脚本的执行和节点之间信号的传递。服务器工作独立的线程上,每种服务器有自己独立的线程。当节点需要实际实现功能时,与服务器进行异步通信,调用相关功能。
这种设计的好处首先在与可以充分利用多核CPU的性能,不同线程在不同核心上同时运行。其次每个服务器只处理一类数据,并且往往是顺序批量处理,对CPU上的缓存和流水线友好,可以实现高性能。这种架构下当前Godot引擎的性能瓶颈主要出在场景树上,因为只能用主进程。后续已近有场景树多进程改造的计划。
场景树和服务器协作的主要方式是代理服务器中对象的功能。许多节点和资源对象中都包含一个所谓的RID成员,这个RID实际上是某个服务器上对象的句柄。对节点和资源的许多操作实际上都被转发到RID指向的对象上。例如材质资源的RID指向渲染服务器上的某个材质对象,刚体节点的RID指向物理服务器上某个刚体对象。
RID是一种不透明的句柄,主进程完全不了解句柄背后的实际实现。因此,只要句柄不变,背后的服务器可以任意替换而不影响游戏逻辑。例如,渲染服务器有多个版本,基于不同的API和渲染技术,分别支持不同类型的设备:先进的现代电脑、主机(基于Vulkan Forward),手机(基于Vulkan Cluster),过时的电脑和手机(基于OpenGL)。
服务器上所有需要暴露给主线程的对象都存储在一个称为RIDOwner的特殊容器中。RID本质是一个64位整数,分为两个部分,前32位代表对象在容器中的编号,后32位代表对象版本号。使用版本号的作用是发现RID失效。当容器中某个位置的对象被替换时,这个位置的版本号增加1。主线程使用存储的RID访问容器中某个编号的位置时,会比较RID中的版本号与位置的版本号是否匹配。如果不匹配说明RID已经失效,需要重新申请。这种机制可以有效避免所谓的悬挂指针问题,是游戏引擎中的常用技术。
总结
Godot的架构中通过服务器的概念将各类数据和功能拆解到不同线程上,实现高性能。普通用户使用场景树,高级用户可以直接调用服务器优化性能,甚至理论上完全可以抛弃场景树,只调用服务器来开发游戏。