Unity ECS 架构与交通模拟的实现

Unity ECS 架构与交通模拟的实现

笔者在尝试做交通和行人的模拟,自然就想到了Unity 2018的新功能ECS + Job System,线性数据+多线程提高模拟速度,因此本文分两部分:ECS和交通模拟,以下为一个WIP的成果,感兴趣的朋友可以通过开源库下载:

https://lab.uwa4d.com/lab/5bdf5ee572745c25a8fab19a

请输入图片描述

请输入图片描述

该项目的实现主要有几个模块:生成RoadGraph,构造BVH,车辆模拟和行人模拟。

RoadGraph部分,为了Job让里能调用,全都是Array形式存的了,没有指针全是ID来指。构造BVH和行人模拟在笔者另外的博客中讲到了。车辆模拟有三个部分,首先构造BVH,之后每个Vehicle遍历BVH感知周边信息,最后一个更新Vehicle的位置等数据。

最早考虑的是用物理RayCast来做空间查找,让Vehicle感知周边信息。先是想用Pure ECS实现的,写着发现没法加物理,只能变成Hybrid ECS,实例化GameObject加上Collider,还好更新Collider的位置开销不大,主要是RayCast开销大,用于Agent感知周边车辆位置。但之后Profile发现Job里开销最大的是是RayCast物理,就改成了用Job System构造BVH,速度变快很多。

之前:
请输入图片描述

之后:
请输入图片描述
目前可以在我的笔记本上以30fps的跑25000辆车。

代码都挂在UWA Lab上了,详见OSMTrafficSim:

https://lab.uwa4d.com/lab/5bdf5ee572745c25a8fab19a

下面讲一讲我对于ECS的理解。


一,Unity ECS

ECS是Entity-Component-System的简称,2018推出的新功能。是一种比较新的框架体系,主要优点是:

  • 处理大量物体时性能较好,data-layout对Cache友好,便于多线程计算。
  • 耦合性低,代码清晰.Component只有数据没有逻辑,System只有逻辑没有状态。

Unity传统框架是有Entity-Component思想的,但不是ECS这个Entity-Component。传统Unity中Gameobject是Entity,MonoBehavior是Component挂在GameObject上。这样数据分散在内存多处,用指针获取,这样Cache-Miss会很多。而且数据耦合严重,线程不安全。

请输入图片描述

ECS的数据基本都是按一维数组形式存储的,很容易一起放进Cache,加快CPU计算速度。要知道内存-L3-L2-L1的数据读取花费的CPU指令周期是近似数量级降低的。用ID形式访问也对多线程比较友好,避免Data-Race。

笔者更关心计算性能的提升,最明显的是对大量物体的逻辑与渲染有帮助,比如Swarm的模拟,游戏中就可以做万人同屏等了,效率提升可能是数量级的。而对非大量物体的游戏中,提升有多少就不好说了。物理模拟可能是比较有帮助的,可以分配到多线程。其他的逻辑呢,可是本来开销就和物理和渲染不在同一个数量级的。


二,精英的一些基本概念

Unity官方有一些介绍:
https://github.com/Unity-Technologies/EntityComponentSystemSamples/blob/master/Documentation/content/ecs_concepts.md

World
Unreal中也有World,这次Unity也加上了。Play开始后默认创建一个World,你也可以自己创建。每个World有一个EntityManager和很多的ComponentSystem。EntityManager是管所有Entity的,ComponentSystem是System,管行为的,所有的Update都在里面。Component(数据)挂在Entity下面。

ComponentSystem
用于逻辑的,要有Update。注意的是,System是默认进行Update的!Monobehavior需要挂到GameObject上才会Update,ComponentSystem是只要你创建了这个代码就会Update的。另外ComponentSystem不依赖于GameObject,Hierarchy窗口里是找不到ComponentSystem的,但它就一直在Update。

Entity,EntityManager

本身什么就没有,就是一个ID。EntityManager里面可以找到每个Entity,在EntityDebug窗口中也能看到。Entity也是不依赖GameObject。World中可以有不挂在GameObject上的Entity,当然每个GameObject可以加GameObjectEntity变成Entity。

一般是用下面两个方法创建Entity。Archetype可以认为是一个类型,包含了需要哪些类型的Component。Entity就是Architype的实例,注入了数据。

1 EntityManager.CreateArchetype
2 EntityManager.CreateEntity

IComponentData
Component类的接口,所有存数据的地方。注意这是个Struct,其元素必须是非托管的,blitable类型的。

比如bool,int[],NativeArray<>都不能在IComponentData中存在。Unity.Mathematics里面的类都可以。另外每个IComponentData一般会声明一个DataWrapper,这个Wrapper才是一个可以挂在Entity上面的Component。

1 [Serializable]
2 public struct RotationSpeed : IComponentData
3 {
4     public float Value;
5 }
6 
7 public class RotationSpeedComponent : ComponentDataWrapper<RotationSpeed> { }

ComponentGroup,Inject,SubtractiveComponent
这几个存在的意义在于:ComponentSystem的Update对于哪些Entity起作用?
https://forum.unity.com/threads/how-the-demo-manages-to-selectively-enable-only-some-system-on-some-scene.531902/#post-3501129
Inject对象声明后会在ComponentSystem的OnCreateManager()之前获取到需要参与计算的Entity。

