欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Unity ECS学习笔记(8)EntityCommandBufferSystem

程序员文章站 2022-06-06 22:59:48
...

最近在学习unity的ecs框架,转载几篇写的比较好的文章帮助理解

原文日期 2019-12-5 避免误导未来使用正式版的开发者。

 

接下来,我要给大家介绍一个很重要的东西——EntityCommandBufferSystem。

1.不能在Job中执行的操作

我们已经知道,JobComponentSystem配合各种Job(IJobForEach、IJobChunk等),可以方便地实现并行(多线程、多核)执行逻辑。

既然涉及到多线程,就会有一个麻烦的事情——某个线程做了破坏结构的操作,其他线程会受到影响。

这是什么意思呢?

比如,某个Job给实体删除了一个组件,会发生什么事情?

我们的实体都是按块(Chunk)存储的,一个块里的所有实体必定拥有相同数量和类型的组件,一旦某个实体的组件数量或类型改变了,它就不属于当前的块,它会被移到其他块里。

所以,回到刚刚的问题,某个Job给实体删除了一个组件,那么,这个实体就会被移到另一个块里。

那么,另外一个并行Job呢?这个并行的Job还不知道实体被移到另一个块了,也不知道这个实体被删除了某个组件,所以这个并行的Job会做出一些不太正确的操作。(操作了即将不存在的组件、操作了错误的块里的实体)

为了解决这种冲突,ECS规定,以下行为都不能在Job中处理:

创建实体(Create Entities)

销毁实体(Destroy Entities)

给实体添加组件(Add Components)

删除实体的组件(Remove Components)

2.EntityCommandBufferSystem

上面的四种行为都不能在Job中处理,但是,很多情况下,只有在Job中才能决定要不要创建实体、添加组件等,这种时候应该怎么办?

于是,就有了EntityCommandBufferSystem。

简单地说,EntityCommandBufferSystem可以让我们在Job里添加一些任务队列,然后在主线程中执行这些任务。

我们再来回忆一下,上一篇提到的System执行顺序:

Unity ECS学习笔记(8)EntityCommandBufferSystem

我们应该能发现,每一个系统分组下都有两个EntityCommandBufferSystem,并且分别都是Begin和End对应的。

所以,实际上,ECS默认的三个系统分组,有分别都一个Begin和End的EntityCommandBufferSystem。为的是让我们可以在分组的开始或结束时作一些特定的操作。

比如,创建实体,大部分情况下就是在第一个分组的BeginInitializationEntityCommandBufferSystem里进行。

另外,和大家补充一下,System的OnUpdate函数都是在主线程调用的,Job才是在多线程中并行调用的。

所以,上图中的各个System必定是从上到下调用(每帧都调,不断循环)。

我们简单点,只看第一个分组:

Unity ECS学习笔记(8)EntityCommandBufferSystem

InitializationSystemGroup是负责初始化工作的系统分组,假设我们想创建或销毁实体,那么,最好就是在初始化阶段进行。

BeginInitializationEntityCommandBufferSystem是在初始化阶段的第一个System,它是最先执行的,我们只要把创建实体的操作放到它里面执行,就不怕后续的逻辑出现的冲突问题了。

那么,问题就变成了——如何把创建实体的操作放到初始化阶段进行?

更进一步——如何把创建实体的操作放到 BeginInitializationEntityCommandBufferSystem 进行?

3.BeginInitializationEntityCommandBufferSystem

我来给大家演示一下,怎么把创建实体的操作放到EntityCommandBufferSystem里执行。

先看看一个System类代码:

using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;

/// <summary>
/// 任务组件系统(JobComponentSystems)可以在工作线程上运行,但是创建和移除实体只能在主线程上做,从而防止线程之间的竞争
/// Jobs系统使用一个实体命令缓存(EntityCommandBuffer)来延迟那些不能在任务系统内完成的任务。
/// </summary>
[UpdateInGroup(typeof(SimulationSystemGroup))] /// 标记更新组为模拟系统组
public class SpawnerSystem_FromEntity : JobComponentSystem
{
	/// <summary>
	/// 开始初始化实体命令缓存系统(BeginInitializationEntityCommandBufferSystem)被用来创建一个命令缓存,
	/// 这个命令缓存将在阻塞系统执行时被回放。虽然初始化命令在生成任务(SpawnJob)中被记录下来,
	/// 它并非真正地被执行(或“回放”)直到相应的实体命令缓存系统(EntityCommandBufferSystem)被更新。
	/// 为了确保transform系统有机会在新生的实体初次被渲染之前运行,SpawnerSystem_FromEntity将使用
	/// 开始模拟实体命令缓存系统(BeginSimulationEntityCommandBufferSystem)来回放其命令。
	/// 这就导致了在记录命令和初始化实体之间一帧的延迟,但是该延迟实际通常被忽略掉。
	/// </summary>
	BeginInitializationEntityCommandBufferSystem m_EntityCommandBufferSystem;

