« October 2023 | Main | December 2023 »

November 30, 2023

游戏数据包的补丁和更新

我们的游戏引擎是基于虚拟文件系统,可以通过网络把开发机上的目录映射到手机上。这对开发非常方便,开发者只需要在自己的开发机上修改资源,立刻就能反应到手机上。

但当游戏发行(也就是我们正在准备的工作),我们还是需要把所有资源打包,并当版本更新时,一次性的下载更新补丁更好。

之前一直没时间做这方面的工作,直到最近才考虑这个问题。我们到底应该设计一个怎样的补丁更新系统。

我不是第一次设计这玩意,早在 20 多年前我就为大话西游设计过一个。但我这次想重新思考这个问题,用一些更标准的技术来做,比如,使用标准的 zip 包格式,而不是自己重新设计。

当然,怎么把文件打包是次要问题,主要问题是怎么解决版本间的差异更新。用户可能停留在不同的版本上,都应该可以正确更新到最新的版本。如有可能,还应该支持版本回滚。

传统的方法是用一个递增的版本号,打包时,仅打包版本间的差异。用户要更新版本时,下载从本地版本到最新版本间的所有 patch 文件,按严格的次序依次打包。我觉得这个方法固然没什么大问题,但不是特别好。因为它不够健壮,缺失一个 patch 就会让升级无法完成。而频繁的版本更迭会导致太多的 patch 。虽然可以定期打包一个全量的包来阻止太多的 patch 文件,但也只是个不太干净的补救手段。版本回滚和分支版本发布都会比较麻烦。


我们的 vfs 系统其实是一棵 Merkle tree 。每个文件的文件名就是它内容的 hash 值。而整棵树的根的 hash 值就是一个天然的版本号。(btw, 它天然是防篡改的。)所谓打包,就是把当前版本的整棵树的文件打包为一个包文件。这个文件的文件名可以就是它的根的 hash ,也就是版本号。

所以,版本号不需要是递增的数字,这样,从一个版本切到另一个版本,也不用区分是更新、还是回滚、亦或是分叉。git 就是这样管理版本的,我们的 vfs 也一样,只不过现在要处理如何打包补丁的问题。

所谓补丁,我们是为了减少更新的带宽,减少用户设备上的存储空间。因为 vfs 中文件的文件名就是内容的 hash 。所以找到补丁和上个版本的差异,只是找到那些新增的文件即可。假设在打包机器上已经有很多历史版本的包,那么,我们需要做的就是用当前版本的完整列表和历史版本包文件内列表相比较,找到新增文件数量最少的那个,并打包新增加的文件即可。

在包里面,可以在补上一点元信息:这个包是补丁包,它的完整版本还依赖另一个版本 hash 。

用户在更新时,一旦需要切到某个特定版本(更新服务器上有所有版本的列表以及建议的最新版本),就下载那个版本的 hash 名的文件即可。下载后,检查元信息,看看所依赖的版本 hash 本地是否存在,如果不存在,再重复前面的过程。

这样更新的好处是,完全兼容平时开发中的 vfs 同步。如果我们用开发版本同步过某些历史版本(这些版本未必发布过更新补丁),再下载更新补丁的话,也能顺利的找到需要的补丁文件,把本地资源补全到完整版本。


这个方案中,不再区分完整版本包和补丁包。它们都代表了某个特定版本,只不过包内数据全或不全。我们在包的元信息中记录三样信息:

  1. 这个版本的根 hash 是哪个文件。一般同时是包自己的文件名,但这个信息不应该依赖包的文件名,所以也记录在包内的元信息里。这样,包文件名就可以任意发挥。

  2. 这个包的数据不完整的话,数据还依赖哪(几)个 hash 版本。

  3. 这个包依赖哪个版本的二进制执行文件。这个通常是源代码的 git hash 版本号。因为执行文件是不打包在资源包里的,所以需要单独注明,已便运行时校验。

November 24, 2023

Lua 的 C 模块之间如何传递内存块

Lua 的数据类型非常有限,用 C 编写的 Lua 模块也没有统一的生态。在不同模块间传递内存块就是件很头疼的事情。

简单通用的方法就是用 Lua 内建的 string 类型表示内存块。比如 Lua 原生的 IO 库就是这么干的。读取文件接口返回的就是字符串。但这样做有额外的内存复制开销。如果你用 Lua 编写一个处理文件的程序,即使你的处理函数也是 C 编写的模块,也会复制大量的临时字符串。

