又有一段時間沒有寫博客了,真是不爽~~~ 趁著最近兩天沒事,趕緊補上一篇,這次開始寫一篇Pure ECS版本示例解析,對上次Hybrid版本Unity之淺析 Entity Component System (ECS)的補充,使用的是官方案例的Rotation場景。
有說錯或不準確的地方歡迎留言指正
Unity版本 208.2.3f1 Entities 版本 preview.8
ECS雖然現在已經實裝,但還在實驗階段,筆者在開發的過程中也遇到了一些IDE卡頓,Unity編輯器崩潰的情況。這個情況相信在Unity后續版本中會得到改善。
這么多問題為什么還要用呢?那就是計算速度快?。?!真的很快,筆者這垃圾筆記本此場景創建20W個Cube還能保持在20幀左右,所以可見一斑。
主要參考官方文檔地址
對應工程文件下載
- 2018/08/29更新 添加 [BurstComplie]特性 以后如果你打開Burst Complier的話,下面的代碼會在編譯的時候被Burst Compiler優化,運行速度更快,目前Burst只是運行在編輯器模式下,之后正式出了會支持編譯
效果展示
下面筆者會逐步創建示例中的場景,使用Unity版本2018.2.3f1 ,基本配置請參考Unity之淺析 Entity Component System (ECS)
首選需要準備的資源為:
- Unity對應Logo模型
- 一個在場景中對應的Logo Object
- 一個產卵器,生產指定Cube按照規定半徑隨機分布
創建Unity對應Logo模型
在hierarchy中創建一個gameObject命名為RotationLogo然后添加組下組件,這些組件都是ECS自帶的
- GameObjectEntity 必帶組件,沒有的話ECS系統不識別
- PositionComponent 組件對應傳統模式中 transform.position
- CopyInitialTransformFromGameObjectComponent 初始化TransformMatrix中的數據
- TransformMatrix 指定應該存儲一個4x4矩陣。這個矩陣是根據位置的變化自動更新的【直譯官方文檔】
- MeshInstanceRendererComponent可以理解為原來的Mesh Filter與Mesh Renderer結合體,且大小不受tranform中Scale數值控制
- MoveSpeedComponent也是官方自帶組件,因為ECS主要是面向數據編程,此組件僅僅代表一個運行速度的數據
注意:MeshInstanceRendererComponent中需要Mesh是指定使用哪個網格,對應的Material需要勾選Enable GPU Instancing
創建一個產卵器,生產指定Cube按照規定半徑隨機分布
在hierarchy中創建一個gameObject命名為RotatingCubeSpawner然后添加如下組件,這些組件都是ECS自帶的,這里沒有使用TransformMatrix 組件,因為TransformMatrix 組件需要配合其他組件或系統使用,例如MeshInstanceRenderer,這里RotatingCubeSpawner僅僅是一個產卵觸發,所以不需要。
創建腳本 SpawnRandomCircleComponent ,然后添加到RotatingCubeSpawner上
using System;
using Unity.Entities;
using UnityEngine;
/// <summary>
/// 使用ISharedComponentData可顯著降低內存
/// </summary>
[Serializable]
public struct SpawnRandomCircle : ISharedComponentData//使用ISharedComponentData可顯著降低內存
{
//預制方塊
public GameObject prefab;
public bool spawnLocal;
//生成的半徑
public float radius;
//生成物體個數
public int count;
}
/// <summary>
/// 包含方塊的個數個生成半徑等
/// </summary>
public class SpawnRandomCircleComponent : SharedComponentDataWrapper<SpawnRandomCircle> { }
在傳統模式中,我們能把腳本掛到gameObejc上是因為繼承了MonoBehaviour,但是在Pure ECS版本中,如需要的數據掛在對應的Object上,創建的類需要繼承SharedComponentDataWrapper或ComponentDataWrapper,包含的數據(struct)需要繼承ISharedComponentData或IComponentData。
這里大家可能有疑問了,既然都能創建掛載為什么出現兩個類?使用SharedComponentDataWrapper與ISharedComponentData可顯著降低內存,創建100個cube和一個cube的消耗內存的差異幾乎為零。如使用的數據僅僅是讀取,或很少的改變,且在同Group中(后續示例中有展示),使用SharedComponentData是一個不錯的選擇。
接下來開始編寫Logo模型旋轉所需的額外數據
按照示例顯示,Logo圖標在一個指定的位置以規定的半徑旋轉,在Logo一定范圍的cube會觸發旋轉效果
創建如下數據添加到Object上
旋轉中心點和對應半徑的數據
using System;
using Unity.Entities;
using Unity.Mathematics;
/// <summary>
/// 轉動Logo的中心點和轉動半徑
/// </summary>
[Serializable]
public struct MoveAlongCircle : IComponentData
{
//Logo對應的中心點
public float3 center;
//Logo對應的半徑
public float radius;
//運行時間
//[NonSerialized]
public float t;
}
/// <summary>
/// 轉動Logo的中心點和轉動半徑
/// </summary>
public class MoveAlongCircleComponent : ComponentDataWrapper<MoveAlongCircle> { }
Logo碰撞方塊后給予方塊重置的速度數據
using System;
using Unity.Entities;
/// <summary>
/// Logo碰撞方塊后給予方塊重置的速度
/// </summary>
[Serializable]
public struct RotationSpeedResetSphere : IComponentData
{
//方塊重置的速度
public float speed;
}
/// <summary>
/// 方塊旋轉的速度
/// </summary>
public class RotationSpeedResetSphereComponent : ComponentDataWrapper<RotationSpeedResetSphere> { }
觸發方塊旋轉的半徑數據
using System;
using Unity.Entities;
[Serializable]
public struct Radius : IComponentData
{
//觸發方塊旋轉的半徑
public float radius;
}
/// <summary>
/// 觸發方塊旋轉的半徑
/// </summary>
public class RadiusComponent : ComponentDataWrapper<Radius> { }
話不多說,接下來要讓Logo嗨起來! 哦不對,讓Logo轉起來。。。。
下面是Logo旋轉的全部邏輯代碼,筆者會逐步為大家解析
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Burst;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;
//Logo運動相關邏輯
public class MoveAlongCircleSystem : JobComponentSystem
{
// Logo運動相關邏輯中需要用到的數據
struct MoveAlongCircleGroup
{
//Logo位置
public ComponentDataArray<Position> positions;
//旋轉的中心點和半徑數據
public ComponentDataArray<MoveAlongCircle> moveAlongCircles;
//旋轉速度數據
[ReadOnly] public ComponentDataArray<MoveSpeed> moveSpeeds;
//固定寫法
public readonly int Length;
}
//注入數據 Inject自帶特性
[Inject] private MoveAlongCircleGroup m_MoveAlongCircleGroup;
[BurstCompile]
struct MoveAlongCirclePosition : IJobParallelFor//Logo位置旋轉更新邏輯,可以理解為傳統模式中的Update
{
/// <summary>
/// 位置數據
/// </summary>
public ComponentDataArray<Position> positions;
/// <summary>
/// 中心點及半徑數據
/// </summary>
public ComponentDataArray<MoveAlongCircle> moveAlongCircles;
/// <summary>
/// 運行速度
/// </summary>
[ReadOnly] public ComponentDataArray<MoveSpeed> moveSpeeds;
/// <summary>
/// 運行時間
/// </summary>
public float dt;
/// <summary>
/// 并行執行for循環 i 根據length計算 打印的一直是0
/// </summary>
/// <param name="i"></param>
public void Execute(int i)
{
//Debug.Log(i); //打印的一直是0 雖然可以打印,但是會報錯,希望官方會出針對 ECS 的 Debug.Log
//運行時間
float t = moveAlongCircles[i].t + (dt * moveSpeeds[i].speed);
//位置偏移量
float offsetT = t + (0.01f * i);
float x = moveAlongCircles[i].center.x + (math.cos(offsetT) * moveAlongCircles[i].radius);
float y = moveAlongCircles[i].center.y;
float z = moveAlongCircles[i].center.z + (math.sin(offsetT) * moveAlongCircles[i].radius);
moveAlongCircles[i] = new MoveAlongCircle
{
t = t,
center = moveAlongCircles[i].center,
radius = moveAlongCircles[i].radius
};
//更新Logo的位置
positions[i] = new Position
{
Value = new float3(x, y, z)
};
}
}
//數據初始化
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var moveAlongCirclePositionJob = new MoveAlongCirclePosition();
moveAlongCirclePositionJob.positions = m_MoveAlongCircleGroup.positions;
moveAlongCirclePositionJob.moveAlongCircles = m_MoveAlongCircleGroup.moveAlongCircles;
moveAlongCirclePositionJob.moveSpeeds = m_MoveAlongCircleGroup.moveSpeeds;
moveAlongCirclePositionJob.dt = Time.deltaTime;
return moveAlongCirclePositionJob.Schedule(m_MoveAlongCircleGroup.Length, 64, inputDeps);
}
}
解析一
其中這段code 指的是需要聲明一個Group 【可以理解為傳統模式中組件的集合】,這里含有Logo運動相關邏輯中需要用到的數據,注入m_MoveAlongCircleGroup,可以使在unity運行時unity自動尋找符合此數據集合的物體,然后把對應的數據都注入到m_MoveAlongCircleGroup中。這樣我們也就變相的找到了Logo物體
解析二
struct MoveAlongCirclePosition : IJobParallelFor代碼塊中的Execute,可以理解為傳統模式中的Update,不過是并行執行的。相關邏輯就是計算運行時間、運算位置并賦值。
以為這就完了,并沒有,看下面
解析三
想要把MoveAlongCirclePosition中的變量和我們找到的物體聯系起來,且在Job系統中并行執行就需要JobHandle OnUpdate。他的作用是把我們包裝起來的業務邏輯【就是Execute】放到Job系統執行【多核心并行計算】,并且把找到的物體和MoveAlongCirclePosition中的變量關聯起來。
下面我們要讓產卵器動起來
準備產卵器中預制體
在hierarchy中創建一個gameObject命名為RotatingCube然后添加如下組件
除官方自帶組件外添加額外組件RotationSpeedComponent和RotationAccelerationComponent,分別代表cube實時的旋轉速度和cube速度衰減的加速度
實時的旋轉速度 數據
using System;
using Unity.Entities;
/// <summary>
/// 方塊自身速度
/// </summary>
[Serializable]
public struct RotationSpeed : IComponentData
{
public float Value;
}
public class RotationSpeedComponent : ComponentDataWrapper<RotationSpeed> { }
速度衰減的加速度 數據
using System;
using Unity.Entities;
/// <summary>
/// 方塊的加速度 -1 速度逐漸變慢
/// </summary>
[Serializable]
public struct RotationAcceleration : IComponentData
{
public float speed;
}
public class RotationAccelerationComponent : ComponentDataWrapper<RotationAcceleration> { }
然后把預制體拖拽到指定的產卵器中,設置好數據
產卵Cube全部Code
using System.Collections.Generic;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
//產卵器系統相關邏輯
public class SpawnRandomCircleSystem : ComponentSystem
{
//對應產卵器的組件集合
struct Group
{
//含有產卵所需的 個數、半徑、預制體數據
[ReadOnly] public SharedComponentDataArray<SpawnRandomCircle> Spawner;
//產卵器位置數據
public ComponentDataArray<Position> Position;
//產卵器對應的 GameObject Entity 實體
public EntityArray Entity;
//因為目前產卵器只有一個,所以其 Length 數值為 1
public readonly int Length;
}
//注入組件集合
[Inject] Group m_Group;
protected override void OnUpdate()
{
while (m_Group.Length != 0)
{
var spawner = m_Group.Spawner[0];
var sourceEntity = m_Group.Entity[0];
var center = m_Group.Position[0].Value;
//根據產卵的個數聲明對應個數的 entities 數組
var entities = new NativeArray<Entity>(spawner.count, Allocator.Temp);
//實例化cube
EntityManager.Instantiate(spawner.prefab, entities);
//創建對應的position數組(個數等于cube創建個數)
var positions = new NativeArray<float3>(spawner.count, Allocator.Temp);
if (spawner.spawnLocal)
{
//計算出每一個Cube對應的Position位置 使用 ref 填充
GeneratePoints.RandomPointsOnCircle(new float3(), spawner.radius, ref positions);
//遍歷Position賦值
for (int i = 0; i < spawner.count; i++)
{
var position = new LocalPosition
{
Value = positions[i]
};
//為每一個Entity賦值
EntityManager.SetComponentData(entities[i], position);
//因為選擇的是spawnLocal,所以要為對應的 entity添加 TransformParent(類似于原來的 transform.SetParent)
EntityManager.AddComponentData(entities[i], new TransformParent { Value = sourceEntity });
}
}
else
{
GeneratePoints.RandomPointsOnCircle(center, spawner.radius, ref positions);
for (int i = 0; i < spawner.count; i++)
{
var position = new Position
{
Value = positions[i]
};
EntityManager.SetComponentData(entities[i], position);
}
}
entities.Dispose();
positions.Dispose();
EntityManager.RemoveComponent<SpawnRandomCircle>(sourceEntity);
//實例化 & AddComponent和RemoveComponent調用使注入的組無效,
//所以在我們進入下一個產卵之前我們必須重新注入它們
UpdateInjectedComponentGroups();
}
}
}
解析一
看到 ComponentSystem我們就可以知道里面的主要業務邏輯是基于Hybrid版ECS實現的,還是老套路,聲明組件集合(產卵器),然后注入變量m_Group中
解析二
在這一段代碼塊中我們可以看到,因為Length==1(一個產卵器),所以后進入到while循環中執行對應的業務邏輯,當然在最后Length會為0,后續會提到原因。會根據產卵的個數聲明對應個數的 entities 數組。使用EntityManager.Instantiate實例化Cube,創建對應的position數組(個數等于cube創建個數)。使用EntityManager.Instantiate最明顯的特點是創建的Cube在hierarchy視圖中是沒有的。
解析三
使用GeneratePoints.RandomPointsOnCircle設置對應的隨機位置(工程中有提供)。區分使用Local Position主要是這兩地方,用EntityManager.AddComponentData把對應的父物體數據添加進去,類似于原來的 transform.SetParent。
解析四
這一部分也是使Length的值變為0的關鍵,把無用的數據entities與positions進行釋放。移除對應的產卵器再重新注入。換句話說就是destory產卵器。
然后我們創建一個能讓Cube自轉的sysytem,類似于
自轉系統Code
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Burst;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;
public class RotationSpeedSystem : JobComponentSystem
{
[BurstCompile]
struct RotationSpeedRotation : IJobProcessComponentData<Rotation, RotationSpeed>
{
//Time.deltaTime
public float dt;
public void Execute(ref Rotation rotation, [ReadOnly]ref RotationSpeed speed)
{
rotation.Value = math.mul(math.normalize(rotation.Value), quaternion.axisAngle(math.up(), speed.Value * dt));
}
}
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var job = new RotationSpeedRotation() { dt = Time.deltaTime };
return job.Schedule(this, 64, inputDeps);
}
}
解析一
在自轉系統中我們沒有指定對應的Group(組件系統集合),而且執行的Execute代碼塊所繼承接口IJobParallelFor代替為IJobProcessComponentData,IJobProcessComponentData文檔中的解釋筆者并是不是很理解,但根據測試的結果筆者認為是使用ref關鍵字搜索全部的Rotation組件,然后把自身的RotationSpeed數值賦值進去。因為如果在Logo上添加Rotation與RotationSpeed組件,Logo物體也會進行旋轉(賦值相關代碼下面會有講解)。
觸發Cube旋轉系統
全部Code
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Burst;
using Unity.Mathematics;
using Unity.Transforms;
//在RotationSpeedSystem前運行
[UpdateBefore(typeof(RotationSpeedSystem))]
public class RotationSpeedResetSphereSystem : JobComponentSystem
{
/// <summary>
/// Logo對應的entity group
/// </summary>
struct RotationSpeedResetSphereGroup
{
//Logo給予Cube速度對應的數據
[ReadOnly] public ComponentDataArray<RotationSpeedResetSphere> rotationSpeedResetSpheres;
//Logo對應的旋轉半徑
[ReadOnly] public ComponentDataArray<Radius> spheres;
//Logo對應的位置
[ReadOnly] public ComponentDataArray<Position> positions;
public readonly int Length;
}
//注入Logo組件集合
[Inject] RotationSpeedResetSphereGroup m_RotationSpeedResetSphereGroup;
/// <summary>
/// 方塊的entity group
/// </summary>
struct RotationSpeedGroup
{
//方塊自身的旋轉速度
public ComponentDataArray<RotationSpeed> rotationSpeeds;
//方塊的位置
[ReadOnly] public ComponentDataArray<Position> positions;
//固定寫法 數值等于Cube的個數
public readonly int Length;
}
//注入Cube組件集合
[Inject] RotationSpeedGroup m_RotationSpeedGroup;
[BurstCompile]
struct RotationSpeedResetSphereRotation : IJobParallelFor
{
/// <summary>
/// 方塊的速度
/// </summary>
public ComponentDataArray<RotationSpeed> rotationSpeeds;
/// <summary>
/// 方塊的坐標
/// </summary>
[ReadOnly] public ComponentDataArray<Position> positions;
//下面都是Logo上面的組件
[ReadOnly] public ComponentDataArray<RotationSpeedResetSphere> rotationSpeedResetSpheres;
[ReadOnly] public ComponentDataArray<Radius> spheres;
[ReadOnly] public ComponentDataArray<Position> rotationSpeedResetSpherePositions;
public void Execute(int i)//i 0-9 這個i值取對應 Schedule 中設置的 arrayLength 的數值 此Code中設置的為 m_RotationSpeedGroup.Length
{
//UnityEngine.Debug.Log($"長度{i}");
//方塊的中心點
var center = positions[i].Value;
for (int positionIndex = 0; positionIndex < rotationSpeedResetSpheres.Length; positionIndex++)
{
//計算圓球與方塊的距離 ,小于指定具體傳入速度
if (math.distance(rotationSpeedResetSpherePositions[positionIndex].Value, center) < spheres[positionIndex].radius)
{
rotationSpeeds[i] = new RotationSpeed
{
Value = rotationSpeedResetSpheres[positionIndex].speed
};
}
}
}
}
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var rotationSpeedResetSphereRotationJob = new RotationSpeedResetSphereRotation
{
rotationSpeedResetSpheres = m_RotationSpeedResetSphereGroup.rotationSpeedResetSpheres,
spheres = m_RotationSpeedResetSphereGroup.spheres,
rotationSpeeds = m_RotationSpeedGroup.rotationSpeeds,
rotationSpeedResetSpherePositions = m_RotationSpeedResetSphereGroup.positions,
positions = m_RotationSpeedGroup.positions
};
return rotationSpeedResetSphereRotationJob.Schedule(m_RotationSpeedGroup.Length, 32, inputDeps);
}
}
解析一
用的還是前面的老套路,與以往不同是在RotationSpeedResetSphereSystem上添加的[UpdateBefore(typeof(RotationSpeedSystem))]特性,他負責確保RotationSpeedResetSphereSystem在RotationSpeedSystem前執行,可以理解為手動的控制執行順序
最后一步就是Cube速度衰減系統
全部Code
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Burst;
using Unity.Mathematics;
using UnityEngine;
public class RotationAccelerationSystem : JobComponentSystem
{
[BurstCompile]
struct RotationSpeedAcceleration : IJobProcessComponentData<RotationSpeed, RotationAcceleration>
{
public float dt;
//對Cube自身的RotationSpeed進行衰減處理
public void Execute(ref RotationSpeed speed, [ReadOnly]ref RotationAcceleration acceleration)
{
speed.Value = math.max(0.0f, speed.Value + (acceleration.speed * dt));
}
}
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var rotationSpeedAccelerationJob = new RotationSpeedAcceleration { dt = Time.deltaTime };
return rotationSpeedAccelerationJob.Schedule(this, 64, inputDeps);
}
}
解析一
使用的也是IJobProcessComponentData接口,整體和自旋轉系統基本一致。