【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”的方式添加,步骤如下图。

image

image

至于这些包各自有什么用,可以自行查看官方手册。

熟悉DOTS

ECS是在DOTS(data oriented tech stack,面向数据的技术栈)下的框架。要想使用Unity官方提供的ECS框架,就要使用DOTS。

Entity

创建一个空GameObject后,在它的Inspector窗口可以看到ConvertToEntity的选项,勾选后改对象即成为一个Entity,如下。

image

ConvertToEntity可以自动将Transform -> LocalToWorld、Mesh Renderer -> RenderMesh,这俩是ECS框架下的同类组件。

Entity手册指路->Manual/Core ECS/Entities

Component

由于使用的是Unity官方开发维护的一套DOTS,所以所有的Component,包括渲染、物理等组件,都要换成DOTS框架下的。

像这里我删除了地面的Mesh Collider,添加上DOTS框架下的Physics BodyPhysics Shape,它们分别是RigidbodyCollider的替代品。

image

由于运行后Hierarchy中不会出现Entity,无法查看到Entity详情,则可以点击Window->Analysis->Entity Debugger查看ECS框架下的场景详情,如下。

image

作为开发者我们当然可以自定义Component。这里我自定义了一个InputComponent,它实现接口IComponentData

using Unity.Entities;
using UnityEngine;

[GenerateAuthoringComponent]//添加此注解使该其能够被挂载至Entity上
public struct InputComponent : IComponentData
{
	public float x;
	public float y;
}

至于这个名为IComponentData的接口,点进去一看是空的,可能只是为了遵守编程规范。
image

System

查阅资料后得知:我们自己编写的System要继承于SystemBase,以前的ComponentSystemJobComponentSystem正逐步被弃用。

System无需挂载在任何物体上,它在世界(World)中按组(Group)进行组织,由其父组件系统组驱动,如下。组件系统组本身是一种专门的系统,负责更新其子系统。

image

这里我自定义了一个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接口。
image

总的来说,我们使用时,只需创建一个结构体并实现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 的任何副本中访问相同的结果。而且使用完后要手动释放内存,避免内存泄漏。

点击运行,结果如下:
image

由于我们每次为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();
	}
}

image

虽然这样可以达到输出数字递增的效果,但在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 和依赖项

创建实体

在游戏中,动态地创建实体几乎不可避免。由于在非帧头帧尾创建和销毁实体时,可能会引发空引用问题,所以建议使用命令队列,来把对应命令放到帧头或帧尾执行,如下。至于为什么会引发问题,可以去搜索ChunkArchetype

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:不能与其他碰撞体产生碰撞,不产生碰撞事件和触发事件,但可用射线检测。

image

运行后查看Console:

image

image

查看Entity Analysis:

image

可以发现:其效果等同于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 系统

开发过程中的问题

  1. 由于摄像机无法转换为Entity,所以相机跟随只能依靠Mono实现。
  2. 注意指定各个自定义System之间的运行顺序,避免逻辑或数值出错。
  3. 创建、销毁实体不能在子线程中完成,只能在主线程中完成;需要借助命令队列来完成。

开发总结

玩家用鼠标拖动控制小球移动,通过撞击白色方块获得分数,同时需要躲避中央灰色方块发射的子弹。

仓库地址->ECS_MiniDemo,下面是演示:
image

开发初期还不适应用DOP的思想来解决问题,不过熟悉之后能逐渐上手,上手之后非常爽,维护起来非常方便。

有一个点是:每当我需要在OnUpdate中写“触发时只执行一次的代码段”时,就需要设置布尔值开关或枚举状态来管理它,相对麻烦。能否写一个工具类来解决这个问题,暂未探明。

现在的DOTS确实有的地方还不方便,比如碰撞事件、动画系统、UI组件等,坐等官方完善。

ECS的优点

  1. “组合大于继承”。
    ECS广泛采用组合的方式来组织代码结构。这使得整个项目的结构变得扁平,复杂度降低,维护起来十分方便。OOP最被诟病的调用层次深,过度抽象等问题都被解决了。

  2. ECS能提高性能。其原因是大量数据的连续存放对CPU缓存友好。
    指路->浅谈Unity ECS(二)Uniy ECS内存管理详解:ECS因何而快

ECS的缺点

  1. ECS没有提供天然的多态支持。多态必须通过为Entity装配不同的component来实现。

  2. 很多传统的系统在ECS中不太好做,比如行为树和技能系统。不过这个问题可能可以通过保留EC,把S封装成OO来处理。把框架改造得好用才是王道。

  3. 由于每一帧各类System都会遍历场景内的实体,对上面的组件进行逻辑操作,所以实体的销毁要放到帧末进行,否则可能造成空引用。

参考资料

浅谈Unity ECS(一)Uniy ECS基础概念介绍:面向未来的ECS

浅谈Unity ECS(二)Uniy ECS内存管理详解:ECS因何而快

浅谈Unity ECS(三)Uniy ECS项目结构拆解:功能要点及案例分析

漫谈Entity Component System (ECS)

架构设计之从OOP到ECS架构演进

DOTS-Unity's Data Oriented Tech Stack(DOTS)

Manual/Interacting with bodies

C# Job System Overview

深入了解 Unity DOTS Sample (六): Animation 和 Part 系统

基于DOTS的UI解决方案

《Unity DOTS之ECS从入门到真香》(2)DOTS开发环境配置

Unity DOTS(一) Job System 介绍

posted @ 2022-11-13 13:40  AshScops  阅读(937)  评论(0编辑  收藏  举报