前言
Unity官方发布的EntityComponentSystemSamples是非常不错的案例,值得深入学习以用于项目开发中。
一些特性介绍
ECS会出现好多新的特性需要了解一下:
- GenerateAuthoringComponent
类似老的继承Monobehavior,使得脚本能够直接挂在GameObject上,但挂上之后在Inspector上显示会增加一个Authoring后缀,这个attribute会自动创建authoring代码。 - UpdateInGroup
指定系统在哪个系统Group组里面,例如:[UpdateInGroup(typeof(InitializationSystemGroup))] - ReadOnly
顾名思义就是只读特性,在IJobChunk里面会经常会看到一些ArchetypeChunkComponentType的字段加上这个特性,意思就是这个字段是只读的,不能被修改。 - BurstCompile
使用Burst编译,将代码转成目标机器码,性能会有质的飞跃。
老的特性回顾:
- DisallowMultipleComponent
不能在一个对象上重复添加该脚本 - AddComponentMenu
在Component菜单栏上添加组件快捷键
HelloCube
1.ForEach
该示例演示(demonstrate)了一个简单的ECS旋转一对立方体的效果,该示例演示了ECS数据和功能分离,数据存储在组件中,功能则被写入系统中。
ConvertToEntity
ConvertToEntity在唤醒时将GameObject及其子代转为实体和ECS组件。当前,ConvertToEntity可以转换的内置MonoBehaviours集合包括Transform和MeshRenderer。我们可以用”实体调试器”(菜单:”窗口”>”分析”>”实体调试器”)检查由转换创建的ECS实体和组件。它有两种Mode,第一个是ConvertAndDestory,这种在转换之后立马进行销毁,还有一种是ConvertAndInjectGameObject也就是不会销毁。这个组件内部定义了一个继承自ComponentSystem的ConvertToEntitySystem,但有一个特性就是[UpdateInGroup(typeof(InitializationSystemGroup))],说明他是在系统初始化组里Update的。
JobSystems and Entities.ForEach
JobSystems会根据系统上有多少核来创建多少个线程,然后并行执行。Entities.ForEach会遍历所有的Entity。
组件
1 | [GenerateAuthoringComponent] // 加上该标签说明该组件可以挂在GameObject上 |
系统
1 | using Unity.Entities; |
这里我所理解的.Schedule方法就是将当前Job提升日程,待主线程去执行,因为一些组件的操作需要在主线程中操作。这里用到一个内置的Rotation旋转组件1
2
3
4
5
6
7
8
9
10namespace Unity.Transforms
{
[WriteGroup(typeof(LocalToWorld))] //WriteGroup写入组标签
[WriteGroup(typeof(CompositeRotation))]
[WriteGroup(typeof(LocalToParent))]
public struct Rotation : IComponentData
{
public quaternion Value;
}
}
2.IJobChunk
这个示例演示了基于Job方式的ECS系统,该示例是旋转一对多维数据集,这个不像案例1按照实体进行迭代,而是按照块进行迭代。(块是包含所有具有相同原型的实体的内存块,也就是说它们都具有相同的组件集。)
相比较第一个案例自动创建Authoring代码,这个第二个案例就是手动创建Authoring代码了,具有更大的灵活性。它需要定义和组件相同的Public数据,RequiresEntityConversion必须要有,AddComponentMenu可有可无,ConverterVersion也是可有可无。IconvertGameObjectToEntity接口是必须的,实现这个接口来convert我们的数据。
手动Authoring组件代码
1 | /// <summary> |
这个IConvertGameObjectToEntity接口的作用就是用来将我们面板上的数据实例化一个数组组件挂在实体上的。
IJobChunk
IJobChunk跟IJobForEach相比,前者能够处理更复杂的情况,同时保持最高的效率。
要构建一个System来使用数据,System里面有两种创建Job的方式,第一种就是案例1中使用lambda表达式,第二种就是案例2中定义的Job struct方式。两种方式都可以利用BurstComplier来提高性能。案例1中并没有用Burst,但如果想要用可以在foreach方法之前加上.WithBurst(FloatMode.Default,FloatPrecision.Standard,ture)来使用,ref in out是比较重要的,对于编译器来说,它可以决定job的依赖和是否并行,串行。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69public class RotationSpeedSystem_IJobChunk : JobComponentSystem
{
//查询到特定组件的实体,将其放入这个组中
EntityQuery m_Group;
protected override void OnCreate()
{
//查询具有Rotation和RotationSpeed_IJobChunk的组件的集合
//ReadOnly=只读会加快获取实体的速度,ReadWrite=读写,相对较慢
m_Group = GetEntityQuery(typeof(Rotation), ComponentType.ReadOnly<RotationSpeed_IJobChunk>());
}
//用于遍历包含多个匹配实体的连续内存块,比IJobForEach需要更多的代码设置,但其访问过程中可以对数据进行最直接的访问,可以直接修改其实际存储数据。
[BurstCompile] //使用了Burst编译来加速
struct RotationSpeedJob : IJobChunk
{
public float DeltaTime;
//原型块组件类型=Rotation
public ArchetypeChunkComponentType<Rotation> RotationType;
[ReadOnly] //只读 原型块组件类型=RotationSpeed_IJobChunk
public ArchetypeChunkComponentType<RotationSpeed_IJobChunk> RotationSpeedType;
/// <summary>
/// 找出满足条件的实体来执行
/// </summary>
/// <param name="chunk"><原型块/param>
/// <param name="chunkIndex">块索引</param>
/// <param name="firstEntityIndex">第一个实体索引</param>
public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
{
//获取组件块,类型是NativeArray
var chunkRotations = chunk.GetNativeArray(RotationType);
var chunkRotationSpeeds = chunk.GetNativeArray(RotationSpeedType);
//遍历所有的组件快然后通过计算修改Rotation的Value值
for (var i = 0; i < chunk.Count; i++)
{
var rotation = chunkRotations[i];
var rotationSpeed = chunkRotationSpeeds[i];
chunkRotations[i] = new Rotation
{
Value = math.mul(math.normalize(rotation.Value),
quaternion.AxisAngle(math.up(), rotationSpeed.RadiansPerSecond * DeltaTime))
};
}
}
}
// OnUpdate runs on the main thread.
/// <summary>
/// 这个方法在主线程上运行
/// </summary>
/// <param name="inputDependencies">输入依赖</param>
/// <returns></returns>
protected override JobHandle OnUpdate(JobHandle inputDependencies)
{
var rotationType = GetArchetypeChunkComponentType<Rotation>(); //可读写组件块
var rotationSpeedType = GetArchetypeChunkComponentType<RotationSpeed_IJobChunk>(true); //参数表示是否只读,默认是false,这个这个RotationSpeed_IJobChunk组件只是数据配置组件,不需要修改,所以参数为只读 true
//实例化出JobChunk对象
var job = new RotationSpeedJob()
{
RotationType = rotationType,
RotationSpeedType = rotationSpeedType,
DeltaTime = Time.DeltaTime
};
//将job提上日程
return job.Schedule(m_Group, inputDependencies);
}
}
DOTS逻辑图表
3.SubScene
这个示例演示了子场景的工作流程。子场景提供了一种在Unity中编辑和加载大型游戏场景的有效方法。
Plus:Unity没有能运行出效果,不知道为啥,反正就是点击运行就卡着不动。家里电脑和公司电脑都是这样。
子场景
/3.png)
子场景跟案例1类似,唯一有区别的就是没有挂转成实体的组件,这里不需要转换,因为整个子场景里面的物体加载出来的时候就已经被转成Entity实体集合了。
保存场景时,Unity会将所有子场景转换为本机二进制格式。
此格式可用于存储器,并且可以在RAM中的数据发生最小改动的情况下加载或流式传输。该格式非常适合流式传输大量实体。
您可以在播放中自动加载子场景。您还可以推迟加载,直到从代码中流送SubScene为止(使用RequestSceneLoaded组件)
默认情况下,即使从编辑器中也从实体二进制文件中加载了子场景。
您可以选择一个子场景,然后在Unity Inspector窗口中单击“编辑”按钮进行编辑。
编辑时,您会在“场景”视图的“子场景”中看到实体的GameObject表示,并且可以像编辑任何GameObject一样编辑它们。
实时链接转换管道会将您对Game View场景所做的任何更改都应用到其中。
该功能仅能编辑场景的一部分,而仍将所有其他子场景作为实体加载到上下文中,这为编辑大型场景创建了非常可扩展的工作流。
如何制作子场景
在GameObject上右击然后选择New SubScene from Selection,就会生成一个子场景并且生成一个添加了一个SubScene的组件,我们可以点击Edit来显示子场景的内容,大概看了一下SubScene的脚本,加载的时候就是将它当成我们的实体Entity来加载的。
为什么需要SubScene呢?
因为速度快,可以根据需求来快速加载场景资源,看下SubScene工作原理,也就就更明白了!
根据上图所示,当我们保存SubScene的时候,是以原生的二进制(Binary)形式来储存的。这种原生二进制加载速度非常快,可以说是时刻准备着(memory-ready),它将以随机存取储存器RAM (Random Access Memory) 最小代价来加载。这种形式非常适合加载大量的实体,所以在主场景(MainScene)加载SubScene的时候,RAM将返回大量的实体到主场景中,从而完成子场景SubScene的加载。
这些从SubScene加载到主场景的大量的实体依然由DOTS来掌控,一旦加载完成就自动交接给DOTS了,剩下的东西在前面的内容了。
4.SpawnFromMonoBehaviour
这个实例演示如何使用Prefab GameObject生成实体和组件,场景中产生了成对的旋转立方体”场”.Unity.Entities提供了GameObjectConversionUtility方法将Prefab转成Entity的表示形式,然后可以通过EntityManager对象的Instantiate方法来实例化更多的Entity。
效果图
代码
1 | /// <summary> |
5.SpawnFromEntity
这个示例跟上面一个示例效果是一样的,都是生成了一个Cube旋转方阵,但这个有一个不同的是Spawner也转成了实体Entity,然后通过这个Entity去创建生成Cube实体方阵。
代码
1 | using System.Collections.Generic; |
1 | /// <summary> |
有了前面第二个案例的基础,看这个就很easy。将Spawner转成实体,然后再由这个实体生成更多的预设的实体。
猜想:Component和实体代码都非常简单,我们在开发的时候可以写个工具,让策划配置要生成的数据,或者根据excel表格的数据来生成数据实体和组件,我们要做的只是编写各种System系统。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66using Unity.Burst;
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;
protected override void OnCreate()
{
//在开始的时候缓存这个对象,就不需要每帧去创建调用
m_EntityCommandBufferSystem = World.GetOrCreateSystem<BeginInitializationEntityCommandBufferSystem>();
}
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
//取代直接执行结构的改变,一个任务可以添加一个命令到EntityCommandBuffer(实体命令缓存),从而在主线程上完成其任务后执行这些改变
//命令缓存允许在工作线程上执行任何潜在消耗大的计算,同时把实际的增删排到之后
var commandBuffer = m_EntityCommandBufferSystem.CreateCommandBuffer().ToConcurrent();
//这个job将实例化命令添加到EntityCommandBuffer
//由于这个job仅在第一帧上运行,因此我们要确保Burst在运行前对其进行编译以获得最佳性能(WithBurst的第三个参数)
//job在编译后将被缓存(Burst仅编译一次)
var jobHandle = Entities
.WithName("SpawnerSystem_FromEntity")
.WithBurst(FloatMode.Default, FloatPrecision.Standard, true) //通过Burst编译,提高效率
.ForEach((Entity entity, int entityInQueryIndex, in Spawner_FromEntity spawnerFromEntity, in LocalToWorld location) =>
{
for (var x = 0; x < spawnerFromEntity.CountX; x++)
{
for (var y = 0; y < spawnerFromEntity.CountY; y++)
{
var instance = commandBuffer.Instantiate(entityInQueryIndex, spawnerFromEntity.Prefab);
var position = math.transform(location.Value,
new float3(x * 1.3F, noise.cnoise(new float2(x, y) * 0.21F) * 2, y * 1.3F));
commandBuffer.SetComponent(entityInQueryIndex, instance, new Translation {Value = position});
}
}
commandBuffer.DestroyEntity(entityInQueryIndex, entity);
}).Schedule(inputDeps);
///生成任务并行且没有同步机会直到阻塞系统执行
///当阻塞系统执行时,我们想完成生成任务,然后再执行那些命令(创建实体并放置到指定位置)
/// 我们需要告诉阻塞系统哪个任务需要在它能回放命令之前完成
m_EntityCommandBufferSystem.AddJobHandleForProducer(jobHandle);
return jobHandle;
}
}
涉及到的知识点:
- 这里用到了JobSystem,是为了让我们安全的使用多线程,提高运行效率
- 任务组件系统可以在工作线程上运行,但创建和移除实体只能在主线程上做,从而防止线程之间的竞争
- 为了确保任务可以完成,这里引入了命令缓存机制,先把任务缓存起来,等待主线程完成工作后,在进行增删实体的操作
- 关于阻塞系统,是为了安全而生,当线程在执行任务的时候,将其阻塞起来,避免其他任务误入,等任务完成之后再执行下一个任务,从而有序的进行
- 有的任务由于等待太久而错过时机咋办,Play Back回放,大概是把没有执行的任务重新添加到队列
这种实体生成实体的需求还是满常见,比如一个机甲是,它发射出来的导弹也是实体,但它本身也要是实体,那么就是实体生成实体的需求了。
DOTS逻辑图表
Spawn流程
DOTS系统
6.SpawnAndRemove
这个示例演示了从世界生成和删除实体。
效果图
代码
有了前面的基础,这个案例虽然脚本多一点,但看起来应该也不会费劲,有很多相似之处。这个案例是在第六个基础之上做了一些修改,忽略相同之处,看不同的地方就是移除从操作。
不同的就是添加了声明系统,但也非常好看懂1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
/// <summary>
/// 生命周期,这里属于Component
/// </summary>
public struct LifeTime : IComponentData
{
public float Value;
}
/// <summary>
/// 这个系统负责场景中所有实体的生命周期
/// 也可以将其改装来负责特定实体的生命周期,添加刷选条件Filter即可
/// </summary>
public class LifeTimeSystem : JobComponentSystem
{
/// <summary>
/// 实体命令缓存系统--阻塞
/// </summary>
EntityCommandBufferSystem m_Barrier;
/// <summary>
/// 将阻塞缓存起来
/// </summary>
protected override void OnCreate()
{
m_Barrier = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
}
[BurstCompile]
struct LifeTimeJob : IJobForEachWithEntity<LifeTime>
{
public float DeltaTime;
[WriteOnly]
public EntityCommandBuffer.Concurrent CommandBuffer;
/// <summary>
/// 每帧执行,如果寿命 < 0 则摧毁实体
/// </summary>
/// <param name="entity">实体</param>
/// <param name="jobIndex">任务索引</param>
/// <param name="lifeTime">寿命</param>
public void Execute(Entity entity, int jobIndex, ref LifeTime lifeTime)
{
lifeTime.Value -= DeltaTime;
if (lifeTime.Value < 0.0f)
{
CommandBuffer.DestroyEntity(jobIndex, entity);
}
}
}
/// <summary>
/// 在主线程上每帧运行OnUpdate
/// </summary>
/// <param name="inputDependencies">输入依赖</param>
/// <returns>任务</returns>
protected override JobHandle OnUpdate(JobHandle inputDependencies)
{
var commandBuffer = m_Barrier.CreateCommandBuffer().ToConcurrent();
var job = new LifeTimeJob
{
DeltaTime = Time.DeltaTime,
CommandBuffer = commandBuffer,
}.Schedule(this, inputDependencies);
m_Barrier.AddJobHandleForProducer(job);
return job;
}
}