Unity ECS 入门
曾经待的项目使用了Unity ECS 0.5,那时候功能很不完善,几乎所有功能都从头写了一遍。现在Unity ECS 1.0发布了,有点失望。看了一圈感觉系统复杂性提高了很多,而且不能保证系统的稳定性(尤其是有错的代码编译),非常不适合独立游戏制作
数据驱动
ECS的本质就是数据驱动的编程,随着守望先锋的爆火进入大家视野,结果现在守望2都凉了,Unity才把ECS端上来。。。
缓存友好
每一个程序员都应该知道的数字
截至2020年,电脑访问数据的速度为
L1缓存:1ns
分支预测错误:3ns
L2缓存:4ns
解/锁互斥锁:17ns
内存:100ns
固态硬盘随机读取:16,000ns
固态硬盘顺序读取1,000,000bytes:49,000ns
机械硬盘顺序读取1,000,000bytes:825,000ns
我们发现缓存的访问速度远快于内存和硬盘,缓存友好的程序性能会更好
OOP与DOP
对于OOP(Object-Oriented Programming)来说,我们可能每次仅迭代对象的某一项属性,其他属性白白加载了,造成性能的浪费和缓存的不友好
而DOP(Data-Oriented Programming)的实体由多个纯数据组成,系统运行时收集并处理所需的数据,这些数据大多为密集的同质数据,缓存友好
SOA
SOA不同于Unity ECS,放在这里用来抛砖引玉,便于理解数据驱动
数据驱动有很多实现方式,比如SOA(Struct of Array),将原本同质的数据合并为数组。
struct Particle { Vector3 position; Vector3 velocity; Color color; float age; } m_particles[N];
struct Particles { Vector3 position[N]; Vector3 velocity[N]; Color color[N]; float age[N]; } m_particles;
这种实现不需要为了对齐做padding即可完美对齐,能节省内存
可以使用SIMD(单指令多数据)加速读写
缓存对齐可以去看TBB Padding ,可以使得两个相邻的数据位置更远,从而不出现在一个缓存行中,进而避免了假共享 现象
易拓展
相较于面向对象,数据驱动更易拓展
OOP的方案
对于面向对象的数据结构,如果要拓展一个新的字段,如果直接将数据放在类里面(不管这个字段会不会被用到),会增大对象大小,浪费内存,缓存不友好,最后导致系统越来越抗拒新的拓展
如果使用union,确实可以实现不同成员共享空间,省去了那些没有被用到的数据的大小。但需要我们预先决定哪些数据不会被同时使用,大幅提高了编程难度和出错率
struct Data { int dataType; union { int intValue; float floatValue; char stringValue[10 ]; } dataValue; };
如果使用哈希表,确实得到了弹性,但会产生成员进map的消耗
struct Data { unordered_map<Key, Variant> kv; };
SOA Flexible Table
个人感觉本质就是哈希表,使用起来很像处理JSON和XML
运行时为table添加项,添加项时提供项的定义信息、初始值
MetaTable meta; const TypeID floatType = meta.AddType ("float" , 4 , 16 );const AttributeID positionXAttribute = meta.AddAttribute ("positionX" , floatType, 0.0f );const AttributeID positionYAttribute = meta.AddAttribute ("positionY" , floatType, 0.0f );const AttributeID positionZAttribute = meta.AddAttribute ("positionZ" , floatType, 0.0f );const AttributeID velocityXAttribute = meta.AddAttribute ("velocityX" , floatType, 0.0f );SOATable particles (meta) ;particles.ReserveRows (N); particles.AppendRows (N); for (size_t i = 0 ; i < N; i++) { particles.SetValue (i, positionXAttribute, ...); }
GO与ECS
Unity的GO和ECS是对OOP和DOP的具体实现
传统的GO+Mono模式:场景中有大量的GO,他们拥有各自的脚本和组件,运行时遍历GO,按生命周期执行Mono脚本(一定是所有OnEnable()
执行完后,再执行所有的Start()
)
ECS模式,场景由Entity和System组成,这些Entity拥有纯数据的Component,而System负责收集其负责的Component,集中处理
能看出,ECS模式是数据密集型的,同类的数据集中存储,集中处理。而GO是一个相对完整独立的个体,每个GO都会处理自己的数据。
ECS缓存友好,适用于单指令多数据、并行、上下文切换等机制,于是在逻辑处理上会比GO强很多
初始化
安装
之前0.5的时候,Unity ECS就一直藏在掖着,仿佛不肯用户发现安装一般,现在正式发售了,也没有放进包管理器里
打开包管理器,点击按名字添加包
依次添加com.unity.entities
和com.unity.entities.graphics
打开快速Play
真的很快
打开Scene预览
不开启这个,你运行时创建的Entity无法在Scene窗口查看
Rider
个人建议配合Rider2023的新UI使用啊,效率能大幅提高,而且还有DOTS类型模板
不过一定要关闭自动保存,不然一切屏就报错
入门
SubScene
这个SubScene很久以前就有,但以前我们可以通过主动加载Prefab的方式实现流式加载,于是很少用这个SubScene,但现在好像把Prefab转Entity这个工作流取消了?
在Hierarchy节目按右键New Sub Scene即可创建,你可以像操作GO一样在SubScene中添加物体,会自动转化为Entity
SubScene右侧有一个Checkbox,这个是用来加载/卸载场景的,SubScene最初的用法就是用来流式加载的
Entity
这属实优化了不少
现在Entity的制作流程极其简答,按GO的方式制作,然后会自动转化为Entity,为了方便你编辑,还提供了两套窗口,通过按右上角的圆圈,即可调整窗口
传统的GO界面(Authoring)
Entity界面(Runtime)
Component
还是一如既往的脱裤子放屁,突出一个意义不明
IComponentData
纯数据结构体
public struct CubeProperties: IComponentData{ public Vector2 FieldSize; public int CubeCount; public Entity CubePrefab; }
public struct CubeRandom : IComponentData{ public Random Value;
MonoBehaviour
IComponentData
的数据无法在Editor面板上显示(why?),需要使用Mono封装传递(what?)
public class CubeMono : MonoBehaviour { public Vector2 FieldSize; public int CubeCount; public GameObject CubePrefab; public uint RandomSeed; } public class CubeBaker : Baker <CubeMono >{ public override void Bake (CubeMono authoring ) { AddComponent(new CubeProperties { FieldSize = authoring.FieldSize, CubeCount = authoring.CubeCount, CubePrefab = GetEntity(authoring.CubePrefab) }); AddComponent(new CubeRandom { Value = Random.CreateFromIndex(authoring.RandomSeed) }); } }
将Mono脚本挂在Entity上,切换为Runtime界面,就能看到Component信息了
Aspect
这次ECS还新增了一个IAspect
,看上去好像是负责收集Component数据的转换层,可以将多个Component的数据结合在一起,方便System调用
An aspect is an object-like wrapper that you can use to group together a subset of an entity’s components into a single C# struct
namespace ECS.Study { public readonly partial struct CubeAspect : IAspect { public readonly Entity Entity; private readonly RefRW<LocalTransform> _localTransform; private readonly RefRO<CubeProperties> _cubeProperties; private readonly RefRW<CubeRandom> _cubeRandom; private readonly RefRW<SphereSpawnPoints> _sphereSpawnPoints; public int CubeCount => _cubeProperties.ValueRO.CubeCount; public Entity CubePrefab => _cubeProperties.ValueRO.CubePrefab; #region SphereCreate public bool SphereSpawnPointsIsCreated () { return _sphereSpawnPoints.ValueRO.Value.IsCreated && SphereSpawnPointsCount > 0 ; } private int SphereSpawnPointsCount => _sphereSpawnPoints.ValueRO.Value.Value.Value.Length; #endregion #region Transform public LocalTransform GetRandomCubeTransform () { return new LocalTransform { Position = GetRandomPosition(), Rotation = GetRandomRotation(), Scale = GetRandomScale(0.5f ) }; } private float3 GetRandomPosition () { float3 randomPosition; do { randomPosition = _cubeRandom.ValueRW.Value.NextFloat3(MinCorner, MaxCorner); } while (math.distancesq(_localTransform.ValueRO.Position, randomPosition) <= CUBE_SAFETY_RADIUS_SQ); return randomPosition; } private float3 HalfDimension => new () { x = _cubeProperties.ValueRO.FieldSize.x * 0.5f , y = 0f , z = _cubeProperties.ValueRO.FieldSize.y * 0.5f }; private float3 MinCorner => _localTransform.ValueRO.Position - HalfDimension; private float3 MaxCorner => _localTransform.ValueRO.Position + HalfDimension; private const float CUBE_SAFETY_RADIUS_SQ = 100 ; private quaternion GetRandomRotation () { return quaternion.RotateY(_cubeRandom.ValueRW.Value.NextFloat(-0.25f , 0.25f )); } private float GetRandomScale (float min ) { return _cubeRandom.ValueRW.Value.NextFloat(min, 1f ); } public float2 GetRandomOffset () { return _cubeRandom.ValueRW.Value.NextFloat2(); } #endregion } }
System
要说System最大的改动,我感觉就是这东西好建不好删,这东西只要你写了,也不需要挂载,就会直接全局生效,我开着Editor删文件会报DLL被占用,也不好修改,没写好编译就会报错,然后Rider呼吁我Revert掉
[UpdateInGroup(typeof(InitializationSystemGroup)) ] public partial struct CubeGenerateSystem : ISystem{ [BurstCompile ] public void OnCreate (ref SystemState state ) { state.RequireForUpdate<CubeProperties>(); } [BurstCompile ] public void OnUpdate (ref SystemState state ) { state.Enabled = false ; var cubeEntity = SystemAPI.GetSingletonEntity<CubeProperties>(); var cube = SystemAPI.GetAspect<CubeAspect>(cubeEntity); var builder = new BlobBuilder(Allocator.Temp); ref var spawnPoints = ref builder.ConstructRoot<SphereSpawnPointsBlob>(); var arrayBuilder = builder.Allocate(ref spawnPoints.Value, cube.CubeCount); var spawnOffset = new float3(0f , -2f , 1f ); var cmd = new EntityCommandBuffer(Allocator.Temp); for (int i = 0 ; i < cube.CubeCount; i++) { var newCube = cmd.Instantiate(cube.CubePrefab); var newTransform = cube.GetRandomCubeTransform(); cmd.SetComponent(newCube, newTransform); var newSpawnPoint = newTransform.Position + spawnOffset; arrayBuilder[i] = newSpawnPoint; } var blobAsset = builder.CreateBlobAssetReference<SphereSpawnPointsBlob>(Allocator.Persistent); cmd.SetComponent(cubeEntity, new SphereSpawnPoints{ Value = blobAsset }); builder.Dispose(); cmd.Playback(state.EntityManager); } [BurstCompile ] public void OnDestroy (ref SystemState state ) { } }
public struct SphereSpawnPoints : IComponentData{ public BlobAssetReference<SphereSpawnPointsBlob> Value; } public struct SphereSpawnPointsBlob{ public BlobArray<float3> Value; }
此时启动游戏,会发现生成了一百个Cube,他们分布在一个正方形内部圆外部,转向随机
Job
用起来很像一个函数对象
Job是从System发出的任务
public partial struct DoSomeJob: IJobEntity{ public float DeltaTime; public EntityCommandBuffer ECS; private void Execute (CubeAspect cubeAspect ) { ... } }
System通过创建对象来调用这个任务
[BurstCompile ] public void OnUpdate (ref SystemState state ){ var deltaTime = SystemAPI.Time.DeltaTime; var ecbSingleton = SystemAPI.GetSingleton<BeginInitializationEntityCommandBufferSystem.Singleton>(); new DoSomeJob { DeltaTime = deltaTime, ECB = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged) }.Run(); }
SystemBase
系统交互相关的系统
下面是一个相机盯着中心看,并做旋转的示例
public partial class CameraControllerSystem : SystemBase { protected override void OnUpdate () { var cameraSigleton = CameraSingleton.Instance; if (cameraSigleton == null ) return ; float positionFactor =(float ) SystemAPI.Time.ElapsedTime * cameraSigleton.Speed; float scale = 1 ; var radius = cameraSigleton.RadiusAtScale(scale); var height = cameraSigleton.HeightAtScale(scale); cameraSigleton.transform.position = new UnityEngine.Vector3 { x = Mathf.Cos(positionFactor) * radius, y = height, z = Mathf.Sin(positionFactor) * radius }; cameraSigleton.transform.LookAt(Vector3.zero, Vector3.up); } }
下面这个代码要挂在场景中(建议挂在相机上)
public class CameraSingleton : MonoBehaviour { public static CameraSingleton Instance { get ; private set ; } private void Awake () { if (Instance != null ) { Destroy(gameObject); return ; } Instance = this ; } [SerializeField ] private float startRadius; [SerializeField ] private float endRadius; [SerializeField ] private float startHeight; [SerializeField ] private float endHeight; [SerializeField ] private float speed; public float RadiusAtScale (float scale ) => Mathf.Lerp(startRadius, endRadius, 1 - scale); public float HeightAtScale (float scale ) => Mathf.Lerp(startHeight, endHeight, 1 - scale); public float Speed => speed; }
锐评
就这个简单的小demo,我的编辑器就卡死崩溃了多次,尤其是当我保存编译一个有报错的System文件时,几乎必然要用任务管理器杀项目,太不稳定了
而且这个ECS不仅跟Mono差异巨大,还和0.5版本的ECS差异也巨大,所有人都对这个系统不熟,官方也没提供一些特别好的适普的教程
如果是用Unity做小项目,完全不需要ECS,你的性能瓶颈大概率是渲染和资产。把粒子特效砍一砍,查看一下场景中有没有面数惊人的模型,模型是否有LOD,是否针对设备做了渲染分级,控制场景中实时光源数量,砍掉一些昂贵而作用不明显的渲染feature(比如基于快速傅里叶的水,比如高精度的布料、破坏仿真)
此外我诚心建议Unity把重点放在Editor上,实现一套能用 的地表编辑器、动画编辑器、资产编辑器、大世界分块编辑器。引擎好不好用,关键靠Editor,你就算架构设计的再好,再适合客制化,小公司没精力没技术魔改,大公司不稀罕你的原生功能,甚至很多公司跟你闹掰转UE了,你在大型项目中有半点优势吗?
参考
https://www.youtube.com/watch?v=IO6_6Y_YUdE
https://www.tmg.dev/tuts/zombieupdate/
https://docs.unity3d.com/Packages/com.unity.entities@1.0/manual/index.html
《为实现极限性能的面向数据编程范式》叶劲峰 GDC 2005