[Unity ECS入门]9.如何回到主线程搞事情——EntityCommandBufferSystem

笨木头  2019-12-5 14:29   ECS入门   阅读(3,624)   11条评论

转载请注明,原文地址:http://www.benmutou.com/archives/2829
文章来源:笨木头与游戏开发

接下来,我要给大家介绍一个很重要的东西——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类代码:

 

OnUpdate里的逻辑我暂时删掉了,来看看目前的逻辑:

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

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

 

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

 

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

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

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

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

d. 最后,讲ForEach.Schedule返回的Job添加到BeginInitializationEntityCommandBufferSystem里。换言之,ForEach内的操作,实际上已经添加到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函数:


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

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

 

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

 

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

答案是:不会。

 

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

 

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

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

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

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

 

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

 

组件的代码很简单,但要注意,组件的字段是一个Entity。

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

 

这段代码大家应该有一部分是有印象的,在【[Unity ECS 入门]6.筛选实体数据的方式4——IJobChunk】中创建实体的方式和这段代码有点类似,只是,这次的复杂一点。

 

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

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包,是预览版,不代表正式版的时候也适用。

转发本系列文章的朋友请注意,带上原文链接和原文日期,避免误导未来使用正式版的开发者。

 

11 评论

  1. 博主您好,请教一下 instance = commandBuffer.Instantiate(entityInQueryIndex, spawnerFromEntity.Prefab)已经被删除了,如何在后续动态复制这个instance呢

  2. 您好,请教一下,最初的实体(instance = commandBuffer.Instantiate(entityInQueryIndex, spawnerFromEntity.Prefab))已经被 Destroy了,如何在后续再动态创建这个实体 ?

    1. 能通过Spawner_FromEntity筛选出数据,那么就可以继续创建实体了。
      也就是说,往world里再添加一个含Spawner_FromEntity的数据就可以了

      1. ”往world里再添加一个含Spawner_FromEntity的数据就可以了“: 这句话怎么做到呢,这个组件中的prefab 怎么赋值啊 ,引用的prefab不是需要声明的嘛 就像SpawnerAuthoring_FromEntity中的操作一样,辛苦解答

        1. 这个在文章的后面有介绍:Spawner_FromEntity是怎么来的。
          就是一个GameObject挂载了SpawnerAuthoring_FromEntity,所以,动态创建一个这样的GameObject,就会出现一个Spawner_FromEntity。

          你可以把这个GameObject弄成一个prefab,在你需要的时候去创建,创建了这个GameObject后,我们的SpawnerSystem_FromEntity又能找到新的Spawner_FromEntity数据,就又创建了一个新的实体了。

  3. 博主你好,请问Job的数据筛选不是在OnUpdate()方法内部的ForEach()方法里面筛选的吗?它怎么会在外部知道已经没有符合条件的数据了呢?
    我在OnUpdate()方法里对静态变量做了自加操作,它确实只执行了一次。

    1. 因为我蛮久没研究了,我凭记忆回答一下。
      你应该有注意到,OnUpdate其实是返回了一个JobHandle的,这个JobHandle就是有ForEach().Schedule()来生成的,因此,其实它们之间是有关联的。
      因为我没把当时研究源码的过程写到教程里,所以我现在自己都不记得源码里具体是怎么处理的,但我猜测是通过这个jobHandle来判断。

    1. 你是指【commandBuffer.Instantiate】这句代码?
      这个是在EntityCommandBufferSystem里调用的,不会在当前System的OnUpdate里调用。

笨木头进行回复 取消回复

电子邮件地址不会被公开。 必填项已用*标注