« 几款重口味的桌游 | 返回首页 | 捣糨糊 »

3d engine 中的贴图资源管理

今天有同事问了我一个涉及贴图管理的问题,看起来他们是想改进他们项目现在用的 3d engine (前些年由网易方舟工作室开发的)。我们随便聊了一下,最后的结果是他们取消了一开始的一个需求。

是的,知道自己最终需要什么是特别重要的,不要把过程当成目的。

下面要写的和今天的讨论无关,只是想记录一下:我们的 3d engine 中的贴图资源管理方案。

资源管理是一个很复杂的模块,当然我们应该尽量简化它。在此之前,我曾经记录过我们的资源管理模块的设计变迁。上篇文章到现在也有几个月了,我又做了些设计上的简化,不过变化不大,暂时不提。

而贴图资源作为一种特殊资源,又有所不同。仔细斟酌之后,我把它放在高一层次专门管理。

如前辈所言:确保特殊的情况是真的特殊。在《The Elements of Programming Style》和 《Unix 编程艺术》中都有提及。

那么,第一个问题是:为什么其是特殊的?

在 3d engine 中,贴图往往占用的是显存,而不是内存。系统显存的上限和内存上限是独立的。显存的使用策略,和相关 api 限制(比如多线程因素)稍微复杂一点。

另外,贴图并不总是可以直接从外存加载进来。在支持换装系统的 3d engine 中,某些贴图是由多张小贴图,或多张贴图的一部分组合起来的。比如把人物的上衣、皮带、裤子等等分放在不同的贴图上,使用的时候再组合成一整张。这是因为,对于显卡来说,零碎的贴图会极大的降低性能。

组合贴图这点破事,看起来很好实现。实际上优雅的管理它们,又兼顾一些性能,还是不太容易的。据我所见,至少网易已经运营的 3d 游戏所用的 engine 中(天下所用之 bigworld 和大唐系列所用之大唐)并无很好的管理方案。其结果是,人物换装不那么细腻。而在 wow 中,你可以单独更换身体的许多细节部分,而在外观上表现出来(哪怕只有一小点变化)。

ps. 当然,换装系统不仅仅是更换贴图。

我所用的方法是定义一个贴图组(Texture bundle)数据文件。里面描述了不只一张贴图。对于 engine ,直接使用贴图组的默认贴图。但是可以动态组织它。

如果需要整张贴图更换,直接设置贴图组的状态,切换到某一张具体贴图数据上。

这样做的原因是,模型由美术制作好后,并非可以任意更换贴图数据。往往只是某几张特制的贴图可以互换。如果由使用者为模型任意指定贴图显然是不合适的,而切换贴图组的状态则更为易用、健壮。

如果需要拼接贴图,则在贴图组中描述出矩形的区域划分。每张具体贴图都按统一的划分方案制作局部图案。

单张贴图可以只有局部图案,而不需要全部区域填满。

使用的时候,使用者提供一个 pattern 来组合出最终需要的贴图。即:每个区域使用第几张贴图。

在实现的时候,我采用方便解析又适合做 hash key 的单个字符串来描述 pattern 。


关于贴图的管理:

采用一个两级 hash map 和一个 list 的复合数据结构。

第一级 hash map 用来从一个资源对象检索出贴图数据块(对应一个贴图组)。第二级 hash map 用来 cache 从一个特定的贴图数据中,以一个特定 pattern 组合出的需要的贴图。

list 是一个先入先出的按使用频率排序的最终贴图对象列表。

从第一级 hash map 得到的贴图数据块,以一个永久有效的 handle 索引。(因为它一定会对应唯一数据文件,数量有限,故而我们不需删除 handle )

engine 使用贴图前,都需要从上述 handle 中拣选出所需要 pattern 。使用者不可认为拣选出来的真正贴图对象一直有效。事实上,engine 只保证其有效期维持到当前帧渲染完毕。

这个设计可以让 engine 的贴图管理模块,以类似操作系统管理物理内存那样,根据实际资源情况释放掉暂时不在使用的显存。并 cache 住那些常用的贴图 pattern 。


由于贴图数据的特殊性,有时候,我们需要其以 id 的形式纳入显卡驱动层的管理下;而另一些时候,我们可能需要访问其数据。所以贴图对象有两种存在于 engine 内的形式。

我们在设计的时候,参考了 freetype 2 对字模的管理方式。让这些数据以 slot 的形式存在并让用户去访问。访问之前,调用对应的 api 加载即可。而不用理会其生命期。(engine 保证数据到当前帧渲染完毕前都有效)


btw, 对于 DXT 类的压缩贴图。了解了它的原理和数据格式 后,也是可以直接切分和组合的。只是要按 4x4 像素为一最小单元去操作。

