Unity Jobsystem 详解实体组件系统ECS

2023-07-30,,

原文摘选自Unity Jobsystem 详解实体组件系统ECS

简介

随着ECS的加入,Unity基本上改变了软件开发方面的大部分方法。ECS的加入预示着OOP方法的结束。随着实体组件系统ECS的到来,我们在Unity开发中曾使用的大量实践方法都必须进行改变以适应ECS,也许不少人需要些时间适应ECS的使用,但是ECS会对游戏性能的提升产生很大作用。

面向对象编程是一个很好的编程模式,OOP非常容易掌握和易于理解,尤其适合初学者。OOP的最大优点是它的可访问性,开发者可以在几乎没有任何相关知识的情况下创建类并维护编写的代码。然而OOP方法带有严重的缺点,它会给总体性能带来不好的影响,因为OOP方法很难避免出现重复的代码,造成一定程度上的性能开销,而且OOP虽然简单,但是却很依赖引用。

面向对象编程是基于特定对象的概念实现的,对象的种类由类的实例定义,它们通过互相交互来构建程序。对象可以包含属性和方法等形式的数据。

ECS实体组件系统的方法和OOP不同,它的数据和行为/方法是明确分离的,这样能大大提高内存使用效率,从而提高性能。

现在对于Unity而言,ECS还处于早期阶段,有很大的发展空间,但是开发者已经可以开始使用ECS。本文将讲解ECS的常见方法,Hybrid ECS和Pure ECS,并且介绍ECS的实现方法、语法以及如何开始使用ECS。

ECS方法

对于ECS,我们会更多谈论实体(Entity),而不是游戏对象。或许你会觉得实体和游戏对象没有太大区别,因为你可以将实体视为组件的容器(Container)。但是,如果你深入研究的话,会发现二者区别很大,实体其实只是特定组件的句柄。

OOP和ECS所提到的“组件”是否相同?不,这二者并不一样。在ECS出现前,我们通常将附加到游戏对象上的MonoBehaviour视为组件。MonoBehaviour包含数据和行为,它的数据来自所有变量,行为来自用于定义和调用游戏对象的函数。ECS是不同的,因为实体和组件都没有任何行为逻辑,它们只包含数据。

所有逻辑都保存在Managers/Systems中,它会获取一组实体,然后根据分组实体所包含的数据来执行所请求的行为。这意味着现在,不是所有实体都会处理自己的行为,而是所有实体行为都会在同一位置进行处理。

要完全理解为什么ECS方法比旧OOP方法的速度更快,你需要了解内存管理的相关知识。在OOP方法中,数据不会被组织起来,而是会分散再整个内存中,这是因为使用了自动内存管理功能。

自动内存管理功能介绍

像C#这种语言,内存的分配和释放过程是通过垃圾收集器自动完成的。该过程开始后,Mono平台会从操作系统请求特定容量的内存,并使用该部分内存来生成代码可使用的堆内存空间。该堆空间随着代码需要使用更多内存而逐渐增大。如果不再需要之前声明的内存,内存会释放回操作系统,堆的大小也会随之减小,这便是垃圾回收器的工作方式。

对于之后的内存分配,如果大小合适的话,垃圾回收器会使用之前用于保存数据并在释放后产生的剩余“间隙”。

因此,将数据从内存移动到缓存需要消耗较多性能,因为必须先找到引用才可以进行移动。

对于内存管理而言,ECS更加优化,因为ECS的数据会根据类型进行保存,而不是根据数据的分配时间。试想一下,一个商店根据商品的上架顺序放置商品,另一个商店根据商品的分类放置商品,你认为哪个商店的做法更好?

ECS性能更高效的另一个原因是,由于数据已明确地分离出来,ECS只会缓存相关数据。这是什么意思呢?

当使用OOP时,无论何时访问游戏对象,即使只需要一个特定属性,都必须缓存该对象的所有属性,该做法会对性能产生很大影响,因为缓存这些类型的话,会导致原生系统和托管系统之间的交互,而这样做就会产生垃圾,导致垃圾回收的发生。

Hybrid ECS

目前想纯粹使用ECS来编写一个完整的游戏还不太现实,因为部分Unity功能还未支持ECS,但是这个原因不会阻止我们使用ECS。

