找回密码
 立即注册
查看: 477|回复: 4

Unity 实体组件系统(ECS)——预览与体验

[复制链接]
发表于 2021-12-3 11:23 | 显示全部楼层 |阅读模式
Hi,大家好。吃饱喝足,该写点东西了。


这次给大家带来一期"新技术"的介绍。没错,主角就是Unity官方正在推行的ECS框架(Entity-Component-System)。
相信大家多少听说过ECS(实体组件系统),或者在网络上查找过相关资料,甚至动手实现过一个自己的简易ECS框架。如果没有听说过也没有关系,可以通过实践可以更好地理解它。
简单介绍一下ECS的核心概念:
Entity(实体):由一个唯一ID所标识的一系列组件的集合。
Component(组件):一系列数据的集合,本身没有任何方法。只能用于存储状态。
System(系统):只有方法,没有状态的工具,类似静态类。
这种设计看上去很新颖且奇特,具体到游戏开发的环节中是什么样的呢?
简单来说,Entity相当于一个只有唯一ID的GameObject,Component就是一个只有字段的Struct,System只有方法没有任何字段。Entity通过不同Component的组合可以被不同的System关注。
如下图所示:


Player(Entity)拥有PositionMoveSpeedVelocityPlayer这些Component。那么他就会被PlayerInputSystemMoveSystem所关注,这些系统在Update时会对该实体的组件进行读写操作。
系统的调用顺序也可以打乱。PlayerInputSystemAIInputSystem由于写的是不同的实体的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中创建出EntityManagerEntityArchetype
    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("Player");

        //下面的类型是一个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实体已经拥有了两个组件:PositionMeshInstanceRenderer。
关于ISharedComponentData接口:他的作用是当实体拥有属性时,比如:球形的实体共享同样的Mesh网格数据时。这些数据会储存在一个Chuck中并非每个实体上,因此在每个实体上可以实现0内存开销。
值得注意的是:如果Entity不包含Position组件,这个实体是不会被Unity的渲染系统关注的。因此想在屏幕上看见这个实体,必须确保MeshInstanceRendererPosition都添加到了实体上。

我们运行游戏就会看到我们创建的Player被显示出来了:


细心的你也发现了,在Hierarchy中并没有这个实体的信息。


原因是现在Unity编辑器还没有与ECS整合,因此我们需要打开Window -> Analysis -> Entity Debugger面板查看我们的系统与实体。


在EntityManager中可以看到Entity 0,那就是我们创建的player实体。此时他的Inspector菜单也会有数据填充:


每个Value对应一个Component及其具体的值。可以看到他的坐标,Mesh与Material都被更改了。

现在我们搭建一个简易场景,首先创建一个Plane并命名为Ground。然后创建一个灰色的材质球挂上去:


同时保持他的默认组件就好了:


运行游戏看一下效果:


我们只需要修改摄像机的Transform就能让游戏画面呈现出俯视角的效果:


视角调的还不错:


这时如果我们想在ECS框架中控制这个小球(Player)的移动该怎么实现呢?
<hr/>我们之前在Bootstrap脚本中已经实现了AwakeStart了,其实每一个System都会实现Update方法。并且会在Start调用后开始调用。
player现在只包含两个组件:PositionMeshInstanceRenderer,显然缺乏一个标识组件,我们创建一个脚本并命名为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中寻找同时拥有PlayerComponentPosition组件的实体,接着获取他们的这些组件,注入我们声明的不同数组中。
我们只需要在结构中声明好筛选的条件与我们需要的组件,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("Horizontal");
            float z = Input.GetAxisRaw("Vertical");
            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("Cube").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中用到之前的物理系统最好是自己写一个单独的系统并关注RigidbodyBoxCollider这些传统组件,然后在OnUpdate中使用它们。
<hr/>看到这里你应该已经明白了ECS特点与Unity ECS的用法了,希望可以勾起你们对于ECS的兴趣,在以后针对多核开发的时代,相信ECS会成为高性能的代表。
介于篇幅原因,JobComponentSystemNativeArraySystem并行组件的先后顺序读写权限这些跟性能优化相关的点就没有介绍了,感兴趣的话可以去Unity ECS官网了解。
附上项目下载地址:

等以后Unity ECS更完善时再出一期。这期文章就到这里了,拜拜咯。

想系统性学习游戏开发、学习Unity开发的,新技术的,欢迎围观:
另外开发群走过路过也不要错过:869551769

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2021-12-3 11:24 | 显示全部楼层
之前看Unity官方对ECS用法的说明,看了一下午搞的云里雾里。这种文章早点出就好了。
发表于 2021-12-3 11:29 | 显示全部楼层
go~
发表于 2021-12-3 11:37 | 显示全部楼层
源码地址:https://github.com/ProcessCA/UnityECSPreview
发表于 2021-12-3 11:37 | 显示全部楼层
写得非常好,照着敲了一遍受益匪浅
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-9-23 02:17 , Processed in 0.098057 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表