我们的游戏引擎是基于 Lua 开发的,在文件 IO 上就封装了自己的库,就是为了减少这个不必要的字符串复制开销。比如读一个贴图、模型、材质等文件,最后把它们生成成渲染层用的 handle ,数据并不需要停留在 Lua 虚拟机里。但是,文件 IO 和资源组装(比如贴图构造)的部分是两个不同的 C 模块,这就需要有效的内存交换协议。

我们又不想让所有的 C 模块统一依赖同一个自定义的 userdata 类型。例如 bgfx 的 Lua binding 就是一个通用模块,不一定只在我们这个游戏引擎中使用。引入一个特定的 userdata 感觉不太好。

所以,我倾向于协定一个数据交互的协议,而不是共同依赖同一个库实现的特定用户类型。

首先,用 string 交换内存块肯定是最通用的协议,它的问题是低效,有无谓的内存拷贝,多余的对象需要通过 gc 清理。

我们很早就给几乎所有的 C 库增加了 raw userdata 的支持:即把不带 metatable 的 userdata 视为普通的 string 。userdata 和 string 在 Lua 的内部实现中也非常类似,均可以表达一个带长度的内存块,区别在于 userdata 的数据是可变的,string 的数据是不变的。

我在很多自己编写的 C 库中增加了第三种协议,用一个 lightuserdata + integer 表示一个内存地址和长度。比如 skynet 的 C 库就支持这种协议。这个协议的问题有两个,其一参数变成了两个,和单个 string 或 userdata 不一样,处理起来非常麻烦;其二,无法管理 lightuserdata 的生命期。

为了解决生命期管理问题,在实现 bgfx lua binding 时,我又增强第三种协议:在内存地址和长度之后,允许再增加一个叫 lifetime 的 object 。如果需要管理生命期,Lua 侧就把这个对象引用住,不再使用那个地址后,就解开引用。当这个 lifetime object 是 string 时,我们就可以用前面的 lightuserdata 指定字符串内的子串,而不需要真正构建一个新的字串对象了;这个lifetime object 也可以是带 gc 元方法的 table 或 userdata ,负责最后释放内存指针。

今天,我们又重新审视了这个问题。动机是这样的:

过去,我们在每个线程(独立虚拟机)中分别做 IO 。这样,我们自己实现的 IO 库可以使用上面的第二种协议返回一个 userdata ,传递给其它模块使用。最近,我们想把 IO 全部挪到唯一的 IO 线程做,它读取数据后,再传递给请求方。这样,就涉及虚拟机间的数据传递。

在上面第二方案中,raw userdata 必须在同一虚拟机内创建再使用,无法接收外部传来的数据。而换成第三方案(在我们现在的游戏引擎中并未使用过)又没有很好的解决第一个问题:多于一个参数和单个 string / userdata 不同,会让协议实施起来很麻烦。

考虑再三后,我觉得可以引入第四个方案:用单个 lua object 承担内存地址、长度、生命期管理三项数据。

简单说,我们需要一个 tuple ,把三元组打包在一起。Lua 中可以用来表示 tuple 的有三种东西,table (array) ,userdata + uservalue ,function closure 。因为 raw userdata 已经放在第二方案中使用,我不想和它冲突,那么可选的就是 table 和 function 了。我觉得 function 最为合适。

当传递一个 function 时,我们用 lua_call(L, 3, 0); 调用它,就可以拿到一个三元组。前两个就是内存地址(lightuserdata)和长度(integer);第三个是可选项,用来管理这个地址的生命期。进一步,当这个生命期对象是另一个 function 时,我们还可以直接在使用完内存后调用一下这个关闭函数,解除内存的引用;或者(当它不是 function)和前面第三方案一样,依赖这个对象的 gc 清理内存。

贴图管理模块及 UI 上的 3D 模型

我们游戏引擎的 UI 使用的是类似网页的技术,是将 RmlUI fork 出来的自行维护的版本 。目前游戏中大量遇到的一个需求是:把 3d 模型作为 UI 组件使用。这个需求在我经历过的历史项目中都曾遇到过,在不同的游戏引擎中我见过各种解决方案。

最典型的是 RPG 类游戏的人物属性面板。通常需要在面板上显示 3D 人物模型。通常还可以旋转这些模型,让玩家 360 度观看。我们目前的游戏类似 Factorio ,没有 Avatar ,但点开建筑的信息面板时,也需要把建筑的 3D 模型动态展现出来。

最初,我们没去细想 3D 渲染怎么和已有的 RmlUI 结合在一起,直接把模型渲染在 UI 层之上。相当于在 UI 模块外开了个后门。UI 上只需要把位置空出来,等 UI 渲染完后,再叠加 3D 模型上去。但这样做的坏处是很明显的:3D 模型无法和 UI 窗口有一致的层次结构。

