接下来,我要给大家介绍一个很重要的东西——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.Transforms;
public class SpawnerSystem_FromEntity : JobComponentSystem
{
BeginInitializationEntityCommandBufferSystem m_EntityCommandBufferSystem;
protected override void OnCreate ()
{
/* 在OnCreate函数里获取或创建BeginInitializationEntityCommandBufferSystem */
m_EntityCommandBufferSystem = World.GetOrCreateSystem<BeginInitializationEntityCommandBufferSystem>();
}
protected override JobHandle OnUpdate (JobHandle inputDeps) { }
}
OnUpdate里的逻辑我暂时删掉了,来看看目前的逻辑:
a.BeginInitializationEntityCommandBufferSystem 是ECS自带的System类,为了避免在每一帧都创建或获取这个类对象,我们在OnCreate函数里通过World.GetOrCreateSystem获取这个类对象。
b.之前已经说过,默认情况下,我们的所有System都会被添加到World里,所以从World里获取某个System就很好理解了
接下来,再看看OnUpdate函数的逻辑:
protected override JobHandle OnUpdate (JobHandle inputDeps)
{
/* 相当于获取了队列 */
var commandBuffer = m_EntityCommandBufferSystem.CreateCommandBuffer().ToConcurrent();
var jobHandle = Entities
.ForEach((Entity entity, int entityInQueryIndex, in Spawner_FromEntity spawnerFromEntity) =>
{
/* 在Buffer中创建实体 */
var instance = commandBuffer.Instantiate(entityInQueryIndex, spawnerFromEntity.Prefab);
/* 删除筛选出来的实体对象,这个很重要,后面会解释 */
commandBuffer.DestroyEntity(entityInQueryIndex, entity);
}).Schedule(inputDeps);
/* 把Job添加到EntityCommandBufferSystem */
m_EntityCommandBufferSystem.AddJobHandleForProducer(jobHandle);
return jobHandle;
}
这是有点熟悉又有点陌生的代码,我们来看看它做了什么:
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组件是怎么样的?
这就涉及到另外一种创建实体的方式的了,我们来看看组件的代码:
using Unity.Entities;
public struct Spawner_FromEntity : IComponentData
{
public Entity Prefab;
}
组件的代码很简单,但要注意,组件的字段是一个Entity。
好,接着看看转换实体的代码:
using System.Collections.Generic;
using Unity.Entities;
using UnityEngine;
[RequiresEntityConversion]
public class SpawnerAuthoring_FromEntity : MonoBehaviour, IDeclareReferencedPrefabs, IConvertGameObjectToEntity
{
public GameObject Prefab;
// Referenced prefabs have to be declared so that the conversion system knows about them ahead of time
public void DeclareReferencedPrefabs (List<GameObject> referencedPrefabs)
{
referencedPrefabs.Add(Prefab);
}
// Lets you convert the editor data representation to the entity optimal runtime representation
public void Convert (Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
{
var spawnerData = new Spawner_FromEntity
{
// The referenced prefab will be converted due to DeclareReferencedPrefabs.
// So here we simply map the game object to an entity reference to that prefab.
Prefab = conversionSystem.GetPrimaryEntity(Prefab),
};
dstManager.AddComponentData(entity, spawnerData);
}
}
这段代码大家应该有一部分是有印象的,在【[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包,是预览版,不代表正式版的时候也适用。
转发本系列文章的朋友请注意,带上原文链接和原文日期,避免误导未来使用正式版的开发者。