游戏引擎中预制件的设计
Unity 推广了预制件 Prefab 这个概念,在此之前,Unreal Engine 里有个类似的东西叫做蓝图 Blueprint 。当然它们不完全是一种东西。我现在自己设计游戏引擎,更喜欢 Unity 中的 Prefab ,但我认为 Blueprint 这个名字其实更贴切一些。
当我们说制作一个 Prefab 的时候,其实制作的是一个预制件的模板。引擎运行时对应的那些数据,其实按照这个模板生产出来的。所以,工具制作出来的 Prefab 其实是一个 template ,所以,它本质上就是一张蓝图。但因为 Unity 深入人心,所以我还是打算基于把这个东西叫预制件。
对于 ECS 系统来说,预制件是一组数据,通常保存在文件中,可以以该资源文件的内容为模板,来构造一组 Entity。注意:预制件作为资源文件时,和贴图、模型数据等这些资源文件是不同的。贴图之类的资源,映射到内存后,就是一个数据块或一个引擎中的 handle ,可以被共享使用。但预制件本身只是一个模板,它用于生产数据,本身不是数据。从这个角度讲,如果把预制件文件当作资源纳入资源管理模块统一管理的话,预制件资源对应的是一个 function (生成器)而不是 table (数据集)。
预制件本身是通过引擎的工具制作而成的,如果我们把场景也看成一个嵌套而成的大的预制件模板生产出来的产品,那么预制件编辑器其实就是场景编辑器。
从编辑好的场景转换为(预制件)模板,其实就是从预制件生产出一系列 Entity 的逆过程。这是一对针对构成 Entity 的 Component 的序列化和反序列化过程。
因为 Entity 是 Component 的聚合,而 Component 就是纯数据,对于单个 Entity ,序列化每个 Component 都不是难事。难点在于多个 Entity 之间的关系该如何描述?
游戏场景中的物件是以树状层次结构堆砌在一起的。我们需要描述谁是谁的父亲,谁是谁的孩子。在运行数据中,描述这些关系的字段中,通常都是 entity id 。但我们是不能直接把这些 id 持久化的。在我们引擎之前的设计中,我们将这些 id 先转换为 GUID ,然后再做序列化工作。反序列化时再转换为 entity id 。而在最近的重构中,我反思了这一块的设计,感觉这并不是一个好方法。
理由:
- 如果缺少 schema ,我们很难知道 Component 中的 entity id 字段做自动的转换。为此,我们之前的 ECS 框架设计中,要求为 Component 描述非常具体的 schema 。而在最近的重构中,我觉得引入一个繁复的 schema 获得的好处比引起的麻烦多得多。毕竟我们的开发语言 Lua 惯例上是不考虑数据的 schema 的。
- 建立 Entity 之间的联系未必只是在数据结构中把 id 填对就够了。它往往在构建流程中还需要额外做一些事情。对于单个 Entity ,可以用类似构造函数的范式去解决;但是对于多个 Entity 往往需要等这些 Entity 都创建好后,才能进一步的建立起联系。我们现在的 ECS 框架设计中,引入了消息机制来解耦;但是还是需要一个额外的过程去发送这条消息。
- 预制件模板能用来生产多个 Entity ,这些 Entity 既有内部之间的相互引用,还可能有对外的引用。内部之间的引用并不需要全局 GUID 来描述,而对外的引用 GUID 其实无法解决问题。这是因为,预制件并不是预制好的一组数据(刚才已经解释过),而是预知数据的模板方法。所以这里的 GUID 语义上就不指代某个具体的对象。当我们把一个 Prefab 实例化两次后,其实生成的是两套对象,相互是独立的。
- 预制件是可以嵌套的。即预制件可以由若干预制件构成。但,一个预制件引用另一个预制件特定的一部分却是没有意义的。因为上面已经分析过,预制件描述的是一组相互有关联的 Entity 的聚合体的构成方法(模板),聚合体中的部分并不具备完整的语义(无法描述出部分构造的方法)。故而,给预制件中的部分生成一个全局的 GUID 就失去了意义。
那么该怎么设计?
这里有两个问题:其一,我们怎么生产出一个预制件数据文件;其二,我们加载这个文件并根据数据生产出实物的 API 应该有怎样的行为。
第一个问题和引擎的工具链有关,第二个问题涉及的是引擎运行期的 API 设计。它们是相关的问题,对我们现阶段的引擎开发来说,又是一个先有鸡还是先有蛋的问题。这类问题通常都比较头痛。需要一起来考虑。
先看第一个问题,可能我们面临两个具体需求:
- 我们需要从外部工具,例如 max maya 等建模工具中导入模型数据。一般是 fbx gltf 等通用格式。我需要引擎能直接使用。那么最好能一致的将这些通用格式文件转换为我们的预制件文件。即,一个 .fbx 或 .gltf 文件就能视同引擎支持的预制件文件。
- 当我们在编写场景/预制件编辑器时,需要复选已经编辑好的场景中的若干物件,为其生成一个预制件文件,供以后重复使用。
暂时我们先解决第一个需求就够了。它会是一个独立的工具。以后再实现第二个需求,它会是一个库共编辑器调用。有可能当我们实现完第二个需求后,这个库又可以反过来成为第一个需求中的工具的一部分。那就是后面重构的工作。
但是这两个需求面对的数据源是不一样的:前一个需求面对的是标准数据文件,后一个需求面对的是内存中的 Entity 。我们要考虑好,这个预制件文件的格式是怎样的,如果设计不好,即使现在把第一个需求的工具做好了,以后可能无法很好的编写第二个需求中要的库。
经过一番思考,我认为 prefab 文件中的数据应该分成两个大部分。
一个是 Entity 集合,及每个 Entity 的数据是怎样构成的,这些数据就是构成 Entity 的 Component 的序列化结果。我们不需要设计 Schema ,而让数据自描述,这样会更具弹性。
但在这些序列化数据中,不应该包括 Entity 之间的联系。例如,层次结构的描述就不应该包含在这些数据中。能保证每个 Entity 能独立地从数据中创建出来就够了。
第二个是如何为上面数据集中的 Entity 建立关联。我设想的是让俺组动作数据,每组由一个 action 的名字跟着若干 id 构成。这些 id 既可以指代第一部分中的 entity ,也可以指代某个外部 Entity 。例如,
mount 1 0
这样一行表示,我需要把第一个 entity 挂接在外部根对象(0) 的层次结构之下。
对于从预制件文件生产出运行时对象的 API 大概是 produce(prefab, entity_id)
。其中,prefab 是模板数据,从预制件文件中获得;entity_id 是可选的,如果提供,则是一个已经存在的 Entity 的 id 。
- 将预制件中描述的一组 Entity 基于数据创建出来,得到了一组 entity id 。
- 依次运行预制件文件后半段中的动作(前例中的 mount 那些),将参数中的内部 id 转换为第一步产生出来的 entity id 以及外部传入的 id 。
- 返回最终的 entity id 组,通常会被调用者忽略。
如需在编辑器程序中生成这样的预制件模板文件,对于前一部分,简单的序列化构成 Entity 的 Component 即可。
对于后一部分,因为我们的 ECS 框架中 Entity 缺少类型信息。所以我采用了一种粗暴但简单的方式:注册一组函数。每个函数可以自己探测要不要为每个 Entity 生成一个 action 。例如,mount 这个 action 对应的函数,会探测一个 Entity 是否属于场景层次结构中的对象,如果是,就生成一行 mount parent_id child_id
;否则就忽略。
prefab 可以嵌套。如果需要构建的 prefab 模板对应的数据源对象集合中有一个对象表明当初是用 prefab 构建出来的,那么它是一个场景上的虚拟节点,即只有场景场次结构的信息,而不具备渲染实体。那么序列化结果只是一个 prefab 的资源文件名。那么在 action 字段中,我们添加一个 produce id 行,即可让 prefab 构建流程能够递归的创建出每一级的对象。
此外还有一些细节,由于尚未去实现,这里就不展开。等这一块重构完成,可能还会发现新的细节问题。
例如:一个 prefab 可能是在另一个 prefab 基础上构建的。虽然一个 fbx/gltf 文件可以视为 prefab 使用。但我们的工具不具备修改 fbx 文件的能力。如果在编辑工具中导入一个 fbx 文件,又不想复制为自己的专有格式的话,那么对这样的预制件修改后的结果,就应该看成是一个 prefab + patch 得到一个新的 prefab 。
而另一方面,我们很可能需要把修改后的结果保存为全新的 prefab ,去掉对原有 prefab 的引用。
不是所有 prefab 都是场景层次结构上的物件。例如“平行光源” 就不存在于场景层次结构中,只是场景的一个属性。但是“平行光源” 这个东西作为一组数据,并会在运行时作为 Entity 存在于 world 中。它的处理方法必然和前面所述的 prefab 不太相同。
关卡也不可完全视为是场景实物的层叠堆砌,它还包含了上一段提到的 “平行光源” 等额外的对象。如果有和关卡有关的代码,我也希望可以纳入 prefab 的统一管理。从这个角度看,prefab 就会更接近 Unreal 的蓝图的概念。