ECS架构简介

初探Unity2019的DOTS技术

Posted by John Young on June 27, 2019

前言

ECS架构很早就被提出,近两年因为《守望先锋》、Unity等工业强度的应用,被越来越多地讨论。本文主要是对ECS架构作一些入门向的介绍。

1. ECS架构是什么

ECS stands for Entity Component System,一般翻译为“实体组件系统”。注意这里的E、C不是作定语来修饰S,三者是并列关系,而不是”由实体和组件组成的系统”。所以即使称之为CES、SCE、“组件系统实体”,除了会失去术语带来的降低理解成本的便利外,也没有什么问题。ECS是一种以数据为导向的编程方式,和几乎每个程序员都熟知的面向对象编程(OOP)编程方式有本质的不同。

先来回顾一下面向对象的编程。在面向对象编程中,抽象是很重要的概念。一般地,我们使用虚函数来实现动态绑定,从而达成运行时的多态性。这种多态性意味着,我们可以仅仅操作父类,而不需要关心子类中与该操作无关的部分,这其实就达成了一种解耦(外部系统只与父类耦合,但是父类和子类之间还是强耦合的)。

然而正是因为继承时父类和子类的强耦合性,只有在两个类之间有Is-A关系的时候我们才能使用继承(Liskov替换原则)。在游戏编程中(其他领域也基本如此),大多数时候Has-A可能更适合于描述两个类之间的关系,所谓组合优于继承,这其实就是组件的思想。在Unity的设计之初,组件系统在游戏引擎的设计中已经非常流行,unity也正是采用的组件系统,通过附加不同的组件,来描述不同的游戏对象(Transform组件、Renderer组件etc)。unity的这种组件系统和我将要介绍的ECS也不一样(实际上unity也已经发布了面向数据的技术栈DOTS,其中包含了unity ECS,文章后面会介绍),unity旧的组件系统基于的还是传统的面向对象的编码方式,包含堆内存分配、GC、虚函数(Monobehaviour的Awake、Update等方法)等等,这些因素从底层形成了代码的运行速率提升的瓶颈。

要想突破这些瓶颈,不得不放弃面向对象,换一种从一开始就从效率角度考虑的程序设计方式。ECS是就是这样一种以数据为主导的一种程序架构,它可以提供给开发者一种方便的写出高性能代码的方式,同时代码的逻辑架构足够清晰,模块之间的耦合性足够小。它的基本思想是将数据与行为分离,让数据在内存中紧凑排列,提高cpu的缓存命中率;同时不使用引用类型,不使用继承,让多线程代码的编写更为简单,使各种batch技术的应用成为可能;从程序的可维护性、可扩展性上来看,ECS将数据和行为分离,在Component中仅储存数据,System中仅储存行为,System通过依赖注入的方式访问Component,这可以很大程度上解耦,框架的表达能力和模块化并不会比面向对象的方式弱。ECS概念中有一些术语,下面依次介绍一下:

  • Entity:实际上就是一个id,表示游戏世界中的一个实体,可以被创建、被销毁,可以添加组件、删除组件。在概念上,它和Unity中的GameObject很相似。在Unity ECS中,它的生命周期由EntityManager管理,创建、销毁、增删组件都通过EntityManager进行。Entity是一个index,通过EntityManager,实际上它被映射到的是一个包含元信息的类型(Archetype)和一个内存地址的数据结构(称为EntityData),通过元信息可以访问到Entity包含了哪些组件,以及相应的offset和size,再结合内存地址和index,就能访问到具体的组件了。
  • Component(Unity中的ComponentData):用于储存数据。在内存中同一种类型的Component是紧密排列的,这样在System遍历时,可以减少很多cache miss。和Unity旧的组件系统中的组件概念区别很大,ECS中的组件不包含任何方法,它只是数据。Unity的旧的组件系统中,组件都从Monobehaviour派生,它可以实现Awake、Update、OnDestrory等等虚函数;而在ECS中,各种类似的行为是在System中完成的。在Unity ECS中,除了Component,还有一种SharedComponent。很好理解,挂载了Component的不同Entity,该组件对应了不同的内存位置;而挂载了同一个SharedComponent的Entity,该组件在内存中对应同一位置。SharedComponent适用于很多实体共享同一种资源的时候。例如说,想用同一种renderer、material渲染许多怪物,可以用这种组件储存数据,再通过EntityManager批量实例化。
  • Archetype:Unity ECS中的一种概念,储存元信息,包含了一组Component的offset、size、type,还记录了Component的数量、Entity的数量。通过Archetype这一中间类型,是为了能将相同类型的Component在内存中紧密排列的同时,提供访问上的便利。
  • Group:其实就是组件类型的元组,充当filer的作用,用于筛选出带有某些组件的实体,以供System遍历。
  • System(Unity中的ComponentSystem):所有的行为都在系统中,同时系统中不包含任何数据,不储存状态(至少原则上是这样的)。System中使用的数据,是通过依赖注入的方式传给System的。通过System提供的Group,ECS框架会帮我们筛选出含有这些Component的实体,在System中就可以遍历啦。如果Component只需要read操作,我们还可以用JobSystem方便地使用多线程来完成任务。对于各个System来说,还有一个需要解决的是它们更新的先后顺序的问题。有些System之间多多少少存在一些依赖,例如PlayerMoveSystem可能依赖于InputSystem,那InputSystem.OnUpdate就需要先执行。在Unity ECS中,目前的解决方案是给各个System打一些属性标签,表明它要在哪一些System之后执行。这些依赖其实就形成了一个有向无环图。据unite 2017上他们的技术leader回答观众提问时的说法,这种依赖可能会换一种更直观、可编辑的方式,也许会做成可视化的编辑器。
  • World:Entity的集合。可以同时存在多个World(后面的回放会讲到使用两个World的情况)。

