« December 2018 | Main | February 2019 »

January 31, 2019

设计了一个数据格式

最近一段时间在忙着设计和实现我们游戏引擎用到的数据格式。

在此之前,我们一直在直接使用 lua 描述数据;但最近随着数据类型系统的完善,同事建议设计一种专有数据格式会更好。希望专用格式手写和阅读起来能比 lua 方便,对 diff 更友好,还能更贴近我们的类型系统,同时解析也能更高效一些。lua 的解析器虽然已经效率很高,但是在描述复杂数据结构时,它其实是先生成的构造数据结构的字节码,然后再通常虚拟机运行字节码才构造出最终的数据结构。这样的两步工作会比一趟扫描解析构造要慢一些且消耗更多的内存。

现有的流行数据格式都有一些我们不太喜欢的缺点:

json 是目前最流行的,但是它更适合通讯协议,数据由程序生成。对手写和阅读不是很友好:json 不支持注释;字典结构中 key 需要给字符串加引号,显得累赘。支持的数据类型有限,不易扩展数据类型。另外,标准 json 无法对浮点数做精确表达。

xml 是另一种通用选择。它比 json 更严谨,在扩展数据类型方面很方便。但 json 有的缺点它更盛:在手写的时候,往往一个很简单的值,需要额外写很多格式要求的信息,不借助专有编辑器时,书写不太方便。阅读起来有效信息比很低,需要借助专有查看器才能变得方便。冗余信息太多导致对 diff 也不算太友好。

ini 格式在 windows 上很流行,在 windows 之外也有很多人用。但是它做配置文件很舒服,描述复杂数据结构的话却有点力不从心。ini 只是在键值对的数据组加了一个层次,如果要描述多层结构,就很难沿用一致的语法。

lisp 是我所青睐的。Paradox 的数据格式就是采用的类 lisp 结构 。据说顽皮狗的引擎也使用了 lisp 做内部数据结构。不过同事不太喜欢太多的括号,我也觉得这点值得改进。

yaml 看起来是最符合易于书写和阅读的格式。但它的解析器过于复杂,虽然格式设计上是为了方便一趟扫描解析,但现在依然没有一个特别高效的实现。我曾经跟踪过很长时间 libyaml 这个项目,贡献过 bugfix ,提过建议 。其中一个建议被开发团队持续讨论了一年多。因为要考虑多语言实现的 yaml 的一致性,所有细节都必须被反复推敲。不能产生方言:否则在一种语言环境上编码的数据,去到另一个环境就无法正常解析,这就失去了数据交换格式的意义。考虑到 yaml 相当复杂的格式定义,和诸多的实现版本,这太难了。后面我还会提到一些小的特殊需求,实际上扩展 yaml 是不实际的(很难被接纳),还不如干脆自己设计一套不是 yaml 的新格式,这也是最近这些工作的动机。


设计并实现一个专有数据文件格式,在提出要做这么一件事之前,我觉得我们的项目并不需要多复杂的东西。保留一定的通用性的前提下,设计的足够简单,满足我们的需求就好了。已有的数据格式不喜欢,那么改成我们喜欢的样子,我觉得有一个下午就能搞定。

在那个冬日的周末,中午的太阳晒得人懒洋洋的。我乘娃打了个哈欠,赶紧把他哄睡着。打开编辑器,想着儿子醒来前这几个小时差不多够用了。

事实上并没有这么简单。

第一版我对 Paradox 的数据格式做了一个简单的模仿。曾考虑过用 lua / lpeg 来实现,但又感觉未来可能需要更高的性能,且格式不复杂,用 C 实现也可以很清晰,不必列出 BNF ,不需要用 yacc 。手写一个解析器不过几小时的工作。实现的时候,我还顺手加上了一点我觉得方便的特性:在解析到 lua 中时,可以通过使用 [] 或 {} 来选择把字典解析成列表还是字典。整个解析器不过几百行 C 代码,一趟扫描就可以完成,并生成 lua 的数据结构。

