真相背后的故事:设计数据模型

之前讲过一点 关于 真相 -我们用来表示数据的系统 机械. 但我并没有说太多它背后的理由——为什么我们选择这种方法而不是其他方法. Jascha Wedowski 写信给我们,想知道更多,我认为这是一个非常有趣的博客话题, 我们开始吧!

什么是数据模型?为什么需要一个数据模型?

让我们从头说起. 大多数软件程序都需要某种表示和存储数据的方法. 要用一个词来形容它,我们称之为应用程序 数据模型. 有许多可能的数据模型:

  • XML 文件和一些 模式.
  • JSON 文件与一些商定的结构.
  • 应用程序特定的自定义二进制格式.
  • 具有良好定义结构的共享二进制格式,例如 ASN.1.
  • A 关系数据库.
  • A 非关系数据库(nosql).
  • 具有哈希标识符的版本化对象的层次结构(如git存储库).
  • 等等……

一个应用程序可以有相同数据的多种表示形式. 例如,程序可能使用磁盘上的JSON配置文件, 但是当程序启动时,这些文件被读入内存中的数据结构中. JSON文件是永久性的, 有一个良好定义的结构,并且可以在程序之间轻松共享. 内存中表示的访问速度更快, 但临时, 缺乏高层结构,不容易共享.

在这篇文章中,当我谈到 数据模型 我主要讲的是这些永久性的、结构化的模型.

大多数程序不能处理那么多的数据,而且计算机速度很快,所以每次程序运行时,它们都可以在结构化文件表示和高性能内存表示之间进行转换, 或者每次打开文件的时候. 但游戏就不同了. 它们经常处理千兆字节的数据,需要运行得非常快. 在游戏每次启动时转换所有数据将导致非常长的启动时间. 因此,它们经常单独地将结构化格式转换为内存中的格式 data-compile 一步. 它们将内存中的表示存储在磁盘上(它需要存储在某个地方), 但这种方式可以使它快速流进内存并立即使用,而不需要任何代价高昂的解析或转换.

为什么不一直使用内存格式而跳过编译步骤呢? 可以,但通常更结构化的格式(无论采用什么格式)有一些优势. 例如,编译的数据可能很大 .pak 文件,它不能很好地与版本控制. 它可能不能很好地合并,因此当一个以上的人在项目中工作时,它就不适合. 它还可能丢弃调试字符串或压缩纹理等信息,以减少最终游戏的大小.

有一个结构化的数据模型是有用的,因为它允许我们实现我们希望我们的数据上的特性 数据模型本身,而不是上 使用它的系统. 这意味着我们只需要实现一次特性, 所有系统都会得到它, 而不是在每个系统中单独执行.

例如,考虑 向后兼容性. 向后兼容性 意味着我们的程序的未来版本能够打开旧版本的文件, 即使数据以某种方式发生了变化(例如, 我们可能已经给对象添加了新的属性). 这是一个非常重要的功能, 因为没有它, 应用程序更新会破坏所有用户的旧文件.

没有数据模型支持, 支持向后兼容性可能意味着保留所有代码来解析数据的每个过去版本. 你的代码看起来像这样:

if (版本 == VERSION_1_0) {
    ...
} 其他的 if (版本 == VERSION_1_1) {
    ...
} ... 

相反,如果数据模型处理向后兼容性,则无需做任何操作. 以JSON为例. 只要你只是添加和删除属性, 并为任何新属性提供合理的默认值, JSON将自动向后兼容. JSON还可以做一个体面的工作 向前兼容性 — i.e.,允许旧的可执行文件打开新的数据. 旧的可执行文件只会忽略它们不理解的属性. 如果没有某种结构化数据模型,向前兼容性是很难实现的, 既然你不能做 如果(版本 = = ???) 测试未知的数据未来版本.

除了向后和向前兼容性,数据模型还可以帮助解决其他一些问题:

  • 依赖跟踪. 如果数据模型具有一致的表示引用的方法, 您可以使用它来检测丢失的或孤立的对象(没有任何人使用的对象).

  • 复制/粘贴. 如果数据模型支持克隆对象,则可以在此基础上实现复制/粘贴操作. 这意味着您不必为所有不同的对象编写自定义复制/粘贴代码.

  • 撤销/重做. 如果数据模型跟踪更改的历史, 撤消可以通过简单地倒带历史记录来实现. 这比使用类似 命令模式 to 实现撤销.

  • 实时协作. 如果数据模型具有同步协议,则可以免费获得协作. 用户可以对他们的数据进行本地更改, 通过复制协议, 这些更改将在同一会话中传播给其他用户.

  • 线下合作. 通过线下合作, 我指的是合作,你明确地推动和拉动合作者的变化(而不是所有的变化都是实时发生的). 换句话说,就是常规版本控制模型. 因为大多数版本控制工具都是基于文本的合并, 为了更好地支持离线协作, 您的文件格式必须是人类可读和易于合并的(除非您想编写自己的合并工具).

简而言之, 通过将许多职责放在数据模型上,可以使编辑器的UI代码简单得多. 这对我们很重要, 因为我们在Bitsquid/Stingray项目中遇到的一个问题是 很多 花时间开发UI和工具. 有时我们会花30分钟的时间在运行时中添加一个特性,然后花一周的时间为它创建一个UI. 在机械中,我们希望解决这种不平衡,并确保我们能够像编写运行时代码一样高效地编写工具和UI代码. (当然,任何涉及人机界面设计的东西都是如此 有些 耗费时间.)

Bitsquid/Stingray数据模型

选择一个数据模型意味着平衡一系列不同的关注点. 代码需要运行多快? 它需要处理多少数据? 我们是否需要模型来支持撤销、复制/粘贴、协作等?

这不是一个容易的选择,一旦你做出了选择,你通常就会被困住. 在不破坏所有用户数据或编写数据迁移工具的情况下,您无法更改数据模型, 哪个比较棘手和耗时.

来理解我们的选择 机械, 将它与我们在上一个大项目Bitsquid/Stingray中使用的数据模型进行比较会有所帮助. 我们所做的选择 机械 这在一定程度上是对我们所看到的模型问题的反应吗.

在Bitsquid引擎中, 数据表示为磁盘上的JSON文件(有一些例外), 我们使用二进制数据来处理纹理). 数据由独立但协作的可执行文件组成的联合读取,例如 动画编辑器, 声音编辑器,关卡编辑器等. 对于运行时,该JSON数据被编译为高效的 .pak 可以从磁盘直接流进内存的文件.

Bitsquid/Stingray数据模型.

Bitsquid/Stingray数据模型.

这种拥有多个独立工具以及编辑器和运行时数据分离的模型既有优点也有缺点.

从好的方面来说,它提供了很多独立性. 运行时并不关心工具,它只需要以它喜欢的格式提供数据. 这些工具甚至不需要了解彼此. 每个工具都可以专注于特定的任务,从而使源代码更小、更易于管理. 很容易将一个新工具加入其中,或者用一个新工具替换现有的工具. 不必要的耦合 是大型软件系统最大的问题之一,所以这种独立性真的有价值吗.

缺点是你最终会有很多重复, 因为很多工具都需要类似的功能:UI, 引擎视窗, 复制/粘贴, 撤销, JSON解析等. 在某种程度上,这可以通过使用公共库来缓解, 但是依赖公共库意味着耦合又开始悄然回归. 随着这些公共库越来越大,共享的功能越来越多, 工具变得越来越不独立.

通过设计, 不同工具之间以及工具和运行时之间的合作也很棘手. 记住,数据模型由 磁盘上的JSON文件. 这意味着一个工具无法看到另一个工具所做的更改,除非这些文件被保存到磁盘上. 对于运行时,必须将更改保存到磁盘 编译为运行时数据格式.

这有很多含义. 首先,它禁止工具之间的任何实时合作,因为它们不共享数据. 第二个, 这意味着工具中的3D视图(使用运行时)无法显示用户的更改,直到这些更改被保存和编译. 最后, 因为运行时只能访问已编译的数据, 不能使用运行时编辑数据. 这意味着创建像虚拟现实编辑器这样的东西变得很棘手——你可以从虚拟现实编辑器中编辑东西 运行时.

这些问题可以通过各种方式解决,而这正是我们最终所做的. 举个例子, 解决编辑器视图只能显示“已保存”的数据的问题, 这些工具会不断地将用户未保存的编辑保存到临时文件中,然后运行时可以编译并显示这些文件.

虽然像这样的hack可以工作,但这不是一个很好的解决方案. 它使系统变得复杂和难以思考. 有许多不必要的磁盘操作, 当多个工具试图编写临时文件并编译项目时,可能会出现竞态条件.

Autodesk收购Bitsquid并将其更名为Stingray, 我们想从这个工具集合转移到一个统一的编辑器. 虽然这在理论上应该可以在工具之间共享数据并修复一些问题,但重写是一个如此大的项目,事实上 在欧特克工作期间从未完全完成. 最后, 仍然有一些任务需要使用“旧工具”, 磁盘上JSON文件的基本设置是“权威的”,没有改变.

Bitsquid数据模型的其他问题是:

  • 因为模型是 基于磁盘的 (磁盘上的JSON文件是权威的)它不能表示内存中的操作,例如 复制/粘贴撤销. 因此,这些操作必须为每个工具定制编写.

  • 而数据模型可以用路径表示对其他文件的引用 纹理= "树/ 03 /落叶松” 这些“引用字符串”与JSON文件中的其他字符串没有区别. 因此,如果不知道每个文件的语义,就无法推断引用. 同样,也没有一致的方式来表示对子对象的引用 内部 一个文件.

  • 再一次, 对于基于磁盘的模型,没有办法在数据模型中实现实时协作. 而Bitsquid级别编辑器有实时协作支持(基于其撤销队列中的命令序列化), 这些工具之间缺乏一致的数据模型,这意味着协作不能延续到其他工具上. 当这些工具被统一到一个一体化的编辑器时,这种协作特性就消失了.

真相:我们用于机器的数据模型

在机器中,我们将数据存储为 对象与属性. 这有点类似于我们在Bitsquid中使用的JSON模型, 除非后面有解释, 我们实际上并没有将数据表示为文本文件. 每个对象都有一个类型,而类型定义了对象的属性. 可用的属性类型有 bool, integer, float, string, buffers,引用, 其子对象引用或子对象的集合.

对象/属性模型告诉我们 向前和向后兼容性 并允许我们实现诸如 克隆 不知道数据的任何细节. 我们还可以用统一的方式表示对数据的修改 (对象,属性,旧值,新值) 用于撤消/重做和协作.

与Bitsquid方法相比,该模型是 基于内存的 而不是基于磁盘的. I.e. 考虑数据的内存中表示 权威的. 对数据的读/写访问由线程安全的API提供. 如果两个系统想要合作, 它们通过与相同的内存模型对话来实现, 不是通过共享磁盘上的文件. 当然, 为了持久化,我们仍然需要将数据保存在磁盘上, 但这只是内存模型的“备份”,我们可能会为不同的目的使用不同的磁盘格式(i.e. 协作工作的git友好表示vs单个项目的单一二进制表示).

因为我们有一个基于内存的模型,它支持克隆和更改跟踪, 可以根据数据模型定义复制/粘贴和撤销. 还支持实时协作, 通过串行化修改并通过网络传输. 因为运行时对数据模型有相同的访问权限, 在虚拟现实会话中修改数据也是可能的. 这修复了Bitsquid方法的许多痛点.

我们明确表示 “缓冲区数据”和“对象数据”的区别. 对象数据 是否可以在每个属性级别上进行推理. I.e. 如果用户A改变了对象的一个属性,而用户B改变了另一个属性,我们可以合并这些改变. 缓冲数据 二进制数据团对数据模型是不透明的吗. 我们将其用于大量二进制数据,如纹理、网格和声音文件. 由于数据模型无法推断这些blob的内容,例如,它无法合并不同用户对相同纹理所做的更改.

区分缓冲区数据和对象数据是很重要的,因为将数据表示为对象需要付出一定的开销. 只有当收益大于成本时,我们才愿意支付这些管理费用. 大多数游戏数据(以字节为单位)都存在于纹理等内容中, 网格, 音频数据, 等等,并没有真正从存储在类似json的对象树中获得任何好处.

有时,在“缓冲数据”和“对象数据”之间划一条界线是很棘手的。. 比如动画数据?

我想说的是,任何可以被认为是一大堆统一物品的东西都可以 缓冲数据. 所以我将原始动画轨迹(位置), 旋转)缓冲区, 但是要将树和状态机混合到对象数据中.

