Subsections

面向数据设计

面向数据设计,已经以各种形式存在了几十年,但直到 2009 年 9 月,才在 Noel Llopis 的同名文章 [#!nllopis!#] 中以这个名字首次出现。它算不算是种编程范式,一直以来争议不断。有些人认为,它可以与面向对象、过程式、函数式编程等其他范式混用。某种意义上来说,的确如此,面向数据设计确实能同其他范式共同发挥作用,但也不否认,它是种更广义的编程方式。Lisp 程序员会知道,函数式编程可以与面向对象共存,C 程序员很清楚面向对象能与过程式编程共存。这里我们将这些争议先搁置,直接在此断言:面向数据设计是新的重要工具;它能够与其他工具共存。[*]

2009 年是非常合适的时机。革命性的硬件业已成熟;潜力巨大的计算机被无视硬件的编程范式束缚;开发者的编码方式能让许多引擎程序员落泪。但时代变了。现在许多移动端和桌面端解决方案,似乎不太需要面向数据设计,并不是机器擅长处理低效实现,而是设计中的游戏要求不高,也不复杂。但现在手游的开发趋势似乎正向 AAA 级迈进,又一次,产生了对复杂情况管理的需求,以期最大程度发挥硬件。

现如今,我们被多核设备围绕着,口袋里这个也不例外。学习如何较少依赖串行开发软件就变得尤为重要。面向数据的程序员能获得诸多好处,包括但不限于:摆脱对象信息传递、获得即时响应等。编程时,坚实地依赖对数据流的认识,为将来进入 GPGPU 和其他计算方法做好准备。由此带来诸多落地游戏构想的工作。面向数据设计的需求只会增长。抽象和串行思维将制约你的竞争对手,而那些接受面向数据方法的人会茁壮成长。

一切围绕数据

数据即一切。数据是为了创造用户体验而需要变换的;是打开文档时加载的;是屏幕上的图形;是手柄按钮的脉冲;是扬声器震动空气产生的波;是角色升级的路线;也是对手向玩家开枪的诱因。数据是炸药引信的时长;是撞到尖刺掉宝的数量;是游戏结束时绚丽场景中每个粒子即时的位置和速度:经由源码,编译至汇编指令,再由解码器变换为机器指令,操纵磁盘读取内容,再一步步最终呈现到眼前。

没有数据的应用并不存在。没有图像,Adobe Photoshop 无从谈起。没有画笔、图层、笔压,它什么也算不上。没有字符、字体、分页符,Microsoft Word 也没有意义。没有事件,FL Studio 毫无价值。没有源码,Visual Studio 也只是个花瓶。过去所有的程序,都是为了基于输入数据,输出数据。数据的形式有时极其复杂,有时简单到无需文档,但所有应用程序都接收、产出数据。如果它们不需要可识别的数据,就最多只算玩具,Demo。

指令也是数据。指令会占用内存,使用带宽,并且可以被变换,加载,保存,构建。对于开发者,自然不会认为指令是数据 [*],但在旧的、保护性较差的硬件上,它们的区别很小。尽管大多数当代硬件,会保护为可执行文件预留的内存,避免其被损害、被修改,但这一相对较新的发明仍未成熟。改进的哈佛架构对内存中的数据和指令同等依赖 [*]。因此,指令仍是数据,它们也是要变换的对象。我们接受指令并将其转化为行动。指令的数量、大小、频率都很重要。我们控制、筛选、使用哪些指令来解决问题,即是优化。知道了数据是什么,便能决定如何处理数据。了解指令,便有了理论支撑,能决定哪些指令是必要的,哪些是冗余的,哪些可以用低成本方案替代。

现在,我们有了面向数据的开发方法的论证基础,但还遗漏了一个主要因素。所有这些数据和及其变换,从字符串,到图像,再到指令,都必须在某样东西上运行。这个东西有时相当抽象,如,未知硬件上运行的虚拟机;有时又很具体,比如自己的,已知 CPU 、GPU、内存容量、带宽。所有的情况下,数据又不仅仅是数据,而是存在于某个硬件上的数据,而且必须经由同一硬件变换。本质上讲,面向数据设计是变换结构良好的数据,设计软件的方法,其中结构良好的标准是由数据的目标硬件,对其执行变换的模式与类型共同决定。有时,数据并不是很明确,硬件可能也捉摸不透。但大多数情况下,良好的硬件评判能力几乎对每一个软件项目都有所帮助。

如果应用程序的最终结果是数据,且所有的输入都可以表示为数据,并且了解所有的数据变换都没有凭空发生,那就可以基于此原则建立一个软件开发的方法论:理解数据,了解机器对特定数量、频率、统计量的数据执行变换时都发生了什么。在此基础上,就可以草拟一个关于如何使方法论面向数据的声明。

数据不是问题域

原则一:数据不是问题域。

对于有些人,面向数据设计似乎处于大多数其他编程范式的对立面,因为它不太容易让问题域进到软件源码中。它不鼓励将对象概念映射到用户的语境(Context)。因为数据刻意地,自始至终没有意义。重视抽象的范式会假装计算机和它的数据不存在,将字节、CPU管道、其他硬件特征等概念抽象出去,取而代之的是:引入问题模型。他们经常把有观点的模型引入代码,或者把世界模型作为问题的语境。就是说,要么围绕预期解决方案的属性,要么围绕问题域的描述来构建代码。

赋予数据意义就能创造信息。意义并非数据固有。只有一个 4 时,几乎没什么意义,但如果说 4 英里,或 4 个鸡蛋,它就有了意义。假设有 3 个数字,作为一个三元组意义不大,但如果将它们命名为 $x , y , z$ ,就可以赋予它们位置的意义。有一份游戏中的位置列表,在没有语境的情况下也没什么意义。面向对象设计可能会把位置作为对象的一部分,通过类的名称和相邻的数据(也已经命名),就可以推断出数据的含义。如果没有已命名的语境数据与之关联,位置可以被赋予其他意义。虽然某种程度上,把数字放在语境当中是好的,但同时也阻碍了把位置作为三个数字的集合来思考,然而这一点对程序员思考如何解决的真正问题时,至关重要。

举例来讲,当把数据放在对象的深层,到后来又忘了它的存在。想想诸多已发售或尚在制作中的游戏,本可以使用一个 2D 或 3D 网格(grid)系统处理数据布局。不知为何,开发人员将地图上的每一个引用都实例化了。这还不是个例,在已经发售的游戏中,这种以对象为中心的方法摧残硬件的案例并不少见:相较于由真正的网格驱动,有数百个对象直接放置在世界空间的网格坐标上。可能程序员看到一个网格,看到所需的元素数量,就会对是否要为它分配一块内存而犹豫不决。一个简单的 256x256 的 tileMap 需要 65,536 个 tile 。面向对象程序员可能会觉得 6 万多个对象相当耗费。对他们来说,只在必要的时才为 tile 分配对象可能更有意义,甚至到了在编辑器中真的有 65000 个人工创建的 tile 的地步。但正由于它们是人工放置的,必要性就被确定了,于是就变成了不得不处理的确切问题。

缺乏对底层的认识,不仅会导致用糟糕的方式处理渲染、放置元素,同时在解释元素的位置时,也引入更高的复杂度。在无网格的形式上访问元素往往会有一些障碍,比如保有相邻元素的链接(需要保持更新);或需要执行整个元素列表(开销很大);或引用一个辅助的增强网格对象(或空间映射系统)管理那些被游戏设计限制移动的对象(原本可以自由移动的)。这种无网格设计带来的虚假的自由,流露出对数据的理解不足,并且已经给一些游戏造成了显著的性能损失。同样也是对程序员心智的极大浪费。

除了当用不用网格系统,很多游戏还将每个对象都实例化,而不是用一个变量保存物品数量。对于某些游戏,这算种优化,因为创建、销毁对象也会产生相当大的开销。但这种趋势实在令人担忧,这种存储方式将游戏的数据结构埋藏至深处。

许多游戏都试图把关于玩家的所有信息都保存在玩家类里。如果玩家在游戏中死亡,则须作为一个已死亡的对象继续存在,否则将无法访问成就数据。将数据是什么、存在哪里、与谁共享生命周期的联系到一起,带来单体类以及种种难以理清的关系。而这些关系被也随后被证明是最大的 bug 来源。在这里我不会提及任何游戏名称,不只是一个游戏,也不只是一个工作室,这是种不良技术设计的流行病。似乎那些使用现成的面向对象引擎的人比那些自己开发的人更易感,而且绝不局限于某个范式。

面向数据设计并不会把现实问题的模型引入到代码里。资深的面向对象开发者常将其看作是面向数据方法的缺陷,因为面向对象设计的成功范例来自于:把人类的概念带到机器上,然后在这个中间地带,可以写出一个人类和计算机都能理解的解决方案。面向数据方法则把问题域留在设计文档中,因而放弃了些人类的可读性,将约束和期望的因素带入到变换中。但也正是这一类操作,可以防止机器在数据层面上处理人类的概念。

现在考虑,在提倡无谓抽象的编程范式中,问题域如何成为软件的一部分。对于对象而言,我们把它包含的类及其相关的函数联系起来,将意义与数据联系起来。在高层次的抽象中,我们通过高层次的概念将操作与数据分离,而这一类概念可能并不适用于底层,从而使函数变得更难实现。

类包含数据,就赋予了这些数据语境,但有时也会限制数据的复用,影响对操作的理解。针对语境添加函数可以访问更多的数据,但很快就会导致类中包含许多不同的数据。这些数据本身并不相关,但却得在同一个类里。因为某个操作需要语境,而这个语境由于某些原因需要更多数据,如,其他相关的操作。听起来很熟悉,引用 Joe Armstrong 的话说:"我认为缺乏复用性出现在面向对象的语言里,而不是函数式语言里。面向对象的语言的问题是它们随时携带着所有隐含的语境。你想要一根香蕉,但你得到的是一只拿着香蕉的大猩猩和整个丛林。" [*]显然这是被面向对象语言的语境引用问题困扰产生的吐槽。

使用接口(或依赖注入)消除语境间的联系倒也情有可原,但实际的联系不止如此。对象中的语境往往用于联接不同类型,不同级别的数据。比如一根香蕉,有多种不同用途,可以作为一种水果,也可以代表一种颜色,抑或是作为以字母 B 开头的单词。需要仔细考量香蕉作为实例带来的问题,香蕉同时也可以是“种类”的实例。如果从进口商品的法律角度去看,或者要获取它的营养价值的信息,显然,相对于香蕉的库存数量,是截然不同的呈现。好在还是从香蕉说起。如果谈论的是大猩猩,那么我们也会止步于:大猩猩个体的信息;动物园或丛林中的大猩猩;以及大猩猩的种类。上述示例是给同一个名字的东西的三个不同层次的抽象。至少对于香蕉,每个个体并没有多少重要的数据。现实世界中也经常能看到这种语境的联系,但在对话中我们能够很好地处理了这种复杂度。一旦开始强行规定这些语境,就使得不同语境之间产生了联系。那么原本赋予的意义就会变得脆弱不堪。

混合在一起的抽象层都很难解开,因为对每个语境进行操作的函数都会从各种类中拖入随机的数据块。也就意味着,为了保证正常访问,就不能随意删除数据。这足以阻止大多数程序员尝试大规模的软件项目。同时还有另一个问题,那就是隐藏对数据的操作,会引入不必要的复杂度。当看到链表、树、数组、map、表单、行,很容易就推测出其交互、变换方式。但如果你想对家庭、办公室、道路、上班族、咖啡馆、公园做同样的事情,往往会先陷入对问题域概念的思考中。反而因此错失了探明更好的数据表达和算法的这一类细节的机会。

很少有计算机科学的算法不能在原始数据类型上重复使用。但是当引入新类,有自己的内部数据布局,没有明确遵循现有数据结构的模式,那么就不能完全利用这些算法,甚至可能看不到它们会如何应用。把数据结构放在你的对象设计中,从它们的本质来看可能是有意义的,但从数据操作的角度来看,往往没有什么意义。

当从面向数据设计角度考虑数据时,数据只是一种存在,为了获取所需格式的输出,可以用任何必要的方式解释它。我们只关心我们做了什么变换,以及数据的最终去向。实践中,抛弃数据的含义,就减少了事实与其语境相互纠缠的几率,因此也降低了仅仅为了一两个操作而混合无关数据的可能性。

数据与统计

原则二:数据指类型、频率、数量、布局、概率。

这个原则是指,数据不仅仅是结构。对于面向数据设计,一个常见的误解是,以为只跟缓存命中(cache miss)有关。即便只是为了保证缓存命中率,也只是通过结构化数据,将冷、热数据分离开。这是种有效的编程技巧,但面向数据设计要考量的,是数据的所有方面。要写一本关于如何避免缓存未命中的书,需要的不仅仅是些关于如何组织结构的技巧,还需要了解当计算机在运行程序时,里面究竟发生了什么。在书里讲这些也不太现实,因为这只适用于一代的硬件和一代的编程语言。尽管最大的获益语言是 C++,而收效最大的硬件是任何存在不平衡的瓶颈的硬件,但面向数据设计并不只植根于一种语言和某些不寻常的硬件。数据的模式很重要,但是数值和数据的变换方式同样重要,甚至更重要。通过猎豹的照片来了解它能跑多快终究是纸上谈兵。要在野外环境里去看,去了解慢的真正代价。

面向数据设计模式以数据为中心。以实时的、真实的、同时也是信息的数据为支点。而面向对象的设计则以问题的定义为中心。对象不是真实的东西,而是要被解决的问题的语境的抽象表示。对象通过操作所需的数据以表示它们,不考虑硬件或现实世界的数据模式与数量。这就是为什么面向对象设计能够快速建立起应用程序的原型,允许把早期的设计文档或问题定义直接放进代码,从而快速尝试解决方案。

面向数据设计采取了另一套策略,相较于假设用户对硬件一无所知,这里选择假设用户对问题的真正性质知之甚少,并将数据模式贬为二等市民。任何一个写过大型软件的人都会意识到,一个项目的技术结构和设计经常会发生很大的变化,以至于在后来的实施过程中,几乎没有任何部分能维持初稿的设计。面向数据设计避免了资源浪费,它从不认为设计需要存在于文档之外的任何地方。通过一系列上层代码来控制事件序列,解决当前问题,并指定模式来赋予数据临时的意义,从而推进工作。

面向数据设计从已有或预期的数据中获取线索。相较于为所有可能性,或保证扩展性做规划,不如说它倾向于使用最可能的输入来决策算法。与其说计划需要支持扩展性,不如说计划要简单、可替换,并能够落实。扩展性能以后再添加,通过单元测试这张安全网,确保它简单,且仍能正常工作。好在已经有一种不需要过多考虑,就能够保证扩展性的技术了:就是利用经过多年实践开发的数据库技术。

引入关系模型后,数据库技术发生了巨大的转变。在Out of the Tar Pit[#!bmospmark!#] 一文中提到了通过函数式方法变换关系模型数据结构,使得函数式关系编程[*]又向前迈进了一步。这份文献,正是一部教你如何调整数据结构匹配需求的秘籍。

数据是可变的

面向数据设计只适用于当下。它无法解决过去的问题,也不是什么新颖的方案,更不是解决潜在问题的通用方案。拘泥于过去会干扰灵活性,一味的着眼未来则又可能一场空,毕竟程序员也不是什么算命先生。以作者之浅见,很少有面向未来的系统。实际应用中,伴随着设计发生变化,面向对象设计的弱点才开始显现。

在面向对象设计的介绍中常提到:面向对象设计能很好地处理底层实现细节的变化。但实际上,仅限于那些显著的、可预期的。它无法很好地处理诸如用户需求、输入格式、数量、频率、信息传输通道等这一类更实际的变化。在 On the Criteria To Be Used in Decomposing Systems into Modules[#!DLParnas!#] 中提到,当时许多人会像管线一样利用模块化,在方案的各个阶段使用可执行单元。每个阶段都可以看作解决局部问题的方案。在早期的文档中,模块化通过隐藏数据得以实现。当时这还算是一种改进,但在后来的 Software Pioneers: Contributions to Software Engineering [#!mbroyedenert!#] 一书中,作者重新审视了这个问题,并提醒我们,虽然这样在开发初期根据业务状况做方案选择时更快,但同时也会增加维护和迭代成本。受到这种固有惯性的影响,面向对象的设计方法始终会有问题域与实现之间的耦合。如前所述,当问题域被引入到实现中,可以立即看到实现是否有效处理、解决了当下问题,因而可以快速做出决策。但面向对象设计的问题在于,在更高层次上的变化是不可避免的。

设计会因许多原因改变,偶尔也会包括实际上没有改变的时候。对设计的误解,或者是曲解,会直接改变设计,进而改变实现。面向数据代码的设计从数据层面理解其变化的意义,反过来指导设计。不同于 OOP 在封装内部操作状态,面向数据还允许在数据源发生变化时,修改代码。通常而言,对比对象的复用和突变,数据块及其变换的耦合和解耦更易实现,因而 DOD 能更好地处理变化。

数据,与其特征和用法产生了关联。把数据及其功能与对象混为一谈时,对象即为数据的画皮。数据的特征与对象关联,意味着很难从其他视角考虑数据。因而数据的用例和真实的设计,都与对象暗含的用法和特征产生联系。若数据的布局与用法相关联,而用法又与数据的特征相关联,就很难仅仅根据特征拆解数据。不同特征用到不同的数据子集时,因其(特征)相互交叠,便会化为重重阻碍。同时交叠的数据又会形成一个越来越大的值集,作为独立的单元在系统中到处传递。这种情况,常见的做法是将一个类重构为多个类,或将数据的所有权交给不同的类。这就是将数据与一种特征联系起来。强行赋予数据以目的。而对于静态对象,则是多个预定义的目的合集,甚至还会引入原本不存在的联系。有些目的可能不再是设计所需。然而,需求的关联总比不需求的更明显,看得见的、看不见的,随着时间的推移,关联只会越来越多。

倘若通过数据的关联性来决定其操作,如给一个类添加新方法:在数据改变或被拆分时,就很难再移除对数据的操作了;而当一个操作需要数据关联在一起,那不太方便再拆分数据了。但如果把数据与对其的操作分开,将数据的各个特征、用途,从操作与数据变换中提取出来,就不难发现:原本面向对象代码重构时会遇到的困难,变得微不足道。但也是有代价的,需要维护一份操作与其所需数据的标记(用于间接查找和访问),同时面临二者可能的不同步的风险。综上,代码保持面向对象的风格:其中对象负责保持内部一致性,效率和可变性的优先级也不是那么高。有些时候,面向对象的设计是要远优于面向数据。例如系统或硬件驱动层: Vulkan 和 OpenGL 是面向对象的,只不过对象的粒度很大,并在它自己的体系里与保持理念一致;又或者像 FILE 类型的面向对象方法:文件系统中的打开、关闭、读取、写入等操作。

许多刚接触面向数据设计范式的人,常会有一个误解:可以通过抽象,设计一个静态库(或一组模板)作为通用的面向数据的方案,就能够解决本书中提出的所有问题。同领域驱动设计 (DDD)一样,面向数据设计是针对产品和工作流的。这里学习的是如何做面向数据设计,而不是如何将其添加到项目中。这里遵循的基本原则是:尽管数据的类型可以是通用的,但在其使用层面却不是。数值千变万化,但常常隐含我们可以利用的模式。数据能够通用的想法,从根本上就是错的,面向数据设计则要去纠正它。应用于数据的变换,在某种意义上可以通用,但实际执行的操作及其次序,才是实质上的解决方案。源码是将数据从一种形式变换为另一种形式的秘方。不会有一个模板库去理解和利用数据中的模式,这应当是一个成功的面向数据设计的任务。诚然,我们可以建立算法来匹配数据中的模式(比如压缩),但提及面向数据设计时,这个模式是更高层次的,特定域(domain-specific)的,而非单纯的频率映射。

程序运行时,常会使用一些专业技巧优化性能。这样或许会降低代码可读性,但也常常因不是面向对象,或因为是硬编码的原因不被采纳。硬编码一个变换,可能要比把它包装进通用容器,再套一层算法来假装它不是硬编码来得好。如果熟悉现有模板库,直接用现成,可读性会更好;当然如果恰巧用到的是通用功能,潜在错误也会减少。但如果该功能没能很好地映射到现有的通用解决方案中,此时通过函数模板再对其扩展,无疑增加了理解代码的难度。取巧地将背后技术已被替换这一事实隐藏,会产生误导。这时候,硬编码一个实质上的新算法可能会更好,当然前提是做好充分测试。如果限定在具体数据上,只用简单真实的数据(而不是什么通用数据,通用类型)测试,测试也更好写。

数据的形态

现如今的游戏有大量不同格式的数据:针对不同平台的纹理;根据骨架、播放类型优化过的动画;音频、光照、脚本;还有由多个不同属性的 buffer 组合成的网格。只有很小一部分有固定用途,如顶点数据中的位置、UV 和法线。游戏开发中的数据很难框定,并且越来越难。许多以前无法实现的想法,现在逐渐流行。这也是为什么,需要在编辑器和工具链上花费大量时间,以便将设计师和美术们自由创作的产出,以某种形式放进引擎里。如果没有工具链、编辑器、查看器、调整工具,就不可能以同等时长产出游戏。面向对象是处理所有这些不同格式数据的方法之一。它能提供集中的视图,显示每种类型数据的归属,并根据可对其执行的操作归类。它还很容易快速添加、使用数据,但实现、封装这些不同的对象需要时间。有时对象归类的方式,无法再添加新功能时,可能还需要大量重构。例如,在许多过去的引擎中,纹理总是每像素 1、2、4 字节。随着引入浮点纹理,这些代码就都需要做些重构了。以前,顶点着色器中无法访问纹理。所以当基于纹理的蒙皮出现时,许多程序员不得不重构引擎渲染模块,使其能够更新顶点着色器的纹理,因为更新 transform 用以渲染蒙皮的网格时,可能会用到。PlayStation2 面世时,或某个引擎首次使用到着色器时,“材质” 这一概念,就发生了变化。从小型 3D 场景看向更开阔的世界的过程中,细节层次(LoD)不断变大。于是工程师们开始考虑,"渲染" 到底意味着什么。新的硬件越来越注重对齐,因此实现不得不变得难以插入操作。许多引擎中的网格数据是为渲染优化过的,但是如果必须对网格投射射线,以确定子弹命中的位置,或用 IK,或用物理,一个实体就需要有多重呈现。从这点来看,面向对象的方法就像是拼凑起来的,只有较少的对象用以代表实物,更多的则只是容器,程序员就得从更大的构建块的角度思考。实际上,这些块只会阻碍思考,在脑海中就只剩了独立的块,而其中内在的联系很快就被抛诸脑后了。从 2D 精灵到 3D 网格,始终遵循硬件厂商的格式,自定数据流和运算单元被转化为渲染的三角形。音频波形,到 bank 文件,到包络控制的音频粒子和多层音频的回放。Tile-map,到传送门、房间,再到流式的有多级 Lod 的世界,最后到混合网格调色板(hybrid mesh palette)、数据、特殊的混合资源。从翻书到欧拉角序列,到四元数和球形内插动画,到动画树和行为映射/树。不变的只有变化本身。

如果读者从事过游戏行业,可能已经有接触这些数据类型。许多引擎确实做了会抽象这些相对基本类型。新的数据类型被大量使用时,就会作为核心类型集成到引擎里。通常,该类型被推广之前,会当作特殊情况处理,算是在可用性和性能之间的权衡。谁都不想在游戏开发中,为尚未充分理解的元素敞开大门。那些不愿或不能投入时间了解新功能最佳实践的人,也可能会吃到闭门羹。面向对象开发中的对象,并不会呈现数据本身,转而向了解更高级的工具的用户提供各种功能。

除了代表数字资产的对象外,还有用于内部游戏逻辑的对象。每个游戏,都有一些对象仅仅为了推动游戏玩法而存在。可收集的卡牌游戏有很多纹理,但也有大量的规则、卡牌统计、玩家卡组、比赛记录,以及表示当前游戏状态的对象。这些都是为一个游戏完全定制的。游戏可能会有续作,但除非是换皮,不然游戏逻辑变化可能会相当大,因此需要不同的数据,意味着需要实现新的方法,原有的对象,实际上已经不再是前作中的那一个了。

游戏数据很复杂。第一次数据布局几乎都是受最初设计的启发。一旦开发启动,布局就需要跟上游戏开发的变化。面向对象技术能够快速实现任何给定设计,在分别实现每个单一设计时非常快,但难以胜任从一种干净或优雅的数据模式迁移到下一个。当然也有一些小窍门,比如使用基于版本的资产管理程序,或结合更新系统并变换脚本的框架,但通常情况下,游戏开发者会同时改变工具链和引擎,完全重新导出所有资产,然后一次性提交到下一个版本。如果必须同时更新多个网站;或者资产量巨大;又或者试图为同时用于多个项目的引擎提供支持,而只有其中一个项目想要更新;那这个过程或许会相当痛苦。Django 框架是面向对象方法的一个例子,它能比较优雅地处理设计的迁移。但原因是,这些对象呈现的是数据模型的视图,而非数据本身。

尝试建立出一个通用的游戏资产解决方案,到目前为止还没有一个成功案例。可能是因为所有的游戏都有很多微妙的不同,如果真的提供了一个通用的解决方案,那就不是游戏解决方案,只是一种新的语言。试图提供一个游戏可以使用的所有可能的对象类型,是不会找到解决方案的。但如果我们回到对游戏本身的思考,把它当作只是在一些数据上运行一组计算,那就有一个解决方案。截止到 2018 年 ,能得到的最接近的尝试是 FBX 格式,当然,它还一定程度上依赖当前的标准着色器语言。目前的解决方案似乎还有不太容易去除的包袱。由于需要通用,许多细节在以非对抗方式呈现数据的抽象过程中丢失了。

框架(framework)

无论是从底层性能的角度,还是从上层的游戏性与交互角度,游戏开发者们对于开发的理解都声名狼藉。或许由于高性能代码与内容层代码之间的差距越来越大了。面向对象技术能很好地覆盖上层需求,生产内容的程序员们对此十分满意。而性能专家们则致力于利用硬件做更多的事情,以至于内容创作者们常常会觉得在优化过程中没他们的份。可在游戏开发中,并不存在什么 “中间环节”,这可能也是为什么不采用大型计算机 [*] 的架构和性能技术。其次,游戏开发者通常不需要开发预期维护十几年的系统和应用 [*],因而不太可能在代码封装和保护上费心,甚至不会劳神维护文档。20 世纪 90 年代末,游戏开发行业首次蓬勃发展,较大的工作室开始涌现。但那时学术界和企业的软件工程实践却备受质疑,哪里有他们的身影,哪里就出现性能骤降,来自这些行业的雇员,几乎都没能留下印记。随着游戏机变得更像标准的微机,而标准微机在设计上更接近以前的大型机,那些标准专业软件工程实践的用处开始逐渐显现。现在,游戏的规模已经发展到与硬件相匹配,但行业已经不再关注那些非游戏开发实践的方向。作为一个行业,我们应该关注前人走过的路,而最接近的学术和专业开发技术似乎是以模拟和海量数据分析为基础的。我们要面临行业特有的挑战,比如在足够多的 AI 环境中遇到的高频高异构转化需求的问题,以及网络环境中的用户距离问题,又比如 MMO 中有基于位置的事件时,面临的带宽 $n^2$ 问题,因为每个人都在试图给其他人发消息。

随着游戏世代更迭,开发者创作游戏的时间也在增加,这就是为什么项目管理和软件工程实践在大型游戏公司里已经标准化。曾几何时,游戏开发者被视做顶尖程序员,根据需求开发新的技术;但随着不太冒险的硬件出现(最知名的是第八代 x86 架构处理器),重心从巧妙的编码实践转变成为标准化的过程。也即是说,为了确保发布日期与营销日期吻合,游戏开发进度可以调整。高调的游戏开发中,总会有随机因素存在。总会有新的原因,几乎可以保证无法准确预测项目(或某个阶段)的时长。即便不通过面向数据设计来提升游戏的运行效率,也可以靠它让游戏开发的时间表变得规律。

在游戏中引入新功能,困难之一在于数据布局。若要在现有框架内改变数据布局,就需要重新设计或扩展对象。即便没有新的数据,一个功能也可能从以前的独立系统变得突然需要密切交换信息。这种耦合往往会导致整个系统的混乱,进一步引发时间耦合和额外的边界情况。而这些情况或许只有百万分之一的复现几率。听起来好像问题也不大,但如果期望游戏能卖出几百万甚至几千万份,百万分之一的话,就是几个到几十个玩家。然后他们录下游戏的 bug 集锦传到网上,表示这游戏是垃圾,开发者都不好好干活:这么明显的 bug 都没有修复。这还不是最差的,如果这个问题是个规避内购的方法,而发现的人知道复现方式,随后这些步骤在网上大肆传播,或许足以在一个 MMO 游戏里产生一股破坏游戏内经济系统的资源流 [*]。现在怎么办?若是买断制的游戏,如果已经卖出了几百万几千万份,大可不必在意。但若是现如今的免费游戏,五百万玩家可能只算个好开局,而差评会遏制增长。绕过内购会直接扼杀收入,经济崩坏则直接断送前程。

早在 20 世纪 70 年代,大型计算机的开发人员们就有这样的担忧。由于他们的程序经常在与真实货币交易有关的数据上工作,因而软件必须以高标准构建。他们需要编写操作数据的业务逻辑,但必须确保数据是通过一套可证明的谨慎操作来更新的,进而保证其完整性,这一点非常重要。数据库技术的发展正是源于对处理和存储需求:对数据进行复杂分析,存储,更新,并保证其无论何时都有效。因此使用 ACID 测试来确保数据库的原子性 (atomicity)、一致性 (consistency)、隔离性 (isolation)、耐久性 (durability)。原子性测试确保所有事务只有“完成”或“不做”两种状态。如果一个数据库一次金融交易只更新一个账户,那可以说是非常差劲了。如果交易不是原子性的,就可能引发错误。一致性是为了确保在交易中应当发生的结果状态都会发生,也就是说,所有应触发的触发器都会被触发,即便是递归的也会,没有限制。若某个账户触发了欺诈检测需要被封禁,这一点就显得尤为重要。如果其中一个触发器失灵,数据库的使用者 (公司) 可能要因未能及时阻止账户而担责。隔离性是指确保所有发生的交易不会干扰任何其他交易行为。也就是说,如果两个交易出现要在相同的数据上工作,就必须排队,而非试图同时操作。尽管通常不会出什么问题,但它确实会引起并发问题。最后,耐久性。这是四要素中第二重要的,确保一个事务一旦完成,就要一直保持下去。在数据库术语中,耐久性意味着交易将确保以某种方式存储,即使服务器崩溃或停电时仍然存在。这一点对于联网的计算机来说是非常重要的,当服务器崩溃或连接中断时,需要知道哪些交易确实已经发生。

现代在线游戏也不得不担心类似的非常重要的数据。对于非免费的可下载内容,消费者关心的是一致性。对于付费的可下载内容,用户会关心每一笔交易。为了提供数据库 ACID 测试需求的大部分功能,游戏开发者们开始回过头来研究数据库如何应对严格的要求,找到大量关于分阶段提交、幂等函数、并发等参考,从文献中学习如何为数据库设计表。

结论和启示

前面已经谈到了面向数据设计是一种思考,布局数据,并决定架构的方式。面向数据设计时的许多决定由两个原则来驱动。在本章结束之前,让我们用一些可以直接应用的 tips 开始旅程吧。

考虑一下称谓如何影响数据。考虑临近数据对数据本身可能的影响,会把它困在灵活性受限的模型中。第一个原则:数据不是问题域,要考虑以下问题:

目标平台不是未知设备。了解数据,了解目标硬件。或者说,了解每个数据流的优先级,对每个使用者的重要程度。理解改进的成本和潜在收益。访问模式也很重要,如果在突发情况下访问数据,然后在整个应用周期内不再碰它们,就会影响到缓存命中。第二个原则:数据指类型、频率、数量、布局、概率,接下来考虑以下条目: