← 博客

将标签和层作为资产

将标签和层作为资产

2026-10-05
本页面为自动翻译,可能包含错误。如果发现问题,请通过“编辑此页面”链接帮助修正。

标签和层是小功能,影响范围却大得出人意料。它们决定哪些对象会碰撞、摄像机能看到哪些对象,以及玩法代码能找到哪些对象。它们的数据会进入场景、预制体、编辑器控件、存档文件,有时还会进入网络消息。

我已经在 Turian 中实现过一套常规方案:物理层、渲染层、标签,以及相关的筛选和查询 API。用固定表实现这些很直接:层变成一个小索引,掩码变成几个位,运行时就能以很低的成本完成判断。

但在检查实现时,我开始思考:随着引擎发展,这些表会怎样?物理和渲染只是两个使用方。音频、网络、存档系统和玩法模块都可能用得上类似的分类。独立分发的模块还需要稳定的标识,即使安装到已有表项的项目中,也不能改变。

Turian 已经有 DataAsset 系统,用来保存共享、可编辑的游戏数据。这让我想到一个问题:能不能让标签和层本身成为资产,同时让运行时表示足够小,适用于成千上万个节点?

本文介绍我最终选择的架构及其取舍。持久化状态的基础已经实现并通过测试;与节点、使用方和编辑器工作流的完整集成仍在进行中。

设计需要保留什么

我希望同时满足三个要求:

  1. 稳定的标识。 重命名层、更改显示方式或移动资产,都应该保留对它的引用。
  2. 可组合性。 项目应该能组合自身代码中的声明、设计师创建的资产,以及独立开发的模块。
  3. 较低的运行时成本。 剔除和物理系统会频繁读取归属信息。这些读取应使用紧凑的数据,而且添加一个模块不应该在每个节点上分配一张大表。

资产系统为标识和显示信息提供了存放位置。运行时仍然需要根据访问模式选择自己的表示方式。

定义组、值和标签

制作时的模型主要由四部分组成:

资产 保存的内容
LayerValueAsset 一个值的 GUID、显示名称、颜色和说明
LayerGroupAsset 按顺序排列的值引用清单,以及明确指定的默认值
TagAsset 标签的 GUID 和显示元数据
NodeLayerSettings 项目的有序组列表、可用标签,以及物理和渲染所绑定的组

一个组描述一个分类空间。例如,项目可以定义一个包含 Default、Water 和 Actors 的物理组,一个包含 Default、World 和 Interface 的渲染组,以及拥有自己取值的音频组。

节点在每个组中选择一个值。使用方的掩码则选择该组取值的一个子集。标签是彼此独立的归属关系:节点可以同时带有多个标签,不必采用组内只能选一个值的方式。

名称是显示元数据,可以更改,不同资产也可以使用相同的显示名称。引用通过 GUID 识别资产。每个组拥有自己的值清单,因此模块可以编辑自己的组,不必把所有声明都塞进一张庞大的共享表。

物理和渲染的组绑定也是显式引用。设计师可以重命名任一组,而不改变使用方实际使用的组。

三种用途,三种表示

核心设计决策是让制作、会话初始化和运行时查询分别使用适合自己的表示:

阶段 表示 用途
制作与持久化 资产 GUID 和组/值 GUID 对 保留标识和编辑意图
会话初始化 从 GUID 映射到槽位/索引的不可变布局 验证清单并绑定标识
运行时 字节形式的归属信息、ushort 标签 ID 和位掩码 以较低成本响应频繁查询
flowchart LR
    subgraph A["制作与持久化"]
        direction TB
        A1["资产和类型化的代码键"]
        A2["GUID 标识与显示信息"]
    end
    subgraph B["会话初始化"]
        direction TB
        B1["LayerInterningService"]
        B2["验证、驻留、生成指纹"]
    end
    subgraph C["运行时查询"]
        direction TB
        C1["NodeLayers — 每个节点 24 B"]
        C2["ushort 标签 ID 和 256 位掩码"]
    end
    A -- "会话开始时绑定" --> B
    B -- "紧凑的不可变布局" --> C

会话初始化时,LayerInterningService 会验证解析后的设置并创建快照。每个组获得一个整数槽位。组内默认值的索引为零,剩余值按清单顺序排列。标签则按 GUID 升序获得紧凑的 ID。

这些表建立后,运行时读取归属只需要一个槽位和一个字节。显示名称、颜色、说明和资产对象仍然作为共享的制作数据保留。

这遵循了推动 Open Asset Package 设计的同一条原则:无论数据的命名或组织方式如何变化,引用都应该保持有效。这里多了一步:把这些标识转换成适合当前会话的紧凑布局。

不使用字符串键的代码注册

资产定义也需要适用于用 C# 编写的模块。注册 API 接受带有显式 TypeId 特性的类型:

using MASS4.Attributes;
using Turian.Engine.Core;

[TypeId("ea3c1636-272b-453b-8a73-2187d01581a5")]
public sealed record Physics : ILayerGroupKey;

