« 虚拟文件系统的资源惰性编译 | 返回首页 | Lua 的 C 模块之间如何传递内存块 »

贴图管理模块及 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:// 罢了。

Comments

* librocket本身也是按照这个思路来的。比如他的字体模块取纹理,不过好像被rml的作者删掉了。https://github.com/libRocket/libRocket/blob/master/Source/Core/TextureResource.cpp。

* 最近重写了自己引擎的资源管理部分,资源唯一id全换成了url。按照electron的protocol的形式进行设计。https://www.electronjs.org/docs/latest/api/protocol
1. 对 atom 这个 protocol 传入url。
2. 因为没有promise的存在,所以注册的 resolver 返回的是一个 stream。
3. 因为resolver是动态进行注册的,所以对于一些自定协议可以在debug游戏和release下进行切换。如:debug下返回网络流,release下返回zip流。


如果每帧执行的输入参数不变,把执行请求注册到目标系统内部会不会更好?

id 到 handle 转换涉及到多线程都要做,用缓存反而复杂。

我们这里为了简单,利于多线程调用, id 用的全局 int16 数组,最多 64k 个 id 。转换只是查一下数组。

id到handle的转换只应该必要时才做。每帧都做同样的一件事,是不是该用某种缓存机制来替代?比如现代渲染引擎的pso。
handle的定义类似frame graph中对各种动态buffer的管理。frame graph似乎只用在核心渲染流程上。是不是可以扩大到整个引擎甚至游戏逻辑?或许能简化资源管理、优化内存占用。
对同一个资源的每次引用请求分配一个新的id,仅用于该次请求相关的管理?额外的人肉引用计数。

云风的设计思路有很多操作系统的影子

Dota2的Panorama中跟这个差不多:每个特效,模型都是额外的vmap,要设置相机,光照等额外信息,都是走的renderTarget,不过我个人不太喜欢这种,显示大量特效和模型的时候还是有点性能问题

茅塞顿开!这样处理得好干净啊!
非常感谢!

Q: 为什么相机角度、光源之类的信息不通过UI层传送过来?

A: 我们这个需求中,所有的建筑静态快照都是固定好相机、调好光源、然后拍照的。所以不需要每个建筑都传一次。也就是不用把这些参数编码在 URI 字符串中。

Q: 如果模型或者视频的渲染依赖这个管理器的提供,这个管理器又依赖UI层的渲染来激活的话,那么UI是需要每帧都渲染才能保证模型的流畅吧?

A: 管理器和 UI 层是分离的。管理器并不专门针对 UI 服务,它给所有模块服务。UI 层只是提交这个位置需要渲染一张贴图,它提供的是贴图的 handle 。渲染底层(非 UI 层)在处理这个 handle 时,每帧向显卡提交时,才向管理器要的贴图资源。所以,“每帧”并不是 UI 层的责任。

确实是一个干净利索又利于扩展的好方法。之前见过的一般是在rendertarget这个思路做继续开发,UI只关注rendertarget出来的图片资源,做类似这种独立管理服务的方式,但是硬编码多,扩充性可预期的不如您这个。
不过有两个小问题,可能我没看明白,请不吝赐教:
1、是为什么相机角度、光源之类的信息不通过UI层传送过来?这个感觉更方便上层人员使用(当然,我这个思路是偏向我一开始说的思路)
2、是如果模型或者视频的渲染依赖这个管理器的提供,这个管理器又依赖UI层的渲染来激活的话,那么UI是需要每帧都渲染才能保证模型的流畅吧,这方面好做优化吗?是存在渲染模型的情况下就每帧都激活,否则就有变化才激活渲染是吗?

看完后理解了下,相当于各个服务各自独立工作,显示位置部分处理逻辑简化,都是协同以及异步工作。

Post a comment

非这个主题相关的留言请到:留言本