« September 2023 | Main | November 2023 »

October 25, 2023

游戏引擎的虚拟文件系统

目前我们游戏用的引擎早在 2018 年就开始了。因为一开始,它就定位为一个主要用于手机平台的游戏引擎,要方便手机开发。因为我们不太可能直接在手机设备上编写代码、制作资源,所以开发机一定是和游戏运行环境分离的。从一开始,我们就设计了一个虚拟文件系统,它可以通过网络,把开发机上的文件系统映射到手机设备上,同时兼有版本管理的功能。这样,才可以做到在开发期间,开发机上所做的大多数修改,都能立刻反映到手机上。

我们的游戏引擎的大部分是用 Lua 开发的,这也意味着文件系统中不光有游戏用的资源素材,还包括了代码本身。甚至包括了虚拟文件系统自身的实现。这个东西比一开始想的要麻烦,我们这几年不断地修改它,直到最近。比如一开始认为最麻烦的自举部分 ,在去年就去掉了,为的就是减少系统的复杂度。

简单说两句自举的问题:

常规的考虑是有一个自更新的机制,在发现需要增加需求或修补已有的 bug 时,可以不经过 app 重新打包的流程将新版本更新并持久化在外存中,并可以在自举过程中完成更新顺利初始化新版本的系统并无缝启动引擎(文件系统一定要先于整个引擎完成初始化)。但由于 iphone 手机这样的特殊环境,我们很难启用多个进程完成这个这个工作,甚至难以重启自身进程,所以做起来非常麻烦。另外,一旦更新失败,还需要一定的自恢复机制,保证在不重新安装 app 的前提下,回滚到上一个版本。

我们最初的实现虽然基本完成了任务,但在启动阶段引入如此繁杂的过程总是让人不安。所以我们在去年一次重构中去掉了这些,换成了一个无法自更新的简单版本。但是,更新问题依然需要解决。大多数情况下,我们选择重新打包和发布 app ;但也可以选择在内存中更新虚拟文件系统(而不是永久性的更新磁盘上的版本)。这得益于 Lua 的动态性,即使启动完毕,我们以后可以在后面的业务代码中,修改已在内存中的 api 实现。另外,虚拟文件系统工作在 ltask 这个多任务框架上,它的核心部件是一个独立的 IO 线程,在独立的 Lua 虚拟机内,这也方便了在内存中单独更新它。一旦有问题,因为不涉及外存,也就不必回滚。

btw, ltask 固然用独立虚拟机增进了 vfs (虚拟文件系统)的健壮,同时也带来了一些麻烦:因为 ltask 的很大一部分也是 lua 实现的,它们的代码本身是放在 vfs 中。这个麻烦也是我们简化 vfs 的自举部分的动机。


今天这篇 blog 想展开另外一个问题:我们设计 vfs 的初衷是为游戏引擎服务,而游戏引擎在使用文件系统时基于了一个假设:这个文件系统是只读的,如有存档等需求,会把这些文件保存在 vfs 之外。也就是说,对游戏来说,vfs 仓库在游戏启动那一刻,所有的文件结构都已经确定了。基于这点,我们才能用简单的方法实现它,并给它加上类似 git 的版本管理机制。在 vfs 中,目录结构是一颗 Merkle tree ,我们可以通过 hash 值获取到任何一个版本的文件或目录。但这都基于目录树在运行过程中不会发生变化。

但在开发过程中,我们发现,有不少基于这个引擎开发的应用程序会打破这个规则:编辑器就是一个典型的例子。编辑器开起来一定会修改本地文件,而这些文件最终又会变成引擎需要读取的资源。另一个例子是,vfs 是一个 C/S 结构,引擎的运行时部分是 C ,它会把 vfs 的文件 cache 在本地的物理文件系统中,但仅仅是一个 cache ;这些文件的源头是 S 通过网络同步过来的。我们为这个 S 开发了一个叫 fileserver 的工具。看起来,这个 fileserver 也会打破:在运行过程中 vfs 仓库不会变化,这条约定。

由于这样的现状,我们只好开发了两个版本的 vfs 模块,一个是上面提到的 C/S 结构中的 C ,另外一个不需要 S 而直接访问本地文件系统,它们的 api 是几乎一致的(后者多一些修改本地文件的方法)。虽然,两个版本已经相安无事的同时运行了几年,但在前几年,我们主要在 PC 上做开发,贪图方便,大家均使用后一版本(它更方便调试);只有在手机上,才运行前一个完整版本(需要 fileserver 支持)。这两者在实现上毕竟有所不同,所以,经常会出现两个版本的某些行为不一致。

我为这个问题苦恼了很久。一直在致力于统一成同一个版本。但如何解决 vfs 需要读写这个问题呢?我并不想为了游戏运行时不需要的写特性,而放弃那个简单的仓库不变的设计。

让我们来看看编辑器的具体需求:

编辑器其实是对一个可编辑实体(通常被我们成为 prefab 预制件)的修改操作。在编辑过程中,我们还会为 prefab 导入新的外部模型、贴图、编写新的 shader 等等。这些新增的资源原本是在 vfs 中不存在的。而引擎的底层,需要通过 vfs 拿到这些资源以做渲染。

重新审视编辑器需求,我发现其实向文件系统写入文件和目录其实并不是必须的。虽然看起来我们需要修改和增加它们,再让渲染底层把它们读出来。在此之前,我们已经把游戏中用到的资源:模型、贴图、着色器等等分门别类的在独立服务中管理起来了。应用层不在直接持有它们的底层 handle 。这样,可以实现透明的异步加载。渲染层持有一个间接 handle ,背后对应的是一个 vfs 路径。如果实体不在内存中,根据不同的类型,底层会有不同的替代方案,例如,贴图会有一张统一的替代图。这样,实体不必等待所依赖的资源完全加载到内存就可以创建出来,不会因为 IO 阻塞住程序。

