☰
14-04-对比-数据结构在游戏引擎中的对比-Unity-vs-Unreal
2026/9/25 19:17:31 网站建设 项目流程

Unity vs Unreal:引擎数据结构的所有权、布局与调度

系列:C# 与常用数据结构源码剖析 · 演进与对比篇
阅读时间:约 90 分钟
版本基线:Unity2022.3 LTS(Mono/IL2CPP、Burst/Collections/Entities 具体包版本随项目记录)与 Unreal Engine5.3。两边后续版本均持续演进,私有布局和 API 应按目标 tag/包复核。
比较原则:只比较同一业务语义、容量、元素布局、线程与构建配置;不从“C# vs C++”直接推导性能,不提供脱离原始报告的倍数。


一、不是两套容器,而是多个内存域

“Unity 用 C# 集合,Unreal 用 C++ 容器”只能解释最表层代码。实际项目至少同时存在下列域:

引擎域典型结构所有权/回收
Unity托管脚本List<T>、Dictionary<TKey,TValue>、class/arrayUnity 所集成托管运行时与 GC;UnityEngine.Object还有原生侧身份
UnityNative Collections/JobsNativeArray<T>、NativeList<T>、NativeHashMap<TKey,TValue>Allocator + Dispose + Job 依赖和安全句柄
UnityEntities ECSarchetype/chunk、组件查询、实体句柄World/Entity 生命周期与结构变更协议
Unreal普通 C++/RAIITArray<T>、TMap<K,V>、TSet<T>、智能指针值/容器析构、allocator、RAII 或显式 owner
UnrealUObject 反射域UObject、UPROPERTY容器、强弱对象引用Unreal 的 UObject 可达性 GC 与反射元数据
UnrealMass Entityarchetype/chunk、Fragment/ProcessorEntity manager、execution context 与处理阶段

同一个游戏功能可跨多个域。例如 UnityMonoBehaviour持有 NativeArray,UnrealUActorComponent持有TArray<TObjectPtr<UObject>>。这时“容器析构/Dispose”和“元素对象是否存活”是两个问题。

选择结构前先画出:数据由谁拥有、地址是否稳定、何时可修改、哪条线程访问、是否要被 Inspector/反射/存档看到、构建后端是什么。容器名称相似不代表契约相同。


二、动态数组:List、NativeList 与 TArray

2.1 共同抽象与不同边界

三者都可表达一段逻辑连续元素和可增长容量,尾部追加通常具有摊销常数成本,中间插入/删除需要移动尾部。但元素表示、生命周期和并发协议不同。

维度UnityList<T>UnityNativeList<T>UETArray<T>
存储托管T[]Native 连续内存C++ allocator 管理的连续元素
元素约束任意合法托管 T通常 unmanaged满足 UE/C++ 容器构造移动要求的 T
生命周期容器由 GC 管,后备数组随可达性必须按 Allocator/owner DisposeRAII 析构或 owner 生命周期
地址稳定扩容会换数组;GC/pin 另有边界扩容会换 Native 块扩容会重分配,指针/引用失效
Job/线程普通 List 无并发写保证Job Safety + 依赖/ParallelWriter 等特定路径普通 TArray 无自动线程安全

增长策略不是公开永久契约,不能笼统写“三者都每次翻倍”。任何预留 (Capacity/Reserve) 都是在扩容次数与常驻内存之间权衡。

2.2 删除与顺序

Unity List 的RemoveAt、NativeList 对应稳定移除、UETArray::RemoveAt等都会为保持顺序移动尾部;两边也有不保持顺序的 swap-back/RemoveAtSwap类方案。只有业务明确不观察顺序时才能使用。

// Unity 业务示例:索引会变化,外部反向索引必须同步更新。 int last = enemies.Count - 1; enemies[index] = enemies[last]; enemies.RemoveAt(last);
// Unreal 业务示例:RemoveAtSwap 改变 index 处元素身份。 Enemies.RemoveAtSwap(Index);

