抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

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
SSR噪点

我们发现缓存的访问速度远快于内存和硬盘,缓存友好的程序性能会更好

OOP与DOP

对于OOP(Object-Oriented Programming)来说,我们可能每次仅迭代对象的某一项属性,其他属性白白加载了,造成性能的浪费和缓存的不友好

而DOP(Data-Oriented Programming)的实体由多个纯数据组成,系统运行时收集并处理所需的数据,这些数据大多为密集的同质数据,缓存友好

SOA

SOA不同于Unity ECS,放在这里用来抛砖引玉,便于理解数据驱动

数据驱动有很多实现方式,比如SOA(Struct of Array),将原本同质的数据合并为数组。

// AOS(Array of Struct)
struct Particle
{
Vector3 position;
Vector3 velocity;
Color color;
float age;
// ...
} m_particles[N];
// SOA(Struct of Array)
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添加项,添加项时提供项的定义信息、初始值

Flexible 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就一直藏在掖着,仿佛不肯用户发现安装一般,现在正式发售了,也没有放进包管理器里

  1. 打开包管理器,点击按名字添加包
  2. 依次添加com.unity.entitiescom.unity.entities.graphics

包管理器

打开快速Play

真的很快

进入场景设置

打开Scene预览

不开启这个,你运行时创建的Entity无法在Scene窗口查看

SceneView

Rider

个人建议配合Rider2023的新UI使用啊,效率能大幅提高,而且还有DOTS类型模板

RiderECS RiderECS2

不过一定要关闭自动保存,不然一切屏就报错

关闭自动保存

入门

SubScene

这个SubScene很久以前就有,但以前我们可以通过主动加载Prefab的方式实现流式加载,于是很少用这个SubScene,但现在好像把Prefab转Entity这个工作流取消了?

在Hierarchy节目按右键New Sub Scene即可创建,你可以像操作GO一样在SubScene中添加物体,会自动转化为Entity

SubScene

SubScene右侧有一个Checkbox,这个是用来加载/卸载场景的,SubScene最初的用法就是用来流式加载的

Entity

这属实优化了不少

现在Entity的制作流程极其简答,按GO的方式制作,然后会自动转化为Entity,为了方便你编辑,还提供了两套窗口,通过按右上角的圆圈,即可调整窗口

传统的GO界面(Authoring)

GO界面

Entity界面(Runtime)

Entity界面

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)
{
// 为当前Entity添加Component,可以添加多个
AddComponent(new CubeProperties
{
FieldSize = authoring.FieldSize,
CubeCount = authoring.CubeCount,
CubePrefab = GetEntity(authoring.CubePrefab) // 这里做了Prefab转Entity
});
AddComponent(new CubeRandom
{
Value = Random.CreateFromIndex(authoring.RandomSeed)
});
}
}

将Mono脚本挂在Entity上,切换为Runtime界面,就能看到Component信息了

CubeProp

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;
// TransformAspect 已经被取消
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;

// 在中心创建一个球,球内将不会创建Cube
#region SphereCreate
public bool SphereSpawnPointsIsCreated() {
return _sphereSpawnPoints.ValueRO.Value.IsCreated && SphereSpawnPointsCount > 0;
}
private int SphereSpawnPointsCount => _sphereSpawnPoints.ValueRO.Value.Value.Value.Length;

#endregion

// 生成Cube的位置和朝向
#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
}
}

Aspect

System

要说System最大的改动,我感觉就是这东西好建不好删,这东西只要你写了,也不需要挂载,就会直接全局生效,我开着Editor删文件会报DLL被占用,也不好修改,没写好编译就会报错,然后Rider呼吁我Revert掉

// 设置初始化顺序,能在System界面看到
[UpdateInGroup(typeof(InitializationSystemGroup))]
public partial struct CubeGenerateSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
// 当场景中包含至少一个 CubeProperties 组件时,启用 Update
state.RequireForUpdate<CubeProperties>();
}

[BurstCompile]
public void OnUpdate(ref SystemState state)
{
state.Enabled = false; // 禁用 Update
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;
}

System

此时启动游戏,会发现生成了一百个Cube,他们分布在一个正方形内部圆外部,转向随机

ECS-Scene2

Job

用起来很像一个函数对象

Job是从System发出的任务

public partial struct DoSomeJob: IJobEntity
{
// 这两个public的成员用来传递参数
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

评论