[TypeId("89414f36-2137-4d82-b7ea-cced74c4864e")]
public sealed record Water : ILayerValueKey<Physics>;

[TypeId("812a8d5a-ad5e-4f26-ae8a-6d5e6d120d91")]
public sealed record Player : ITagKey;

var settings = new LayerRegistrationBuilder()
    .Group<Physics>(group => group.Default().Value<Water>())
    .Tag<Player>()
    .Build();

settings.PhysicsGroup = settings.Groups[0];

示例中的 GUID 用来标识键。只要 TypeId 不变,即使重命名类型或命名空间,标识也不会改变。系统不会创建标记类型的实例。Water 通过 ILayerValueKey<Physics> 声明所属的组,因此编译器可以拒绝将它注册到其他组。

目前的标记 API 使用 class 和 record class。共享的 TypeId 特性以类为目标,因此 readonly record struct 键仍是一项已知限制。

设计师创建的资产可以使用与代码键的 TypeId 相同的资产 GUID。这是有意为代码中的标识补充信息:匹配的值资产或标签资产提供显示数据,匹配的组资产提供完整清单和默认值。DataAsset 类本身的 TypeId 则有另一项职责:标识序列化载荷的类型。

16 个内联槽位,加上稀疏的溢出存储

早期草案把组数硬性限制为 16。好处显而易见:每组一个字节,归属数据一共 16 字节。但一考虑模块,问题就出现了。即使某个系统只用几个值,也仍然会占掉项目全部 16 个槽位中的一个。

实际实现的归属缓存把前 16 个组内联保存;对于额外的组,只有显式指定了非默认值,才把它存入有序的稀疏数组。

在 64 位运行时中,NodeLayers 占用 24 字节:16 字节的内联归属信息,加上一个 8 字节的数组引用。没有溢出项时,这个引用为 null。仅仅注册更多组,不会增加任何逐节点的内存分配。未赋值的溢出组会解析为默认索引零。

每个溢出项占 8 字节,包含填充;此外还有数组头和分配开销。溢出项的读取使用二分查找。编辑时会复制这个小数组,保持结构体各个值副本之间的独立性;赋值没有变化时,不会分配内存。

因此,组的顺序也是性能决策。物理和渲染应排在前面,因为每帧都要跨大量节点读取它们的归属。显式绑定给这两个使用方的组,只要有一个落在前 16 个槽位之外,构建布局时就会发出警告。如果性能分析发现某个溢出组被频繁访问,其使用方应该把归属信息移到场景持有、以节点句柄为索引的列式存储中。

24 字节指的是归属缓存。标签、掩码、持久化的 GUID 状态和节点的其他部分,都有各自的存储成本。把标识与执行分离,并不意味着持久化状态没有成本;它让我们可以共享显示元数据,并有意识地选择运行时存储方式。

一个字节选择一个值,掩码选择一个子集

每组支持 256 个值。节点的一个字节是这些值的索引。使用方的掩码为每个值保留一个位,用四个内联 ulong 存储。

这个区别很重要。八个独立标志有 256 种可能的组合。而归属字节是在 256 个值中选择一个,使用方的 256 位掩码则能表示这些值的任意子集。如果要在同一组内同时归属于多个值,就需要另一种节点表示和 API。

标签使用 ushort 的全部 65,536 个 ID。零也是有效 ID。查询是否成功会单独返回,因此不必保留某个 ID 作为缺失值的哨兵。一个模块即使声明 8,192 个标签,也会给其他模块和项目内容留下 57,344 个 ID。

这些仍然是明确做出的容量选择。无效清单会在可用布局构建完成前被拒绝。项目的组数没有固定的 16 槽位上限,但共享表和实际赋值的溢出项仍然消耗内存。

运行时索引有其有效期

假设某个组的非默认值是 Water 和 Actors。在它们前面插入另一个值,可能会让它们在下一次会话中的字节索引发生偏移。它们的 GUID 不变。

由此得到一条严格的持久化规则:运行时索引只在当前会话内有效,绝不持久化。 场景、预制体、游戏存档和制作时配置的掩码保存的是标识。加载到新布局时,再次解析这些标识。

NodeLayerState 保存显式的组/值 GUID 对和标签 GUID。LayerMaskState 保存一个组 GUID 和所选值的 GUID。绑定后得到 NodeLayers、紧凑的标签 ID 和 CompiledLayerMask。系统会拒绝把运行时归属缓存和掩码缓存序列化为 JSON,并提示改为保存 GUID 状态对象。

游戏存档的保存和读取往返已经通过测试,包括在保存后、读取前插入新值的情况。Node 的自动场景/预制体持久化和克隆仍需接入这套基础。

默认值也要保留选择意图

跟随组的默认值,与显式选择一个恰好是当前默认值的值,存在微妙的区别。

假设默认值为 X。一个节点未赋值,另一个显式选择了 X。之后,设计师把默认值改为 Y。未赋值的节点应变成 Y,显式赋值的节点则应保留 X。