2. ECS架构为什么可以提升效率

CPU的cache line

上图是一般的CPU的缓存结构。可以看出,每个核心都有各自的一级缓存和二级缓存,同时所有核心共用一个三级缓存。在讲述计算机体系结构的书籍中,一般都会提到在计算机储存介质的速度上,寄存器 > L1 Cache > L2 Cache > L3 Cache > 主存 > 磁盘,储存空间递增,访问速度递减。在访问速度上,寄存器是1个cycle,L1 Cache是3~4 cycles,L2 Cache 10~20 cycles,L3 Cache 40~45 cycles,内存100~200 cycles。在容量上,寄存器一般几十~几百B,L1 Cache几十~几百KB,L2 Cache几百KB~几MB,内存几百MB~几GB。

为什么64位的程序可以比32位更快呢?因为64位的程序的可以使用的寄存器容量更大,编译器会使用更多奇妙的优化,更少地从地址中取数据,充分利用寄存器的速度优势。这是编译器如何充分利用访问速度更快的储存介质来优化程序运行速率的一个例子。 如果程序使用的数据在内存中是相邻的,那么它们会在上一段数据被使用时,被预先加载到缓存中。当使用这些数据时,就可以从缓存中读,不需要从内存中读,速度上快几十倍。但是如果程序使用了很多堆分配的内存,或者使用了比较多的函数调用,又或者是加载了很大的数据段,那么就会频繁从内存中加载数据,这就是cache miss。

ECS的设计方案

从之前的ECS概念介绍中已经提到过很多,其实也大概讲完了它是怎么设计的。在ECS中的System中,我们一般只关心Component,而不关心具体的Entity是哪个,也不希望迭代Component时,加载其他不必要的数据。因此在ECS的内存管理上,相同的Component在内存中是tightly packed的。这样我们访问一个组件时,其他同类型的组件会被加载到缓存中,因此很少需要从内存中读取。当我们需要访问Entity挂载的组件时,依靠的是Archetype,它记录了Entity所具有的组件类型以及内存布局信息。

除了数据在内存中的排列方式,这些数据本身需要是值类型,不能是引用类型。很好理解,紧密排列的,指向随机的内存区域的指针,其实毫无意义。由于是值类型,也完全不需要垃圾回收,消除了GC的开销。同时,由于是值类型,在复制、赋值时可以直接使用memcpy进行操作,实例化Entity的时候会非常快。在Unite 2017的演讲上,演讲者展示了unity ECS创建Entity的开销,非常低,仅仅10%~20%的overhead(将memcpy的速度作为theoretical limit的话)。

3. ECS在代码编写上的优势

除了效率上非常出色,ECS架构也能使一些代码的编写更为优雅简单。

这一部分非常推荐Tim Ford在GDC 2017上关于守望先锋团队使用ECS的经验分享。这次分享主要不是从代码效率上介绍ECS的优势,而是ECS技术是如何降低代码的复杂度的,并举了ECS架构是如何帮助《守望先锋》Gameplay团队解决复杂的网络同步问题的例子。

Tim举了一个例子,在现实世界中,每个主体观察一颗树的角度是不同的:园丁会关注茂密程度、鸟儿会关注可供栖息的树枝、白蚁会关注可供筑巢的根基。在编程中,我们大多数时候也只关心一个主体的某一部分,并且不同观察者感兴趣的部分也可能不一样。在传统OOP中,对象的方法在它本身。例如在OOP中表述上述的那颗树,它的Update函数可能就很奇怪,园丁要对它更新,鸟儿要对它更新,白蚁也要对它更新,实际上不同的模块在这里耦合了。对于ECS架构则没有这种混乱,一个System只会关注它所感兴趣的组件,这一定程度上减小了模块间的耦合。

