我们游戏引擎的 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:// 罢了。