Hybrid ECS允许我们将ECS逻辑结合到现有的项目中,利用ECS的优点,而且不会影响还未支持ECS的功能的使用。

Hybrid ECS会和helper类一同工作,例如:Game Object Entity,它会将游戏对象转换为实体,并将附带的MonoBehaviour转换为组件。

我们编写的C#脚本派生自MonoBehaviour,它只包含数据但没有行为,然后我们将这些MonoBehaviour附加到带有Game Object Entity的游戏对象上。

我们的行为会放到Manager/System中,该类必须派生自ComponentSystem。我们不需要将该类附加到场景中的游戏对象,因为Unity会检测到该类并自动执行它。

在System类中,我们将通过定义它附带的组件来将Entity定义为struct。我们不会使用Update函数,而是使用OnUpdate函数来获取实体,并在实体中进行迭代并执行行为。

ECS Solar System (Hybrid)

我们已经了解到实体组件系统是什么,现在来研究一下实际案例。

如前文所所述,学习使用ECS对一些人而言很难,因为必须改变自己编写程序的方法。希望下面部分内容能够帮助你轻松理解该过程。

我们将使用Hybrid ECS编写一个小型宇宙,因为Hybrid ECS非常容易掌握,希望看完本案例后,你不会对ECS感到恐惧,而是更自信地尝试使用ECS。

项目准备

本文中使用的版本是Unity 2018.2.0.2,首先从资源包管理器获取Entities资源包。打开Window >> PackageManager,找到Entities进行安装。

安装完成后,Console中会出现报错内容,但这是正常现象。我们还要将脚本运行时版本从.Net 3.5改为.Net 4.x,方法是打开Build Settings >> Player Settings,修改脚本运行时版本。

预制件

Hybrid ECS拥有能将MonoBehaviour转换为组件的helper类,我们来仔细研究一下。

首先我们为星球、行星、椭圆形和月球创建大量预制件。

从上图中可以看到,该预制件就像普通预制件一样,没有什么特别之处。预制件上带有Transform、Mesh Filter、Mesh Renderer和Mesh Collider。

该预制件上添加了Game Object Entity组件。该组件不是自定义编写的脚本,而是前面提到的helper类,它将把普通游戏对象转换为实体。

Planet Comonent组件,代码如下:

using UnityEngine;

public class PlanetComponent : MonoBehaviour
{

public float rotationSpeed;
   public float orbitDuration;
   public OrbitalEllipse orbit;
}

Components Job用来保存数据,所以在该函数中没有其它内容。下面列出了所有需要的组件,这些组件就不进行太多讲解。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MoonComponent : MonoBehaviour {

public float movementSpeed;
   public GameObject parentPlanet;
}

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class OrbitalElipseComponent : MonoBehaviour {

public float xExtent;
   public float yExtent;
   public GameObject parent;
}

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class StarComponent : MonoBehaviour {

[Range(0f, 100f)] public float twinkleFrequency;
}

这样组件和预制件就设置好了,是不是非常简单。

HybridECSSolarSystem

在我们通过创建组件来处理所有数据之前,如果仔细阅读前文,就知道现在我们需要处理行为。

我们将添加新C#脚本,命名为HybridECSSolarSystem,我们不会将该脚本附加到任何对象上,实际上它甚至不是MonoBehaviour,它派生自ComponentSystem。

Unity会在内部检测并执行该脚本,请记得添加using Unity.Entities声明。我们将在该类的开始定义Stars、Planets和Moons的struct,前面提到实体是组件分组的句柄,所以Stars类型的实体是附带StarComponent和MeshRenderer组件的所有实体。

或许你还注意到,我们没有Update()函数,而是使用了protected override void OnUpdate(),派生自ComponentSystem的每个类都需要拥有OnUpdate()函数,我们的实体行为将在该函数中执行。

仔细观察OnUpdate()函数中第一个foreach循环会发现,该循环获取了所有Stars类型的实体,然后循环处理每个实体。我们通过检查依赖starComponent.twinkleFrequency实体的Random.Range来获得随机性。

using UnityEngine;
using Unity.Entities;