Comments

我看cloud这里讲了一个很重要的思想,用handle把游戏绘制和贴图管理分离开来,消除了其相关性。游戏绘制可以任意绘制,贴图管理可以任意释放;至于是合并贴图还是不合并,都是可以定制的。

金属腰带是配金属铠甲的,而不是配布衣的。如果要换 mesh ,本身就是整个换。

分解 mesh 和分解 texture 是正交的。如果要追究合理性,应该追究是否需要组合和分解 texture。并非支持一个就不支持另一个。

我的答案是,可以满足这个需求。因为 texture 本来就需要考虑动态更换这个需求。比如为一个 mesh 制作多套可更换的 texture 。组合多个 texture 文件的数据,和从多个 texture 文件中选出一个使用,都是一种 pattern 的形式而已,对于接口并没有增加复杂度。

这样做正是要降低复杂度。效率才是放在后面考虑的。

我觉得这也是一种过渡方案,虽然能当前降低一定的开销,但是不是很通用,虽然你更换了贴图,但是无法更换材质,如果想完全用不同的材质,那么还是需要分离出单独的Mesh,比如说,我穿一条金属腰带和一条丝质腰带,这样材质就差了。

正如你所说的,不要为了一点效率的提升而提高复杂度,组合贴图我觉得更适合旧的固定管线,不是很符合当前3d引擎的发展趋势。

恩,不讨论了,各有各的实现方式吧.

贴图尺寸是根据游戏需求来的。跟以上讨论的方案无关。不是说用某种方案就需要用更小的贴图。

这个方案在接口设计上是简洁的。从整体上看,管理分块的贴图,比管理更细的模型切分要简洁,因为它们是独立正交的层次。

实现上也不复杂,划分和组合以及确保性能都不是难题。关键在于怎样实现这个方案。实现的复杂度也可以很好的隐藏在简单接口的背后。而划分更细的模型则无法做到这点。它使得上一层次必须直接面对更多的对象数量(包括贴图图象的数量和网格对象的数量)。

设计时考虑的更多的应该是接口简洁,而不应该是渲染效率。因为渲染效率的影响因素是会随着硬件发展而变化的;是可以在实现层重新优化的。简洁的接口保证了更容易切合这些。

正所谓:保持简洁以获得速度。

btw, 贴图切分和模型切分是正交的,使用更繁杂的贴图组合方案还是采取更细的模型切分方案,只是使用上的选择而已,不是底层接口设计问题。

本文的方案只是通过接口上的 pattern 设置,把组合贴图和切换贴图的操作统一起来而已。即使不提供切分和组合贴图的特性,为同一模型切换不同的贴图本也是必要功能。多加一个功能并没有增加接口复杂度。

@analyst,

diffuse,normal,specular 是分属不同的贴图,并不能打包在一个贴图对象中。它们是由材质模块去管理的。这已经超出了本文的讨论范畴。


256*256对现代游戏来说确实是不够的,凑近了没法看。

要追求效果的话,光一张diffuse贴图也是完全不够的,还要有AO,normal,specular等贴图。

高精度的角色,256x256太小了,可能1024x1024~2048x2048比较正常一点.实现起来并不简单,至少你要面对不同的贴图格式的转换以及切分的问题,而且这些操作都要求效率,需要优化.DXT1,DXT5格式转换的问题,法线贴图的话可能还要面对3dc.
使用模型为单位进行组合不会有资源上的重复占用,只是在渲染时会略有点慢,对现代显卡来说,是不值一提的.
我觉得这不是好方法,需要review一下.总有一天你会觉得它繁琐的.

需要占用多少显存资源取决于在很短时间内,出现的不同装扮的角色的个数。

MMORPG 中,玩家扮演的角色总是趋向于打扮的与众不同(除非你不提供给他们这样的丰富的选择)

那么,按同时出现 100 个人来算,最坏情况就是为 100 个角色保存 100 组不同的贴图。

按一个角色需要 3 张 256*256 贴图,那么一个角色就是 100k 左右。 100 个就是 10M ,不算太过分。不比拆开来更过分。(如果提供相当丰富的可更换装备,玩家依旧倾向于穿的不同,开销是几乎一样的。如果穿着相同,比如相同的套装,同样会重复利用)

至于如何用简洁可行的方案来实现上述想法,正是本文所记录的。

实现出来并不更复杂。

ps. 设计的原则是,减少复杂度。尽量不要增加不必要的层次。如果非要单独划分出层次(贴图管理层必不可少),那么最大的要点就是减少管理对象的粒度。

拼装贴图正是一种减少粒度、隐藏复杂度的方案。对于高一层次来看,是一个单一对象。

