Unity DOTS-Subscene 介绍

为什么要学 DOTS ?

DOTS 代表了 Unity 的未来。Unity 的架构正在朝着全面采用 DOTS 的方向发展。面向数据的设计(DoD)堪称是实时3D 行业的未来。利用 DoD 原理和实现来解决问题并开发复杂的解决方案已成为大势所趋,尤其是在游戏领域。
例如,瑞典游戏工作室 Far North Entertainment 就使用了 DOTS 来解决他们的性能问题,相关说明见 “Creating a third-person zombie shooter with DOTS”(使用DOTS创建第三人称僵尸扫荡游戏)。
还有国内公司海彼游戏研运的《蛋壳特攻队》,就是使用的 Unity DOTS 技术,相关技术介绍见《弹壳特攻队》开发技术分享
据称使用了 DoD 的非 Unity 游戏示例包括:暴雪的《守望先锋》和 CD Projekt 的《巫师3》。

DOTS 介绍

DOTS 是 Data-Oriented Tech Stack 的缩写,官方翻译为多线程式数据导向型技术堆栈。它可以利用多核处理器来实现数据的并行处理并提高 Unity 项目的性能。
目前,DOTS 可与不同的格式共存,并与 Unity MonoBehaviours(非 DOTS 结构)一起使用。最终,Unity 将完全过渡到 DOTS。
DOTS 包含以下元素:

  1. 实体组件系统(ECS) - 提供使用面向数据的方法进行编码的框架。它通过 Entities 软件包进行分发,您可以通过 Package Manager 来添加编辑器。在 1.0 版本中,分成了两个软件包 Entities 和 Entities Graphics。
  2. C# 作业系统(Job System) - 提供一种生成多线程代码的简单方法。它通过Jobs软件包进行分发。
  3. Burst 编译器 - 可生成快速、优化的本机代码。它通过Burst软件包进行分发,可通过 Package Manager 在编辑器中使用。
  4. 本机容器 - 属于 ECS 数据结构,可提供对内存的控制。

unity_dots_archite

烘焙(Baking)

通过烘焙转换数据

烘焙是将 Unity 编辑器中的游戏对象数据(Authoring data,即创作数据)转换成写入实体场景的实体(Runtime data,即运行时数据)的过程。烘焙是一个不可逆的过程,它将一组性能密集型但灵活的游戏对象转变为一组针对性能进行优化的实体和组件。
创作数据(Authoring data):是在编辑应用程序期间创建的任何数据,例如脚本、资产或任何其他游戏相关数据。这种数据类型灵活且可读:专为人类交互而设计。
运行时数据(Runtime data ):是 ECS 在运行时处理的数据,例如进入 Play 模式时处理的数据。此数据类型针对性能和存储效率进行了优化:专为计算机处理而设计。

unity_dots_flow1
unity_dots_flow2

烘焙过程

烘焙仅发生在编辑器中,而不是在游戏中,就像资产导入一样。烘焙仅在编辑器中进行,因为每次需要时处理数据的 GameObject 表示及其烘焙代码需要大量时间和处理能力。此过程意味着如果 Unity 在游戏中执行烘焙,应用程序的性能将会降低。
每当创作场景中的创作数据发生变化时,就会触发烘焙过程。 Unity 如何烘焙数据取决于您是否将创作场景作为子场景打开。
如果相应的创作场景的子场景打开,则会触发实时烘焙。实时烘焙是指在您处理创作数据时,Unity 将创作数据烘焙到 ECS 数据中。根据 Unity 需要处理的创作数据量,它要么增量执行烘焙过程,要么执行数据的完整烘焙:

  • 完整烘焙(Full baking):Unity 处理整个场景并对其进行烘焙。
  • 增量烘焙(Incremental baking):Unity只烘焙修改过的数据。
    如果相应创作场景的子场景关闭,则 Unity 将在后台执行异步烘焙。它在创作场景中执行数据的完整烘焙。

unity_dots_baking1
unity_dots_baking2

烘焙阶段