public class HybridECSSolarSystem : ComponentSystem
{
   struct Stars
   {
       public StarComponent starComponent;
       public MeshRenderer renderer;
   }

struct Planets
   {
       public PlanetComponent planetComponent;
       public Transform transform;
   }

struct Moons
   {
       public Transform transform;
       public MoonComponent moonComponent;
   }

protected override void OnUpdate()
   {
       foreach (var starEntity in GetEntities<Stars>())
       {
           int timeAsInt = (int)Time.time;
           if(Random.Range(1f, 100f) < starEntity.starComponent.twinkleFrequency)
           {
               starEntity.renderer.enabled = timeAsInt % 2 == 0;
           }
       }

foreach (var planetEntity in GetEntities<Planets>())
       {
           
           planetEntity.transform.Rotate(Vector3.up * Time.deltaTime * planetEntity.planetComponent.rotationSpeed, Space.Self);
           
           planetEntity.transform.position = planetEntity.planetComponent.orbit.Evaluate(Time.time / planetEntity.planetComponent.orbitDuration);
       }

foreach (var moonEntity in GetEntities<Moons>())
       {
           Vector3 parentPos = moonEntity.moonComponent.parentPlanet.transform.position;

Vector3 desiredPos = (moonEntity.transform.position - parentPos).normalized * 5f + parentPos;

moonEntity.transform.position = Vector3.MoveTowards(moonEntity.transform.position, desiredPos, moonEntity.moonComponent.movementSpeed);
           moonEntity.transform.RotateAround(moonEntity.moonComponent.parentPlanet.transform.position, Vector3.up, moonEntity.moonComponent.movementSpeed);
       }
   }
}

我们没有通过以前的方法使用Update函数,而是执行了System类中的Update,这就是在使用Hybrid ECS时的区别。

虽然我们已经完成将游戏对象转换为实体所需的操作,但我们还需要一个类来实例化银河系,现在进入下一部分。

HybridECSInstantiator

HybridECSInstantiator类的内容非常简单明了,不必进行过多赘述。我们在实例化整个太阳系的场景中,设置了一组变量用来创建游戏对象。

我们通过使用处理对象位置的onUnitSphere和UniverseRadius,将宇宙的大致结构视为球形。通过计算生成椭圆形,然后使用LineRenderer组件进行绘制,我们便得到了椭圆形轨道。

我们使用了HybridECSSolarSystem.cs类中的Ellipse.Evaluate()函数实现了行星的移动。

