一、前言


1,我们试图解决什么问题?


当用 GameObject/MonoBehaviour模式做应用时,很容易编写代码,但结果却是难以阅读、难以维护、难以优化。这是多种因素综合导致的,包括:面向对象模式、由Mono编译的非机器码、垃圾回收和单线程编程。


2,使用Entity-Component-System(ECS)来拯救你的工程


ECS是一种编写代码的新方式,着重于你真正该解决的问题:构成你应用的数据(data)和行为(behavior)。


译注:所谓的行为,具体来说就是方法。


除了从设计角度讲这是种更好地编程方式之外,使用ECS还可让你发挥Unity Job System和Burst编译器的功力,充分利用当今的多核处理器。

我们发布了Unity原生的Job System,用户可以使用它并结合ECS C#脚本来获得多线程批处理的性能优势。这套Job System内置了用于检测线程竞争条件(race condition)的安全功能。

所以,我们需要引入一种新的思维和编码方式,以充分发挥Job System的优势。


二、什么是ESC?


1,MonoBehavior —— 亲切的老朋友


MonoBehaviours既包含数据也包含行为。一个进行旋转的简单例子:



class Rotator : MonoBehaviour
{
	//数据-可以在编辑器面板里编辑值
	public float speed;

	//行为-从这个Coponent中读取speed,然后根据它改变Transform的rotation
	void Update()
	{
		transform.rotation *= Quaternion.AxisAngle(Time.deltaTime * speed, Vector3.up);
	}
}



但是,MonoBehaviour继承了一大堆类;每个都包含了它自己的一大套数据——不管我们用不用得到。因此,我们无缘无故浪费了许多内存。所以,我们得想一想到底需要哪些数据,然后好好优化一下我们的代码。


译注:继承是面向对象编程的三大特征之一,同时也是一大缺点,它导致我们继承了一些无用的数据,浪费了太多内存,内存命中率低下。因此,我们不得不放弃过去的面向对象编程方式,用面向数据编程来进行连续紧凑的内存布局,以提高内存命中率。


2,ComponentSystem——迈入新领域的第一步


在我们的新模式中,一个Component只包含数据,而不包含行为。ComponentSystem才会包含行为,它负责用一组匹配的Component来更新所有GameObject(这些Component也不同于过去继承自MonoBehaviour的组件,它们是用结构struct体定义的,而非类class)。
用ComponentSystem实现上文MonoBehavior 相同的功能:


private class Rotator : MonoBehaviour
{
    //数据 - 可以在编辑器面板里编辑值
    public float Speed;
}

class RotatorSystem : ComponentSystem
{
    struct Group
    {
        //定义这个ComponentSystem需要处理哪些Component
        Transform Transform;
        Rotator   Rotator;
    }

    override protected OnUpdate()
    {
        // 我们马上看到了第一个优化
        // 我们知道所有rotator的deltaTime都是一样的,
        // 我们就把它存成个本地变量,以便获得根本更好的性能。
        float deltaTime = Time.deltaTime;

        // ComponentSystem.GetEntities<Group>
        // 让我们可以高效地遍历每个有Transform & Rotator的GameObject
        // (因为它们都被定义到上文的Group结构体里了)。
        foreach (var e in GetEntities<Group>())
        {
            e.Transform.rotation *= Quaternion.AxisAngle(e.Rotator.Speed * deltaTime, Vector3.up);
        }
    }
}



三、混合ECS: 用ComponentSystem配合现存的GameObject/Component模式

目前还存在大量基于MonoBehaviour/GameObject编写的代码,我们或许想要不费力地让ComponentSystem配合现存的GameObject/Component一起工作。其实一次性把一个项目转换成ComponentSystem风格其实也不难的。

从上述例子你就能看出来,我们很轻易地用ComponentSystem配合GameObject/Component遍历了所有的Rotator和Transformt。


1,ComponentSystem是怎么知道GameObject身上的Rotator和Transform的?

EntityManager需要事先知道那些对应的Entity,然后才能像上面的例子那样去遍历所有的Component。

ECS附带一种GameObjectEntity组件。 当 OnEnable时,GameObjectEntity会创建一个包含GameObject身上所有Component的Entity。这样,整个GameObject和它的全部Component就都能被ComponentSystem遍历到了。