迁移时若 Unity 系统依赖 List 的稳定过滤顺序,不能看到 UE 有RemoveAtSwap就机械采用;反方向也一样。确定性回放、网络同步和渲染排序都可能观察顺序。

2.3 元素生命周期

List<MyClass>数组保存托管引用,Clear/Remove 会清除有效引用槽,实例在无根后等待 GC;List<MyStruct>将值内联在数组中。NativeList<T>保存 unmanaged 值,不会追踪其中托管对象。

TArray<FValue>直接构造/析构 FValue;TArray<UObject*>/TObjectPtr保存对象引用语义,容器元素离开不等于立即 delete UObject。普通 C++ unique/shared/weak pointer 又有不同生命周期,不得与 UObject 指针混用心智模型。


三、键值索引:Dictionary、NativeHashMap 与 TMap

3.1 同语义比较才有意义

比较查找前先固定:键类型与大小、相等语义、哈希函数、命中率、重复策略、插入/删除分布、是否需要稳定枚举顺序、容量和线程模型。否则测到的是键设计差异,不是引擎差异。

UnityDictionary<TKey,TValue>是托管泛型字典;实现细节随 Unity BCL 代际变化,不能直接套.NET 8私有字段。NativeHashMap<TKey,TValue>用 Native 存储并要求适合的 unmanaged 键值,容量、并发写和枚举行为按 Collections 包版本核对。UETMap<Key,Value>建立在 UE 哈希集合布局/KeyFuncs 体系上,不等同于std::unordered_map,也不应画成 .NET Dictionary 的同一桶链。

3.2 哈希与键不变量

三者共同要求:键进入表后,参与相等和哈希的状态保持不变;相等键得到兼容哈希。Unity 可提供IEqualityComparer<TKey>;UE 常通过GetTypeHash、operator==或 KeyFuncs 定义;NativeHashMap 对键哈希的具体入口按包 API。

// UE 概念示例:相等与哈希字段必须一致且稳定。 struct FCellKey { int32 X; int32 Y; friend bool operator==(const FCellKey& A, const FCellKey& B) { return A.X == B.X && A.Y == B.Y; } }; uint32 GetTypeHash(const FCellKey& Key) { return HashCombine(GetTypeHash(Key.X), GetTypeHash(Key.Y)); }

Unity 迁移到 UE 时,不能保留 C#Equals语义却漏掉 UE hash;UE 迁 Unity 时,也不能依赖指针地址作为跨运行/存档稳定 ID。

3.3 重复、查找和引用失效

Dictionary 的 Add 对重复键抛错,索引器可覆盖;TMap 的 Add/FindOrAdd 等 API 有各自覆盖/构造语义;NativeHashMap 的 TryAdd 表达唯一插入。迁移应显式写出预期,不能用同名 Add 猜测。

容器扩容或删除会使内部元素地址/ref/pointer 失效。CollectionsMarshal.GetValueRef...(目标 Unity 未必可用)、NativeHashMap value 引用与 UE TMap value pointer 都有严格有效期。把内部地址跨一次潜在写操作缓存,是两边共有的高风险错误。

3.4 并发

普通 Dictionary、NativeHashMap 主视图与 TMap 都不是“任意多线程读写安全”。Unity Jobs 使用声明式依赖和特定 ParallelWriter;Unreal 可用锁、分片 owner、任务归并或专用并发结构。任务图/Burst 不会自动让普通容器线程安全。


四、集合:HashSet、NativeHashSet 与 TSet

集合的语义是“按等价关系唯一”,不是“无值的字典就必然相同布局”。UnityHashSet<T>属于托管类库;NativeHashSet 属于 Collections 包;UE TSet 是 TMap 的重要基础结构并有自己的 element ID/稀疏数组与哈希索引实现细节。

枚举顺序通常不应作为跨版本、跨构建或序列化契约。即使某次运行看似插入顺序稳定,rehash、删除复用、包/引擎升级都可能改变。需要稳定输出时显式排序或维护顺序数组。

