Unity ECS学习笔记(8)EntityCommandBufferSystem

最近在学习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执行顺序:

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

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

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

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

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

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

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函数:

筛选了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函数:

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

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

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

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

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

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

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

 

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

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

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

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

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

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

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

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

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

 

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

 

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

图解

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

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值