这两个节点最初都会解析为运行时索引零,因此这个字节无法保留两者的区别。持久化状态必须保留显式的 GUID 对。另设的“跟随默认值”或重置操作才会清除引用。回归测试覆盖了这一行为,Inspector 的操作流程也必须让用户看得出这种区别。

删除内容也有类似的编辑问题。引用了缺失值的节点会回退到已知组的默认值,同时保留失去目标的 GUID。掩码保留缺失值的 GUID,但不为它设置任何位。缺失标签也保留标识,但不产生运行时归属。每个缺失标识在每份快照中只警告一次。如果内容恢复,这些引用就能再次解析。

确定性需要显式输入

设置资产中的有序列表和显式的构建器调用,是注册入口。组的发现绝不依赖文件系统遍历顺序、资产数据库枚举顺序或程序集发现顺序。模块启动同样必须使用显式的注册顺序。

显式注册也是 NativeAOT 的约束,不只是对确定性的偏好。Turian 以提前编译的二进制文件分发。常见的替代方案是在启动时扫描已加载程序集,寻找标记特性,而这恰恰是会在裁剪时出问题的模式:除非通过链接器注解明确保留,否则类型可能从二进制文件中消失,而且每增加一个模块,所需的注解就会更多。构建器 API 把发现工作移到编译期。运行时看到的是显式注册序列,而 TypeId GUID 是普通的字符串字面量,编译器无需额外帮助就会保留它们。

会话布局还带有一个版本化的 SHA-256 指纹。它覆盖按顺序排列的组、值和标签标识及其运行时槽位和索引,也包括物理和渲染的组绑定。显示元数据不会影响它。字节协议已有文档,并使用独立于 C# 实现生成的基准数据进行测试。

这个指纹让通信对端或基于索引的回放能够确定:自己的紧凑数据是否具有相同含义。不匹配就拒绝兼容。普通的 GUID 存档可以绑定到变更后的布局;交换紧凑索引则要求布局和内容一致。

编辑器中的修改也必须遵守同样的边界。计划中的工作流会显示当前指纹和拟议指纹,列出需要重新验证的已连接对端,并提供重启/重新绑定或推迟应用的选项。推迟会保留现有的会话快照,并显示有变更待应用的横幅。运行中的会话绝不能通过替换表来重新解释现有字节。

CPU 的容量不决定 GPU 的位宽

渲染器目前在光照路径中使用 32 位的层数据。我最初认为,采用 256 位掩码就需要一起修改 CPU、持久化、uniform、着色器和 SPIR-V。

进一步检查后,我发现 GPU 需要的只是渲染组的掩码。其他组可以使用自己的位宽,不必更改渲染 uniform。

当组声明的值不超过 32 个时,CompiledLayerMask.TryGetLegacyBits() 允许采用现有的打包方式。更大的组会被拒绝,即使某次选择恰好只使用低位也不行:渲染器仍然需要解释这个组内所有有效的归属。

这样,只要渲染组仍能放进现有 GPU 路径,就可以先推进 CPU 和持久化的集成。在 uniform 和着色器完成扩展前,位宽更大的渲染组必须在与渲染器绑定时被明确拒绝。静默截断会导致筛选结果取决于检查是在 CPU 还是 GPU 一侧执行。

玩法代码需要保留的行为

更改标签存储必须保留程序员已经在用的查询行为。查询选择活动节点,考虑祖先节点的活动状态,并排除已销毁的节点。单结果查询未命中时返回 null;全结果查询则返回空列表。

同一场景内,结果按节点注册顺序排列。场景管理器的查询按加载顺序访问已加载场景,然后访问持久节点。更改标签或激活状态不会改变注册位置,重复的标签归属也不会产生重复结果。

计划中的公开查询 API 接受 GUID 和类型化的标签键,在会话注册表内部解析为 ushort ID。存储变更必须保留现有的标签编辑、父节点变更、销毁和场景卸载钩子。Play Mode 必须为自己的场景副本建立独立的注册。

实现进展

制作资产、类型化注册、标识信息补充、不可变布局、紧凑归属缓存、GUID 状态对象、失效引用处理、掩码、指纹校验保护和验证测试,均已实现。

剩余工作是把这些部分接到会话启动、Node 序列化和克隆、标签注册表存储和公开查询、物理/渲染使用方、编辑器控件、模板,以及通信对端/回放加载流程中。当渲染组超过 32 个值时,还需要扩展 GPU 路径的位宽。

取舍很具体:初始化要做验证和标识解析;溢出读取比内联读取成本更高;溢出编辑会分配内存;紧凑 ID 有容量限制;布局变化需要明确的切换边界。

我喜欢这套架构的地方,在于它把责任划分得很清楚。资产保留标识和设计师的意图。会话初始化验证并绑定这些数据。运行时的使用方使用紧凑的归属和掩码。这样,我就能把标签和层扩展到更多系统,同时让频繁访问的数据保持精简,并在布局变化时明确报告兼容性问题。

#architecture#engine#csharp#gamedev