TSet/TMap 删除可能在稀疏存储中留下可复用槽;GetAllocatedSize、Shrink/Compact等 UE API的语义按 5.3 文档/源码验证。托管 HashSet 的 Trim/Ensure API 又随 BCL 版本变化。不要用一个引擎的“Count 下降”推断另一个引擎物理内存立即归还。


五、强引用、弱引用和引擎对象

5.1 Unity 双重生命期

UnityEngine.Object是托管包装与原生引擎对象的桥。原生对象销毁后,托管包装可能仍被 List/Dictionary 引用,并具有 Unity 定义的特殊 null 比较。普通WeakReference<T>只描述托管对象可达性,不等价于 Unity 资产的可卸载软引用。

场景对象索引优先使用稳定领域 ID,并在 OnDestroy/场景卸载时移除。Addressables/AssetReference 处理资产加载生命周期又是另一层,不要靠弱引用替代资源系统。

5.2 Unreal UObject GC

Unreal 的普通 C++对象可用值、unique/shared/weak pointer 和 RAII;UObject 则由反射可达性 GC 管理。被 GC 识别的强引用通常要通过UPROPERTY/TObjectPtr等符合版本规则的路径,或显式引用收集接口。裸UObject*成员若不被引用系统看到,不能假定保活。

TWeakObjectPtr不保活 UObject,访问前检查有效性;TSoftObjectPtr表达可解析资产/对象路径与按需加载语义,不等于普通 weak_ptr。TSharedPtr通常不用于管理 UObject 生命周期。UE 5.3 的具体推荐宏/指针类型按模块与文档确认。

5.3 容器不是 owner 证明

TArray<UObject*>、List<UnityEngine.Object>、Dictionary 缓存或 TMap 都不能仅凭“容器还在”推导资源一定有效。必须注明引用是否强、反射是否可见、资源系统是否有 handle,以及卸载时谁清理。


六、反射、Inspector 和序列化

Unity 序列化规则不是任意 .NET 序列化。2022.3 中字段可见性、[SerializeField]、支持类型、[SerializeReference]、UnityEngine.Object 引用等决定 Inspector/资产行为;普通 Dictionary 默认序列化支持边界与自定义实现需按版本确认。Mono/IL2CPP 的反射裁剪还要 link.xml/属性或源生成策略。

Unreal Header Tool/反射宏(UCLASS/USTRUCT/UPROPERTY等)生成元数据。只有支持的容器/类型/标记才能进入编辑器、复制、蓝图、存档或 GC 引用追踪。TMap/TArray中元素是否可反射,取决于键值是否为支持的反射类型及属性声明。

迁移存档不能把 Unity JSON/序列化字段直接映射为 UE 内存布局:

  • 明确 schema 版本和稳定字段名;
  • 对对象引用使用可迁移的资产/领域 ID,不序列化进程指针/InstanceID;
  • 明确 Dictionary/TMap 的 comparer/大小写/重复策略;
  • 不持久化 Hash、bucket、枚举顺序或结构体 padding;
  • 为旧版本做受控升级测试。

两边热重载都可能让反射/序列化状态复杂化,不能用编辑器一次成功替代冷启动打包测试。


七、分配器与内存峰值

Unity 托管集合从托管堆分配对象/数组,使用 Unity 集成 GC;Unity 2022.3 的 Mono/IL2CPP GC 行为应按对应手册,不得写成“IL2CPP 是分代精确压缩 GC”。Native Collections 从 Allocator.Temp/TempJob/Persistent 等域分配,必须按生命周期 Dispose。

Unreal TArray/TMap/TSet 使用 UE allocator 体系/可指定 allocator 模板;容器对象本身的析构会释放其存储,但 allocator 可能保留页/缓存,不保证进程 RSS 立即下降。UObject GC 与普通 C++ allocator 回收是两条路径。

预分配/Reserve 在两边都能减少增长重分配,却可能放大常驻与峰值。对象池也会保留历史峰值,并引入清理、双重归还和跨场景所有权。测量至少包括:逻辑 Count、Capacity/allocated size、分配峰值、存活对象、场景卸载后的回落和最大帧时间。