烘焙分成三个阶段:

  1. 实体创建:在 Baker 运行之前,Unity 会为子场景中的每个创作游戏对象创建一个实体。在此阶段,除了一些内部元数据之外,实体没有任何组件。
  2. 烘焙器(Baker)阶段:Unity 创建实体后,它会运行烘焙器。每个烘焙器处理特定的创作组件类型,并且多个烘焙器可以使用相同的创作组件类型。Unity 的 ECS 提供了一些默认的烘焙器。比如用于渲染的渲染烘焙器、用于物理的刚体烘焙器等。
  3. 烘焙系统阶段:所有烘焙器运行完毕后,Unity 运行烘焙系统。烘焙系统是具有 BakingSystem 属性的 ECS 系统,用于指定它们只能在烘焙过程中运行。

代码示例

简单的烘培示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//这个 RotationSpeedAuthoring 类必须遵循 MonoBehaviour 约定
//并且应该保存在名为 RotationSpeedAuthoring.cs的文件中
public class RotationSpeedAuthoring : MonoBehaviour
{
public float DegreesPerSecond;
}

public struct RotationSpeed : IComponentData
{
public float RadiansPerSecond;
}

public class SimpleBaker : Baker<RotationSpeedAuthoring>
{
public override void Bake(RotationSpeedAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.Dynamic);
AddComponent(entity, new RotationSpeed
{
RadiansPerSecond = math.radians(authoring.DegreesPerSecond)
});
}
}

为了保持增量烘焙工作,需要跟踪烘焙游戏对象时使用了哪些数据。 Unity 会自动跟踪创作组件中的任何字段,如果任何数据发生变化,面包师就会重新运行。
但是,Unity 不会自动跟踪其他来源的数据,例如创作组件或资产。你需要向烘焙器添加依赖项,以便它可以跟踪此类数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public struct DependentData : IComponentData
{
public float Distance;
public int VertexCount;
}

public class DependentDataAuthoring : MonoBehaviour
{
public GameObject Other;
public Mesh Mesh;
}

public class GetComponentBaker : Baker<DependentDataAuthoring>
{
public override void Bake(DependentDataAuthoring authoring)
{
// 在任何提前退出之前,声明对外部引用的依赖。
// 因为即使这些值为 null,它们仍然可能是对缺失对象的适当 Unity 引用。
// 依赖项确保在恢复这些对象时将触发烘焙器。

DependsOn(authoring.Other);
DependsOn(authoring.Mesh);

if (authoring.Other == null) return;
if (authoring.Mesh == null) return;

var transform = GetComponent<Transform>();
var transformOther = GetComponent<Transform>(authoring.Other);

if (transform == null) return;
if (transformOther == null) return;

var entity = GetEntity(TransformUsageFlags.Dynamic);
AddComponent(entity, new DependentData
{
Distance = Vector3.Distance(transform.position, transformOther.position),
VertexCount = authoring.Mesh.vertexCount
});
}
}

Subscene

为什么需要 Subscene ?

由于某些项目包含大量数据,因此如果数据包含在一个创作场景中,Unity 编辑器可能很难处理所有数据。 ECS 可以有效地处理数百万个实体,但在其游戏对象表示中,它们可能会导致编辑器停止运行。因此,将创作数据放入几个较小的创作场景中会更有效。
这样有以下两点优势:

  1. 便于多人协作。
  2. 方便全局预览,不会导致 Unity 编辑器卡死。

场景概览

在实体组件系统(ECS)中,场景的工作方式有所不同。这是因为 Unity 的核心场景系统与 ECS 不兼容。在 ECS 中,场景被分成以下三种类型:

  1. 创作场景(Authoring scenes):创作场景是一种可以像任何其他场景一样打开和编辑的场景,但设计用于烘焙处理。它包含 Unity 在运行时将其转换为 ECS 数据的 GameObjects 和 MonoBehaviour 组件。
  2. 实体场景(Entity scenes):实体场景包含烘焙过程产生的 ECS 数据。
  3. 子场景(Subscenes):子场景是对创作或实体场景的引用。在 Unity 编辑器中,您创建一个子场景以添加创作元素。当子场景关闭时,会触发相关实体场景的烘焙过程。
    子场景和实体场景经常相互混淆。但子场景只不过是一个附着点,用于方便地加载实体场景。
    子场景是一个 GameObject 组件,允许将场景加载为其 GameObject 创作表示(以对其进行操作)或其 ECS 表示(只读,但高性能)。

子场景介绍