后来,我们额外构造了一个 render target ,改造了一点 RmlUI ,让它可以支持一个矩形区容纳这个 rendertarget 的画布 。这样,3D 模型渲染就比较好的和 UI 模块融合在一起。但是需要单独编写 UI 上 3d 元素的相关代码,尤其是管理它 ( rendertarget )的生命期。

最近,我希望在 UI 上增加更多 3d 模型。它们仅仅是用来取代原来的 2D 图片。从 UI 角度看,这些就应该是图片,只不过这些图片并不是文件系统中的图片文件,而是运行时由 3d 渲染模块生成的。如果继续沿用目前的图片方案,我们就多出一些开发期处理这些预渲染图片的维护成本。但是,如果直接使用已有方法的话,那个看起来临时的解决方案又有点不堪重负。

由于前段时间我们已经重构了贴图管理模块,引擎中所有贴图均纳入同一个线程的同一 Lua 虚拟机内管理。我突然想到,如果 UI 认为这些 3D 模型应该是一张图片,那么,它们其实就是图,而不应该是画布。图片目前是用一个字符串指定的本地文件路径标注的,但和网页技术一样,这个字符串应该是 URL 才对。URL 的前缀可以指明资源的来源,我们可以从本地文件系统中获取,也可以用 http 协议向服务器索取,当然也可以让 3d 渲染器渲染出来。

先介绍一下,我们重构的贴图管理模块做了些什么。

现在引擎中一切引用贴图的模块,都会用一个 handle 指代一张贴图。而渲染底层,我们使用的是 bgfx 。在最初,这个 handle 就是 bgfx 的 texture 对象的 handle 。再重构之后,我们把它换成了一个间接层的 id 。把这个 id 提交到渲染层时,需要用一个 C API 转换为 bgfx handle 再提交渲染。任何模块都不再长期持有底层 handle ,而是每帧都做这个转换。

然后,我们在 ltask 中开辟了一个独立的服务管理所有的贴图 handle 。所有贴图都可以立刻创建,得到一个 handle ,但数据的加载却可以是异步完成的。也就是说,任何用到贴图的模块都可以同步立刻加载贴图,但一开始只是一张纯色的替代图,在几帧之后,贴图管理服务(线程)加载好了数据,它才被替换成真正的图片。而且,贴图管理服务有权不告知使用者而自行把它认为不再使用的贴图从内存中清除。这使用的是一个简单的 LRU 算法,只要一定时间内,使用者没有提交贴图去渲染就会被清理。

每张贴图都是以一个字符串做唯一索引的,如果这个字符串是本地的文件路径。那么,当贴图管理器自行删除了一张很久没被使用过的贴图后,如果又有人想渲染它,那么,管理器就会依靠这个字符串把它加载回来。

后来,我们把这里的字符串从本地文件路径改成了 URI ,加上了协议名,这样就不局限于从文件系统加载了。如果我们另外定义个叫贴图渲染器的协议,那么它也可以从特定的渲染器生成这张图片。

到这里,在 UI 上显示一个 3d 模型就变成了顺利成章的事情。 RmlUI 并不需要关心图片是怎么生成的,它只是去问贴图管理器要了一个叫 render://name 的东西。而贴图管理器也并不直接渲染图片,它根据协议名,把加载图片的请求转发到了 render 服务。而 name 并不需要携带所有渲染图片的一切状态参数,比如摄像机的角度,光照信息。这些是别处直接和 render 服务沟通好的。写在 UI 描述的 css 文本中的只是一个简单的名字字符串。

其实并不限于静态的图片,一个每帧都在变换的动画图和静态图并没有区别。因为 RmlUI 并不关心贴图 handle 背后到底是什么。如果有一天我们需要在 UI 上插入视频播放,依然会按图片处理,只不过图片的协议名会改成 video:// 罢了。

November 06, 2023

虚拟文件系统的资源惰性编译

上篇谈了一下我们游戏引擎的虚拟文件系统(vfs)。我觉得这个系统中,游戏资产的管理部分还是个满有意思的设计,值得写一下。

VFS 的设计动机是方便把开发机磁盘上的数据同步到运行设备(通常是手机)中。传统游戏引擎的做法通常是建一个叫做资产仓库的东西,在开发期间不断添加维护这个仓库。需要把游戏部署在运行设备时,再打包上传过去。因为传统游戏引擎在开发期间一般直接在开发机上运行,所以打包上传(从开发机转移游戏资产)并不频繁。

