« June 2023 | Main | August 2023 »

July 18, 2023

有序数列的数据结构优化

在我们的 ecs 模块中,有一个重要的内部数据结构是 eid 的数组。它是 Component 结构的一部分,表示每个 Component 属于哪个 Entity 。目前,它是以一个有序的 id 数组实现的。

这个数据结构常见的操作分别是:遍历、随机访问、查找 id 所在的位置。一个有序数组可以很好的完成任务。O(1) 的随机访问时间,O(Log N) 的查找时间。

我们的 Entity ID 是 48bit 的,我觉得保存 48 或 64bit 的 id 数组有点浪费,且空间越大,实际操作效率也会相应降低。实际上,Entity 的个数不会太多,我觉得限制在 2^24 (一千六百万左右)足够了,所以用了一个间接索引,保存 24bit id ,再用它去索引真正的 entity id 。

三个字节保存内部 id 听起来有点别扭,因为内存访问没有对齐。但是 4 字节又略显浪费,2 字节我担心不够用。所以,在上个月时,尝试做了一点优化:

将数据按固定 32 (或至多256) 个元素分组。每组 64bits header + 32 * 8bits 。header 保存第一个元素的高 40bits ,后面是每个元素的低 8bits 。每组分为三个区段: 1. 高位相同 2. 高位+1 3. 其它离散数据。header 中用 2 bytes 保存 1,2 区段个数。用 16bits 保存离散数据的 offset 。

所有离散数据依次用 40+24 bits 数组额外储存。40 bits 高位,24bits 是它的 index 。前面每个组的离散区段在这个离散数组中每段是连续的,并可以直接 offset 索引。所以:任何随机访问都是严格O(1) 的。

最坏情况,数据结构退化成一个 64bits 数组,大小为 n * 31 / 32 加上固定的 每个元素 10bits 开销。二分查找的比较次数在最坏情况也不会恶化。

我尝试做了实现, 但写完后发现代码过于复杂。虽然大 O 的估算性能不是问题,但实际上比平坦数组差距很大。所以我放弃了这个想法。

当然,促使我放弃的更重要原因是:我们游戏实测中,Entity 的个数峰值仅有一万左右,原不到设计时考虑的百万量级。如果 Entity 数量可以控制在 64K 之下,直接用一个 2 字节的内部 id 即可。


所以我转而考虑另一个问题:

一个平坦有序数组,它的插入和删除效率是很低的,时间复杂度为 O(n) 。这里是否有优化空间?

当然,在我们的 ecs 系统中,是不允许插入 Component 的(Entity 必须在创建时决定它的 Component 构成),实际用下来并无问题。而删除操作是集中在每 frame 做的。也就是当 frame 删除的所有 Component 一起的开销是 O(n) ,并不会变成 O(n^2) 。

但 tag 是个例外,一个没有数据的 Component 被称为 tag ,它仅用于 Entity 筛选。tag 可以动态添加和删除。我在前两年专门针对它 做过一些优化 。删除的复杂度可以降低到 O(log N) ,添加最坏是 O(n) 但通常可以比之小。

怎样优化有序数列的插入和删除?这其实是一个经典问题。最为人所知的方法有 B 树和跳表。

B 树的想法是:我们在保存这个数列的时候,将其分段,每段并不填满。这样,插入的时候通常只用移动一段的数据,而不需要处理整个数列。

跳表的想法则是:用链表来保存数列,这样合并 freelist 的策略,就可以以 O(1) 的时间成本插入或删除元素。但,链表不支持随机访问,索引其中特定位置的元素需要 O(n) 的时间复杂度。为了解决这个问题,就增加一个额外空间来换取时间,间隔若干元素再加上若干高层次的链表。这样,在随机访问时,从高层向低层迭代,就可以以大约 O(Log N) 的时间复杂度检索到。

而我面临的问题数据规模不大,大约是数百到上万左右,极少情况会超过 10 万。完全可以针对性的设计一个更简单更高效的数据结构。这就是我今天实现的,它像是 B 树和调表想法的结合:

我把数列以 256 个一组分组,分段保存。每一组有一个很小的 header 。每段 256 个槽位里是一个装满的循环队列。只有最后一组是个例外:它可能是不满的。

当我们做随机访问时,先计算是哪一组,再结合循环队列的头位置做一个简单的取模操作就可以定位到元素。

在删除元素时,先在所在组里删除,最多移动 127 个元素(可能向前滚动,也可能向后滚动) ,然后依次把后序每组的第一个元素移进来。

添加元素则是删除的逆操作,它也最多移动 127 + n 个元素,n 是组的数目。


因为大多数情况下,我们的 entity 数量都不会太多,所以内部 id 的数字都不大,我认为 2 个字节就够了。但是,我还是想支持超过 64K 的情况。所以、我把以上的分组分成两种:致密型和稀疏型。