所以,这些资源模块所依赖的是一个 vfs 路径,而不是 vfs 系统本身。要解决编辑器难题,最简单粗暴的方法是:所有要编辑的东西,都放在内存里。如果一个 vfs 路径对应的资源数据永远在内存,我们就不再关心在文件系统中的那个文件是否真的存在、是否被修改了。而保存编辑结果这个操作,仅仅只是对使用者编辑成果的备份,我们完成了备份即可,而用户并不关心怎样写入磁盘、到底写去了哪里。

如此,我们就把编辑器所依赖的资源分成了两部分:引擎本身和正在编辑的东西;前者走标准的 vfs 只读镜像,后者只存在于内存中,准确说,在专门的资源管理服务的 lua 虚拟机中。对于在内存中的 vfs 路径,就永远不再从外部读取。这样就没有破坏 vfs 在启动那一刻就不再变化的约定。


另一个有趣的问题是 fileserver 。它本身就是为 C 端提供数据的。看起来它作为 S 端,无法将自己运行在 C 端,也就是它本身的源码无法放在 vfs 仓库里。但细想却也未必。

因为我们的游戏运行时(vfs 的 C 端)本身是可以离线工作的。如果它无法连接到 fileserver ,那么它会完全使用上一次的 cache ,那里面通常有一份仓库的快照。所以,只有我们用另外一个 fileserver 为主 fileserver 提供服务,让它把自身用到的代码快照下来放在自己的 cache 中,后续就不再需要第二个 fileserver 了,直到 fileserver 自身的实现需要更新。

October 20, 2023

手机游戏交互的两点改进

最近在手机上玩我们正在开发的游戏时间比较多(之前主要运行的是 Windows 版),发现有许多操作值得改进。这里列举两个值得一提的交互优化。

第一,触摸屏上点击特别小的物件时很难精确。去年我提过一个解决方案 ,我们的游戏一直是这样处理的:连续 tap 屏幕同一个位置,会轮询这个位置附近的物件。这样,一次点不准的话,可以多点几次选到想要的焦点。这不仅可以解决小物件密集的时候,tap 不好区分的问题,还可以选到重叠的物件。这个方法其实在鼠标游戏中已有这么使用的了,比如 rimworld 就是用类似方法处理同一个格子上有多个可选物件的。

但实际用下来,玩家在处理小物件选取时,被培养的最好的操作习惯还是先用双指放大,推进镜头,让小物件在屏幕空间变得很大,然后才去选取。这不仅仅是因为触摸屏不够精确,还因为手指会挡住视线,如果物件过小,很难确定是否选中。

对于手指挡住视线的问题,我用额外的一个提示窗口显示在旁边来解决。选取这件事,我想应该照顾用户的操作直觉,并提供遍历。所以我最近换了一种交互方法:当用户长按屏幕,摄像机转到顶视角,并推进。作为一个 3d 游戏,处于视觉美观的原因,我们平常游戏时,摄像机不不是垂直向下的。而近距离选择物件,则用顶视角更好。这样,高大的建筑才不会挡住背后的东西。如果手指不离开屏幕的话,移动手指不再移动摄像机(即不再移动画布),而改为移动选取焦点。操作者可以通过移动手指选取刚刚按下时的地方附近的东西。因为镜头已经推的很近,所以可以轻易选择,物件在镜头内很大,所以也缓解了手指挡住视线的问题。当手指离开,摄像机则原路返回之前的状态。

这里有个小的技术问题:当手指按压的地方不是屏幕中心时,不能简单的旋转并推进相机。因为这是一个 3d 空间,简单的旋转摄像机,会让按压的地方偏离它的屏幕位置。我们应该一边旋转和推进摄像机,一边调整它的位置,保证整个过程中,手指按压的那个地方永远在屏幕的同一个地方。也就是让选取平面上的一个点成为摄像机镜头内的不动点。这是一个并不复杂的数学问题,这里就不展开了。

第二个问题是:当我们用手指移动焦点时,虽然脑子里想着水平或垂直移动,但实现操作却很难保持直线。对于建筑游戏来说,很多情况下我们都希望把建筑修成整齐的一排,公路平直,但手指远没有鼠标好控制,更比不上键盘控制方向。

在很多设计软件中,都有锁定运动轴,锁定平面的方法。你可以拖动一个被锁定的手柄,运动方向被限制在一个轴上,这样就不会因为手抖而移歪了。

直接把它照搬到游戏中是不合适的,尤其是触摸屏操作会更繁琐。我摸索了一天,找到了一个相对舒适的操作方法。当手指按下并移动时,程序监测移动的速度。只有当移动速度慢过一个阈值,且 X Y 两个轴上的移动距离差距较大(即倾向于轴向移动),自动开启轴锁定状态,这个状态一直持续到手指离开为止。

在轴锁定状态中,屏幕上有明显的标识表明处于这个状态,不至于让操作者觉得怪异。而解除锁定只需要放开手指即可。

实测下来,快速的长距离戳动屏幕移动画布不会受锁定的影响,而需要细致操作时,玩家只需要缓慢的向一个轴向移动,移动马上就会锁定在这个轴上,接下来可以放心的在这个轴上对位置,不用担心手抖错位。