注意:所以,目前你必须要在你想让ComponentSystem能遍历得到或看得到的GameObject上添加GameObjectEntity组件。


2,这对我们程序来说意味着什么呢?

这意味着你可以一个接一个地,把MonoBehaviour.Update模式转变成ComponentSystem模式。
你可以继续使用GameObject.Instantiate

你只是简单地把MonoBehaviour.Update的内容移到了 ComponentSystem.OnUpdate里面去。 数据仍然保存在那个MonoBehaviour或别的类型的Component中。


你这么做能够:

  • 用更简洁地方式把数据和方法剥离;
  • System对物体的操作都是批处理的, 避免了逐物体的虚拟调用(virtual call)。在批处理中进行优化就很简单了 (参见上述的deltaTime优化);
  • 你还能继续使用当前的编辑器面板及其他编辑器工具等;


你这么做不能够:

  • 实例化耗时得不到优化;
  • 加载化耗时得不到优化;
  • 数据是随机访问的,线性内存布局得不到保证;
  • 没有多线程;
  • 没有单指令流多数据流SIMD


所以结合使用ComponentSystem、GameObject和MonoBehaviour是写ECS代码的良好开头,它能给你立即的性能提升,但是它并没有发挥出所有的性能潜力!


四、纯粹ECS:IComponentData 和 Job


使用ECS的动机之一就是你想让程序有最佳性能。所谓最佳性能是说,你写一些简单的ECS 代码就能得到跟你完全手写SIMD指令集代码差不多的性能。

C# Job System不支持托管的类(class),只支持结构体(struct)类型和原生容器(NativeContainer)。所以,只有IComponentData才能被安全地用于C# Job System。
EntityManager 为组件数据的线性内存布局做出了有力的保证。这是使用IComponentData可以实现的C# Job System的重要组成部分。


译注:为什么要进行线性内存布局,EntityManager 怎样为组件数据的线性内存布局做出了有力的保证的,后面的 ECS In Detail(1)文章会有阐述。


下面是使用纯粹ECS方式实现上述相同功能的例子:

1,使用IComponentData储存数据:


// 这个RotationSpeed就是简单地储存一下旋转速度
[Serializable]
public struct RotationSpeed : IComponentData
{
    public float Value;
}

// 目前而言,你要想添加或移除Component,就必须要使用这个ComponentDataWrapper,
// 将来我们想把这个ComponentDataWrapper搞成自动的。
public class RotationSpeedComponent : ComponentDataWrapper<RotationSpeed> { }


2,使用JobComponentSystem实现对数据的多线程批处理:


// 使用IJobProcessComponentData去遍历所有符合这个组件类型的Entity
// Entity的处理时并行的。主线程只负责安排Job。
public class RotationSpeedSystem : JobComponentSystem
{
    //IJobProcessComponentData是用来遍历所有带有所需Compoenent类型Enity的简单方法
    //它也比IJobParallelFor更高效更便捷。
    [ComputeJobOptimization]
    struct RotationSpeedRotation : IJobProcessComponentData<Rotation, RotationSpeed>
    {
        public float dt;

        public void Execute(ref Rotation rotation, [ReadOnly]ref RotationSpeed speed)
        {
            rotation.Value = math.mul(math.normalize(rotation.Value), math.axisAngle(math.up(), speed.Value * dt));
        }
    }

    // 我们继承JobComponentSystem,这样System就可以自动提供给我们所需Job之间的依赖关系了。
    // IJobProcessComponentData声明了它要对RotationSpeed读操作,并且对Rotation写操作。
    // 这样声明以后,JobComponentSystem就连可以给我们Job之间的依赖关系了,包括之前已经安排好的要写Rotation或RotationSpeed的那些Job。
    // 我们要把这个依赖关系renturn出来,这样,依据类型我们已经安排好的Job就能注册到下一个可能会运行的System里去了。 
    // 这么做意味着:
    // * 主线程不发生等待, 主线程只需要根据依赖关系去安排Job (只有依赖关系被确定以后,Job才会被启动)。
    // * 依赖关系为我们自动计算出来了, 这样我们就只写一些模块化的多线程代码就可以了。
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var job = new RotationSpeedRotation() { dt = Time.deltaTime };
        return job.Schedule(this, 64, inputDeps);
    } 
}