对于每个对象,不管是Star还是Planet,我们只是实例化这些对象,设置它们的组件并放置到合适的位置。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class HybridECSInstatiator : MonoBehaviour
{
   [Header("General Settings:")]
   [SerializeField] float universeRadius;

[Header("Sun:")]
   [SerializeField] GameObject sunPrefab;
   [SerializeField] Vector3 sunPosition;

[Header("Moon:")]
   [SerializeField] GameObject moonPrefab;
   [SerializeField] float minMoonMovementSpeed;
   [SerializeField] float maxMoonMovementSpeed;

[Header("Stars:")]
   [SerializeField] GameObject starPrefab;
   [SerializeField] float minStarsize;
   [SerializeField] float maxStarsize;
   [SerializeField] int starsAmount;
   [SerializeField] [Range(0, 100)] float minTwinkleFrequency;
   [SerializeField] [Range(0, 100)] float maxTwinkleFrequency;

[Header("Orbital Elipses:")]
   [SerializeField] int elipseSegments;
   [SerializeField] float elipseWidth;
   [SerializeField] GameObject orbitalElipsePrefab;

[Header("Planets:")]
   [SerializeField] List<Planet> planets = new List<Planet>();

static HybridECSInstatiator instance;
   public static HybridECSInstatiator Instance { get { return instance; } }

GameObject sun;

void Awake()
   {
       instance = this;
       PlaceSun();
       PlaceStars();
       PlacePlanets();
   }

#region Sun
   void PlaceSun()
   {
       sun = Instantiate(sunPrefab, sunPosition, Quaternion.identity);
       GameObject sunParent = new GameObject();

sunParent.name = "Sun";

sun.transform.parent = sunParent.transform;
   }
   #endregion

#region Stars
   void PlaceStars()
   {
       GameObject starParent = new GameObject();
       starParent.name = "Stars";

for (int i = 0; i < starsAmount; i++)
       {
           GameObject currentStar = Instantiate(starPrefab);
           currentStar.transform.parent = starParent.transform;

currentStar.GetComponent<StarComponent>().twinkleFrequency = Random.Range(minTwinkleFrequency, maxTwinkleFrequency);

float randomStarScale = Random.Range(minStarsize, maxStarsize);
           currentStar.transform.localScale = new Vector3(randomStarScale, randomStarScale, randomStarScale);
           currentStar.transform.position = Random.onUnitSphere * universeRadius;
           currentStar.SetActive(true);
       }
   }
   #endregion

#region OrbitalElipses
   void DrawOrbitalElipse(LineRenderer line, OrbitalEllipse ellipse)
   {
       Vector3[] drawPoints = new Vector3[elipseSegments + 1];

for (int i = 0; i < elipseSegments; i++)
       {
           drawPoints[i] = ellipse.Evaluate(i / (elipseSegments - 1f));
       }
       drawPoints[elipseSegments] = drawPoints[0];

line.useWorldSpace = false;
       line.positionCount = elipseSegments + 1;
       line.startWidth = elipseWidth;
       line.SetPositions(drawPoints);
   }

#endregion

#region Planets
   void PlacePlanets()
   {
       GameObject planetParent = new GameObject();
       planetParent.name = "Planets";

for (int i = 0; i < planets.Count; i++)
       {
           GameObject currentPlanet = Instantiate(planets[i].planetPrefab);
           currentPlanet.transform.parent = planetParent.transform;

currentPlanet.GetComponent<PlanetComponent>().rotationSpeed = planets[i].rotationSpeed;
           currentPlanet.GetComponent<PlanetComponent>().orbitDuration = planets[i].orbitDuration;
           currentPlanet.GetComponent<PlanetComponent>().orbit = planets[i].orbit;

GameObject currentElipse = Instantiate(orbitalElipsePrefab, sunPosition, Quaternion.identity);
           currentElipse.transform.parent = sun.transform;
           DrawOrbitalElipse(currentElipse.GetComponent<LineRenderer>(), planets[i].orbit);

if(planets[i].hasMoon)
           {
               GenerateMoon(currentPlanet);
           }
       }
   }
   #endregion

#region Moons
   void GenerateMoon(GameObject planet)
   {
       GameObject moonParent = new GameObject();
       moonParent.name = "Moons";

GameObject currentMoon = Instantiate(moonPrefab);
       currentMoon.transform.parent = moonParent.transform;

currentMoon.GetComponent<MoonComponent>().movementSpeed = Random.Range(minMoonMovementSpeed, maxMoonMovementSpeed);
       currentMoon.GetComponent<MoonComponent>().parentPlanet = planet;
   }
   #endregion
}

[System.Serializable]
public class OrbitalEllipse
{
   public float xExtent;
   public float yExtent;
   public float tilt;

public Vector3 Evaluate(float _t)
   {
       Vector3 up = new Vector3(0, Mathf.Cos(tilt * Mathf.Deg2Rad), -Mathf.Sin(tilt * Mathf.Deg2Rad));

float angle = Mathf.Deg2Rad * 360f * _t;

float x = Mathf.Sin(angle) * xExtent;
       float y = Mathf.Cos(angle) * yExtent;

return up * y + Vector3.right * x;
   }
}
[System.Serializable]
public class Planet
{
   public GameObject planetPrefab;
   public OrbitalEllipse orbit;

public bool hasMoon;

[Header("Movement Settings:")]
   public float rotationSpeed;
   public float orbitDuration;
}

这样一个小型的宇宙便实现了!

小结

实体组件系统ECS就为大家介绍到这里,希望广大开发者能够熟练掌握并且运用在未来的开发中。更多Unity最新功能介绍尽在Unity官方中文论坛(UnityChina.cn)!

安装完成后,Console中会出现报错内容,但这是正常现象。我们还要将脚本运行时版本从.Net 3.5改为.Net 4.x,方法是打开Build Settings >> Player Settings,修改脚本运行时版本。

Unity Jobsystem 详解实体组件系统ECS的相关教程结束。

《Unity Jobsystem 详解实体组件系统ECS.doc》

下载本文的Word格式文档,以方便收藏与打印。