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.关于阻塞系统,是为了确保安全而生,当线程在执行任务的时候,将其阻塞起来,避免其他任务误入,等任务完成之后,再执行下一个任务,从而有序进行。