	struct SpawnerJob : IJobForEachWithEntity<Spawner_FromEntity, LocalToWorld>
	{
		/// <summary>
		/// 当前实体命令缓存
		/// </summary>
		public EntityCommandBuffer.Concurrent m_CommandBuffer;

		/// <summary>
		/// 这里循环实例化实体
		/// </summary>
		/// <param name="entity">实体</param>
		/// <param name="index">索引</param>
		/// <param name="spawner_FromEntity">生成器实体</param>
		/// <param name="location">相对位置</param>
		public void Execute(Entity entity, int index, ref Spawner_FromEntity spawner_FromEntity, [ReadOnly] ref LocalToWorld location)
		{
			for (var x = 0; x < spawner_FromEntity.m_CountX; x++)
			{
				for (var y = 0; y < spawner_FromEntity.m_CountY; y++)
				{
					var instance = m_CommandBuffer.Instantiate(index, spawner_FromEntity.m_Porfab);

					// Place the instantiated in a grid with some noise
					var position = math.transform(location.Value,
						new float3(x * 1.3F, noise.cnoise(new float2(x, y) * 0.21F) * 2, y * 1.3F));
					m_CommandBuffer.SetComponent(index, instance, new Translation { Value = position });
				}
			}
			m_CommandBuffer.DestroyEntity(index, entity);
		}
	}
	
	/// <summary>
	/// 在这个字段中缓存BeginInitializationEntityCommandBufferSystem,这样我们就不需要每一帧去创建
	/// </summary>
	protected override void OnCreate()
	{
		m_EntityCommandBufferSystem = World.GetOrCreateSystem<BeginInitializationEntityCommandBufferSystem>();
	}

	protected override JobHandle OnUpdate(JobHandle inputDeps)
	{
		/// 取代直接执行结构的改变,一个任务可以添加一个命令到EntityCommandBuffer(实体命令缓存),从而在主线程上完成其任务后执行这些改变
		/// 命令缓存允许在工作线程上执行任何潜在消耗大的计算,同时把实际的增删排到之后
		/// 把将要添加实例化命令到EntityCommandBuffer的任务加入计划
		var job = new SpawnerJob
		{
			m_CommandBuffer = m_EntityCommandBufferSystem.CreateCommandBuffer().ToConcurrent()
		}.Schedule(this, inputDeps);

		/// 生成任务并行且没有同步机会直到阻塞系统执行
		/// 当阻塞系统执行时,我们想完成生成任务,然后再执行那些命令(创建实体并放置到指定位置)
		/// 我们需要告诉阻塞系统哪个任务需要在它能回放命令之前完成
		m_EntityCommandBufferSystem.AddJobHandleForProducer(job);
		return job;
	}
}

a.BeginInitializationEntityCommandBufferSystem 是ECS自带的System类,为了避免在每一帧都创建或获取这个类对象,我们在OnCreate函数里通过World.GetOrCreateSystem获取这个类对象。

b.之前已经说过,默认情况下,我们的所有System都会被添加到World里,所以从World里获取某个System就很好理解了

接下来,再看看OnUpdate函数的逻辑:

这是有点熟悉又有点陌生的代码,我们来看看它做了什么:

a. 通过CreateCommandBuffer().ToConcurrent()函数创建了BeginInitializationEntityCommandBufferSystem的一个Buffer对象,我们可以理解成是一个队列,用来存放我们的操作。

b. Execute里查找的是带有Spawner_FromEntity组件的实体,这个组件晚点再说。总之,Spawner_FromEntity组件有一个Prefab字段,它保存了一个实体对象,我们需要通过这个实体对象复制任意多个新实体,即,创建实体。为了方便理解,我这里只创建了一个实体。

c. 通过commandBuffer.Instantiate创建了新实体,然后通过commandBuffer.DestroyEntity删除原来的实体(这个操作很重要,之后再解释)

d. Schedule返回的Job添加到BeginInitializationEntityCommandBufferSystem里。换言之,这一些列的操作,实际上已经添加到BeginInitializationEntityCommandBufferSystem里了。

总结一下就是,创建EntityCommandBufferSystem的buffer队列,将所有涉及到新增、删除实体或者新增、删除组件的操作都加到buffer队列里,最后将Job加到EntityCommandBufferSystem。

