一、前言
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);
}
}