【unity】ECS
前言
早就听闻ECS框架。今天记录一下相关内容,并尝试使用Unity提供的ECS框架来做一个MiniDemo,体会该框架。
ECS的结构
ECS分为Entity(实体)-Component(组件)-System(系统),它的本质是数据和逻辑分离。
Entity
Entity(实体)和Unity中的GameObject类似,它表示一个游戏物体。
它上面可以挂载任意组件,实体能做什么完全由它身上的组件决定。如果它上面什么组件都没有,那它就什么也干不了。
Component
Component(组件)和Unity中的Component类似,它能赋予实体某个特定的功能。
不同的是,ECS中的组件只封装了数据,本身不进行任何逻辑处理,所有逻辑交给System处理。
System
System(系统)用于处理Component中的各项数据。它和Component是一对多的关系,也就是说,你可以在同一个系统中处理多种Component;而System之间一般不直接访问。
它本身不存储任何数据,只进行逻辑处理;而且每个系统只专注于自己负责的逻辑。
在每一Update或者说Tick中,它都会遍历场景中的组件,对其进行逻辑处理。
ECS案例
指路->浅谈Unity ECS(三)Uniy ECS项目结构拆解:功能要点及案例分析。
MiniDemo实战
Unity在某个版本后开发了一套以ECS为架构开发的DOTS技术栈,它还有很多地方不完善,详细了解指路->DOTS-Unity's Data Oriented Tech Stack(DOTS)。现在我尝试使用它来做一个MiniDemo。
我使用的是2021.3.10f1c2版本。
如有错误,还请不吝赐教。
配置环境
配置环境指路->Unity DOTS 一文开启ECS大门
由于ECS的特性,所有的System,包括渲染、物理等,都要脱离引擎的原生命周期,单独拎出来自己管理。
好在Unity官方提供了相关依赖包(虽然是实验性的),让我们不用再自己造轮子。
com.unity.burst
com.unity.jobs
com.unity.entities
com.unity.mathematics
com.unity.physics
com.unity.rendering.hybrid
...
如果在引入包的时候,也可以使用“Add package from git URL”的方式添加,步骤如下图。
至于这些包各自有什么用,可以自行查看官方手册。
熟悉DOTS
ECS是在DOTS(data oriented tech stack,面向数据的技术栈)下的框架。要想使用Unity官方提供的ECS框架,就要使用DOTS。
Entity
创建一个空GameObject后,在它的Inspector窗口可以看到ConvertToEntity
的选项,勾选后改对象即成为一个Entity,如下。
ConvertToEntity
可以自动将Transform -> LocalToWorld、Mesh Renderer -> RenderMesh,这俩是ECS框架下的同类组件。
Entity手册指路->Manual/Core ECS/Entities。
Component
由于使用的是Unity官方开发维护的一套DOTS,所以所有的Component,包括渲染、物理等组件,都要换成DOTS框架下的。
像这里我删除了地面的Mesh Collider
,添加上DOTS框架下的Physics Body
和Physics Shape
,它们分别是Rigidbody
和Collider
的替代品。
由于运行后Hierarchy
中不会出现Entity,无法查看到Entity详情,则可以点击Window->Analysis->Entity Debugger查看ECS框架下的场景详情,如下。
作为开发者我们当然可以自定义Component
。这里我自定义了一个InputComponent
,它实现接口IComponentData
。
using Unity.Entities;
using UnityEngine;
[GenerateAuthoringComponent]//添加此注解使该其能够被挂载至Entity上
public struct InputComponent : IComponentData
{
public float x;
public float y;
}
至于这个名为IComponentData的接口,点进去一看是空的,可能只是为了遵守编程规范。
System
查阅资料后得知:我们自己编写的System要继承于SystemBase
,以前的ComponentSystem
和JobComponentSystem
正逐步被弃用。
System无需挂载在任何物体上,它在世界(World)中按组(Group)进行组织,由其父组件系统组驱动,如下。组件系统组本身是一种专门的系统,负责更新其子系统。
这里我自定义了一个InputSystem
,它继承自SystemBase
。
using UnityEngine;
using Unity.Entities;
using Unity.Physics;
using Unity.Transforms;
using UnityEditor;
public partial class InputSystem : SystemBase
{
protected override void OnUpdate()
{
float v = Input.GetAxisRaw("Vertical");
Entities.WithAll<InputComponent>().ForEach((ref InputComponent ic) =>
{
ic.angle += v * 0.02f;
}).Run();
}
}
官方手册写明了System的生命周期函数,可以自行查阅->Manual/Core ECS/Systems
。
Job System
Job System是基于C#的多线程管理系统。有了JobSystem,我们可以更方便地利用多线程,提高游戏的性能。下文会提到它的用法。
Burst
Burst是一个编译器,它使用LLVM将IL/.NET字节码转换为高度优化的本机代码。它作为Unity包发布,并使用Unity Package Manager集成到Unity中。Burst主要用于与Job系统高效协作。
常用功能总结
由于刚上手DOTS,很多用法和功能都不甚熟悉。在这里先把一些常见的方法总结起来,方便理解和查阅。
使用Job System编写多线程代码
借用C# Job System Overview中的陈述来快速了解JobSystem。
Q:什么是Job?
A:job是一个小单位的工作,一般包含一个特定任务。
一个job接受参数并根据数据进行操作,接近于一个方法的工作方式。
job可以是独立的,也可以依赖于其他job,等待其他job完成后再执行。
Q:为什么Job会相互依赖?
A:在复杂的系统中,像是游戏开发中系统,不太可能每一个job都是独立的。
一个job通常会准备下一个job所需要的数据。jobs了解并支持这种依赖来确保可以正常工作。
如果jobA依赖于jobB,那么job system会确保jobB执行完毕后才开始执行jobA。
Q:什么是JobSystem?
A:job system通过创建jobs而不是线程来管理多线程的代码。
它会把jobs放到一个job队列中。
它会管理运行在多个核心上的一组工人线程(worker threads),job system中的工人线程从job队列中取出内容并执行他们。
一个job system会管理依赖性并确保jobs以正确的顺序被执行。
下图是IJob接口。
总的来说,我们使用时,只需创建一个结构体并实现IJob接口,如下。
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;
struct MyJob : IJob
{
public int num;
public NativeArray<int> result;//这个我们下文会讲
public void Execute()
{
num++;
result[0] = num;
}
}
创建好了job,接下来该考虑如何调度一个job,让它在该执行的时候执行。
为了在主线程(OnUpdate)中调度一个job,你必须实例化一个job->填充job中的数据->调用Schedule方法。
例如我这里写了一个System来调度上文的MyJob。
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using UnityEngine;
public partial class MySystem : SystemBase
{
protected override void OnUpdate()
{
//创建MyJob
MyJob myJob = new MyJob();
//填充其数据
myJob.num = 0;
NativeArray<int> result = new NativeArray<int>(1, Allocator.TempJob);
myJob.result = result;
//调度MyJob
JobHandle handle = myJob.Schedule();
//等待MyJob执行完
handle.Complete();
//同一 NativeArray 的所有副本都指向同一内存,您可以在同一 NativeArray 的任何副本中访问相同的结果
Debug.Log("The result[0] in myJob is :" + myJob.result[0]);
Debug.Log("The result[0] in MySystem is :" + result[0]);
//释放数组内存
myJob.result.Dispose();
}
}
你肯定注意到了上面代码段中的NativeArray
。
我们上文讲到了job有时会依赖其他job,意味着会使用其他job执行完后的数据,多个job中如何传递数据呢?依靠的就是NativeArray
:
同一 NativeArray 的所有副本都指向同一内存,您可以在同一 NativeArray 的任何副本中访问相同的结果。而且使用完后要手动释放内存,避免内存泄漏。
点击运行,结果如下:
由于我们每次为num赋值时都赋值为0,输出的结果自然始终为1。
如果要想输出的数不断递增,写法很多。你可以再拿一个变量记录NativeArray
中的值。
如果像下面这样写,可能会引发问题。
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.VisualScripting;
using UnityEngine;
public partial class MySystem : SystemBase
{
NativeArray<int> result = new NativeArray<int>(1, Allocator.TempJob);
protected override void OnCreate()
{
result[0] = 0;
}
protected override void OnUpdate()
{
//创建MyJob
MyJob myJob = new MyJob();
//填充其数据
myJob.num = result[0];
myJob.result = result;
//调度MyJob
JobHandle handle = myJob.Schedule();
//等待MyJob执行完
handle.Complete();
//NativeArray 的所有副本都指向同一内存,您可以在同一 NativeArray 的任何副本中访问相同的结果
Debug.Log("The result[0] in myJob is :" + myJob.result[0]);
Debug.Log("The result[0] in MySystem is :" + result[0]);
}
protected override void OnDestroy()
{
//释放数组内存
result.Dispose();
}
}
虽然这样可以达到输出数字递增的效果,但在Allocator.TempJob
的情况下,NativeArray
的生命时间是4帧,引擎会判定你没释放它的内存,可能造成内存泄漏。
除了NativeArray
,还有如下数据结构:
NativeList - 一个可变长的NativeArray
NativeHashMap - 键值对
NativeMultiHashMap - 每个Key可以对应多个值
NativeQueue - 一个先进先出(FIFO)队列
更多详情见:Unity C# Job System介绍 安全性系统和NativeContainer
除了NativeArray
,你可能还注意到了上文代码段中声明了一个JobHandle
,它标识了一个job。
如果一个作业依赖于另一个作业的结果,则可以将第一个作业的 JobHandle 作为参数传递给第二个作业的 Schedule 方法,如下所示:
JobHandle firstJobHandle = firstJob.Schedule();
secondJob.Schedule(firstJobHandle);
有关作业的并行化调度和其他详情参见Unity- JobHandle 和依赖项。
创建实体
在游戏中,动态地创建实体几乎不可避免。由于在非帧头帧尾创建和销毁实体时,可能会引发空引用问题,所以建议使用命令队列,来把对应命令放到帧头或帧尾执行,如下。至于为什么会引发问题,可以去搜索Chunk
和Archetype
。
public partial class EnemySystem : SystemBase
{
EndSimulationEntityCommandBufferSystem endSimulationEcbSystem;
protected override void OnCreate()
{
endSimulationEcbSystem = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
}
protected override void OnUpdate()
{
EntityCommandBuffer ecb = endSimulationEcbSystem.CreateCommandBuffer();
for (int i = 0; i < 10; i++)
{
Entity template = GameManager.instance.enemyEntity;
Entity temp = ecb.Instantiate(template);
ShootComponent sc = new ShootComponent
{
shootCD = 5f,
targetEntity = characterEntity
};
ecb.SetComponent(temp, translation);
ecb.SetComponent(temp, sc);
}
}
}
public class GameManager : MonoBehaviour
{
//BlobAssetStore提供缓存,使对象的创建更快
private BlobAssetStore _blobAssetStore;
...
void Start()
{
_blobAssetStore = new BlobAssetStore();
_settings = GameObjectConversionSettings.FromWorld(World.DefaultGameObjectInjectionWorld, _blobAssetStore);
enemyEntity = GameObjectConversionUtility.ConvertGameObjectHierarchy(enemyPrefab, _settings);
}
...
}
销毁同理。
由于销毁命令会将实际操作推迟到帧头或帧尾,而不是立即执行,所以在处理碰撞销毁时需要再枚举状态或者设置布尔开关。
参考Scripting API Unity./Entities/Entity Manager
和Unity ECS实例:制作俯视角射击游戏。
遍历组件
WithAll
— 原型必须包含All类别中的所有组件类型。
WithAny
— 原型必须至少包含Any类别中的一种组件类型。
WithNone
— 原型不得包含无类别中的任何组件类型。
Entities.WithAll<CharacterComponent>().ForEach((in Translation t) =>
{
targetPos = t;
}).Run();
注意这里DOTS为我们提供了匿名函数的方式来遍历组件,但这里的限制是:子线程中不能new对象,所以想取组件中的值给外部的话,还得先在外部new对象传进去拿值,十分麻烦。
物理碰撞
com.unity.physics对游戏世界中的碰撞提供了ICollisionEventsJob
接口,其中提供了Execute
的方法来处理碰撞。
我们如下声明一个它的实现类MyCollisionJob
。
using Unity.Entities;
using Unity.Physics;
using UnityEngine;
using static UnityEngine.EventSystems.EventTrigger;
struct MyCollisionJob : ICollisionEventsJob
{
//PhysicsVelocity只有添加了PhysicsBodyAuthoring才会被添加到Entity,意味着我们可以以此来过滤球
public ComponentDataFromEntity<PhysicsVelocity> PhysicsVelocityGroup;
public void Execute(CollisionEvent collisionEvent)
{
//在PhysicsVelocityGroup查找是否有collisionEvent.EntityA
if (PhysicsVelocityGroup.HasComponent(collisionEvent.EntityA) &&
World.DefaultGameObjectInjectionWorld.EntityManager.GetName(collisionEvent.EntityA) == "Ball")
{
Debug.Log(collisionEvent.EntityA + " : CollisionEvent is done.");
}
else if (PhysicsVelocityGroup.HasComponent(collisionEvent.EntityB) &&
World.DefaultGameObjectInjectionWorld.EntityManager.GetName(collisionEvent.EntityB) == "Ball")
{
Debug.Log(collisionEvent.EntityB + " : CollisionEvent is done.");
}
}
}
自定义一个碰撞事件处理系统MyCollisionEventSystem
,由它配合我们刚刚写的Job
进行工作。
using Unity.Entities;
using Unity.Physics;
using Unity.Physics.Systems;
using UnityEngine;
public partial class MyCollisionEventSystem : SystemBase
{
//private BuildPhysicsWorld buildPhysicsWorld;
private StepPhysicsWorld stepPhysicsWorld;
protected override void OnCreate()
{
//buildPhysicsWorld = World.GetOrCreateSystem<BuildPhysicsWorld>();
stepPhysicsWorld = World.GetOrCreateSystem<StepPhysicsWorld>();
}
protected override void OnUpdate()
{
//像普通Job一样进行使用,添加依赖
MyCollisionJob collisionJob = new MyCollisionJob()
{
PhysicsVelocityGroup = GetComponentDataFromEntity<PhysicsVelocity>()
};
//已弃用的写法:
//Dependency = collisionJob.Schedule(stepPhysicsWorld.Simulation, ref buildPhysicsWorld.PhysicsWorld, Dependency);
//由StepPhysicsWorld接收碰撞事件。
Dependency = collisionJob.Schedule(stepPhysicsWorld.Simulation , Dependency);
}
}
注意运行前先将球的Physics
->Collision Response
改为Collide Raise Collision Events
。
它的碰撞响应方式有如下四种:
Collide:可以与其他碰撞体产生碰撞,但不产生碰撞事件和触发事件。
CollideRaiseCollisionEvents:碰撞器。可以与其他碰撞体产生碰撞,可产生碰撞事件。
RaiseTriggerEvents:触发器。不能与其他碰撞体产生碰撞,可产生触发事件。
None:不能与其他碰撞体产生碰撞,不产生碰撞事件和触发事件,但可用射线检测。
运行后查看Console:
查看Entity Analysis:
可以发现:其效果等同于unity原生的OnCollisionStay
。
Unity Physics没有状态,因此似乎不能使用诸如“碰撞开始”,和“碰撞结束”之类的事件。
Trigger同理。
物理手册指路->Manual/Interacting with bodies,里面提供了碰撞查询、操作物理体的多种方法,需要时可以查看。
调整System之间的运行顺序
如下注解,能够让每一帧中的TimeSystem运行在InputSystem之后。
[UpdateAfter(typeof(InputSystem))]
public partial class TimeSystem : SystemBase
类似的注解如下:
[UpdateBefore(typeof(InputSystem))]
如果想让某自定义System归类到某Group中(下面以SimulationSystemGroup
为例),可以使用如下注解。
[UpdateInGroup(typeof(SimulationSystemGroup))]
动画系统
游戏中的动画管理是很重要的。而DOTS中的动画API很繁琐,即使简单地播放一个动画就需要不少繁琐的工作,令人难受。据说动画系统还不算很成熟,这里就只了解一下,不做深入。
详情请见深入了解 Unity DOTS Sample (六): Animation 和 Part 系统。
开发过程中的问题
- 由于摄像机无法转换为Entity,所以相机跟随只能依靠Mono实现。
- 注意指定各个自定义System之间的运行顺序,避免逻辑或数值出错。
- 创建、销毁实体不能在子线程中完成,只能在主线程中完成;需要借助命令队列来完成。
开发总结
玩家用鼠标拖动控制小球移动,通过撞击白色方块获得分数,同时需要躲避中央灰色方块发射的子弹。
仓库地址->ECS_MiniDemo,下面是演示:
开发初期还不适应用DOP的思想来解决问题,不过熟悉之后能逐渐上手,上手之后非常爽,维护起来非常方便。
有一个点是:每当我需要在OnUpdate中写“触发时只执行一次的代码段”时,就需要设置布尔值开关或枚举状态来管理它,相对麻烦。能否写一个工具类来解决这个问题,暂未探明。
现在的DOTS确实有的地方还不方便,比如碰撞事件、动画系统、UI组件等,坐等官方完善。
ECS的优点
-
“组合大于继承”。
ECS广泛采用组合的方式来组织代码结构。这使得整个项目的结构变得扁平,复杂度降低,维护起来十分方便。OOP最被诟病的调用层次深,过度抽象等问题都被解决了。 -
ECS能提高性能。其原因是大量数据的连续存放对CPU缓存友好。
指路->浅谈Unity ECS(二)Uniy ECS内存管理详解:ECS因何而快。
ECS的缺点
-
ECS没有提供天然的多态支持。多态必须通过为Entity装配不同的component来实现。
-
很多传统的系统在ECS中不太好做,比如行为树和技能系统。不过这个问题可能可以通过保留EC,把S封装成OO来处理。把框架改造得好用才是王道。
-
由于每一帧各类System都会遍历场景内的实体,对上面的组件进行逻辑操作,所以实体的销毁要放到帧末进行,否则可能造成空引用。
参考资料
浅谈Unity ECS(一)Uniy ECS基础概念介绍:面向未来的ECS
浅谈Unity ECS(二)Uniy ECS内存管理详解:ECS因何而快
浅谈Unity ECS(三)Uniy ECS项目结构拆解:功能要点及案例分析
漫谈Entity Component System (ECS)
DOTS-Unity's Data Oriented Tech Stack(DOTS)
Manual/Interacting with bodies
深入了解 Unity DOTS Sample (六): Animation 和 Part 系统