简介
DOTS(Data-Oriented Technology Stack),中文名:多线程数据导向型技术堆栈,它主要由三个部分组成。
- C# Job System:充分利用多核CPU的内核,高效安全的运行多线程代码。
- ECS(Entity Component System):将数据与行为分离,并让数据紧密的排列在内存中。
- Burst Complier:编译生成高效的代码。
Job System和ECS是两种不同的概念,均可以分开始使用。当它们一起使用的时候会发挥出更大的优势。而同时使用这三种技术,就是我们的DOTS技术。
C# Job System
Unity内部是多线程处理,但对外是不允许开发人员使用多线程的。因为Unity害怕多线程会被开发人员滥用,而引发许多莫名其妙的BUG。但实际上我们仍然可以使用C#多线程来对数据进行运算,当然这里的多线程中不能包含Unity相关的API。
而C# Job System的出现使得开发人员能够通过它来进行多线程的安全使用。
如今的CPU大多都是多核的。以往的游戏开发中,很多时候只用到了其中一个核心,而其余的核心大多时候都处于空闲状态。这使得CPU的性能并没有被完全激发出来。而C# Job System内部的多线程机制,能够使得这些原本处于空闲状态的CPU内核都运转起来。这就使得游戏运行的效率大为提升。
但在使用C# Job System的时候,我们只能在Job(一个线程)中使用值类型的数据。如果想使用数组(引用类型)的话,必须使用NativeArray
HPC#
HPC#(High Performance C#),中文名:高性能C#。
在C#中引用类型的数据是被分配在堆上的,程序员无法手动的去释放它,它的内存释放是由GC(垃圾回收机制)所控制的。又因为GC是每隔一段时间去清理一次,所以这使得在没有到达清理之前,即使我们去主动的把数据设置成null也是没用的。但HPC#提供了一个数据结构NativeArray
NativeArray
我们需要通过Dispose()去手动去释放NativeArray
如何使用C# Job System?参考代码如下:
public class Sample : MonoBehaviour
{
struct MyJob : IJob
{
public NativeArray m_NativeArray;
public void Execute()
{
for (int i = 0; i < m_NativeArray.Length; i++)
{
m_NativeArray[i] = i;
}
}
}
struct MyJobParallelFor : IJobParallelFor
{
public NativeArray m_NativeArray;
public void Execute(int index)
{
m_NativeArray[index] = index;
}
}
private void Update()
{
NativeArray nativeArray = new NativeArray(1000, Allocator.Persistent);
for(int i=0;i
Burst Complier
实际上就是一个编译器。它从抽象出来了一个中间语言IR。在对C#代码进行编译的时候,会先将C#语言转换成IR语言,然后再对IR语言进行优化最终转换成机器码。 (C#==>转换==>IR==>优化==>机器码)
多指针指向同一内存
对于一般的编译器而言,当它在处理两个指针的时候,是不知道两个指针是否指向同一块内存的,这时在处理这两个指针的时候,就会以一种安全但不高效的方式来处理。而对于Burst编译器,它内部是禁止了多个指针指向同一块内存的情况,因此它可以才用更高效的方式来处理。
如何启用BurstComplier?参考代码如下:
//[BurstCompile]只有修饰了Job System的Job相关接口时才有用
[BurstCompile]
struct Job : IJob
{
//......
}
ECS(Entity Cmponent System)
中文名是:实体组件系统。它实际上是将原本属于游戏对象以及它上面的数据和行为相互分离,分离成Entity、Component和System。
Entity
Entity是就相对于GameObject,但它实际上是一个标识,是一个key。它标识了它的数据到底对应着哪个Archetype。
Archetype是一个容器,一个Archetype中由多个chunk(一个16k内存块)组成。
Archetype的容量是动态变化的,不够就加一个chunk。
Archetype里面存放什么数据类型取决于游戏对象上有哪些Component(ECS),这其实是一种组合。当然也可以通过代码自行组合Component产生新的Archetype。
一个Archetype存放了这种组合下的所有数据。
Component
对于以往的开发方式,一个Component(以往)必须要继承MonoBehaviour才能被挂到游戏对象上。而对于MonoBehaviour它是多次继承的产物,它里面有很多很多的数据。而这里面的大多数数据,我们实际上是根本用不到的,但它却必须存在。这就使得有大量的内存我们白白浪费了。
还有一个问题就是,对于Component(以往)来说。每个Component在内存中是不连续的,它是分散在内存中的。这就使得缓存的命中率非常的低,自然效率就很低。
而ECS下的Component(ECS)里面只包含所需要用到的数据,不需要继承自MonoBehaviour,内存大为节省。同时由Component(ECS)类型组合而成的Archetype,会在内存中高度紧密的排列。这就使得缓存的命中率非常的高,效率大幅提升。
- Component(ECS)写法如下:
using Unity.Entities;
//继承IComponentData,再被[GenerateAuthoringComponent]修饰
//如果继承的是ISharedComponentData则内存中只有一份实例
[GenerateAuthoringComponent]
public struct MoveSpeed : IComponentData
{
public float Value;
}
System
在以往的开发中,行为和数据是绑定在游戏对象上的。这就使得行为的复用性非常的低,因为我们需要为很多游戏对象编写它对应的行为和数据。虽然我们可以通过许多设计模式来提高行为的复用性,但这也会使得系统越加复杂,难以理解。并且大量虚函数的调用也会是一笔不小的开销。
而对于ECS下的System就能以非常高的复用性来复用游戏对象原本的行为。因为在ECS体系下,System能看见的就只有数据。也就是说只要一个游戏对象上拥有这个系统所需要处理的数据,那么这个游戏对象就能拥有这个System所对应的行为,和这个游戏对象是人、是NPC、是房子、是石头......没有一点关系。
这就能使得不同的游戏对象可以复用相同的行为,甚至这些游戏对象可以是不同类别。(可以不派生自同一个超类)
针对DOTS做了一个案例,代码如下:
比较了10w个Cube旋转+移动时,以往开发和DOTS开发的差别。
以往开发
MoveNormal.cs
public class MoveNormal : MonoBehaviour
{
public float m_Speed;
void Update()
{
transform.position += Vector3.forward * m_Speed * Time.deltaTime;
}
}
RotateNormal.cs
public class RotateNormal : MonoBehaviour
{
public float m_Speed;
void Update()
{
transform.rotation *= Quaternion.AngleAxis(Mathf.Rad2Deg * m_Speed * Time.deltaTime, Vector3.up);
}
}
DOTS开发
MoveSpeed.cs
[GenerateAuthoringComponent]
public struct MoveSpeed : IComponentData
{
public float Value;
}
RotateSpeed .cs
[GenerateAuthoringComponent]
public struct RotateSpeed : IComponentData
{
public float Value;
}
MoveSystem.cs
public class MoveSystem : JobComponentSystem
{
EntityQuery m_Group;
protected override void OnCreate()
{
m_Group = GetEntityQuery(typeof(Translation),
ComponentType.ReadOnly());
}
[BurstCompile]
struct MoveJob : IJobChunk
{
public float dt;
public float3 direction;
public ArchetypeChunkComponentType translationType;
[ReadOnly] public ArchetypeChunkComponentType moveSpeedType;
public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
{
var translationArray = chunk.GetNativeArray(translationType);
var moveSpeedArray = chunk.GetNativeArray(moveSpeedType);
for (int i = 0; i < chunk.Count; i++)
{
float3 offset = dt * moveSpeedArray[i].Value * math.normalize(direction);
translationArray[i] = new Translation()
{
Value = translationArray[i].Value + offset
};
}
}
}
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
MoveJob job = new MoveJob()
{
dt = UnityEngine.Time.deltaTime,
direction = new float3(0, 0, 1),
translationType = GetArchetypeChunkComponentType(),
moveSpeedType = GetArchetypeChunkComponentType(true)
};
return job.Schedule(m_Group, inputDeps);
}
}
RotateSystem.cs
public class RotateSystem : JobComponentSystem
{
EntityQuery m_Group;
protected override void OnCreate()
{
base.OnCreate();
m_Group = GetEntityQuery(typeof(Rotation), ComponentType.ReadOnly());
}
[BurstCompile]
struct RotateJob : IJobChunk
{
public float dt;
public ArchetypeChunkComponentType rotationType;
[ReadOnly] public ArchetypeChunkComponentType rotateSpeedType;
public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
{
var rotationArray = chunk.GetNativeArray(rotationType);
var rotationSpeedArray = chunk.GetNativeArray(rotateSpeedType);
for(int i=0;i(),
rotateSpeedType = GetArchetypeChunkComponentType()
};
return job.Schedule(m_Group, inputDeps);
}
}
产生Cube
Spawn.cs
public class Spawn : MonoBehaviour
{
public GameObject m_PrefabECS;
public GameObject m_PrefabNormal;
[Header("长")]
public int m_CountX;
[Header("宽")]
public int m_CountZ;
[Header("高")]
public int m_CountY;
private int m_Amount = 0;
Entity m_EntityPrefab;
private void Start()
{
m_EntityPrefab = GameObjectConversionUtility.ConvertGameObjectHierarchy(m_PrefabECS,
GameObjectConversionSettings.FromWorld(World.DefaultGameObjectInjectionWorld, null));
}
private void OnGUI()
{
if(GUILayout.Button("产生CubeDOTS"))
{
InstantiateCubes(m_EntityPrefab);
}
if(GUILayout.Button("产生Cube(Normal)"))
{
InstantiateCubes(m_PrefabNormal);
}
GUILayout.Label("Amount:" + m_Amount);
}
List InstantiateCubes(T1 prefab) where T2:IInstantiateCubes,new()
{
m_Amount += m_CountX * m_CountY * m_CountZ;
return new T2().InstantiateCubes(GetPositions(),prefab);
//=====================================
List GetPositions()
{
List result = new List(m_Amount);
float3 pos = float3.zero;
for (int i = 1; i <= m_CountX; i++)
{
pos.y = 0;
for (int j = 1; j <= m_CountY; j++)
{
pos.x = 0;
for (int k = 1; k <= m_CountX; k++)
{
result.Add(pos);
pos.x += 1.5f;
}
pos.y += 1.5f;
}
pos.z += 1.5f;
}
return result;
}
}
}
public interface IInstantiateCubes
{
List InstantiateCubes(List positions, T prefab);
}
public class SpawnCubeDOTS : IInstantiateCubes
{
public List InstantiateCubes(List positions,Entity prefab)
{
EntityManager entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
var result=positions.Select(pos => entityManager.Instantiate(prefab))
.Select((entity, index) =>
{
entityManager.SetComponentData(
entity,
new Translation { Value = positions[index] });
return entity;
});
return new List(result);
}
}
public class SpawnCubeNormal : IInstantiateCubes
{
public List InstantiateCubes(List positions, GameObject prefab)
{
var result = positions.Select(pos => GameObject.Instantiate(prefab,pos,Quaternion.identity));
return new List(result);
}
}
对比结果
- 以往开发
- DOTS
- 提示
- Unity.Mathematics一定要用上。这是Unity新的数学库,对这里面的数据类型进行运算时,运算指令能映射到硬件的SIMD寄存器上。这就使得一条指令可以进行多项运算,效率大幅度提升。
- 还要就是在Material中,一定要把 Enable GPU Instancing勾上,能很大程度的降低Draw Call。