这样:现在只考虑角色的上半身,A,B两个角色都穿着同样的铠甲,但他们的腰带不同,那是不是这两个角色在画他们的上半身时会使用两张不同的贴图,这两张贴图上只有腰带部分是不同的,而其余很大面积的贴图都是相同的,这些相同的部分不是重复了吗?
如果各种组合数量不是太多的话,应该也能接受.组合很多的话,就需要一些管理了,不过这样使程序写起来复杂多了,换装系统需要两套不同的换装机制,策划配置起来会更繁复一些.组装贴图的管理器也比较复杂,而且算法可能和底层的贴图资源管理器有类似之处,代码显得冗余.
如果是小块的纹饰也可以做成模型的,几个面片而已,可以从原模型上切下来,使用alpha blend+alpha test在原模型上再绘制一层就是了.当然不好的地方是有重复绘制,大的纹饰就不太适合了;好处是比较简洁,也比较灵活,可以使用不同的shader,产生不一样的效果,比如带uv动画的,闪闪发光的项链之类

@ix,

如果有 7 个可跟换部件,不一定有 7 个 mesh 的。

比如腰带可以更换,但是腰带并不需要单独做一个模型。而只是上半身模型的一部分。

同理,脖子上多个项链,胳膊上多个纹身,都不需要为之做单独模型,更换贴图即可。

可能我还没有搞懂你的意思.
我说的方式是:比如一个角色有7个部分,那么一共有7个模型,每个模型对应一张独立的贴图,在绘制这个角色时会画7次,每次用不同的贴图和模型,你说的做法是不是这7张贴图会用某种方式组合在一张大贴图上,然后绘制次数也是7次,只是在每次绘制时不需要切换贴图了?我的意思是使用这两种方式绘制的效率并不会差太多,而且用组合贴图的方式,会占用多余的贴图,比如两个角色各有7个部分,只有一个部分不同,那你就需要两张大贴图了,但其实只需要8张小贴图(因为一共就8个部件)

@ix,

换装系统中,分开独立的 mesh 数量是少于可更换部件的数量的。

比如胸甲上的挂饰。脖子上的项圈,甚至手指上的戒指等等。

如果为每个独立的可更换部件都分离出 mesh 那才是真正的浪费。如果这样做,游戏就倾向于把可更换部件的种类做的更少,而不那么丰富。比如天下就是这样。

而 wow 里,更换腰带的时候,显然是没有为腰部专门做一个 mesh 的。

至于重复贴图的问题,就依赖设计良好且独立的 cache 管理和淘汰算法了。在我这里,是用 pattern 去做 hash 的。

原来也想不明白游戏的图像怎么反应的那么快,原来是把图分成一块块,而把这些块用一个hash.把它们装起来,再把经常用的又用一个hash.要是把不常用的也用hash,如果有新的来了,只要到不常用的中把一地方把这新来的安安居之,明白了,那渲染一般是怎么作的。

如果角色是可以动态换装的,那么每个部件肯定需要一个单独的draw call,那干吗需要这些部件的贴图都放在同一张贴图上面,没什么好处吧,贴图状态的切换在显存足够的情况下是没啥性能影响的,
还有一个问题,比如一张胸甲的贴图,如果有两个角色都穿了这副胸甲(但其它装备不同),那不是说这张胸甲的贴图会被组合到两张不同的贴图上去吗,这样不就浪费了.

@ix, 这个方法不需要重新计算 uv ,因为都是相同区域替换。

@analyst, 对非人物模型也用。
对于静态贴图(可从外存加载),每次 bind 的时候,会检查是否有效,无效则通过 cache 重新加载。

组合贴图这种事感觉没有太大的用处,而且牵涉到uv的重新计算,比较麻烦的,用在动态生成模型的字体显示方面也许还行,对于模型渲染只会使问题变复杂.实际上使用多个批次绘制带来的性能影响并不是那么可怕,刻意的去追求batching也有开销的,也许得不偿失.

minix的内存管理好象和你的贴图管理一个样

另外,这个可以任意组合贴图数据块成一张大贴图的贴图管理方案,听上去是专门针对人物换装这样系统来设计的,对于其他的3D模型,例如建筑、地形、特效等等,也是用这样的贴图管理方案的吗?

> 我们在设计的时候,参考了 freetype 2 对字模的管理方式。让这些数据以 slot 的形式存在并让用户去访问。访问之前,调用对应的 api 加载即可。而不用理会其生命期。(engine 保证数据到当前帧渲染完毕前都有效)

这段话没太看懂。是说每个贴图对象都有个proxy,需要访问贴图时才把实际的贴图数据加载上来?引擎用某种淘汰算法把不需要的贴图自动汰换掉?

占个沙发,学习学习......

Post a comment

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