Unity ECS记录
参考:What are Blob Assets?
参考:Converting scene data to DOTS
参考:unity_dots_packages
参考:unity_entities_package_documents
前言
我之前写过文章Entity Component System与Entity Component介绍EC系统和ECS系统的区别,几年前的Unity和UE还都是用的EC框架,但这几年我看他们都开始往ECS架构调整了,Unity推出了DOTS系统,UE5里推出了Large Entities of Entities with Mass,ECS架构对内存和数据存储更友好一些,两个引擎都做出这个操作也是很合理的。
Unity的ECS框架貌似是让Editor下保留原本的EC框架,打包Runtime后变成ECS框架,然后还提供了一些方法和接口,用于把老的EC框架的Unity项目,过渡到新的ECS框架。
DOTS
DOTS全称为Data-Oriented Technology Stack,它其实是一种Unity使用的技术,具体的代码Unity把它拆分到了Packages里,一共有这么些,ECS其实是Unity的DOTS的一部分:
这里的Job System和Burst Compiler是Unity早就部署好的Package,简单介绍一下:
- Job System:为了方便写多线程的代码,Unity内部的C++源码创了Job System,同时把接口暴露在了C#层,所以用户也可以在C#这边使用它的Job System
- Burst Compiler:Clang/LLVM(Low Level Virtual Machine)是C++常规的编译器,LLVM是一个虚拟机,类似于编译器编译语言时的背景,而Clang是基于LLVM的编译器,可以编译C、C++、Objective-C、Objective-C++。Burst Compiler就是基于LLVM,可以把C#这边写的job代码编译成Machine Code
Entity Package
With ECS, we are moving from object-oriented to a data-oriented design.
Entity Package是基于Unity已有的Job System和Burst Compiler的基础上开发的ECS系统,相关内容都放到了Unity的名为entities的package里,命名空间为Unity.Entities
。此Package还包含了Unity Live Link,有了它,可以直接在Unity Editor里改动,然后马上看到在runtime下的情况。
blob asset
A blob asset is an immutable data structure stored in unmanaged memory. Blob assets can contain primitive types, strings, structs, arrays, and arrays of arrays. Arrays and structs must only contain blittable types. Strings must be of type “BlobString” (or a specialized unmanaged string type such as “NativeString64”.
BLOB,代表Binary Large Object,总之,blob asset就是Unity在C++上分配的一段内存,这里的asset并不代表传统意义上的资源,而是表示的内存Data。Unity早在推出ECS包之前,就开始着手Blob系统的构建了,它可以帮助用户把数据放到C++层,从而可以在C#层高效的读写数据,同时支持Job System,随着Unity推出Entity系统,它可以帮助来存Component里的数据。
我总结了一下BLOB asset相较于C++的堆内存分配、以及Unity的NativeContainer的区别:
- BLOB asset在系统里分布的内存是随时可以relocate的,所以无法使用裸指针去记录Blob asset的地址,需要使用blob asset类提供的相较于固定memory address的offset,获取对应的指针的reference
- BLOB asset里的写入,不会像native containers一样,需要进行安全检查
- blob asset是runtime下只读的,各个Job都可以访问,它特别适合存放那种Runtime下完全不可能更改的Data,比如动画的Curve数据、导航网格等信息
blob asset里的数据
Unlike native containers, blob assets have no safety checks against concurrent writes.
其数据都是runtime下只读的,像struct、class、string这种非primitive的都有对应的blob的表示方法,比如:
public struct MyPointAsset
{
public BlobArray<float> array1;
}
由于blob asset的relocatable的特性,Unity提供了BlobBuilder来创建和管理blob asset,它会记录blob asset相对于固定内存地址的offset,也会记录offset的变化,让用户获取对应的数据。
BlobBuilder
BlobBuilder对象负责创建blob asset,即在C++上分配一段内存,流程如下:
- 创建代表blob asset对应的struct
- Create a BlobBuilder object
- 调用BlobBuilder的
ConstructRoot<T>
方法,T
代表blob asset对应的struct,其实就是Allocate了内存,返回对应的指针而已 - 填充struct里的primitive数据,比如float、int这种的
- 对于struct里涉及堆内存的数据,比如struct、class、string又要分配对应的内存再初始化
- 最后Dispose BlobBuilder对象
别小看这个流程,实际写代码的时候要严格参考这个步骤,这里面可以通过BlobBuilder.CreateBlobAssetReference
来创建blob asset的引用
创建blob asset例子
先举一个最简单的例子,struct中只有primitive类型:
// 数据必须是struct
struct MarketData
{
public float PriceOranges;
public float PriceApples;
}
// 返回BlobAssetReference<MarketData对象
BlobAssetReference<MarketData> CreateMarketData()
{
// Create a new builder that will use temporary memory to construct the blob asset
var builder = new BlobBuilder(Allocator.Temp);
// Construct the root object for the blob asset. Notice the use of `ref`.
// 注意写法
ref MarketData marketData = ref builder.ConstructRoot<MarketData>();
// Now fill the constructed root with the data:
// Apples compare to Oranges in the universally accepted ratio of 2 : 1 .
marketData.PriceApples = 2f;
marketData.PriceOranges = 4f;
// Now copy the data from the builder into its final place, which will
// use the persistent allocator
// 使用BlobAssetReference作为长期引用
var result = builder.CreateBlobAssetReference<MarketData>(Allocator.Persistent);
// Make sure to dispose the builder itself so all internal memory is disposed.
builder.Dispose();
return result;
}
BlobArray例子
然后是当数据struct里有数组的情况:
struct Hobby
{
public float Excitement;
public int NumOrangesRequired;
}
// 数据里存了个数组
struct HobbyPool
{
public BlobArray<Hobby> Hobbies;
}
BlobAssetReference<HobbyPool> CreateHobbyPool()
{
// 没啥特别的
var builder = new BlobBuilder(Allocator.Temp);
ref HobbyPool hobbyPool = ref builder.ConstructRoot<HobbyPool>();
// Allocate enough room for two hobbies in the pool. Use the returned BlobBuilderArray
// to fill in the data.
// 通过builder为数组分配uninitialized内存
const int numHobbies = 2;
BlobBuilderArray<Hobby> arrayBuilder = builder.Allocate(
ref hobbyPool.Hobbies,
numHobbies
);
// Initialize数组元素
// An exciting hobby that consumes a lot of oranges.
arrayBuilder[0] = new Hobby
{
Excitement = 1,
NumOrangesRequired = 7
};
// A less exciting hobby that conserves oranges.
arrayBuilder[1] = new Hobby
{
Excitement = 0.2f,
NumOrangesRequired = 2
};
// 没啥特别的
var result = builder.CreateBlobAssetReference<HobbyPool>(Allocator.Persistent);
builder.Dispose();
return result;
}
BlobString例子
然后是数组里有string的情况,也差不多:
struct CharacterSetup
{
public float Loveliness;
public BlobString Name;
}
BlobAssetReference<CharacterSetup> CreateCharacterSetup(string name)
{
var builder = new BlobBuilder(Allocator.Temp);
ref CharacterSetup character = ref builder.ConstructRoot<CharacterSetup>();
character.Loveliness = 9001; // it's just a very lovely character
// Create a new BlobString and set it to the given name.
builder.AllocateString(ref character.Name, name);
var result = builder.CreateBlobAssetReference<CharacterSetup>(Allocator.Persistent);
builder.Dispose();
return result;
}
BlobPtr例子
如果struct里有指针,应该用BlobPtr
,写法如下:
// struct里是一个指针、一个数组
struct FriendList
{
public BlobPtr<BlobString> BestFriend;
public BlobArray<BlobString> Friends;
}
BlobAssetReference<FriendList> CreateFriendList()
{
var builder = new BlobBuilder(Allocator.Temp);
ref FriendList friendList = ref builder.ConstructRoot<FriendList>();
const int numFriends = 3;
var arrayBuilder = builder.Allocate(ref friendList.Friends, numFriends);
builder.AllocateString(ref arrayBuilder[0], "Alice");
builder.AllocateString(ref arrayBuilder[1], "Bob");
builder.AllocateString(ref arrayBuilder[2], "Joachim");
// Set the best friend pointer to point to the second array element.
// 指针需要调用SetPointer函数
builder.SetPointer(ref friendList.BestFriend, ref arrayBuilder[2]);
var result = builder.CreateBlobAssetReference<FriendList>(Allocator.Persistent);
builder.Dispose();
return result;
}
struct的例子
对于struct而言,其实直接赋值就可以了,比如:
struct MyData
{
public Vector3 oneVector3;
}
// 用的时候直接这么写, 就行了, struct已经会在ConstructRoot里就完成了allocate工作
// 在这里只是进行初始化操作
root.oneVector3 = new Vector3(3, 3, 3);
或者用BlobPtr也行,写法上麻烦一些:
struct MyData
{
public BlobPtr<Vector3> oneVector3;
}
// BlobPtr需要单独Allocate
ref Vector3 oneVector3 = ref builder.Allocate(ref root.oneVector3);
oneVector3 = new Vector3(3, 3, 3);
class的例子
在Component上获取blob asset
之前在创建BlobAsset时返回了一个BlobAssetReference<T>
的对象,可以把该引用存到Component里,
// Hobbies属于ComponentData, 里面是一个HobbyPool对象的引用
struct Hobbies : IComponentData
{
public BlobAssetReference<HobbyPool> Blob;
}
float GetExcitingHobby(ref Hobbies component, int numOranges)
{
// Get a reference to the pool of available hobbies. Note that it needs to be passed by
// reference, because otherwise the internal reference in the BlobArray would be invalid.
ref HobbyPool pool = ref component.Blob.Value;
// Find the most exciting hobby we can participate in with our current number of oranges.
float mostExcitingHobby = 0;
for (int i = 0; i < pool.Hobbies.Length; i++)
{
// This is safe to use without a reference, because the Hobby struct does not
// contain internal references.
var hobby = pool.Hobbies[i];
if (hobby.NumOrangesRequired > numOranges)
continue;
if (hobby.Excitement >= mostExcitingHobby)
mostExcitingHobby = hobby.Excitement;
}
return mostExcitingHobby;
}
blob asset作为参数或返回值
Blob assets must always be accessed and passed by reference using the ref keyword or using BlobAssetReference
可以使用ref
,也可以使用BlobAssetReference
,
Archetypes
A unique combination of component types is called an EntityArchetype
Arche这个单词有始源、初始的意思,在这里指的是Component的组合类型,有相同的Component的Entity属于同一个ArcheType,如下图所示:
UnityEditor在Dots里还提供了ArcheWindow,可以看ECS里各个类型ArcheType的entities的内存占用情况:
更多的窗口介绍参考Archetypes window
Memory Chunks
ECS allocates memory in “chunks”, each represented by an ArchetypeChunk object.
Chunk其实就是内存块而已,同一个Chunk里存的都是相同的ArcheType的Entity的Component数据,如下图所示,同一个类型的ArcheType的Entity的Component数据,可以对应多个Chunk:
最重要的一点就是:Chunk里存的是Component数据,而且是按照AAAABBBBCCCCDDDD的顺序存的,里面的相同的Component数据的连续分布在内存里的,这完全就是ECS里存储C的方法。用这种存储方法,能够快速的找到拥有指定Component组合的所有Entityes的数据,
补充一点,同种ArcheType的Entity在Chunk里的分布顺序是没有什么讲究的,当Entity被创建或删除时,为了保证Chunks remain tightly packed,在Chunk里的处理逻辑如下:
- Entity从无到有创建时,它会直接分配内存到对应ArcheType的第一个有空余位置的Chunk的最后的空余位置上
- Entity改变了ArcheType时,它原本在内存上的位置,会被该Chunk的最后一个Entity所替换(相当于把数组最后一个元素,移到了前面),然后把该Entity移到新的Chunk的最后空余位置
The values of shared components in an archetype also determine which entities are stored in which chunk. All of the entities in a given chunk have the exact same values for any shared components. If you change the value of any field in a shared component, the modified entity moves to a different chunk, just as it would if you changed that entity’s archetype. A new chunk is allocated, if necessary.
额外还需要注意,对于共享特定Component数据的相同ArcheType的Entity,它们都会存在同一个Chunk里,也就是说Chunk里存在的都是拥有相同Component数据的Entity,一旦某个Entity的Component数据单独被修改,那么它自己会挪到别的Chunk里(如果是独一无二的Component数据,可能一个Entity对应唯一一个Chunk)
Entity queries
https://docs.unity3d.com/Packages/com.unity.entities@0.50/manual/ecs_core.html
Converting scene data to DOTS
Unity官方还专门出了个视频,来介绍这个东西,方便用户把原本的EC架构下的游戏工程转化为DOTS架构的游戏,有点意思。Unity这边现在有两种架构,Editor下仍然是原本的EC架构,而Runtime下Unity把它转成了ECS架构。也就是说,Unity的转化功能把Editor下的GameObject转化成了Runtime下的Entity。
简单介绍一下吧,这里有个SubScene的概念,比如在默认Unity场景里加了个Cube的GameObject,然后给它上面加了个自我旋转的MonoBehaviour脚本:
此时的Hierarchy为:
Unity在进行转换时,对于所有出现在hierarchy里的Tree,应该会根据需要(我理解的是包含MonoBehaviour的Tree),把它放到对应的SubScene里:
此时要把现有的Rotate这个MonoBehaviour转化为Unity的ECS系统,需要把逻辑拆分到S里,数据拆分到C里,所以此时会有三个类:
- 一个类继承于原本的MonoBehaviour类,Unity会负责把它转化为ECS系统里
- 一个新的数据类,记录了Component的数据,在这里就是旋转速度
- 一个行为类,记录了在System里执行的代码
所以最终的三个类代码如下:
// 原本的类需要多继承一个接口
public class RotateToBeConverted : MonoBehaviour, IConvergGameObjectToEntity
{
public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
{
dstManager.AddComponentData(entity, new RotateComponentData { RadiansPerSecond = this.RadiansPerSecond; });
dstManager.AddComponentData(entity, new RotationEulerXYZ());// 这个应该是Unity自带的Component
}
}
public class RotateComponentData : IComponentData
{
public float RadiansPerSecond;
}
public class RotateSystem : ComponentSystem
{
protected override void OnUpdate()
{
Entities.ForEach((ref RotateComponentData rotateData, ref RotationEulerXYZ rotation) =>
{
rotation.y += rotateData.RadiansPerSecond * Time.deltaTime;
});
}
}
这个例子是指的正常的Component类,不过如果Component类里包含了Spawn其他GameObject的代码,那么需要继承额外的IDeclareReferencePrefabs
接口,这里的转化相当于一个递归过程,因为可能会有Prefab的链式依赖的情况:
附录一些实际情况
BlobBuilderArray里填充数据不会直接改变BlobArray
我写了个简单的BlobArray的例子:
public class Test : MonoBehaviour
{
// 数据必须是struct
struct MarketData
{
public BlobArray<float> Prices;
}
// 返回BlobAssetReference<MarketData>对象
BlobAssetReference<MarketData> CreateMarketData()
{
var builder = new BlobBuilder(Allocator.Temp);
ref MarketData marketData = ref builder.ConstructRoot<MarketData>();
BlobBuilderArray<float> arrBuilder = builder.Allocate(ref marketData.Prices, 100);
arrBuilder[0] = 5.0f;
var result = builder.CreateBlobAssetReference<MarketData>(Allocator.Persistent);
builder.Dispose();
return result;
}
Debug走到下图这里的适合,发现arrBuilder这个BlobBuilderArray里面是有数组信息的,实际marketData里的数组却是空的
这个CreateBlobAssetReference函数,会把刚刚创建的数据复制到result里,此时的result里就会真正的存储数组数据。
但我实际工作里碰到了这么个问题,我需要Cache住这个arrBuilder,然后异步的执行这个函数,直到完全填充好这个数组,最后调用CreateBlobAssetReference函数,难点在于:
- BlobBuilderArray类型为
public unsafe ref struct
,貌似是不能直接存成Cache的
但是这个难点让我解决了,我发现这个类本质就是一个void*指针,和一个数组长度的interger,Unity还提供了这么个构造函数,意味着我只要存起来它的ptr和数组大小即可:
可以使用该类的函数获取对应指针:
/// <summary>
/// Provides a pointer to the data stored in the array.
/// </summary>
/// <remarks>You can only call this function in an <see cref="Unsafe"/> context.</remarks>
/// <returns>A pointer to the first element in the array.</returns>
public void* GetUnsafePtr()
{
return m_data;
}
当BlobAsset里有嵌套的数组时应该怎么Allocate
我发现这么写是不可以的:
public class Test : MonoBehaviour
{
struct SubArray
{
public BlobArray<float> Prices;
}
// 数据必须是struct
struct MarketData
{
public BlobArray<SubArray> arrs;
}
// 返回BlobAssetReference<MarketData>对象
BlobAssetReference<MarketData> CreateMarketData()
{
var builder = new BlobBuilder(Allocator.Temp);
ref MarketData marketData = ref builder.ConstructRoot<MarketData>();
BlobBuilderArray<SubArray> arrBuilder = builder.Allocate(ref marketData.arrs, 100);
// 这么写会Runtime报错, 因为marketData.arrs为空数组, length为0
BlobBuilderArray<float> subArrBuilder = builder.Allocate(ref marketData.arrs[0].Prices, 100);
var result = builder.CreateBlobAssetReference<MarketData>(Allocator.Persistent);
builder.Dispose();
return result;
}
}
需要借助这里的BlobBuilderArray来创建:
public class TestTuoba : MonoBehaviour
{
struct SubArray
{
public BlobArray<float> Prices;
}
// 数据必须是struct
struct MarketData
{
public BlobArray<SubArray> arrs;
}
// 返回BlobAssetReference<MarketData>对象
BlobAssetReference<MarketData> CreateMarketData()
{
var builder = new BlobBuilder(Allocator.Temp);
ref MarketData marketData = ref builder.ConstructRoot<MarketData>();
BlobBuilderArray<SubArray> arrBuilder = builder.Allocate(ref marketData.arrs, 100);
// 借助BlobBuilderArray
BlobBuilderArray<float> subArrBuilder = builder.Allocate(ref arrBuilder[0].Prices, 100);
subArrBuilder[20] = 5f;
//arrBuilder.GetUnsafePtr();
var result = builder.CreateBlobAssetReference<MarketData>(Allocator.Persistent);
builder.Dispose();
// 发现arrrrr[20]显示为5
var arrrrr = result.Value.arrs[0].Prices.ToArray();
return result;
}
奇怪的内存泄露
这里的Allocator.Temp
,会让内存不断增长
private void OnClick()
{
var builder = new BlobBuilder(Allocator.Temp);
ref HobbyPool hobbyPool = ref builder.ConstructRoot<HobbyPool>();
const int numHobbies = 800000000;
BlobBuilderArray<Vector3> arrayBuilder = builder.Allocate(ref hobbyPool.Hobbies, numHobbies);
builder.Dispose();
}
但如果我改成Persistent
,就不会增长内存,奇怪了,可能跟没有调用CreateBlobAssetReference
有关系吧:
var builder = new BlobBuilder(Allocator.Persistent);