元素布局常比容器品牌更重要。List<EnemyClass>/TArray<UObject*>是引用/指针数组,对象分散;NativeArray<FState>/TArray<FState>可内联连续值。AoS vs SoA、热冷字段分离和组件 chunk 会改变缓存行为,必须用同布局比较。


八、线程、Jobs 与 TaskGraph

8.1 Unity

多数 UnityEngine.Object API 受主线程限制。C# Job System 通过 NativeContainer safety handle、读写声明和 JobHandle 依赖表达并行;[ReadOnly]之类声明必须符合真实访问。Burst 编译受支持 C# 子集,托管 List、Dictionary、对象引用和普通 LINQ 通常不属于 Job 热路径。

结构变更、Native 容器扩容和 ParallelWriter 支持各有版本限制。Complete是同步点,过早调用会抹掉并行收益。主线程把托管数据复制进 NativeArray 的成本也要算入端到端测量。

8.2 Unreal

TaskGraph、UE::Tasks、Async、ParallelFor 等让工作分发到线程;它们不使 UObject API 或 TArray/TMap 自动线程安全。很多 UObject 创建/销毁、World/Actor 操作有 game thread 约束。任务应处理纯数据,并在正确线程提交引擎状态。

对共享容器可用 FCriticalSection/RWLock 等同步、线程局部结果后归并、单 owner 阶段或消息队列。锁选择应基于竞争和不变量,不从“C++ 没 GC”推导自由并发。

8.3 同语义任务实验

若比较 Unity Job 与 UE TaskGraph,固定相同实体数、相同组件字节布局、相同算法和 worker 数;包含调度、输入搬运、同步/归并和输出消费。只比较 Burst 内核与包含 UObject 调用的整帧 TaskGraph 没有意义。


九、AOT、Burst、模板与代码体积

Unity Mono 可 JIT(受平台限制),IL2CPP 将 IL 转 C++ 再 AOT;泛型实例、反射可达性、裁剪和包体需验证。Burst 为受支持 Job/函数生成高度优化机器码,但不是所有 C# 的替代后端。

Unreal C++ 模板在编译期实例化,TArray/TMap 的类型特化可优化,也可能增加编译时间和代码体积。反射模板/宏、Unity 泛型 AOT 都可能产生“每个类型组合”的构建成本;不能只比较运行时循环。

函数指针、虚调用、接口调用、模板内联、Burst function pointer 的代价依代码形态和平台。以反汇编/生成 C++、包体 map 和真机 profile 为证据,不写固定倍数。

主机/iOS 等平台限制动态代码,Unity 通常用 IL2CPP;UE 则是原生 AOT 构建。热更新方案若引入解释器/脚本 VM,又形成新的执行域,应单独比较。


十、ECS:Entities 与 Mass 只在同查询语义下比较

Unity Entities 与 UE Mass 都采用 archetype/chunk 思想,把具有相同组件组合的实体数据聚集,让 Processor/System 批量访问 Fragment/Component。它们都不是简单的Dictionary<Entity,Component>。

比较前固定:实体/组件数量与大小、archetype 分布、查询包含/排除、结构变更频率、启用状态、chunk 利用率、并行度和输出。一个项目用 Managed Components、另一个全是紧凑 POD Fragment,结果不能归因于 ECS 品牌。

实体句柄通常含索引/版本(具体布局按版本),避免已销毁实体槽复用导致陈旧引用误命中。不要跨 World 保存句柄,也不要把它当永久存档 ID。持久化应使用领域 GUID/ID 并在加载后解析。

结构变更可能迁移实体到另一 archetype/chunk,使组件引用/迭代器失效。Unity ECB 与 Mass command/deferred mutation 都强调在安全阶段提交。迁移算法要从“遍历中立即改结构”改为记录命令后批量执行。


十一、热重载与编辑器迭代