周一拿给同事看的时候,并不满意。从原来 lua 数据文件中转过来的数据文本中太多的括号看起来并没有比原来 lua 版本好看多少。尤其是在序列化内存的大量 Entity 时,最外层结构需要一个数据列表,感觉 ini 风格的 section 分段会漂亮很多。

ini 风格的 section 是用 [name] 这样的形式来区分段落的。因为缺少括号,必须依赖下一个段落的开始来结束上一个段落。这样,就很难表达多级层次。我考虑借鉴 markdown 的方法,用 ### title 来表示段落。井号的数量可区分不同层。同时,还是想保留括号作为可选项。因为在描述一个向量的时候,我更希望沿用 { 0,0,0,1 } 这样的风格。

没有结束符的区段结构,解析器写起来要麻烦许多。更重要的是要防止人误用产生有歧义的结构文本。我决定让段落标识只能出现在最外层,一旦使用 {} 表示内部层次,内部层次中就不可以再出现段落符号。

做这个新特性时,我发现之前快速写出来的词法解析及语法解析模块很按新需求扩展。我意识到未来很可能还会做大改变,干脆就推倒重写,这次不图快,尽可能的写清晰,用更直白(但更啰嗦)的实现。

完成之后,我们查看了生成的数据,发现虽然语法上可以表达出层次,但是没有缩进的多层结构实在惨不忍睹。或许是程序员早已习惯了视觉空间上的变化来表达层次结构吧,光有标签是不够的。最后还是为生成的数据加上了缩进。而解释器会简单的将缩进当成分隔符忽略掉。

可是既然有了缩进表示层次,我们何必再用蹩脚的段落表示方法呢。

再一次的大改就是去掉新加的特性,转而用缩进来表示层次。当然,{} 的层次表示方法还是保留的。同样不能混杂使用,只能从外层开始使用缩进,一旦开始用 {} 后,缩进就变成了简单的分割符。

这里我们不想规定缩进到底是 tab 还是空格,是 2 个还是 4 个或 8 个。我对 yaml 略有怨言的地方就是它不能用 tab 缩进,这不符合我的编辑习惯。本质上,缩进就是把层次信息加在每行元素上,从而可以省略层次结构结束的标记。我们只需要认为同样的缩进串表示的是同级的层次,不同的缩进串将关闭前一个层次。理论上,你想把外层向内部从长到短反着排版都没关系,只要保证同层的行的缩进串是相同的就够了。

但我并不想给这种灵活性,允许排版成奇怪的样子没什么好处。最终我还是规定更深的层次必须有更长的缩进串,但不规定每个新层次需要累加固定的长度。比如第二层可以用两个空格,第三层加到 6 个或者家一个 tab 也是没关系的。

另外,我增加了 yaml 里用 --- 表示区段的方法。这可以减少最外层的缩进。这是一个喜闻乐见的特性,在很多数据文件格式中都可以看到类似的东西。比如 record-jar 就用 %% 来表示分段。


主体功能完成后,整个结构看起来就像一个简化版的 yaml 。最直接的改进就是可以比标准 yaml 解析要高效的多。如果做配置文件,效率不会是问题,但我们的游戏引擎打算把它做成通用数据格式,效率高一点可以缩短日后的管卡数据加载时间。

接下来就是加一些 yaml 中没有,或是难以实现,但我们又需要的特性了。

其一就是 anchor 的向后引用。yaml 可以用 &anchor 的方式对一组数据做一个锚点,然后之后用 *anchor 对之前的结构进行引用。这是一个很有用的特性,在别的格式中很难做到。我们在序列化场景树时就用的上:*anchor 相当于一个指针,可以指向另一个数据结构。这可以避免直接序列化整棵树导致的缩进层次过大。且能解决 DAG (有向无环图)的序列化问题:多个孩子引用了同一个子节点。

但是 yaml 为了解析器实现方便(保证可以一次扫描解析完毕),它规定,对锚点只能做向前引用,即必须先声明锚点,才可以对其引用。锚点可以重复,总是引用最近的一个。