(旁白:你说的我都懂,但我就是不明白,为什么这样就能把新增实体的操作放到主线程了)

4.细节解释

我知道,大家可能有点懵,用法是这么用,代码大家可能也没有什么疑惑,但心里可能还是很纠结——这一切是怎么实现的?

是的,如果不搞懂这个的话,大家是没法好好利用EntityCommandBufferSystem的。

所以,我来给大家解释一下原理,其实原理非常简单,但我仍然研究了大半天才理顺了。

我们来走一下代码的执行过程(当然,是简化后的)。

a.运行

b.执行InitializationSystemGroup分组(别忘了,系统分组也是System类),发现自己还有子系统,OK,执行子系统

c.执行BeginInitializationEntityCommandBufferSystem,发现队列里没有任何东西,好,白干了

d.又执行了一大堆System

e.执行SimulationSystemGroup分组,发现自己还有子系统,O了个K,执行子系统

f.又执行了一大堆System,来到了我们的SpawnerSystem_FromEntity系统,好,执行。于是,添加了一个Job到BeginInitializationEntityCommandBufferSystem

g.又执行了一大堆System、执行PresentationSystemGroup分组、又执行了一大堆System

h.好了一轮执行完了,又回到InitializationSystemGroup分组,发现自己还有子系统,OK,执行子系统

i.执行BeginInitializationEntityCommandBufferSystem,发现队列里有东西了!激动!执行!于是,我们之前添加的Job成功执行了,而且是在主线程里。

j.执行其他System

k.又结束一轮

只要理解了,这是一个循环,那就没什么难度了。

5.Job会被无限添加吗?

细心的朋友肯定发现一个重大问题了,SpawnerSystem_FromEntity的OnUpdate函数不是每帧都执行一次吗?

那不就代表每帧都添加了一个Job到BeginInitializationEntityCommandBufferSystem吗?

那不得出问题了吗?

你这ECS有毒!

唔,是的,其实这个细心的朋友就是我,我就纠结了这个问题很久。

后来研究了很久,才豁然开朗。

答案就是:在JobComponentSystem中,如果Job没有筛选出实体数据,那么,OnUpdate是不会被调用的。

比如,再看一次我们的OnUpdate函数:

Unity ECS学习笔记(8)EntityCommandBufferSystem

筛选了Spawner_FromEntity组件的,而我们这个程序里只有一个实体拥有这个组件,而后面又通过DestroyEntity将筛选出来的实体删除了。

于是,在下一轮的循环中,已经筛选不出任何实体了,于是,OnUpdate函数也不会被调用。

我本来想结合ECS的源码讲解的,但是有点饶,我怕自己没理清,误导大家,所以就不展开了。

另外,被添加到EntityCommandBufferSystem的Job会不断被执行吗?

答案是:不会。

EntityCommandBufferSystem每次执行队列的任务后,都会清空,所以不用担心。

好了,关于EntityCommandBufferSystem,就说这么多。

理解起来可能有点乱,用多几次就好了。

6.另一种创建实体的方式

等等!好像有个坑还没填——Spawner_FromEntity组件是怎么样的?

这就涉及到另外一种创建实体的方式的了,我们来看看组件的代码:

using Unity.Entities;

public struct Spawner_FromEntity : IComponentData
{
	public Entity m_Porfab;
	public int m_CountX;
	public int m_CountY;
}

好,接着看看转换实体的代码:

using Unity.Entities;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 先将自己转换成实体,再由预设生成新的实体
/// </summary>
[RequiresEntityConversion] ///必须实体转化
public class SpawnerAuthoring_FromEntity : MonoBehaviour, IDeclareReferencedPrefabs, IConvertGameObjectToEntity
{
	public GameObject m_Prefab;
	public int m_CountX;
	public int m_CountY;

	/// <summary>
	/// IDeclareReferencedPrefabs接口的实现,声明引用的预设,好让转化系统提前知道它们的存在
	/// </summary>
	/// <param name="referencedPrefabs">引用的预设</param>
	public void DeclareReferencedPrefabs(List<GameObject> referencedPrefabs)
	{
		referencedPrefabs.Add(m_Prefab);
	}

	/// <summary>
	/// 我们将编辑器的数据表述转化成实体最佳的运行时表述
	/// </summary>
	/// <param name="entity">实体</param>
	/// <param name="dstManager">目标实体管理器</param>
	/// <param name="conversionSystem">转化系统</param>
	public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
	{
		var spawnerData = new Spawner_FromEntity
		{
			/// 被引用的预设因为做了声明将被转化成实体
			/// 所以我们这里只是将游戏对象标记到一个引用该预设的实体上
			m_Porfab = conversionSystem.GetPrimaryEntity(m_Prefab),
			m_CountX = m_CountX,
			m_CountY = m_CountY
		};
		dstManager.AddComponentData(entity, spawnerData);
	}
}