Unity 脚本重编译通常涉及 AppDomain/程序集重载,是否禁用 Domain/Scene Reload 会改变静态 List/Dictionary 和对象状态保留。SerializeField 数据、原生对象包装、Native 分配与静态缓存的恢复规则不同;退出 Play 时未 Dispose 的 Native 容器可能被安全系统报告。

Unreal Live Coding/Hot Reload 对 C++ 实现迭代有帮助,但反射布局、UCLASS/UPROPERTY 变化、构造默认值和已有对象实例并非总能安全热替换。复杂结构/序列化变化后应关闭编辑器冷启动验证,不能把 Live Coding 成功视为打包兼容。

两边迁移数据结构字段时都要考虑已有编辑器资产与运行中实例。更改容器类型、键比较或元素布局应提供版本迁移,而不是期待热重载自动转换。


十二、迁移案例:Unity 敌人索引到 Unreal

12.1 原始语义

Unity 系统维护:按稳定 EnemyId 查敌人;按生成顺序遍历;对象销毁后索引必须清除;存档只保存 EnemyId 与纯数据。实现可能是List<Enemy>+Dictionary<int,Enemy>。

迁到 UE 不能只换成一个 TMap,因为 TMap 不承诺所需生成顺序。可以使用:

TArray<TObjectPtr<AEnemy>> SpawnOrder TMap<int32, TWeakObjectPtr<AEnemy>> ById

具体强弱选择取决于谁拥有 Actor:World/关卡通常管理 Actor 生命周期,索引不应仅为查找强行阻止销毁。访问 Weak 前检查 IsValid;销毁事件中删除 map 与 array。若 Array 使用 RemoveAtSwap,就必须确认生成顺序不再是契约,否则稳定删除。

存档只序列化 EnemyId/状态 DTO,不序列化 UObject 指针。加载后生成 Actor 并重建索引。测试重复 ID、Actor Destroy、关卡切换、弱引用失效和存档迁移。

12.2 迁到 Mass/Entities 的另一种设计

如果敌人数量和系统查询适合 ECS,稳定 ID 可作为组件,运行时用专用映射从领域 ID 到 Entity handle;批处理组件直接走 chunk。结构变更延迟提交,句柄版本校验。不能继续让大量 MonoBehaviour/Actor 引用塞进纯 ECS 热组件,然后期待同样缓存行为。


十三、迁移案例:UE 配置 TMap 到 Unity

UE 配置使用TMap<FName,FItemConfig>,编辑器反射/存档中键具有 FName 语义。迁 Unity 时不能简单改Dictionary<string,Config>:先定义 FName 的大小写、规范化和序列化身份,再选择StringComparer或导入期生成整数 ID。

运行期若只读且高频,可在加载阶段验证重复键并构造 Dictionary/数组索引;Inspector 资产使用可序列化 DTO 列表,OnValidate/导入器生成运行时索引。不要依赖 Dictionary 枚举顺序还原 UE 编辑器展示顺序;单独保存 order 字段/数组。

若 Burst Job 需要配置,把只读纯数据烘焙到 BlobAsset/NativeArray,而不是把 Dictionary<string,class> 直接传 Job。管理 Blob/Native 生命周期和版本。测试文化/大小写、重复、旧资产、IL2CPP 裁剪和真机读取。


十四、常见反例

  1. 把 Unity IL2CPP 写成分代、精确、压缩 GC,并由此预测停顿。
  2. 声称 Unreal 没有 GC,忽略 UObject 引用图和回收。
  3. 用std::vector/unordered_map代替 TArray/TMap 描述 UE 私有实现。
  4. 只比较 List 与 TArray 名称,元素一边是 class、一边是内联 struct。
  5. 用 RemoveAtSwap 迁移保序系统,破坏确定性和索引。
  6. 缓存任何容器内部元素地址,扩容后继续使用。
  7. 将普通 Dictionary/TMap 从多个任务并发写入。
  8. 用TSharedPtr管 UObject,或裸 UObject 指针成员假定被 GC 强引用。
  9. 用 WeakReference 代替 Unity Addressables 资产生命周期。
  10. 持久化 InstanceID、Entity handle、指针或哈希值作为长期 ID。
  11. 依赖 HashSet/TSet/TMap 的偶然枚举顺序做网络协议。
  12. 把 NativeArray/Burst 内核与包含 UObject 工作的整帧直接比较。
  13. 忽略 Reserve 后常驻峰值和 allocator 缓存,只看 GC Alloc。
  14. 热重载后运行正常就不做冷启动/打包资产迁移。
  15. 引用没有源码、硬件和项目的固定性能倍率。