ECS的数据是没有行为的值类型,或者说是纯数据,这使得系统可以非常方便地进行快照以及回滚操作。这一点对于网络同步系统来说,可以减少很多复杂度。在《守望先锋》这样的射击游戏中,状态预测是非常重要的,假如服务器的延迟比较大,客户端有时需要预测玩家的输入,才能使高延迟的时候的玩家行为表现地平滑。如果客户端预测失败了,则需要对客户端进行状态的回退。对于ECS来说,只需要memcpy就能回退到上一个快照的状态,不可谓不方便。另外,这种特性对于实现“死亡回放”这样的功能也十分方便,只需要将系统状态回退到十秒之前的快照,再播放十秒内的玩家输入和服务器回包即可(当然,死亡回放是在另一个世界渲染的,真实世界仍需要接受服务器输入)。

4. Unity DOTS Demo

除了ECS外,Unity的DOTS(Data-Oriented Tech Stack)还包含了这两项:

  • Burst Compiler:针对ECS架构做了很多优化的编译器,可以auto-vectorization,既自动使用SIMD指令集进一步提升程序速度,比一般的Mono编译器要快不少。
  • Unity JobSystem:可以写出安全的多线程代码,自动调度任务,自动处理任务之间的依赖。

Unity的DOTS目前还是Preview阶段,网上很多教程或者文档的API都过时了,甚至官方的Demo ,用最新的pakage都有编译错误,只能回滚到老版本。

我实现的Demo也可能很快就不能用最新的package运行了,API的更新还是很快的,因此了解思路更重要。

Demo使用的是Unity 2019.1.8f1,全部代码只有三个文件,其中Bootstrap.cs挂在一个GameObject上,附上需要批量实例化的Mesh和Material。代码中有详细的注释:

1)MoveComponent.cs

using Unity.Entities;

//从IComponentData继承,只有speed一个字段
public struct MoveComponent : IComponentData
{
    public float speed;
}

2)MoveSystem.cs

using Unity.Jobs;
using Unity.Entities;
using Unity.Transforms;
using UnityEngine;

//从JobComponentSystem继承可以用JobSystem处理System的任务
//也可以从ComponentSystem继承,处理不适合JobSystem处理的任务
public class MoveSystem : JobComponentSystem
{
    //从ComponentData层面处理的Job
    struct MoveJob : IJobProcessComponentData<MoveComponent, Translation>
    {
        //Time.deltaTime是静态变量,在Job中不能使用,需要在local thread存一份
        public float dt;
        public void Execute(ref MoveComponent move, ref Translation translation)
        {
            if(translation.Value.y > 5.0f || translation.Value.y < -5.0f)
            {
                move.speed = Mathf.Abs(move.speed) * (translation.Value.y >= 0 ? -1 : 1);
            }
            translation.Value.y += move.speed * dt;
        }
    }

    //这里的inputDeps是input dependencies的意思, job之间可能会有依赖关系
    //job.Schedule之后,会将依赖传递下去
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var job = new MoveJob { dt = Time.deltaTime };
        return job.Schedule(this, inputDeps);
    }
}

3)Bootstrap.cs

using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Rendering;
using Unity.Transforms;
using UnityEngine;

//用于引导的Monobehaviour,操作全局的World
//可以理解为入口
//创建好Entity后可以销毁掉
public class Bootstrap : MonoBehaviour
{
    //目前Unity DOTS的render部分是hybrid的,不是pure ECS.
    //这里的RenderMesh的类型是ISharedComponentData,在内存中只会有一份
    //但是Mesh和Material还是引用类型,因此它是hybrid的,之后unity应该会慢慢迁移
    [SerializeField] private Mesh mesh;
    [SerializeField] private Material material;

    void Start()
    {
        //全局的World的EntityManager
        var entityManager = World.Active.GetOrCreateManager<EntityManager>();
        //带四个组件的Archetype,Translation是GameObject中的transform.localPosition
        var archetype = entityManager.CreateArchetype(
                typeof(RenderMesh),
                typeof(Translation),
                typeof(MoveComponent),
                typeof(LocalToWorld)
            );
        
        //NativeArray是不受到内存管理的,使用完一定要Dispose,否则会内存泄露
        //这里创建10000个Entity
        NativeArray<Entity> entityArray = new NativeArray<Entity>(10000, Allocator.Temp);  
        entityManager.CreateEntity(archetype, entityArray);

        for (int i = 0; i < entityArray.Length; ++i)
        {
            var entity = entityArray[i];
            entityManager.SetComponentData(entity, new Translation
            {
                // Entity 100x100 地分布
                Value = new float3(i / 100, 0, i % 100),
            });
            entityManager.SetComponentData(entity, new MoveComponent
            {
                // 振动的速度随机
                speed = UnityEngine.Random.Range(-10.0f, 10.0f),
            });
            entityManager.SetSharedComponentData(entity, new RenderMesh
            {
                // 设置mesh和material
                mesh = mesh,
                material = material,
            });
        }

        entityArray.Dispose();
        Destroy(gameObject);
    }
}

Demo截图:

5. 延伸阅读 & 引用