实体组件系统 (ECS) 使用子场景而不是场景来管理应用程序的内容。这是因为 Unity 的核心场景系统与 ECS 不兼容。你可以将 GameObjects 和 MonoBehaviour 组件添加到子场景中,然后烘焙将 GameObjects 和 MonoBehaviour 组件转换为实体和 ECS 组件。还可以选择创建自己的烘培器以将 ECS 组件附加到转换后的实体。

unity_subscence1
unity_subscence2
unity_subscence3

当子场景打开时,会发生以下情况:

  • 在“层次结构”窗口中,Unity 显示具有 SubScene 组件的游戏对象下的子场景中的所有创作游戏对象。
  • 场景视图根据首选项窗口的实体部分中的场景视图模式设置显示运行时数据(实体)或创作数据(游戏对象)。
  • 初始烘焙过程在子场景中的所有创作组件上运行。
  • 对创作组件所做的任何更改都会触发增量烘焙过程。
    当子场景关闭时,Unity 会流式载入烘焙场景的内容。当您进入“播放”模式时,关闭的子场景中的实体需要几帧才能变得可用。在构建中,子场景的行为与编辑器中关闭的子场景相同,因此它们的实体不能立即可用。

注意:Unity 不会流式载入打开的子场景的内容。当您进入播放模式时,打开的子场景中的实体立即可用。

unity_dots_entity1
unity_dots_entity2

场景的流式载入(Scene streaming)

加载大场景可能需要几帧。为了避免卡顿,实体中的所有场景加载都是异步的。这称为流式载入。
流式载入的主要优点是:

  • 当 Unity 在后台载入场景时,应用程序可以保持响应。
  • Unity 可以在大于内存容量的无缝世界中动态加载和卸载场景,而不会中断游戏玩法。
  • 在播放模式下,如果实体场景文件丢失或过时,Unity 会按需转换场景。由于实体场景的烘焙和加载是异步发生的并且在单独的进程中,因此编辑器保持响应。
    流式载入的主要缺点是:
  • 应用程序不能假设场景数据存在,特别是在启动时。这可能会使您的代码变得更加复杂。
  • 系统从场景系统组加载场景,场景系统组是初始化组的一部分。在该帧中更新较晚的系统会在同一帧中接收加载的数据,但早于该组更新的系统直到下一帧才会接收加载的数据。您的代码可能需要考虑到这一点。

加载场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Runtime component, SceneSystem uses EntitySceneReference to identify scenes.
public struct SceneLoader : IComponentData
{
public EntitySceneReference SceneReference;
}

#if UNITY_EDITOR// Authoring component, a SceneAsset can only be used in the Editor
public class SceneLoaderAuthoring : MonoBehaviour
{
public UnityEditor.SceneAsset Scene;

class Baker : Baker<SceneLoaderAuthoring>
{
public override void Bake(SceneLoaderAuthoring authoring)
{
var reference = new EntitySceneReference(authoring.Scene);
var entity = GetEntity(TransformUsageFlags.None);
AddComponent(entity, new SceneLoader
{
SceneReference = reference
});
}
}
}
#endif

[RequireMatchingQueriesForUpdate]
public partial class SceneLoaderSystem : SystemBase
{
private EntityQuery newRequests;

protected override void OnCreate()
{
newRequests = GetEntityQuery(typeof(SceneLoader));
}

protected override void OnUpdate()
{
var requests = newRequests.ToComponentDataArray<SceneLoader>(Allocator.Temp);

// Can't use a foreach with a query as SceneSystem.LoadSceneAsync does structural changes
for (int i = 0; i < requests.Length; i += 1)
{
SceneSystem.LoadSceneAsync(World.Unmanaged, requests[i].SceneReference);
}

requests.Dispose();
EntityManager.DestroyEntity(newRequests);
}
}

使用 EntitySceneReference 来在烘焙过程中保留对场景的引用并在运行时加载它们。在调用 SceneSystem.LoadSceneAsync 期间,仅创建场景实体。 Unity 使用此实体在内部控制加载过程的其余部分。
在此调用期间不会加载场景头文件(scene header)、切片实体(section entities)及其内容,并且它们会在几帧后准备就绪。
SceneSystem.LoadSceneAsync 会引起结构更改。这些结构变化阻止我们在 foreach 中使用查询调用此函数。

卸载场景

1
2
var unloadParameters = SceneSystem.UnloadParameters.DestroyMetaEntities;
SceneSystem.UnloadScene(World.Unmanaged, sceneEntity, unloadParameters);