举个例子,其中SubtractiveComponent代表了,System关心的Entity不能有这些Component。下面这个例子中,group会获取到所有有Position,有Rigidbody,但是没有MeshCollider的Entity。

1class MySystem : ComponentSystem
 2{
 3    public struct Group
 4    {
 5        // ComponentDataArray lets us access IComponentData 
 6        [ReadOnly]
 7        public ComponentDataArray<Position> Position;
 8
 9        // ComponentArray lets us access any of the existing class Component                
10        public ComponentArray<Rigidbody> Rigidbodies;
11
12        // Sometimes it is necessary to not only access the components
13        // but also the Entity ID.
14        public EntityArray Entities;
15
16        // The GameObject Array lets us retrieve the game object.
17        // It also constrains the group to only contain GameObject based entities.                  
18        public GameObjectArray GameObjects;
19
20        // Excludes entities that contain a MeshCollider from the group
21        public SubtractiveComponent<MeshCollider> MeshColliders;
22
23        // The Length can be injected for convenience as well 
24        public int Length;
25    }
26    [Inject] private Group m_Group;
27
28
29    protected override void OnUpdate()
30    {
31        // Iterate over all entities matching the declared ComponentGroup required types
32        for (int i = 0; i != m_Group.Length; i++)
33        {
34            m_Group.Rigidbodies[i].position = m_Group.Position[i].Value;
35
36            Entity entity = m_Group.Entities[i];
37            GameObject go = m_Group.GameObjects[i];
38        }
39    }
40}

如果不用Inject,也可以ComponentGroup方法获得要参与计算的Entity。下面这个例子中,找到了所有包含Position, Rigidbody, SharedGrouping组件的Entity,注意ComponentGroup还有filter方法选择一部分Entity。

 1struct SharedGrouping : ISharedComponentData
 2{
 3    public int Group;
 4}
 5
 6class PositionToRigidbodySystem : ComponentSystem
 7{
 8    ComponentGroup m_Group;
 9
10    protected override void OnCreateManager(int capacity)
11    {
12        // GetComponentGroup should always be cached from OnCreateManager, never from OnUpdate
13        // - ComponentGroup allocates GC memory
14        // - Relatively expensive to create
15        // - Component type dependencies of systems need to be declared during OnCreateManager,
16        //   in order to allow automatic ordering of systems
17        m_Group = GetComponentGroup(typeof(Position), typeof(Rigidbody), typeof(SharedGrouping));
18    }
19
20    protected override void OnUpdate()
21    {
22        // Only iterate over entities that have the SharedGrouping data set to 1
23        // (This could for example be used as a form of gamecode LOD)
24        m_Group.SetFilter(new SharedGrouping { Group = 1 });
25
26        var positions = m_Group.GetComponentDataArray<Position>();
27        var rigidbodies = m_Group.GetComponentArray<Rigidbody>();
28
29        for (int i = 0; i != positions.Length; i++)
30            rigidbodies[i].position = positions[i].Value;
31
32        // NOTE: GetAllUniqueSharedComponentDatas can be used to find all unique shared components 
33        //       that are added to entities. 
34        // EntityManager.GetAllUniqueSharedComponentDatas(List<T> shared);
35    }
36}

Job
一般System里更新Entity的数据都是Job的方式,就是一个多线程的任务,方便CPU调度。
native方式,所以非托管的方式就跟写C++一样了,比如Job里想要一个定长int数组:
Malloc一下,最后要Free掉

1 int* _array= (int*)UnsafeUtility.Malloc(100 * sizeof(int), sizeof(int), Allocator.Temp);
2 ......
3 ......
4 UnsafeUtility.Free(_array, Allocator.Temp);
5 _array= null;

所以一个ECS的基本逻辑是:

1 rt(){
2     创建Entity
3     System找到感兴趣的Entity
4 }
5 
6 Update(){
7     System开Job更新Entity的数据
8 }

三,两个有意思的ECS示例项目

Voxelman,跳舞的体素小人
首先已经有了两个骨骼运动的小人,小人的模型会运行时烘焙成MeshCollider。System首先设置好Voxel,然后RayCast那个小人的collider获得Voxel的位置,最后更新Voxel的位置,渲染。
https://lab.uwa4d.com/lab/5b442a89d7f10a201faf67f3

uSpringBones,飘带物理运算的ECS版本
这个就没有ComponentSystem,全是Monobehavior开Job更新,Component主要用于更新Transform位置。
https://lab.uwa4d.com/lab/5bf67e9d72745c25a836b5ca

参考资料
OSMTrafficSim,笔者项目
https://github.com/maajor/OSMTrafficSim

官方ECS介绍,Github上上这个比官网的全一些
https://github.com/Unity-Technologies/EntityComponentSystemSamples/blob/master/Documentation/index.md

Voxelman,有意思的ECS项目
https://lab.uwa4d.com/lab/5b442a89d7f10a201faf67f3

uSpringBones,有意思的ECS项目
https://lab.uwa4d.com/lab/5bf67e9d72745c25a836b5ca

All of the Unity ECS Job System gotchas so far 很多ECS的小Tips!
https://gametorrahod.com/all-of-the-unitys-ecs-job-system-gotchas-so-far-6ca80d82d19f


这是侑虎科技第480篇文章,感谢作者Major供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群465082844)

作者主页:http://ma-yidong.com,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!