的真理, 引用由id表示. 每个对象都有一个唯一的ID,我们通过它们的ID引用其他对象. 因为引用在真相中有自己的属性类型, 对我们来说,对引用进行推理并找到对象的所有依赖项是很容易的.

通常,数据模型可以通过惟一id (dc002eba-19a5-40d1-b9b8-56c46173bc8f)或经由路径(../纹理/ 03 /落叶松). 例如,文件系统通常同时支持这两种方式 硬链接 (对应于id)和 符号链接 (对应路径). 它们各有优点和缺点. 与id, 系统可以确保引用永远不会中断(只要对象被引用,就保持对象是活的). 它们的解析成本更低,因为它们不需要任何字符串处理. 和, 它们更容易思考, 因为相同的引用总是解析为相同的对象.

另一方面,路径是 人类可读的 是什么让它们更容易编辑和破解. 它们可以用来指可能存在或可能不存在的事物,或者现在可能不存在的事物, 但在未来可能存在. I.e. ../头/帽子 可能会解决 现在,但指的是我戴的帽子. 它们也可以根据上下文指代不同的事物. I.e. ../头/帽子 不同的角色会有不同的含义吗. 不利的一面是,路径更多 脆弱的. 如果一个对象被移动或改变了名称,引用可能会中断.

选择使用路径还是id并不容易,多年来我一直在反复尝试. 在《幸运365官网》中,我们的主要目标是确保引用不会中断, 所以我们决定使用id作为主要机制. 此外,这使代码运行得更快,并为我们节省了大量的检查,以验证引用的对象实际存在.

在一些更“松散”和“动态”解析可能有益的地方,我们可能仍然使用路径引用. 例如,使用 .. 在编写实体行为脚本时,引用实体的所有者(它可能会根据实体在层次结构中的位置发生变化)非常有用. 在这种情况下, 我们付出了较慢的代码和额外检查的代价,以获得松散连接的好处.

“真理”中的子对象是指 拥有 对象. 它们只是作为参考,但在某些情况下有特殊的行为. 为例子, 当对象被克隆时, 它的所有子对象也将被克隆, 而它的引用则不会.

真相真棒吗?

我们是否实现了“真相”数据模型的目标? 肯定.

在内存中使用编辑器状态的单一表示使得实现UI和工具变得容易得多. 我们在真相中免费获得了大量的内容:在不同的编辑器窗口之间自动传播更改, 复制/粘贴, 撤销,甚至实时协作. 更重要的是, 所有这些不仅在我们自己的代码中有效, 还有其他开发者制作的插件.

但我还是想留下现在对《正规英国365网址》做全面的分析还为时过早. 全面了解一个系统的优缺点需要时间和视角. 我们需要投入更多的人和更大的项目,看看它是如何运作的. 还有一些未解之谜:

  • 而《正规英国365网址》中的数据可以非常快速地获取, 它仍然不如打包的自定义二进制格式快. 因此仍然需要“运行时”数据格式. 运行时数据是如何以及何时生成的? 它是存储在真理中(作为缓冲)还是其他地方?

  • 对于大型项目,将所有数据都存储在内存中是非常昂贵的. 数据是如何从真相中部分加载和卸载的?我们如何对未加载的数据进行推理?

  • 我们如何以git友好的方式表示磁盘上的真相数据(i.e. 以支持文本合并的方式)? 我们是否需要多种表示来解释这样一个事实,即git友好的数据的加载速度可能比git不友好的数据慢几个数量级?

有些人认为你不应该开始执行一个系统,直到你已经计划好系统的每个小部分是如何工作的每个细节. 我强烈不同意. 你实施和使用一个系统的次数就越多, 你对使用模式的了解就越多, 潜在的缺陷, 问题区域等. 提前做决定,当你有 最少的信息糟糕的时间.

相反,我喜欢设定一个大致的方向, 确保我有一个很好的想法,我想要系统做什么,以及如何实现它(只是确保我不会把自己逼到一个角落里),然后把手放在键盘上开始执行. 当然,当您了解更多关于系统应该如何工作时,您不能害怕重写.

到目前为止,与真相的旅程进展顺利. 我很想知道接下来会发生什么.

by Niklas灰色