默认情况下, SceneSystem.UnloadScene 仅卸载切片(sections )的内容,但保留场景和切片的元实体(meta entities)。如果稍后要再次加载场景,这非常有用,因为准备好这些元实体可以加快场景的加载速度。
要卸载内容并删除元实体, 调用 SceneSystem.UnloadScene 时请使用 UnloadParameters.DestroyMetaEntities 参数。

场景切片(Scene Section)

Unity 将场景中的所有实体分组为切片,默认为切片 0。场景中的每个实体都有一个 SceneSection 共享组件,指示该实体属于哪个切片。SceneSection 包含场景的 GUID(Hash128 )和切片编号(整数)。
切片索引不需要是连续的,但默认切片 0 始终存在,即使它是空的。例如,您可以有默认切片 0 和索引为 123 的切片。在编辑器中,场景切片仅在子场景关闭时应用。打开的子场景包含第 0 切片中的所有实体。
unity_dots_section

有两种方式可以将实体分配到指定的切片:

  1. 使用创作组件 SceneSectionComponent 。此创作组件会影响其所在的创作游戏对象及其所有子对象(递归地)。
  2. 编写自定义烘焙系统来直接设置 SceneSection 值。你无法在 Baker 中为 SceneSection 分配值。

场景和切片的元实体

烘焙创作场景会生成实体场景文件。每个实体场景文件的标头包含:

  • 切片列表,其中包含文件名、文件大小和边界体积等数据。
  • AssetBundle 依赖项 (GUID) 列表。
  • 可选的自定义元数据。
    切片和 Bundles 的列表决定了 Unity 在加载场景时需要加载的文件列表。您可以选择将自定义元数据用于特定于游戏的目的。例如,您可以将 PVS(潜在可见集)信息存储为自定义元数据,以决定何时流式传输场景,或者可以存储某些条件以决定何时加载场景。
    加载实体场景分两步进行:
  1. 解析阶段加载标头并为每个场景和每个切片创建一个元实体。
  2. Unity 加载各切片的内容。

场景实例化

要实例化世界上某个位置的场景,可以执行以下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var loadParameters = new SceneSystem.LoadParameters()
{ Flags = SceneLoadFlags.NewInstance };
var sceneEntity = SceneSystem.LoadSceneAsync(state.WorldUnmanaged,
sceneReference, loadParameters);

var ecb = new EntityCommandBuffer(Allocator.Persistent,
PlaybackPolicy.MultiPlayback);
var postLoadEntity = ecb.CreateEntity();
var postLoadOffset = new PostLoadOffset
{
Offset = sceneOffset
};
ecb.AddComponent(postLoadEntity, postLoadOffset);

var postLoadCommandBuffer = new PostLoadCommandBuffer()
{
CommandBuffer = ecb
};
state.EntityManager.AddComponentData(sceneEntity, postLoadCommandBuffer);

上面的代码使用名为 PostLoadOffset 的组件来存储要应用于实例的偏移量。

1
2
3
4
public struct PostLoadOffset : IComponentData
{
public float3 Offset;
}

最后,使用自定义系统来应用变换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[UpdateInGroup(typeof(ProcessAfterLoadGroup))]
public partial struct PostprocessSystem : ISystem
{
private EntityQuery offsetQuery;

public void OnCreate(ref SystemState state)
{
offsetQuery = new EntityQueryBuilder(Allocator.Temp)
.WithAll<PostLoadOffset>()
.Build(ref state);
state.RequireForUpdate(offsetQuery);
}

public void OnUpdate(ref SystemState state)
{
// Query the instance information from the entity created in the EntityCommandBuffer.
var offsets = offsetQuery.ToComponentDataArray<PostLoadOffset>(Allocator.Temp);
foreach (var offset in offsets)
{
// Use that information to apply the transforms to the entities in the instance.
foreach (var transform in SystemAPI.Query<RefRW<LocalTransform>>())
{
transform.ValueRW.Position += offset.Offset;
}
}
state.EntityManager.DestroyEntity(offsetQuery);
}
}

学习资料

  1. 官方 ECS 案例 GitHub 仓库
  2. 官方文档翻译后的思维导图,如下:
    unity-dots-mindmap

参考资料

  • Entities 1.0.14
  • DOTS 指南
  • Converting Scene Data to DOTS
  • Unity DOTS编码实践:SubScene