这导致有环的图无法被描述出来。我想去掉这个限制。在 Lua 中,所有复杂数据结构都是用 table 统一承载的,而我们几乎只在 Lua 中使用这个数据结构,这就可以用一种技巧来解决向后引用的问题:我们只需要在解析的时候碰到未定义的锚点时,提前把一个空 table 出来,等到锚点被定义时,再去填充这个 table 即可。

btw, Unity 就是使用 yaml 做数据描述,但并只是部分使用了锚点的机制。它定义了锚点,却没有用 yaml 的语法去引用它们。


另一个想改进的地方是自定义数据类型。yaml 是用自定义 tag 来实现的。通常解析器会提供 event 机制来触发自定义 tag ,动态语言封装解析库的时,接管这个事件来处理自定义数据。不过现有的实现做起来还是太麻烦且低效。

既然我们是专有格式,且只给 Lua 使用,就可以用一种取巧的方法来实现一个简化版本。

我的做法是,允许用户用 [] 取代 {} 来描述一个数据结构。但一旦采用 [] ,解析器在解析完毕后,回调一个用户函数,把数据过滤一次。对于 [ 0, 0, 0 ,1 ] 这样的向量,我们就可以简单的做一次后处理,加工成 lua userdata 返回出去。还可以对外部文件引用写成 [ file path ] 的形式,这是一个列表,第一个字段 file 表示这个自定义结构的类型,后面的是参数。Unity 在类似问题上处理要复杂一些,它对外部文件引用生成了一个类似 {fileID: 400000, guid: 5df3a2b3f00ce8a418ad24d290ed5deb, type: 3} 的数据结构,我猜测这依赖更高层模块的解析,而无法在读取 yaml 的时候同步完成。


这个项目的 github 仓库在这里 。原本以为一个下午就可以搞定,可目前距离第一次提交已经三周了。

等稳定后,我会将这个仓库合并入引擎的主干。

January 09, 2019

粒子系统的设计

因为需要为我们的 3d engine 添加特效系统的模块,我最近读了一篇文章:Efficient CPU Particle Systems 。文章的作者为很多 MMO / MOBA 游戏设计过粒子系统,其中最有名的是上古卷轴 Online 。所以我认为他的实践很有参考价值。

文章很长,夹杂着设计思路,优化,算法实现,渲染实现。对于我来说,由于过去我做过好几版粒子系统,所以读起来不太费力,很多细节可以直接略过,我今天写一篇 blog 把我认为文章中对我最有参考价值的部分列出来。

首先,我们是否需要做一个基于 gpu 的粒子系统?文章的结论是,各有利弊。如果单从性能角度去考虑,GPU 未必更好,尤其是对于 MMO 类发射器很多,每个发射器发射的粒子片并没有多到上千片的情况, GPU 粒子的优势并不明显。

我觉得,对于移动平台来说,把计算负担加在 CPU 或 GPU 上对性能和功耗的考虑也是一种权衡。基于 GPU 的粒子系统会增加一些开发复杂度,但并不算太复杂(bgfx 就在 examples 里给出了一个 gpu 粒子的范例);但 CPU 带来的灵活度的确能带来不少好处。另外,粒子系统的核心计算环节是天生能用多核并行计算的,且计算规模可裁剪(动态砍掉部分效果并不影响业务逻辑),我们不必过于考虑性能问题。

另外,我也读过一些相关的讨论,部分效果,尤其是粒子需要和场景做交互时,可能更适合放在 GPU 中计算。不过大多能找到一些替代方案,不用太担忧。

所以,我认可文章作者的方向,也打算先只给我们的引擎设计一个基于 CPU 的粒子系统。


作者花了很大篇幅谈内存管理。其实这部分并没有太多新的东西,几乎所有商用级别的引擎,粒子系统都很重视这块。用固定内存区维护固定上限数量的粒子片,固定数量的发射器,等等。这有几个明显的好处:

对大规模的计算,内存 cache 友好。基于 CPU 的粒子系统在合理的内存布局设计时,即使是同样的算法,可以带来成倍的计算性能提升。

限制粒子片的物理上限,可以极大的减少运行期的误用。不会因为不小心使用让内存爆掉。对业务逻辑也没有什么影响。最多是粒子发射器无法发射新的粒子,画面效果出错,但不影响其它部分。

内存管理部分作者给出了很多具体代码,我个人认为有少量动态数组部分还可以改进,去掉动态特性。尤其是时间线管理的部分应该做成可共享的固定模板。因为很多数据结构只需要在编辑期才有动态性需求,运行期都是固定的。

我倾向于在运行期用一个 C 模块和简单的 C 数据结构去管理粒子及发射器;而在编辑期,则用 lua 结构管理发射器会更具弹性。我们只需要编写一个 lua 结构到 C 结构的列集函数,在每个编辑修改操作后转换一下即可。

文章中还混杂了多线程管理和渲染流程的具体实现。这对我们的引擎设计是多余的。我想把线程管理和渲染流程从粒子系统模块中剥离出去,只设计一个纯计算模块。


文章中把粒子特效系统的原型设计分为两个层面,其一是基本粒子系统,作者称之为 bread and butter 。即仅仅用简单的多个发射器发射矩形粒子片就可以实现出来的效果:火焰、烟雾、雨、尘埃、雪、火花。

其二是为具体效果定制出来的粒子系统。所以涉及动态构建网格的效果都属于这部分,而每种特定效果都是需要特定编程的。例如:光束、刀光拖尾、闪电、更复杂的天气,等等。

后一种效果文章没有谈,只集中讨论了基本粒子系统的设计。我也想在第一步只在我们的引擎中实现基本部分。

这部分其实只涉及粒子发射器的定义,发射器按预先设定好的参数喷射出粒子,每片粒子在发射出的那一刻,就决定了所有的初始参数。之后,粒子系统每帧按这些参数更新粒子就可以了。在粒子片被构造出来的那一刻开始,整个粒子生命周期的状态变化过程就已经决定了,甚至没有任何随机量。所有随机环节都是在发射器发射时决定的。这样非常适合并行计算。我们可以用多线程,用 SIMD 优化。

btw, 在很多年前,我们第一次设计 3d 粒子系统时犯过过度设计的错误。当时我们实现了一个发射器去发射发射器的特性。现在看来是多余的,增加了许多无谓的复杂度。简单算法支撑的粒子系统一样可以做出丰富的效果。


这篇文章提出了另一个关键点是时间线的基础数据结构。他称为 AnimatedValue 。很多简单的粒子系统对一些参数,例如颜色参量,只提供了 StartColor 和 EndColor 两个控制量明显是不够用的(比如著名的 cocos2d 自带的粒子系统模块)。我们需要用一个时间线,提供任意个关键帧,用一条曲线去描述参数的变化。

另外,就是需要给发射器提供足够多的控制参数。这部分怎么多都不会浪费。

特效设计师总会充分利用这些参数,反复调整,设计出你一开始都想象不出来的效果。我所理解的特效设计师的工作就是在拿到新的特效引擎后,调调这个,改改那个,看看会得到什么结果,反复组合让这些参数之间产生化学反应。引擎设计者和特效师都无法一开始预知能做出什么,所以很难预先提出需求:“我需要这么一个参数”。我们只需要提供更多更多的参数可以调节。

好在这篇文章的作者已经实现过好多商用的粒子系统,他给出的参数列表让我们有个参考标准。他给出了发射器的数据结构定义 ,我个人觉得这是这篇文章对我价值最大的东西。不过我有一点不太赞同的地方:我觉得应该把发射器固有的模板数据和动态变化的数据字段分开,前者应该由多个实例共享。例如在场景中如果有 10 个相同的火把,我认为它们的发射器模板是同一个才对,随粒子系统更新而变化的量才应该分开。而目前作者把它们全部放在了 ParticleEmitter 类中。