这段代码大家应该有一部分是有印象的,只是,这次的复杂一点。

但要理解还是没问题的,仍然一步步来:

a.这个类继承了MonoBehaviour,所以肯定也是要挂到GameObject下的

b.继承了IConvertGameObjectToEntity接口,于是也要实现Convert函数,Convert函数要做的事情和以前差不多,创建一个组件,然后把组件添加到实体里。

c.但是,这个组件有点特别,这个组件有一个Prefab字段,是Entity类型的。于是,调用GameObjectConversionSystem的GetPrimaryEntity函数,可以将我们的GameObject对象转换为Entity对象,然后赋值给组件。

d.于是,我们将当前的GameObject转换为了一个包含Spawner_FromEntity组件的实体,这个实体的组件又包含了一个新创建的实体,这个新实体是通过我们的Prefab预制体创建的。

e.DeclareReferencedPrefabs函数是做什么用呢?是为了让GameObjectConversionSystem对象知道我们的Prefab预制体的存在,以便通过预制体创建实体。

 

有点绕是不是?实际上我们现在有了两个实体了。

第一个:当前MonoBehaviour转换后的实体,包含Spawner_FromEntity组件;

第二个:Spawner_FromEntity组件的字段引用了另外一个实体,这个是通过Prefab预制体创建的实体。

最后,再看一次我们的System类的OnUpdate函数:

Unity ECS学习笔记(8)EntityCommandBufferSystem

a.我们通过Spawner_FromEntity类型筛选出了一个实体,也就是我们的第一个实体。

b.这个实体通过第一个参数【Entity entity】传递进来。

c.接着,通过Spawner_FromEntity组件的Prefab字段(引用了我们的第二个实体)创建了一个新的实体

d.调用DestoryEntity把筛选出来的实体删除(即,删除了我们的第一个实体,所以连同它的组件也消失了,于是第二个实体也消失了)

好了,可能大家有点绕懵了,但,这就是第二种创建实体的方式。

而且,比起以前介绍的方式,这反而是更加推荐的,可能更实用的。

然后大家创建一个空的GameObject,把SpawnerAuthoring_FromEntity挂上去,然后再给它的Prefab拖个预制体上去,然后运行:

Unity ECS学习笔记(8)EntityCommandBufferSystem

 

7.这种创建实体的方式有什么优势?

“好麻烦,不实用”,大家可能心里是这么想的,说实话,我一开始也是,整这么乱做什么。

其实,只要大家熟悉了EntityCommandBufferSystem,就不会觉得乱了。

不会觉得乱之后呢,就会发现,这确实是目前为止最灵活的方式。

首先,我们把空的GameObject转换为了实体,但它只是一个空的实体,不会在场景里展现出来。

而这个空实体的组件里引用了一个真正有用的实体,但这个实体还没有添加到EntityManager中,所以它也不会展现出来。

于是,这就变成了,我们可以在任何时候创建这个实体,而不是在MonoBehaviour的Start函数里创建。

比如,我们需要点击召唤按钮才能召唤生物,这种灵活的创建方式,不就能满足我们的需求了吗?

不过,因为我还没有用ECS做实际开发,所以,实际当中到底怎么样,都不好说。

 

注意,本系列教程基于DOTS相关预览版的Package包,是预览版,不代表正式版的时候也适用。

 

下图出自同样不错的官方案例讲解:https://blog.csdn.net/qq_30137245/article/details/99083411

图解

Unity ECS学习笔记(8)EntityCommandBufferSystem

1.任务系统Jobs是C#为了让我们安全地使用多线程而封装的;
2.不能滥用任务系统,否则会引起线程之间的竞争,例如你有四个线程,但是现在有三个被占用,却有五个任务要完成,这时就3会五个任务去争夺一个线程,从而造成线程安全问题;
3.任务组件系统可以在工作线程上运行,但是创建和移除实体只能在主线程上做,从而防止线程之间的竞争;
4.为了确保任务可以完成,这里引入了命令缓存机制,就在先把任务缓存起来,等待主线程完成工作后,再进行增删实体的操作。
5.关于阻塞系统,是为了确保安全而生,当线程在执行任务的时候,将其阻塞起来,避免其他任务误入,等任务完成之后,再执行下一个任务,从而有序进行。

相关标签: Unity ECS