而我们的游戏引擎特别为手机游戏开发而设计,我们不可能直接在手机设备上开发,所以开发机一是和运行机分离的。为了提高开发效率,所以我们设计了 VFS 系统。可以通过网络同步资源仓库。

为了方便持续开发,vfs 被设计成带版本管理的。这里,我们借鉴了 git 的做法:所有仓库中的文件都以其内容的 hash 值为索引。当文件发生改变时,就产生了一个新索引项。而内容相同的文件,无论它的文件名是什么,放在什么目录下,在仓库中都只有唯一一份。

vfs 可以把本地文件系统的任意目录嫁接在一个虚拟目录树中。这里 采用了 mod 机制 ,可以把两个目录按优先级合并从 vfs 上的同一个虚拟目录。

vfs 的目录结构是一颗 Merkle tree 。仓库中的每个目录是一个文本,内容是它所包含的所有文件的真实文件名以及文件 hash 值的列表。因为 vfs 仓库在游戏运行期间是不变的。整个 vfs 仓库根目录的 hash 就可以看成整个仓库的版本号。vfs 中的任意改动,都会产生一个新的版本,这可以方便我们做差异同步(类似 git),开发期可以随时通过网络把本地数据差异同步到手机上,也可以直接 patch 包,一次把手机上的仓库更新到最新版。

和 git 不同,对于游戏引擎,我们面临着一个难题:游戏仓库中有两类数据,一种是静态数据,它们直接存放在本地文件系统中;另一类是资源,例如贴图、模型等,开发目录中保存的是它们的源文件,而运行时需要先离线处理一下才可以使用。以贴图为例,开发目录下可能是一个 png 文件,但运行时,我们需要根据最终设备,把它做有损压缩转换为 ASTC 格式。

许多传统的引擎是这样解决这个问题的:开发期间开发者通过编辑器把源文件导入仓库,这时就产生了一组在开发机上运行期最终可以使用的数据。这些数据组织在本地的仓库数据库中。而等发布到手机时,再针对手机设备做一次转换,这就是所谓的打包流程。这个过程通常很耗时,实际开发时,往往需要专门用一台机器定期打包(每日构建)。

我不喜欢这样的方案。因为这样的资源仓库很难做版本管理。发布流程很费时,开发时虽然把资源编译过程分散到小段时间,但其耗时有时也会影响开发。如果我们直接对源文件做版本管理,那么每次导入游戏运行仓库的时间开销就更难以接受了。

所以,我们的 vfs 选择了惰性编译资源的方案:即在运行时需要某个资源,才由 vfs 的 fileserver 触发编译过程。fileserver 可以不在特定开发机上,所以也能方便的横向扩展。

资源源文件和运行时数据一般是 n:m 的关系。一组源数据可以对应为多个目标文件。而一组源文件可以用单个源文件里写清引用了外部哪些文件。很多标准化的数据文件也是这样干的,例如,gltf 格式中,可以把依赖的贴图文件放在外部。这样,源数据和运行时文件就是 1:n 的关系。

在 vfs 中,本地数据源的资源文件可以看成是一个软连接,它对应到另一个目录树(的 n 个文件)。所谓编译资源,就是根据这个源文件生成这个目录树。但如果我们真的把资源看成一棵子树(硬连接)的话,就很难计算整个 Merkle tree 了。因为计算 Merkle tree 需要每个节点的 hash 。当资源在根据需要才编译时,编译之前是无法得到其 hash 的。这是游戏资源管理的一个难题。有过传统引擎打包经验的开发者都知道,全量打包少则几分钟,多则几小时。完全不适合开发期边改边用。

我们的 vfs 目前的做法是在结构上把静态文件和资源文件分在两个平级的子树上,同属一个总的根。静态文件中可以有软连接,软连接是一个路径字符串,指向资源文件子树的一个结点。在开发期,没有用过的资源节点在资源子树上是不存在的。运行时发现一个软连接不存在时,可以向 vfs 的 fileserver 提一个请求,要求它生成对应的资源子树。新的资源树增加或修改了节点后,虽然整个树发生了变化,但是静态文件的那颗子树是不会变的。运行期会切换到仓库新的根,但静态文件部分不需要刷新,资源部分已存在的部分也不会变化,只需要通过软连接取到新增加的结点就可以了。

长话短说:vfs 虽然是不变的,但可以通过生成新的版本修改它。我们把资源和静态文件分开,通过软连接关联起来。静态文件仓库是不变的,资源仓库版本会在开发期的运行时变化。这个变化仅限于子树节点的增减,而单组数据本身的数据版本在进程退出前保持不变。