最后,作者还对编辑器的设计提了点建议:虽然一个表格式数据输入界面和一个组件节点连线式的图形编辑界面本质上没啥区别;但是美术的思考模式决定了他们就喜欢后一种。所以我们应该尽可能地做成那样让美术更好的理解粒子系统如何工作。

January 08, 2019

一种 16 倍抗锯齿字体渲染的方法

昨天读了几篇文章,讲解了一种新的抗锯齿字体渲染的方法

我觉得颇有意思,就试着实现了一版 CPU 版本,想看看针对中文的效果。虽然最后觉得这个算法对游戏领域的实用性不大,不过还是挺有启发的。这里写写我对这个算法的理解,以及我所理解的算法局限性。

原文讲解的非常细致,还配了不少图片,我就不再重复了,只简单说两句。

我认为发明这个算法的动机是 “Our UI has a lot of smooth animation, text should be able to move smoothly across the screen.” 这是过去很多传统字体渲染算法很难解决的。

当我们输出的文字略微偏移 1/4 个屏幕像素时,通常会糊掉。但是这个算法则可以尽可能地保留信息。

因为这个算法的本质是把 16 倍大的字形,以黑白二值位图的形式保存在一个每像素 16bit 的贴图中。和传统的保存字形位图的方式不同,它不储存字体抗锯齿后的灰阶图片,而是一个 bit 保存一个像素。贴图的一个图素保存了 4x4 共 16 个像素。把 16 个点阵像素压缩到贴图的一个图素中带来的并不仅仅是压缩的好处,更重要的是,可以让 gpu 做采样时,可以一次拿出更多的字形信息,方便做抗锯齿处理。

我们在纹理采样时,从贴图上采到的一个图素其实包含了 16 个字形像素的信息。如果我们采样 4 次,就可以拿到 16x16 = 256 像素的位图。然后,计算出真正采样的范围,统计这个采样区域 bit 1 的个数,就可以精确的算出灰阶。

相比传统储存方法,偏移部分像素,就只能通过简单的混合算法求出平均灰阶,这种储存方法相当于推迟了字型抗锯齿计算灰阶的时机。

我做了一个简单的动画,让一个 23 像素高的 “好”字在一个像素范围内轻微位移转圈,我们可以看到,好字的包围盒并没有扩大,但肉眼明显能感知到字的移动。

我们把好字的半像素位移展开, X Y 方向各移动 1/4 1/2 3/4 像素,得到 16 个字,是这样的:

放大点可能看的更清楚:

如果我们把灰阶改成类似微软 ClearType 的方法,利用液晶屏 RGB 三个子像素,可以做到更清晰。这部分工作我懒得做了。


但我认为,这个方法对于目前游戏并没有太大意义。因为用 GPU 实现过于复杂,而收益(文字移动平滑这个特性)并不大。

而且算法本身有个弱点,原文并没有解决。那就是当字体放大或缩小时,其实贴图到帧缓冲的算法就不再是 1:1 的关系。如果按原文的方法 4x4=16 像素的窗口去采样,结果是不对的。如果最终显示的文字比原始字型点阵要小,就应该用大于 4x4 的采样窗口;如果要放大原始点阵,则应该用更小的采样窗口。

这个方法的极限可以把字形放大 4x4 倍而不失真。当你需要输出 4x4 倍大的文字时,其实每次只应该在贴图上采样一个 bit (1x1 窗口)。

用 CPU 可以勉强解决这个缩放问题,GPU 来做也未尝不可。我构想的方法是另外做一种带 mipmap 的纯色贴图,不同的 mipmap 层填充不同的颜色通道,比如第一层用纯红,第二层用纯绿,然后根据采样的颜色结果的红绿比例,可以推测出贴图到帧缓冲的映射比。

对英文来说,缩放问题并不麻烦。只要可以快速生成需要的大小的贴图(这也是原文第三篇讨论的话题),加上合适的 cache 即可;但是中文字形太多,我们就需要太大的 cache 。这也是我认为该算法(对中文)并不实用的原因之一。