十五、同语义真机实验

15.1 动态数组

定义相同 POD/值类型布局,测试预留/不预留尾部添加、稳定中删、swap 删除、遍历和峰值回落。Unity 分 List/Mono、List/IL2CPP、NativeList/Burst(适用时);UE 分 TArray game thread/任务纯数据。验证最终顺序/元素集,再记录 CPU、分配、容量、峰值和帧分位数。

15.2 哈希索引

使用相同序列化键集与预生成操作轨迹,统一 hash/equality 语义,测试构建、命中/未命中、删除重插、正常/差哈希。记录比较次数代理、容量、内存和锁/Job 归并。不要在测试区生成 FName/string 或随机数。

15.3 对象引用生命周期

Unity 创建/销毁对象并保留/清理 List、Dictionary、WeakReference/Addressable handle;UE 对 UObject 强、弱、软引用及普通 shared/unique owner 分组。经过场景/关卡卸载和显式 GC/资源流程后,用 Memory Profiler/Unreal Insights 与对象工具验证谁仍被引用。目的不是比较 GC 速度,而是验证 owner 契约。

15.4 Jobs/TaskGraph

相同纯数据 kernel、相同 worker/实体数,分别测调度、输入准备、执行、同步和输出提交。Unity 记录 Burst 开关、Safety Checks、Job batch;UE 记录 TaskGraph/UE::Tasks API、task grain 和 game thread 同步。报告端到端,不只截 kernel。

15.5 ECS

生成相同 archetype/组件分布,测试只读查询、写组件、结构变更批处理和随机访问索引。记录 chunk 利用率、结构迁移、同步点、内存峰值和真机帧。版本、包、处理器配置随报告保存。

所有实验发布源码提交、Unity/UE 完整版本、编译器、后端、平台、CPU/GPU、构建优化、输入、预热、采样工具和原始 capture。无法等价实现的部分应明确写“不可直接比较”。


十六、选型清单与总结

问题Unity 候选Unreal 候选先确认
主线程少量动态序列List/arrayTArray顺序、元素布局、容量
Job/任务纯数据NativeArray/NativeListTArray/自有 buffer + Tasksowner、同步、移动失效
稳定键查找Dictionary/NativeHashMapTMaphash/equality、重复、线程
唯一成员集合HashSet/NativeHashSetTSet枚举顺序不可依赖
引擎对象引用UnityEngine.Object/资源 handleTObjectPtr/TWeak/TSoft强弱、卸载、反射可见性
大规模组件查询EntitiesMassarchetype、结构变更、句柄
编辑器/存档数据Unity 序列化 DTOUPROPERTY/版本化 DTOschema,不使用内存布局

Unity 的托管集合适合脚本与对象图,Native Collections/Entities 适合受控生命周期和数据导向热路径;Unreal 的 TArray/TMap/TSet 适合 C++ 值/指针域,UObject 容器还必须加入反射 GC 语义,Mass 则进入 ECS chunk 域。没有一个域能替代所有其他域。

真正可迁移的是业务不变量:顺序是否重要、键怎样相等、谁拥有元素、引用是否保活、何时允许结构变更、线程如何同步、存档 ID 如何稳定。只要这些写清,容器映射和实验才有意义;若只比较语法与虚构倍数,就会把不同生命周期和数据布局误当成引擎性能差距。


下一篇:BenchmarkDotNet 深度使用指南

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询