Unity DOTS 使开发人员能够充分利用现代处理器的潜力并交付高度优化的高效游戏 - 我们认为它值得关注。
自 Unity 首次宣布开发面向数据的技术堆栈 (DOTS) 以来,已经过去五年多了。现在,随着长期支持(LTS)版本Unity 2023.3.0f1的发布,我们终于看到了正式版本。但为什么 Unity DOTS 对游戏开发行业如此重要?这项技术有哪些优势?
大家好!我叫 Denis Kondratev,是 MY.GAMES 的Unity开发人员。如果您一直渴望了解 Unity DOTS 是什么以及它是否值得探索,那么这是深入研究这个令人着迷的主题的绝佳机会,在本文中 – 我们将这样做。
DOTS 的核心实现了实体组件系统 (ECS) 架构模式。为了简化这个概念,让我们这样描述它:ECS 构建在三个基本元素之上:实体、组件和系统。
实体本身缺乏任何固有的功能或描述。相反,它们充当各种组件的容器,这赋予它们游戏逻辑、对象渲染、声音效果等的特定特征。
反过来,组件有不同的类型,它们只是存储数据,没有自己独立的处理能力。
完成 ECS 框架的是Systems ,它处理组件、处理实体创建和销毁以及管理组件的添加或删除。
例如,当创建“太空射击”游戏时,游乐场将具有多个对象:玩家的宇宙飞船、敌人、小行星、战利品,凡是你能想到的。
所有这些对象本身都被视为实体,没有任何明显的特征。然而,通过为它们分配不同的组件,我们可以赋予它们独特的属性。
为了进行演示,考虑到所有这些对象都在游戏场上拥有位置,我们可以创建一个保存对象坐标的位置组件。此外,对于玩家的飞船、敌人和小行星,我们可以纳入健康成分;负责处理对象碰撞的系统将管理这些实体的健康状况。此外,我们可以将敌人类型组件附加到敌人上,使敌人控制系统能够根据分配的类型来控制他们的行为。
虽然这种解释提供了一个简单、基本的概述,但现实要复杂一些。尽管如此,我相信 ECS 的基本概念是明确的。抛开这个问题,让我们深入研究一下这种方法的优点。
实体组件系统 (ECS) 方法的主要优点之一是它所提倡的架构设计。面向对象编程(OOP) 带有继承和封装等模式的重要遗产,即使是经验丰富的程序员也可能在开发过程中犯架构错误,导致长期项目中的重构或逻辑混乱。
相比之下,ECS 提供了简单直观的架构。一切都自然地分为独立的组件和系统,使得使用这种方法更容易理解和开发;即使是新手开发人员也能以最少的错误快速掌握这种方法。
ECS 采用复合方法,创建独立的组件和行为系统,而不是复杂的继承层次结构。这些组件和系统可以轻松添加或删除,从而允许灵活更改实体特征和行为——这种方法极大地增强了代码的可重用性。
ECS 的另一个关键优势是性能优化。在 ECS 中,数据以连续且优化的方式存储在内存中,相同的数据类型彼此靠近放置。这可以优化数据访问、减少缓存未命中并改进内存访问模式。此外,由单独数据块组成的系统更容易跨不同进程并行化,从而与传统方法相比获得卓越的性能提升。
Unity DOTS 包含 Unity Technologies 提供的一组技术,这些技术在 Unity 中实现 ECS 概念。它包括多个旨在增强游戏开发不同方面的软件包;现在让我们介绍其中的一些内容。
DOTS 的核心是Entities包,它有助于从熟悉的 MonoBehaviours 和 GameObjects 过渡到实体组件系统方法。该包构成了基于 DOTS 的开发的基础。
Unity 物理包引入了一种处理游戏中物理现象的新方法,通过并行计算实现了惊人的速度。
此外, HavokPhysics for Unity包允许与现代 Havok 物理引擎集成。该引擎提供高性能碰撞检测和物理模拟,为《塞尔达传说:荒野之息》、《毁灭战士:永恒》、《死亡搁浅》、《真人快打 11》等热门游戏提供支持。
实体图形包专注于 DOTS 渲染。它可以高效地收集渲染数据,并与通用渲染管道 (URP) 或高清渲染管道 (HDRP) 等现有渲染管道无缝协作。
另外,Unity 还一直在积极开发一种名为 Netcode 的网络技术。它包括用于低级多人游戏开发的 Unity Transport、用于传统方法的 Netcode for GameObjects 以及值得注意的Unity Netcode for Entities包(符合 DOTS 原则)等包。这些软件包相对较新,并将在未来继续发展。
与 DOTS 密切相关的多种技术可以在 DOTS 框架内外使用。作业系统包提供了一种编写并行计算代码的便捷方法。它围绕将工作划分为称为作业的小块,这些小块对自己的数据执行计算。作业系统将这些作业均匀地分布在线程之间,以实现高效执行。
为了保证代码安全,作业系统支持blittable数据类型的处理。 Blittable 数据类型在托管和非托管内存中具有相同的表示形式,并且在托管和非托管代码之间传递时不需要转换。 blittable 类型的示例包括 byte、sbyte、short、ushort、int、uint、long、ulong、float、double、IntPtr 和 UIntPtr。 blittable 基元类型的一维数组和只包含 blittable 类型的结构也被认为是 blittable。
但是,包含 blittable 类型变量数组的类型本身不被视为可 blittable。为了解决这个限制,Unity开发了Collections包,它提供了一组用于作业的非托管数据结构。这些集合是结构化的,并使用 Unity 机制将数据存储在非托管内存中。开发人员有责任使用 Disposal() 方法释放这些集合。
另一个重要的软件包是Burst Compiler ,它可以与作业系统一起使用来生成高度优化的代码。尽管 Burst 编译器存在某些代码使用限制,但它提供了显着的性能提升。
如前所述,Job System 和 Burst Compiler 不是 DOTS 的直接组件,但在编程高效且快速的并行计算方面提供了宝贵的帮助。让我们用一个实际的例子来测试他们的能力:实施
private void SimulateStep() { Profiler.BeginSample(nameof(SimulateStep)); for (var i = 0; i < width; i++) { for (var j = 0; j < height; j++) { var aliveNeighbours = CountAliveNeighbours(i, j); var index = i * height + j; var isAlive = aliveNeighbours switch { 2 => _cellStates[index], 3 => true, _ => false }; _tempResults[index] = isAlive; } } _tempResults.CopyTo(_cellStates); Profiler.EndSample(); } private int CountAliveNeighbours(int x, int y) { var count = 0; for (var i = x - 1; i <= x + 1; i++) { if (i < 0 || i >= width) continue; for (var j = y - 1; j <= y + 1; j++) { if (j < 0 || j >= height) continue; if (_cellStates[i * width + j]) { count++; } } } return count; }
我已向 Profiler 添加了标记来测量计算所需的时间。单元格的状态存储在名为_cellStates的一维数组中。我们首先将临时结果写入_tempResults ,然后在完成计算后将它们复制回_cellStates 。这种做法是必要的,因为将最终结果直接写入_cellStates会影响后续的计算。
我创建了一个 1000x1000 个单元格的区域并运行该程序来测量性能。结果如下:
从结果来看,计算耗时380ms。
现在让我们应用Job System 和Burst Compiler 来提高性能。首先,我们将创建负责执行康威生命游戏算法的作业。
public struct SimulationJob : IJobParallelFor { public int Width; public int Height; [ReadOnly] public NativeArray<bool> CellStates; [WriteOnly] public NativeArray<bool> TempResults; public void Execute(int index) { var i = index / Height; var j = index % Height; var aliveNeighbours = CountAliveNeighbours(i, j); var isAlive = aliveNeighbours switch { 2 => CellStates[index], 3 => true, _ => false }; TempResults[index] = isAlive; } private int CountAliveNeighbours(int x, int y) { var count = 0; for (var i = x - 1; i <= x + 1; i++) { if (i < 0 || i >= Width) continue; for (var j = y - 1; j <= y + 1; j++) { if (j < 0 || j >= Height) continue; if (CellStates[i * Width + j]) { count++; } } } return count; } }
我已将[ReadOnly]属性分配给CellStates字段,允许从任何线程不受限制地访问数组的所有值。但是,对于具有[WriteOnly]属性的TempResults字段,只能通过Execute(int index)方法中指定的索引进行写入。尝试将值写入不同的索引将生成警告。这保证了在多线程模式下工作时的数据安全。
现在,从常规代码中,让我们启动我们的作业:
private void SimulateStepWithJob() { Profiler.BeginSample(nameof(SimulateStepWithJob)); var job = new SimulationJob { Width = width, Height = height, CellStates = _cellStates, TempResults = _tempResults }; var jobHandler = job.Schedule(width * height, 4); jobHandler.Complete(); job.TempResults.CopyTo(_cellStates); Profiler.EndSample(); }
复制所有必要的数据后,我们使用Schedule()方法安排作业的执行。需要注意的是,这种调度不会立即执行计算:这些操作是从主线程发起的,并且执行是通过分布在不同线程之间的工作程序进行的。为了等待作业完成,我们使用jobHandler.Complete() 。只有这样我们才能将获得的结果复制回_cellStates 。
我们来测量一下速度:
执行速度几乎提高了十倍,执行时间现在约为 42 毫秒。在 Profiler 窗口中,我们可以看到工作负载分布在 17 个工作人员之间。这个数字略小于测试环境中的处理器线程数,测试环境是具有 10 个核心和 20 个线程的 Intel® Core™ i9-10900。虽然结果可能因内核较少的处理器而异,但我们可以确保充分利用处理器的能力。
但这还不是全部 - 现在是使用 Burst Compiler 的时候了,它提供了重要的代码优化,但有一定的限制。要启用 Burst Compiler,只需将[BurstCompile]属性添加到SimulationJob即可。
[BurstCompile] public struct SimulationJob : IJobParallelFor { ... }
我们再测量一下:
结果甚至超出了最乐观的预期:速度比最初的结果提高了近 200 倍。现在,100万个单元的计算时间不超过2毫秒。在 Profiler 中,由 Burst Compiler 编译的代码执行的部分以绿色突出显示。
虽然多线程计算的使用可能并不总是必要的,并且 Burst Compiler 的使用也可能并不总是可能的,但我们可以观察到处理器向多核架构发展的共同趋势。这意味着我们应该准备好充分利用他们的力量。 ECS,特别是 Unity DOTS,与此范例完美契合。
我相信 Unity DOTS 至少值得关注。虽然 ECS 可能不是适合所有情况的最佳解决方案,但它可以在许多游戏中证明其价值。
Unity DOTS 框架以其面向数据和多线程的方法,为优化 Unity 游戏的性能提供了巨大的潜力。通过采用实体组件系统架构并利用作业系统和突发编译器等技术,开发人员可以将性能和可扩展性提升到新的水平。
随着游戏开发的不断发展和硬件的进步,采用 Unity DOTS 变得越来越有价值。它使开发人员能够充分利用现代处理器的潜力并提供高度优化和高效的游戏。虽然 Unity DOTS 可能不是每个项目的理想解决方案,但它无疑为那些寻求性能驱动的开发和可扩展性的人带来了巨大的希望。
Unity DOTS 是一个功能强大的框架,可以通过增强性能、实现并行计算和拥抱多核处理的未来,为游戏开发人员带来显着的好处。值得探索和考虑采用它来充分利用现代硬件并优化 Unity 游戏的性能。