新一代Entity Component System(ECS)将会彻底改变Unity的底层概念(GameObject-Component 系统)和现有工作方式。MonoBehavious、Update、GameObject....这些概念已经过时了!
1. 什么是ECS?
ECS,中文:实体-组件系统。并不是什么新鲜玩意,它是在游戏架构中广泛采用的一种架构。在游戏中,每个物体是一个Entity(实体),比如,敌人、子弹、车辆等。每个实体具有一个或多个组件,赋予这个实体不同的行为或功能。所以,一个实体的行为可以在游戏运行时通过加减组件进行改变。ECS经常和数据驱动模式一起使用。 wiki链接
从上面介绍可以看到,Unity中的GameObject好像扮演着实体的角色,但,它不够纯粹!
- 下一代ECS全新架构中,将不会再有GameObject的概念,取而代之的是真正的轻量化的Entity,一个Entity就只是Entity,可以把它看成超级轻量化的GameObject,实际上,一个Entity什么都做不了。它不存储任何数据,甚至连名字都没有!
- 你可以给Entity增加或移去Component,但,旧的Component不复存在,其被重新定义为
ComponentData
,这是一个继承自IComponentData
接口的,高效的结构体:
struct MyComponent: IComponentData
{}
和旧的组件系统不同的是,IComponentData
只存储数据,可随时添加到Entity或者移去。
-
EntityManager
将会管理所有的Entity和其上的ComponentData,它将会保证内存的线性访问。 - 新定义的
ComponentSystem
管理游戏逻辑(类比以前的MonoBehavior),可以操作旧的GameObjects/Components, 或者新的 ECS ComponentData/ Entity。
2.为什么要引入 ECS
传统的 GameObject/MonoBehaviour 系统七宗罪:(深有体会)
- 面向对象的编程方式(曾经的圣典,已经跟不上时代,OOP最大的问题是数据和逻辑混在一起...现在我们要数据驱动模型)
- Mono编译的未经优化的机器码
- 糟糕的垃圾回收
- 不能忍的单线程
Entity-component-system的出现,就是解决这些问题:
- 简单的思想:数据和逻辑构成你的游戏。
- 有了ECS,就可以使用Unity(c#) Job System 和 Burst 编译器,充分发挥多核CPU的潜力
3. Hello world
说了半天,我到底怎么用ECS?
unity世界的helloworld莫过于沿着y轴旋转一个方块,先来看看我们的老朋友MonoBehavior :
using UnityEngine;
class Rotator : MonoBehaviour
{
void Update()
{
transform.rotation *= Quaternion.AngleAxis(Time.deltaTime * speed, Vector3.up);
}
}
多么熟悉的代码啊!伴随了我们将近10年的时间,一想到就要老去,竟然有些伤感...
伤感归伤感,但是上面的代码,一直就有巨大的问题,十几年来我们只不过视而不见罢了。
我们在unity中新建一个脚本,要挂在GameObject上,就必须继承MonoBehaviour,而MonoBehaviour 本身就继承自很多父类,父类中定义的很多字段、属性,在我们的小脚本中根本用不到,可还是不得不继承。白白的浪费了内存。
下面我们来试试ECS:
- 目前ECS尚处于开发阶段,我们需要以下前提条件才能开启:
- Unity2018.1版本以上
-
Build Settings - Player Settings ,设置c# runtime:
- 打开项目目录下packages/ manifest.json 文件,加入以下内容:
{
"dependencies": {
"com.unity.entities":"0.0.11"
},
"testables": [
"com.unity.collections",
"com.unity.entities",
"com.unity.jobs"
],
"registry": "https://staging-packages.unity.com"
}
- 场景中建立一个cube,新建
Rotator.cs
脚本,并拖给cube:
public class Rotator : MonoBehaviour {
//是的,没看错,只有数据
public float speed;
}
- 新建
RotatorSystem.cs
脚本:
using System.Collections;
using System.Collections.Generic;
using Unity.Entities;
using UnityEngine;
/// <summary>
/// ComponentSystem管理游戏逻辑(类比以前的MonoBehavior)
/// 该类只有一个OnUpdate方法需要复写
/// </summary>
class RotatorSystem : ComponentSystem
{
/// <summary>
/// 简单的Group结构体,规定Entity必须包含哪些ComponentData
/// </summary>
struct Group
{
public Transform transform;
public Rotator rotator;
}
protected override void OnUpdate()
{
//遍历场景中同时包含transform和Rotator的Entity,执行操作
foreach (var item in GetEntities<Group>())
{
item.transform.rotation *= Quaternion.AngleAxis(item.rotator.speed * Time.deltaTime, Vector3.up);
}
}
}
这个脚本不用拖拽给任何场景中的物体,运行时它会自动遍历场景中符合条件的Entity。
但此时执行游戏,不会有任何变化,下一步,需要在cube上再挂一个GameObjectEntity
组件,告诉ComponentSystem这是一个GameObject类型的实体。
ECS ships with the GameObjectEntity component. On OnEnable, the GameObjectEntity component creates an entity with all components on the GameObject. As a result the full GameObject and all its components are now iterable by ComponentSystems.
Thus for the time being you must add a GameObjectEntity component on each GameObject that you want to be visible / iterable from the ComponentSystem.
运行游戏,voila!cube开始旋转。
4. 好吧,看起来很炫,但这对我的游戏开发有什么意义?
仔细想想:切换到ECS,我们需要做的只是:从MonoBehavior中把逻辑剥离出来放到ComponentSystem的OnUpdate里。实际上,以上有关ECS的代码示例只是‘Hybird’模式,对于大量已经开发的工程,这是一种无痛解决方案。unity这次的变化太大了,所以必须要有这么一种过渡阶段。
那这样做有什么好处吗?
- 分离数据和逻辑,麻麻再也不用担心我的代码难看了。
- 系统批量处理物体(Entity),而不是单个处理,执行效率大大优化。
- hybird模式允许你继续使用熟悉的模式,inspectors、editor tools等的同时,享受到ECS带来的效率提升。
ok,那在Hybird模式下使用ECS有什么损失呢?
- 初始化时间(遍历寻找Entity的过程)无法优化
- 载入时间无法优化
- 数据在内存中是随机获取的,非线性,执行效率下降
- 无法利用多核处理器
- 没有SIMD
SIMD,Single instruction, multiple data,计算机在多核处理器上同时进行同种运算的能力。数据处理是并行的,但不是并发的。也就是,CPU单进程的并发计算。
5. 纯ECS解决方式:
欢迎进入Unity的未来:ECS+IComponentData+c# jobs
- 我们使用ECS的本意是为了提高执行效率(performance),为了获得这种高效率,你必须使用 SIMD 方式编写代码(custom data layouts for each loop)。
- c# job system 只能够管理结构体和NativeContainers,因此,IComponentData是最好的解决方案。
- EntityManager 保证了线性内存模型下的访问(linear memory layout:https://en.wikipedia.org/wiki/Flat_memory_model)。
三者搭配使用,如虎添翼。
让我们再次转动这个cube:
- 场景中再建一个cube,写以下代码:
using System;
using Unity.Entities;
/// <summary>
/// 一个简单的结构体(ComponentData)
/// </summary>
[Serializable]
public struct RotationSpeed : IComponentData
{
public float value;
}
可以看到,和我们一开始说的一样,这就是一个继承自IComponentData的简单结构体,对应过去的Component,此结构体可以从Entity上增加、删除。
但此时直接拖拽给新的cube,提示不能添加:
...没继承MonoBehavior当然不能添加。
加入一行代码就可以了:
using System;
using Unity.Entities;
/// <summary>
/// 一个简单的结构体(ComponentData)
/// </summary>
[Serializable]
public struct RotationSpeed : IComponentData
{
public float value;
}
/// <summary>
/// 现阶段这个wrapper是为了能够把IComponentData添加给GameObject,
/// 将来会被移去
/// </summary>
public class RotationSpeedComponent : ComponentDataWrapper<RotationSpeed> { }
再写一个RotationSpeedSystem
,此脚本不用赋给任何物体。
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;//c# jobs
using Unity.Mathematics;//新的命名空间
using Unity.Transforms;//新的命名空间
using UnityEngine;
public class RotationSpeedSystem : JobComponentSystem
{
/// <summary>
/// 使用IJobProcessComponentData遍历符合条件的所有Entity。
/// 此过程是单进程的并行计算(SIMD)
/// IJobProcessComponentData 是遍历entity的简便方法,并且也比IJobParallelFor更高效
/// </summary>
[ComputeJobOptimization]
struct RotationSpeedRotation : IJobProcessComponentData<Rotation, RotationSpeed>
{
/// <summary>
/// deltaTime
/// </summary>
public float dt;
/// <summary>
/// 实现接口,在Excute中实现旋转
/// </summary>
public void Execute(ref Rotation rotation, ref RotationSpeed speed)
{
//读取speed,进行运算后,赋值给rotation
rotation.Value = math.mul(math.normalize(rotation.Value), math.axisAngle(math.up(), speed.value * dt));
}
}
/// <summary>
/// 我们在这里,只需要声明我们将要用到那些job
/// JobComponentSystem 携带以前定义的所有job
/// 最后别忘了返回jobs,因为别的job system 可能还要用
/// 完全独立于主进程,没有等待时间!
/// </summary>
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var job = new RotationSpeedRotation() { dt = Time.deltaTime };
return job.Schedule(this, 64, inputDeps);
}
}
cube 又转了。
本系列文章99.9%的内容来自于官方github文档,其余为原创