实体包中的脚本
本节包含有关在实体中编写脚本时的最佳实践的信息,以及您可以在脚本中使用的一些功能。
标题 |
描述 |
使用方面组织代码 |
使用方面将实体组件的子集组合到单个 csharp 结构中。 |
Blob 资产 |
有关 blob 资产的信息,这些资产是针对流式处理优化的二进制数据片段。 |
在运行时加载场景 |
关于实体如何加载场景的信息。 |
转换系统 |
有关转换如何在实体中工作的信息。 |
使用 Baking 转换数据
Baking 提供了一个系统,用于将编辑器中的游戏对象数据(创作数据)转换为写入实体场景的实体(运行时数据)。
烘焙分为多个阶段,但其核心是两个关键步骤:面包师和烘焙系统。
当您打开子场景并在其中编辑创作对象时,也会发生增量烘焙。 ECS 会检测您所做的更改,并确定由于此更改而需要重新运行的最少 Baker 数量。其结果在编辑模式和播放模式期间被修补到编辑器中的实体世界中。
Baker 类
使用 Baker 类直接与 Unity 对象交互,例如创作组件。 Baker 也是隐式或显式捕获依赖项的地方,如果 Baker 重新运行,添加的所有组件都能够自动恢复。 Baker 只能将组件添加到它正在烘焙的主要实体和它自己创建的其他实体。例如:
访问 Baker 中的其他数据源
为了保持增量烘焙正常工作,您需要跟踪哪些数据用于转换 Baker 中的游戏对象。创作组件中的任何字段都会被自动跟踪,如果任何数据发生变化,Baker 就会重新运行。
不会自动跟踪来自其他创作组件的信息,您需要向其添加依赖项才能对其进行跟踪。为此,请使用 Baker 提供的函数来访问其他组件,而不是 GameObject 提供的函数:
同样,如果您访问资产中的数据,则需要为其创建依赖项,以便 Baker 在资产更改时重新运行。
贝克斯的预制件
要声明和转换预制件,请在面包师中调用 GetEntity:
GetEntity 返回用于创建实体预制件的实体,但此时尚未转换。这稍后会在单独的传递中发生。
烘焙系统
烘焙系统是处理面包师产生的输出的常规系统,例如通过组合结果。这意味着 Baking 系统应该只处理实体数据,而不处理托管的创作类型,例如游戏对象和组件。这也意味着 Baking 系统可以使用 Burst 和 Jobs 来处理数据。
要创建烘焙系统,请使用 [WorldSystemFilter(WorldSystemFilterFlags.BakingSystem)] 属性对其进行标记。这允许烘焙发现它们并将它们添加到烘焙世界。烘焙系统在每次烘焙过程中都会更新。
虽然面包师通常是必需的,但烘焙系统是可选的,只有高级用例才需要。
用方面组织你的代码
方面是一种类似于对象的包装器,可用于将实体组件的子集组合到单个 csharp 结构中。
本节说明如何在项目中使用方面。
主题 |
描述 |
方面概念 |
方面概述。 |
创建方面 |
如何使用 IAspect 接口创建方面。 |
方面概念
方面是一种类似于对象的包装器,可用于将实体组件的子集组合到单个 csharp 结构中。方面对于组织组件代码和简化系统中的查询很有用。
例如,TransformAspect 将组件的各个位置、旋转和缩放组合在一起,使您能够从包含 TransformAspect 的查询访问这些组件。您还可以使用 IAspect 接口定义自己的方面。
方面可以包括以下项目:
- 用于存储实体 ID 的单个实体字段
- RefRW 和 RefRO 字段访问实现 IComponentData 的类型 T 的组件数据。
- EnabledRefRW 和 EnabledRefRO 字段,用于访问实现 IEnableableComponent 的组件的启用状态。
- DynamicBuffer 字段
- 其他方面类型
更多信息
- 创建一个方面
- IAspect API 文档
- 转换方面 API 文档
创建一个方面
要创建方面,请使用 IAspect 接口。您必须将方面声明为只读部分结构,并且该结构必须将自身指定给 IAspect 泛型参数:
字段
您可以使用 RefRW 或 RefRO 将组件声明为方面的一部分。要声明缓冲区,请使用 DynamicBuffer。有关可用字段的更多信息,请参阅 IAspect 文档。
只读和读写访问
使用 RefRO 和 RefRW 字段提供对方面中组件的只读或读写访问。当你想在代码中引用一个方面时,使用 in 来覆盖所有引用变为只读,或者使用 ref 来尊重在方面中声明的只读或读写访问。
如果您使用 in 来引用对组件具有读写访问权限的方面,它可能会在写入尝试时抛出异常。
在系统中创建方面实例
要在系统中创建方面实例,请调用 SystemAPI.GetAspectRW 或 SystemAPI.GetAspectRO:
如果您使用任何试图修改底层组件的方法或属性,则 SystemAPI.GetAspectRO 会引发错误。
要在系统外部创建方面实例,请使用 EntityManager.GetAspect 或 EntityManager.GetAspectRO。
例子
在此示例中,CannonBallAspect 设置坦克主题游戏中炮弹组件的变换、位置和速度。
要在其他代码中使用此方面,您可以以与组件相同的方式请求 CannonBallAspect:
实体中的转换
本节包含有关变换如何在实体中工作以及如何控制项目中任何实体的世界空间位置、旋转和缩放的信息。
主题 |
描述 |
转换概念 |
转换如何在实体中工作。 |
使用转换 |
如何在您的项目中使用转换。 |
转换方面 |
如何使用 TransformAspect 来管理项目中的转换。 |
转变观念
您可以使用 Unity.Transforms 命名空间来控制项目中任何实体的世界空间位置、旋转和缩放。
您还可以使用内置方面 TransformAspect,将实体及其父实体移动到一起,并保持实体数据同步。有关详细信息,请参阅 TransformAspect 文档。
主要的 Transform 组件是:
- LocalToWorldTransform:修改这些值以更改实体的世界空间位置。表示世界空间中对象的每个实体都具有此组件。重要提示:如果实体还包含 LocalToParentTransform 和 ParentToWorldTransform,它们将优先并覆盖您输入的 LocalToWorldTransform 值。
- LocalToParentTransform:表示从本地空间到父空间的转换。定义子实体如何相对于其父实体进行转换。
- ParentToWorldTransform:父实体的 LocalToWorldTransform 的副本。
如果所有三个 Transform 组件都存在于一个实体上,则 ECS 计算 LocalToWorldTransform 为:
您可以使用 Convert To Entity 脚本为您创建和初始化所有组件。要使用此脚本,请在 EntitiesSamples 项目中打开 HelloCube 并选择大立方体。在 Inspector 中,选择 Convert To Entity 组件,它将 GameObject 转换为实体。
转换层次结构
Unity.Transforms 是分层的,这意味着您可以根据实体之间的关系来转换实体。
例如,车身可以是其车轮的父级。车轮是车身的孩子。当车身移动时,车轮也随之移动。您还可以相对于车身移动和旋转车轮。
一个实体可以有多个子实体,但只有一个父实体。孩子也可以是他们自己的孩子实体的父母。这些多层次的父子关系形成了一个转换层次结构。层次结构顶部的实体(没有父实体)是根。
要声明一个 Transform 层次结构,您必须从下到上执行此操作。这意味着您使用 Parent 来声明实体的父实体,而不是声明其子实体。如果你想声明一个实体的孩子,找到你想成为孩子的实体,并将他们的父母设置为目标实体。有关详细信息,请参阅使用层次结构文档。
使用变换
要在项目中使用变换,请使用 Unity.Transforms 命名空间来控制项目中任何实体的世界空间位置、旋转和缩放。
要存储位置、旋转和比例值,请使用 UniformScaleTransform:
代表项目中对象的每个实体都有一个 LocalToWorldTransform,您可以使用它来转换实体相对于它在世界空间中的位置:
使用层次结构
您可以单独使用 LocalToWorldTransform。但是,如果要使用 Entities 的层次结构,则必须使用 Parent、LocalToParentTransform 和 ParentToWorldTransform 来转换它们。
要设置子实体的父级,请使用 Parent:
为确保父母找到他们的孩子,并设置他们的子组件,请运行 ParentSystem。
要指定如何相对于其父项定位、旋转和缩放子项,请使用 LocalToParentTransform。例如,这是您可以在父汽车对象上旋转车轮的方式:
另一个重要的组件是 ParentToWorldTransform,它是父级 LocalToWorldTransform 的副本。您需要确保孩子有这个,并且 ParentToWorldTransformSystem 正在运行。
变换方面
方面系统具有内置的 TransformAspect,它包含对子实体的所有三个转换组件的引用:
- 本地到世界转换
- 本地到父转换
- ParentToWorldTransform
对于任何根实体,TransformAspect 仅包含对 LocalToWorldTransform 的引用。
TransformAspect 是管理项目中转换的便捷方式,因为它包含使所有这些组件彼此保持同步的逻辑。例如,如果您想在不使用 TransformAspect 的情况下控制子组件的世界空间位置,则必须同时更新 LocalToWorldTransform 和 LocalToParentTransform,然后在该计算中使用 ParentToWorldTransform。
但是,TransformAspect 会为您管理这个。这是移动可能有父实体的便捷方式。
此示例说明如何使用 TransformAspect 来旋转坦克的炮塔:
Blob 资产
Blob 资产是针对流式处理优化的二进制数据片段。 Blob 是 Binary Large Object 的缩写。通过将数据写入 blob 资产,您可以将其存储在一种可以高效加载并从存储在实体上的组件中引用的格式。与结构组件一样,blob 资产不得包含任何托管数据:您不能在 blob 资产中使用常规数组、字符串或任何其他托管对象。 Blob 资产应该只包含在运行时不会更改的只读数据:它们可以同时从多个线程访问,并且(与本机容器不同)没有针对并发写入的安全检查。
为了快速加载 blob 资产,它们的数据必须是可重定位的:当您将整个 blob 资产复制到另一个内存地址时,blob 资产中数据的含义不得改变。这意味着 blob 资产可能不包含对自身的绝对引用,这排除了内部指针的使用。您通常通过指针存储的任何信息都必须通过相对于 blob 资产本身的内存地址的偏移量来引用。这主要适用于存储字符串和数组。这种使用偏移量而不是绝对指针的间接寻址的细节以两种方式影响与 blob 资产的交互:
- 必须使用 BlobBuilder 创建 Blob 资产。这种类型负责为您计算相对偏移量。
- 必须始终使用 ref 关键字或使用 BlobAssetReference 通过引用访问和传递 Blob 资产。这是确保 blob 资产内的任何相对偏移仍解析为正确的绝对地址所必需的。问题又是重定位:Blob 资产可以在内存中作为一个整体重定位,但按值而不是按引用访问它们通常不能保证复制整个 blob 资产。
如果您尝试使用按值包含内部指针的 blob 资产,则会出现编译器错误。
创建 blob 资产
创建 blob 资产始终至少涉及四个步骤:
- 创建一个 BlobBuilder。这需要在内部分配一些内存。
- 使用 BlobBuilder.ConstructRoot 构造 blob 资产的根
- 用您的数据填充结构。
- 使用 BlobBuilder.CreateBlobAssetReference 创建 BlobAssetReference。这会将 blob 资产复制到最终位置。
- 处理在步骤 1 中分配的 blob 生成器。
例如,这里我们将仅包含原始成员的结构存储为 blob 资产:
blob 构建器的作用是构造存储在 blob 资产中的数据,确保所有内部引用都存储为偏移量,最后将完成的 blob 资产复制到由返回的 BlobAssetReference 引用的单个分配中。
使用 BlobArray
blob 资产中的数组需要特殊处理,因为它们是在内部使用相对偏移量实现的。这是使用 BlobArray 类型实现的。以下是分配 blob 数据数组并填充它的方法:
使用 BlobString
字符串具有与数组相同的问题,并且具有使用 BlobString 的自定义支持。它们同样使用 BlobBuilder API 进行分配。
使用 BlobPtr
如果需要手动设置内部指针,可以使用 BlobPtr 类型。
访问组件上的 blob 资产
获得 Blob 资产的 BlobAssetReference 后,您可以将此引用存储在组件上并访问它。请注意,必须通过引用访问包含内部指针的 blob 资产的所有部分。
我什么时候需要处理 blob 资产引用?
在运行时使用 BlobBuilder.CreateBlobAssetReference 分配的所有 blob 资产都需要手动处理。这对于作为从磁盘加载的实体场景的一部分加载的 blob 资产是不同的:所有这些 blob 资产都是引用计数的,一旦没有组件引用它们就会自动释放。不得手动处理它们。
调试 blob 资产内容
Blob 资产使用相对偏移量实现内部引用。这意味着复制 BlobString 结构(或具有这些内部引用的任何其他类型)将复制包含的相对偏移量,而不是它指向的内容。这样做的结果是一个不可用的 BlobString,它将代表一个基本上随机的字符串。虽然这在您自己的代码中很容易避免,但调试实用程序通常会做到这一点。因此,BlobString 的内容无法在调试器中正确显示。
但是,支持显示 BlobAssetReference 的值及其所有内容。如果要查找 BlobString 的内容,请导航到包含的 BlobAssetReference 并从那里开始调试。
在运行时加载场景
串流
加载大场景需要时间,所以为了避免卡顿,DOTS中的所有场景加载默认都是异步的。这称为流式传输。
由于改造项目以使用流式处理可能很繁重,因此您最好尽早决定是否在项目中使用流式处理。
流式传输的主要优点是:
- 当场景在后台流式传输时,应用程序可以保持响应。
- 通过在不中断游戏玩法的情况下动态加载和卸载场景,可以实现比内存更大的无缝世界。
- 在编辑器播放模式下,如果实体场景文件丢失或过时,场景将按需转换。因为实体场景的转换和加载是异步发生的并且在一个单独的进程中,所以编辑器保持响应。
流式传输的主要缺点是:
- 游戏不能假定加载的数据立即存在,尤其是在启动时。这使得游戏代码有点复杂。
- 场景由“场景系统组”中的系统加载,“场景系统组”本身是“初始化组”的一部分。在帧中更新较晚的系统将在同一帧中看到加载的数据,但更新早于该组的系统直到下一帧才会看到加载的数据。然后,您的代码必须在单个- 框架内解决这种不一致的数据视图。
The Subscene Monobehaviour
Subscene Monobehavior 是一个简单的 Unity 组件,它抽象了转换和流式处理问题。
打开子场景时,创作游戏对象场景会显示在父场景的层次结构中。
关闭子场景时,转换后的场景的内容将流入。
本页的其余部分描述了如何在不使用 Subscene MonoBehavior 的情况下直接控制流式传输。
场景加载 101
用于处理场景的高级 API 由 SceneSystem 提供。
这是在运行时加载场景的最基本示例。这应该在系统的 OnUpdate 中完成。
此示例计划加载。在调用 LoadSceneAsync 期间,唯一创建的是场景实体,然后使用它来控制加载过程的其余部分。值得注意的是,场景标题、部分实体及其内容此时尚未加载,只会在几帧后出现在世界中。
- 在 DOTS 的上下文中,场景 GUID 是 Hash128。
- 该路径指向 Unity 创作场景。如果对应的实体场景文件丢失或过期,则触发转换。
使用场景 GUID
通过 GUID 识别场景比使用字符串路径更有效。因此,通常的方法是在转换期间存储场景 GUID,以用于在运行时加载
在运行时处理 SceneLoader 组件的示例系统如下所示:
在编辑器中,SceneSystem.GetGUID 函数在内部使用 UnityEditor.AssetDatabase 类将场景路径映射到 GUID。
在独立播放器中,无法使用 UnityEditor.AssetDatabase,因此 SceneSystem.GetGUID 改为使用“StreamingAssets/catalog.bin”文件。这个“catalog.bin”文件只不过是一个“GUID 路径”映射表,该目录文件是通过使用构建配置进行独立构建而生成的)。
场景和部分元实体
创作场景的转换会生成实体场景文件。每个实体场景文件的头部包含:
- 部分列表(包含文件名、文件大小、边界体积等数据)。
- 资产包依赖项 (GUID) 的列表。
- 可选的用户定义的元数据。
部分和捆绑包的列表决定了应该加载的文件列表,自定义元数据可用于特定于游戏的目的。例如,自定义元数据可以包含 PVS 信息以告知何时流式传输场景的决定,或者诸如“此场景仅在任务 XYZ 处于活动状态时才相关”的游戏条件。由每个游戏决定如何使用自定义元数据。但是使用自定义元数据既是可选的又是高级主题,因此稍后将对其进行记录和说明。
加载实体场景分两步完成。首先,“解决”阶段加载标题,并为每个场景和每个部分创建一个元实体。只有在这之后,才会加载这些部分的内容。
这些场景和部分元实体用于控制实际的流式传输。默认情况下,调用 SceneSystem.LoadSceneAsync 将解析并加载所有内容。
- 应通过调用 SceneSystem 上的方法加载和卸载整个场景。
- 通过在表示场景的实体上添加和删除 RequestLoaded 组件来加载和卸载场景部分。这些请求由 SceneSectionStreamingSystem 处理,它是 SceneSystemGroup 的一部分。
串流状态
流式传输是异步的,因此无法保证在请求数据后加载数据需要多长时间。虽然 SceneSystem 允许查询场景和部分的加载状态,但在大多数情况下应该不需要这样做。
理想情况下,系统应该对其所需数据的存在或不存在做出反应,而不是对某些场景是否正在加载做出反应。如果系统需要运行的数据是特定场景的一部分,那么判断是否更新系统应该通过检查是否加载了特定数据来完成,而不是检查场景本身是否已经加载。这种方法避免了将系统绑定到特定场景:如果系统所需的数据被移动到不同的场景、从网络下载或程序生成,系统仍将以相同的方式工作,而无需更改其代码。
尽管如此,您仍可以检查场景或部分是否已加载。例如,这可能有助于实现一个加载屏幕,该屏幕应保持可见,直到所有计划的流媒体完成。
场景部分
场景的各个部分可以独立加载和卸载。
场景中的每个实体都有一个 SceneSection 共享组件,其中包含场景的 GUID (Hash128) 和部分编号(整数)。第 0 部分是默认部分。
在转换期间,可以通过更改 SceneSection 共享组件的值来设置实体的部分,如下所示:
将上述组件添加到子场景引用的创作场景中的游戏对象将导致该子场景的检查器如下所示:
请注意,默认部分 0 始终存在(第一行),即使它是空的。名称的“Section: 0”部分被省略,但包含至少一个实体的所有其他部分将以其全名显示。
所有部分都可以引用它们自己的实体和第 0 部分中的实体。描述此参考系统的工作方式超出了此处的范围,但一个重要的结果是从场景中加载任何部分都需要来自同一场景的第 0 部分也是加载。相同的约束适用于卸载:只有当当前没有加载同一场景的其他部分时,才能卸载场景的第 0 部分。
一些现有的 DOTS 功能已经利用了部分加载。您可以通过编写自定义转换系统、使用 IConvertGameObjectToEntity(参见上面的示例)或使用创作组件 SceneSectionComponent(这将影响层次结构中的所有创作游戏对象)来显式控制您自己代码中的部分加载。
独立加载场景部分
使用 DisableAutoLoad 参数调用 LoadSceneAsync 将通过创建场景和部分元实体来解析场景,但不会加载部分内容:
一旦处理了加载请求,就会解析这些部分并创建它们的元实体。然后可以在场景元实体上查询 ResolvedSectionEntity 缓冲区。作为说明,以下代码将加载给定场景的每个其他部分。
卸载场景和部分
卸载整个场景及其所有部分是通过场景系统完成的。
也可以使用场景的 GUID 而不是其元实体来调用 UnloadScene,但这有两个缺点:
- 该函数必须执行(可能代价高昂的)搜索表示与 GUID 匹配的场景的元实体。
- 如果加载同一场景的多个实例,按GUID卸载只会卸载一个实例。