|
Hi,大家好。吃饱喝足,该写点东西了。
这次给大家带来一期"新技术"的介绍。没错,主角就是Unity官方正在推行的ECS框架(Entity-Component-System)。
相信大家多少听说过ECS(实体组件系统),或者在网络上查找过相关资料,甚至动手实现过一个自己的简易ECS框架。如果没有听说过也没有关系,可以通过实践可以更好地理解它。
简单介绍一下ECS的核心概念:
Entity(实体):由一个唯一ID所标识的一系列组件的集合。
Component(组件):一系列数据的集合,本身没有任何方法。只能用于存储状态。
System(系统):只有方法,没有状态的工具,类似静态类。
这种设计看上去很新颖且奇特,具体到游戏开发的环节中是什么样的呢?
简单来说,Entity相当于一个只有唯一ID的GameObject,Component就是一个只有字段的Struct,System只有方法没有任何字段。Entity通过不同Component的组合可以被不同的System关注。
如下图所示:
Player(Entity)拥有Position,MoveSpeed,Velocity,Player这些Component。那么他就会被PlayerInputSystem,MoveSystem所关注,这些系统在Update时会对该实体的组件进行读写操作。
系统的调用顺序也可以打乱。PlayerInputSystem跟AIInputSystem由于写的是不同的实体的Velocity所以可以并行,可以把他们归到一个Group里面。MoveSystem由于需要读取Velocity,所以得等待Group中所有写入操作都完成后才能Update。
至于为什么要使用ECS,相信很多熟悉OOP(面向对象编程)的同学开发稍微复杂点的游戏时都遇到过:一大堆类不知道继承哪一个,为了解耦写一大堆管理器等。
ECS这种反直觉的设计理念在游戏开发中比起OOP有这些显而易见的优点:
- 没有大量的管理器或者中间件,简单地说就是避免了OOP中常见的过度抽象。
- 比起继承,组合的方式更容易塑造新的实体类型。
- 数据驱动,因为Component没有方法且被统一管理,方便利用Excel配置数据。
- 可以利用Utils(工具类)抽出System的共有方法,加上SingletonComponent(单例组件)提供全局访问进行解耦。
<hr/>ECS在1998年就已经被应用在一款叫做:Thief : The Dark Project 的游戏中。直到2017年在 GDC 2017上的演讲:Overwatch Gameplay Architecture and Netcode
守望先锋团队向大家分享了在守望先锋中使用的ECS以及一系列实现上的细节。这下才被广大开发者熟知。
由于ECS架构的一些特点,他可以很容易利用多个CPU实现逻辑并行,紧凑且连续的内存布局,比起OOP可以更方便地获得更大的性能提升。
在Unity2018中,伴随Unity ECS推出的还有Burst编译器与C# Job System。下面列出了一部分Unity ECS的愿景:
- 我们相信我们可以快速编写高性能代码,就像MonoBehaviour.Update一样简单。
- 我们相信,在基础层面,这将使Unity比现在更加灵活。
- 我们会立即为您提供有关任何竞态条件的错误信息。
- 对于小内容,我们希望Unity在不到1秒的时间内加载。
- 在大型项目中更改单个.cs文件时。组合编译和热重载时间应小于500毫秒。
Unity ECS现阶段并不推荐直接用于生产,但是了解他的使用方法还是很有用处的,因为ECS不仅可以提高性能,还可以帮助你编写更清晰,更易于维护的代码。
<hr/>看到这里有没有很想体验一下Unity的ECS?
下面我们就来写一些Unity ECS-Style风格的代码。
首先我们下载一个Unity 2018.X,新建一个工程在Window -> Package Manager 中选择Advanced -> Show Preview Packages,然后选择Entities并点击Install。
(在2018.1中点击All可以看到Entities)
看到Jobs出现就说明Entities已经安装完毕
准备就绪后,我们直接创建一个脚本并命名Bootstrap。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
public class Bootstrap
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
public static void Awake()
{
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
public static void Start()
{
}
}
ECS不同于以往的Monobehavior,他有一套自己的生命周期。
代码中的Awake跟Start方法都是我自己编写的,只要给他们打上一个特性就会被Unity在场景加载的前后时机进行调用。(方法必须为静态方法) 我们在场景中创建一个空物体并命名Player,加上Mesh Instance Renderer Component(渲染组件)。
Mesh选择球形,新建一个材质球Red并且放在Material中,再把Cast Shadows(投影)设置为开启。
打开Bootstrap脚本,在Awake中创建出EntityManager与EntityArchetype:
private static EntityManager entityManager; //所有实体的管理器, 提供操作Entity的API
private static EntityArchetype playerArchetype; //Entity原型, 可以看成由组件组成的数组
[RuntimeInitializeOnLoadMethod(loadType: RuntimeInitializeLoadType.BeforeSceneLoad)]
public static void Awake()
{
entityManager = World.Active.GetOrCreateManager<EntityManager>();
//下面的的Position类型需要引入Unity.Transforms命名空间
playerArchetype = entityManager.CreateArchetype(typeof(Position));
}
通过World创建出EntityManager,EntityManager的对象提供了创建实体,给实体添加组件,获取组件,移除组件,实例化与销毁实体等功能。
按照Unity的说法,默认情况下会在进入播放模式时创建好World,因此我们直接在Awake使用World创建EntityManager就好了。 所以EntityManager就是一个实体的管理器。
上个版本的Unity ECS还是静态类,现在已经该为由World创建的实例了。 等待场景加载完成之后会调用Start方法:
[RuntimeInitializeOnLoadMethod(loadType: RuntimeInitializeLoadType.AfterSceneLoad)]
public static void Start()
{
//把GameObect.Find放在这里因为场景加载完成前无法获取游戏物体。
GameObject playerGo = GameObject.Find(&#34;Player&#34;);
//下面的类型是一个Struct, 需要引入Unity.Rendering命名空间
MeshInstanceRenderer playerRenderer =
playerGo.GetComponent<MeshInstanceRendererComponent>().Value;
//获取到渲染数据后可以销毁空物体
Object.Destroy(playerGo);
Entity player = entityManager.CreateEntity(playerArchetype);
//修改实体的Position组件
entityManager.SetComponentData(player, new Position
{ Value = new Unity.Mathematics.float3(0, 2, 0) });
// 向实体添加共享数据组件
entityManager.AddSharedComponentData(player, playerRenderer);
}
float3 是Unity新推出的数学库(Unity.Mathematics)中的类型,用法跟Vector3基本一致。
Unity建议在ECS中使用该数学库。 通过entityManager对象创建的实体都会被管理起来,在创建Entity时我们可以在CreateEntity方法的参数中填上之前创建好的playerArchetype(玩家原型),即按照原型中包含的组件依次添加到实体上。
在Update方法中获取了之前在场景中设置的渲染组件,并且作为AddSharedComponentData的参数。
这时我们的player实体已经拥有了两个组件:Position跟MeshInstanceRenderer。
关于ISharedComponentData接口:他的作用是当实体拥有属性时,比如:球形的实体共享同样的Mesh网格数据时。这些数据会储存在一个Chuck中并非每个实体上,因此在每个实体上可以实现0内存开销。 值得注意的是:如果Entity不包含Position组件,这个实体是不会被Unity的渲染系统关注的。因此想在屏幕上看见这个实体,必须确保MeshInstanceRenderer跟Position都添加到了实体上。
我们运行游戏就会看到我们创建的Player被显示出来了:
细心的你也发现了,在Hierarchy中并没有这个实体的信息。
原因是现在Unity编辑器还没有与ECS整合,因此我们需要打开Window -> Analysis -> Entity Debugger面板查看我们的系统与实体。
在EntityManager中可以看到Entity 0,那就是我们创建的player实体。此时他的Inspector菜单也会有数据填充:
每个Value对应一个Component及其具体的值。可以看到他的坐标,Mesh与Material都被更改了。
现在我们搭建一个简易场景,首先创建一个Plane并命名为Ground。然后创建一个灰色的材质球挂上去:
同时保持他的默认组件就好了:
运行游戏看一下效果:
我们只需要修改摄像机的Transform就能让游戏画面呈现出俯视角的效果:
视角调的还不错:
这时如果我们想在ECS框架中控制这个小球(Player)的移动该怎么实现呢?
<hr/>我们之前在Bootstrap脚本中已经实现了Awake跟Start了,其实每一个System都会实现Update方法。并且会在Start调用后开始调用。
player现在只包含两个组件:Position与MeshInstanceRenderer,显然缺乏一个标识组件,我们创建一个脚本并命名为PlayerComponent:
using Unity.Entities;
//组件必须是struct并且得继承IComponentData接口
public struct PlayerComponent : IComponentData
{
}
在Bootstrap.Start方法中,在player创建出来后加上一句:
entityManager.AddComponentData(player, new PlayerComponent()); //添加PlayerComponent组件
现在我们创建一个脚本命名为MovementSystem,继承自ComponentSystem:
类似继承Monobehavior,我们的系统只要继承了这个基类就会被Unity识别,并且每一帧都调用OnUpdate。 using UnityEngine;
using Unity.Entities;
using Unity.Transforms;
using Unity.Mathematics;
public class MovementSystem : ComponentSystem
{
protected override void OnUpdate()
{
}
}
Unity ECS帮我们简化了获取实体再获取组件的过程,现在可以直接获取不同的组件。也就是说我们可以获取被EntityManager管理的实体上我们想要的组件组成的集合。
利用一个特性:[Inject]:
//这里声明一个结构, 其中包含我们定义的过滤条件, 也就是必须拥有CameraComponent组件才会被注入。
public struct Group
{
public readonly int Length;
public ComponentDataArray<Position> Positions;
}
//然后声明结构类型的字段, 并且加上[Inject]
[Inject] Group data;
protected override void OnUpdate()
{
float deltaTime = Time.deltaTime;
for (int i = 0; i < data.Length; i++)
{
float3 up = new float3(0, 1, 0);
float3 pos = data.Positions.Value; //Read
pos += up * deltaTime;
data.Positions = new Position { Value = pos }; //Write
}
}
声明了Length属性后,Length会被自动注入,它代表结构中每个数组的总元素数量,方便进行for循环迭代。 [Inject]会从所有Entity中寻找同时拥有PlayerComponent与Position组件的实体,接着获取他们的这些组件,注入我们声明的不同数组中。
我们只需要在结构中声明好筛选的条件与我们需要的组件,ECS就会在背后帮我们处理,给我们想要的结果。
运行后player果然升天了:
趁热打铁,现在我们想自己通过输入控制小球在平面上移动。
先声明一个组件InputComponent作为一个标识:
using Unity.Entities;
public struct InputComponent : IComponentData
{
}
然后再声明一个组件VelocityComponent保存我们的输入向量:
using Unity.Entities;
using Unity.Mathematics;
public struct VelocityComponent : IComponentData
{
public float3 moveDir;
}
我们默认player的速度为1就不单独声明速度值了。 接着创建InputSystem来更改VelocityComponent的值,接下来的工作就是照猫画虎了:
using UnityEngine;
using Unity.Entities;
using Unity.Mathematics;
public class InputSystem : ComponentSystem
{
public struct Group
{
public readonly int Length;
public ComponentDataArray<PlayerComponent> Players;
public ComponentDataArray<InputComponent> Inputs;
public ComponentDataArray<VelocityComponent> Velocities;
}
[Inject] Group data;
protected override void OnUpdate()
{
for (int i = 0; i < data.Length; i++)
{
float x = Input.GetAxisRaw(&#34;Horizontal&#34;);
float z = Input.GetAxisRaw(&#34;Vertical&#34;);
float3 normalized = new float3();
if(x != 0 || y != 0)
normalized = math.normalize(new float3(x, 0, z));
data.Velocities = new VelocityComponent { moveDir = normalized }; //Write
}
}
}
比较麻烦的一点就是,在游戏初期没有确立基础的组件与系统时需要频繁修改。 移动系统也需要修改:
//这里声明一个结构, 其中包含我们定义的过滤条件, 也就是必须拥有CameraComponent组件才会被注入。
public struct Group
{
public readonly int Length;
public ComponentDataArray<VelocityComponent> Velocities;
public ComponentDataArray<Position> Positions;
}
//然后声明结构类型的字段, 并且加上[Inject]
[Inject] Group data;
protected override void OnUpdate()
{
float deltaTime = Time.deltaTime;
for (int i = 0; i < data.Length; i++)
{
float3 pos = data.Positions.Value; //Read
float3 vector = data.Velocities.moveDir; //Read
pos += vector * deltaTime; //Move
data.Positions = new Position { Value = pos }; //Write
}
}
还要回到Bootstrap.Start中,向我们的player继续添加这两个组件:
Entity player = entityManager.CreateEntity(playerArchetype); // Position
//添加PlayerComponent组件
entityManager.AddComponentData(player, new PlayerComponent()); // PlayerComponent
entityManager.AddComponentData(player, new VelocityComponent());// VelocityComponent
entityManager.AddComponentData(player, new InputComponent()); // InputComponent
// 向实体添加共享的数据
entityManager.AddSharedComponentData(player, playerRenderer); // MeshInstanceRenderer
//修改实体的Position组件
entityManager.SetComponentData(player, new Position
{ Value = new Unity.Mathematics.float3(0, 0.5f, 0) });
Duang:
可以WASD操控player移动了
<hr/>Unity ECS只提供了渲染系统并没有提供物理系统,如果要跟以前的项目结合,我们还需要能够访问场景中的游戏物体,比如一个经典的Cube。
设置一下参数
我们的目的是让这个立方体升天
在Bootstrap.Start中获取我们的Cube,并且加上GameObjectEntity组件。
GameObjectEntity 确实叫这个名, 是Unity提供的组件。 添加上这个组件后Cube就可以被entityManager关注,并且可以获取Cube上的任意组件:
//获取Cube
GameObjectEntity cubeEntity = GameObject.Find(&#34;Cube&#34;).AddComponent<GameObjectEntity>();
//添加Velocity组件
entityManager.AddComponentData(cubeEntity.Entity, new VelocityComponent
{ moveDir = new Unity.Mathematics.float3(0, 1, 0) });
我们向Cube添加了一个VelocityComponent组件,在MovementSystem加上这些代码:
public struct GameObject
{
public readonly int Length;
public ComponentArray<Transform> Transforms; //该数组可以获取传统的Component
public ComponentDataArray<VelocityComponent> Velocities;//该数组获取继承IComponentData的
}
[Inject] GameObject go;
在OnUpdate中加上这些代码,针对Transform进行操作:
for (int i = 0; i < go.Length; i++)
{
float3 pos = go.Transforms.position; //Read
float3 vector = go.Velocities.moveDir; //Read
pos += vector * deltaTime; //Move
go.Transforms.position = pos; //Write
}
运行游戏后,我们可以看到:
Cube果然上天了
Cube跟player的移动其实是被不同的系统实现的,player是因为被默认存在的渲染系统关注了所以实现了移动,而Cube是我们自己的MovementSystem实现的。
如果想在ECS中用到之前的物理系统最好是自己写一个单独的系统并关注Rigidbody,BoxCollider这些传统组件,然后在OnUpdate中使用它们。
<hr/>看到这里你应该已经明白了ECS特点与Unity ECS的用法了,希望可以勾起你们对于ECS的兴趣,在以后针对多核开发的时代,相信ECS会成为高性能的代表。
介于篇幅原因,JobComponentSystem,NativeArray,System并行,组件的先后顺序,读写权限这些跟性能优化相关的点就没有介绍了,感兴趣的话可以去Unity ECS官网了解。
附上项目下载地址:
等以后Unity ECS更完善时再出一期。这期文章就到这里了,拜拜咯。
想系统性学习游戏开发、学习Unity开发的,新技术的,欢迎围观:
另外开发群走过路过也不要错过:869551769 |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|