所谓致密型分组,分组中最小的元素和最大的元素相差不超过 64k ,这样,在 header 中保存一个 32bit base ,再在实际的 chunk 中保存 int16 的 delta 。这个 delta 是有符号数,范围是 -32K 到 32K 。如果不能以这样的形式保存的分组,就使用稀疏型分组,直接保存 256 个 4 字节 id 。稀疏型分组需要的内存正好是致密型的 2 倍。

为了管理这两种不同类型的分组,我采用了 freelist ,每个 node 都是 1 个稀疏型分组的大小(1024 字节),它同时可以保存两个致密型分组。

绝大多数 id 都保存在致密型分组内。两个挨在一起的分组占用一个 node 。如果我们需要把一个分组从致密型提升为稀疏型,就先看它是否有伙伴。如果有,则把伙伴设置为单身,而自己从 freelist 中分配一个独立的新 node ;如果没有伙伴,就直接独占当前 node 即可。

从稀疏型降为致密型只需要做相反的操作即可。


今天,我用 C 语言实现了一下。在 O3 优化时,和平坦的简单数组相比,随机访问效率几乎相同;而二分查找性能差不多会损失 5% 到 10% 。数组越大,性能差距越小。我想是因为传统的数组是 32bits 一个元素的,而目前这个实现绝大多数情况都是 16bits ,对内存访问更为友好。而且,header 带有的 base 信息也方便了第一步的初步检索。

July 05, 2023

ttf 字体的一点问题

我们的游戏引擎使用的是 stb 的 truetype 库 来处理 ttf 字体的。最近发现在使用公司提供的 阿里巴巴普惠体 时出了一点问题。

引擎渲染出来的汉字比标称的像素高度矮了不少,想渲染 100 像素高的汉字,结果只有 70+ 像素左右。我们之前测试使用的中文字体( Windows 自带默认字体)没有这个问题。

在网上翻了一下,找到这么一个帖子:https://github.com/nothings/stb/issues/689 以及 imgui 也遇到过类似问题

原来,stb truetype 库提供了两个计算字模大小的 api ,stbtt_ScaleForPixelHeight()stbtt_ScaleForMappingEmToPixels() 分别用两种方式计算字模高度比例。作者比较提倡用前一个 api ,但也保留了后一个 api 的使用场景。

computes a scale factor to produce a font whose EM size is mapped to 'pixels' tall. This is probably what traditional APIs compute, but I'm not positive.

另外在获取字体的 ascent 以及 decent 时,也有两个不同的 api : stbtt_GetFontVMetrics()stbtt_GetFontVMetricsOS2()

我很好奇的翻阅了这些 api 的实现,并查阅了能找到的关于 truetype 的文档,得出了下面的结论:

在字体文件中,有两处地方描述了字体的 ascent 和 descent 值。这个值应该是字体设计者创作时使用的像素大小。原本它们应该记录在字体文件头的 hhea 表中,它最初是由 apple 设计的。我们可以在 apple 的网站查到文档

后来,微软在设计字体时,可能是发现这些信息不够用,也或者是不赞成 apple 的某些设计决定。他们又设计了一张新的表叫 OS/2 ,里面也有类似的字段。文档在微软网站上可以找到

为什么两张表有很多重复的字段,在字体文件里放了两份(带来了冗余信息)?我猜恐怕和微软的代码实现风格有关:微软非常喜欢直接把内存结构映射为文件结构。所以他们选择加一整张表而不是只增加必要的字段在新的表中。

对于大部分字体来说,两张表的 ascent 和 descent 值是一样的。但正如 apple 文档中注释的,没有任何要求它们必须一致。而微软文档的一篇注解 则提到:

Some legacy platforms or applications may not use OS/2 fields at all. Thus, CJK fonts generally should have the same descender value recorded in hhea.descender, OS/2.sTypoDescender, and HorizAxis.ideo (if present) fields, and the same ascender value recorded in hhea.ascender, OS/2.sTypoAscender, and HorizAxis.idtp (if present) fields.

显然,阿里巴巴普惠体没有遵守这一点。

它在 hhea 表中,ascent = 1050 descent =-322 ,在 os/2 中,ascent = 850 descent = -150 。

stbtt_ScaleForPixelHeight() 计算缩放比时,使用的 pixel / (hhea.ascent - hhea.descent) 得到的值小于预期。

为何 stbtt_ScaleForMappingEmToPixels() 可以得到预期值?因为它使用的是 head 表中的 unitsPerEm 字段去计算的,这个值通常等于 ascent - descent (上面的文档中也提到了这一点)。在阿里巴巴普惠体文件中,它也等于 os2.sTypoAscender - os2.sTypoAscender 。

我不清楚为何这个字体会填入这样不同的数据。我猜测可能和 apple / ms 系统采用的默认屏幕 dpi 有关?它间接导致字体设计人员在打包字体时错误填写了某种参数?不过无论如何,我觉得是字体本身的 bug 。虽然,我通过修改我们引擎的代码绕开了这个问题。