ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

14-04-对比-数据结构在游戏引擎中的对比-Unity-vs-Unreal

14-04-对比-数据结构在游戏引擎中的对比-Unity-vs-Unreal Unity vs Unreal引擎数据结构的所有权、布局与调度系列C# 与常用数据结构源码剖析 · 演进与对比篇阅读时间约 90 分钟版本基线Unity2022.3 LTSMono/IL2CPP、Burst/Collections/Entities 具体包版本随项目记录与 Unreal Engine5.3。两边后续版本均持续演进私有布局和 API 应按目标 tag/包复核。比较原则只比较同一业务语义、容量、元素布局、线程与构建配置不从“C# vs C”直接推导性能不提供脱离原始报告的倍数。一、不是两套容器而是多个内存域“Unity 用 C# 集合Unreal 用 C 容器”只能解释最表层代码。实际项目至少同时存在下列域引擎域典型结构所有权/回收Unity托管脚本ListT、DictionaryTKey,TValue、class/arrayUnity 所集成托管运行时与 GCUnityEngine.Object还有原生侧身份UnityNative Collections/JobsNativeArrayT、NativeListT、NativeHashMapTKey,TValueAllocator Dispose Job 依赖和安全句柄UnityEntities ECSarchetype/chunk、组件查询、实体句柄World/Entity 生命周期与结构变更协议Unreal普通 C/RAIITArrayT、TMapK,V、TSetT、智能指针值/容器析构、allocator、RAII 或显式 ownerUnrealUObject 反射域UObject、UPROPERTY容器、强弱对象引用Unreal 的 UObject 可达性 GC 与反射元数据UnrealMass Entityarchetype/chunk、Fragment/ProcessorEntity manager、execution context 与处理阶段同一个游戏功能可跨多个域。例如 UnityMonoBehaviour持有 NativeArrayUnrealUActorComponent持有TArrayTObjectPtrUObject。这时“容器析构/Dispose”和“元素对象是否存活”是两个问题。选择结构前先画出数据由谁拥有、地址是否稳定、何时可修改、哪条线程访问、是否要被 Inspector/反射/存档看到、构建后端是什么。容器名称相似不代表契约相同。二、动态数组List、NativeList 与 TArray2.1 共同抽象与不同边界三者都可表达一段逻辑连续元素和可增长容量尾部追加通常具有摊销常数成本中间插入/删除需要移动尾部。但元素表示、生命周期和并发协议不同。维度UnityListTUnityNativeListTUETArrayT存储托管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 元素生命周期ListMyClass数组保存托管引用Clear/Remove 会清除有效引用槽实例在无根后等待 GCListMyStruct将值内联在数组中。NativeListT保存 unmanaged 值不会追踪其中托管对象。TArrayFValue直接构造/析构 FValueTArrayUObject*/TObjectPtr保存对象引用语义容器元素离开不等于立即 delete UObject。普通 C unique/shared/weak pointer 又有不同生命周期不得与 UObject 指针混用心智模型。三、键值索引Dictionary、NativeHashMap 与 TMap3.1 同语义比较才有意义比较查找前先固定键类型与大小、相等语义、哈希函数、命中率、重复策略、插入/删除分布、是否需要稳定枚举顺序、容量和线程模型。否则测到的是键设计差异不是引擎差异。UnityDictionaryTKey,TValue是托管泛型字典实现细节随 Unity BCL 代际变化不能直接套.NET 8私有字段。NativeHashMapTKey,TValue用 Native 存储并要求适合的 unmanaged 键值容量、并发写和枚举行为按 Collections 包版本核对。UETMapKey,Value建立在 UE 哈希集合布局/KeyFuncs 体系上不等同于std::unordered_map也不应画成 .NET Dictionary 的同一桶链。3.2 哈希与键不变量三者共同要求键进入表后参与相等和哈希的状态保持不变相等键得到兼容哈希。Unity 可提供IEqualityComparerTKeyUE 常通过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 hashUE 迁 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 使用声明式依赖和特定 ParallelWriterUnreal 可用锁、分片 owner、任务归并或专用并发结构。任务图/Burst 不会自动让普通容器线程安全。四、集合HashSet、NativeHashSet 与 TSet集合的语义是“按等价关系唯一”不是“无值的字典就必然相同布局”。UnityHashSetT属于托管类库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 比较。普通WeakReferenceT只描述托管对象可达性不等价于 Unity 资产的可卸载软引用。场景对象索引优先使用稳定领域 ID并在 OnDestroy/场景卸载时移除。Addressables/AssetReference 处理资产加载生命周期又是另一层不要靠弱引用替代资源系统。5.2 Unreal UObject GCUnreal 的普通 C对象可用值、unique/shared/weak pointer 和 RAIIUObject 则由反射可达性 GC 管理。被 GC 识别的强引用通常要通过UPROPERTY/TObjectPtr等符合版本规则的路径或显式引用收集接口。裸UObject*成员若不被引用系统看到不能假定保活。TWeakObjectPtr不保活 UObject访问前检查有效性TSoftObjectPtr表达可解析资产/对象路径与按需加载语义不等于普通 weak_ptr。TSharedPtr通常不用于管理 UObject 生命周期。UE 5.3 的具体推荐宏/指针类型按模块与文档确认。5.3 容器不是 owner 证明TArrayUObject*、ListUnityEngine.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 集成 GCUnity 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、分配峰值、存活对象、场景卸载后的回落和最大帧时间。元素布局常比容器品牌更重要。ListEnemyClass/TArrayUObject*是引用/指针数组对象分散NativeArrayFState/TArrayFState可内联连续值。AoS vs SoA、热冷字段分离和组件 chunk 会改变缓存行为必须用同布局比较。八、线程、Jobs 与 TaskGraph8.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 UnrealTaskGraph、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 通常用 IL2CPPUE 则是原生 AOT 构建。热更新方案若引入解释器/脚本 VM又形成新的执行域应单独比较。十、ECSEntities 与 Mass 只在同查询语义下比较Unity Entities 与 UE Mass 都采用 archetype/chunk 思想把具有相同组件组合的实体数据聚集让 Processor/System 批量访问 Fragment/Component。它们都不是简单的DictionaryEntity,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 敌人索引到 Unreal12.1 原始语义Unity 系统维护按稳定 EnemyId 查敌人按生成顺序遍历对象销毁后索引必须清除存档只保存 EnemyId 与纯数据。实现可能是ListEnemyDictionaryint,Enemy。迁到 UE 不能只换成一个 TMap因为 TMap 不承诺所需生成顺序。可以使用TArrayTObjectPtrAEnemy SpawnOrder TMapint32, TWeakObjectPtrAEnemy ById具体强弱选择取决于谁拥有 ActorWorld/关卡通常管理 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 到 UnityUE 配置使用TMapFName,FItemConfig编辑器反射/存档中键具有 FName 语义。迁 Unity 时不能简单改Dictionarystring,Config先定义 FName 的大小写、规范化和序列化身份再选择StringComparer或导入期生成整数 ID。运行期若只读且高频可在加载阶段验证重复键并构造 Dictionary/数组索引Inspector 资产使用可序列化 DTO 列表OnValidate/导入器生成运行时索引。不要依赖 Dictionary 枚举顺序还原 UE 编辑器展示顺序单独保存 order 字段/数组。若 Burst Job 需要配置把只读纯数据烘焙到 BlobAsset/NativeArray而不是把 Dictionarystring,class 直接传 Job。管理 Blob/Native 生命周期和版本。测试文化/大小写、重复、旧资产、IL2CPP 裁剪和真机读取。十四、常见反例把 Unity IL2CPP 写成分代、精确、压缩 GC并由此预测停顿。声称 Unreal 没有 GC忽略 UObject 引用图和回收。用std::vector/unordered_map代替 TArray/TMap 描述 UE 私有实现。只比较 List 与 TArray 名称元素一边是 class、一边是内联 struct。用 RemoveAtSwap 迁移保序系统破坏确定性和索引。缓存任何容器内部元素地址扩容后继续使用。将普通 Dictionary/TMap 从多个任务并发写入。用TSharedPtr管 UObject或裸 UObject 指针成员假定被 GC 强引用。用 WeakReference 代替 Unity Addressables 资产生命周期。持久化 InstanceID、Entity handle、指针或哈希值作为长期 ID。依赖 HashSet/TSet/TMap 的偶然枚举顺序做网络协议。把 NativeArray/Burst 内核与包含 UObject 工作的整帧直接比较。忽略 Reserve 后常驻峰值和 allocator 缓存只看 GC Alloc。热重载后运行正常就不做冷启动/打包资产迁移。引用没有源码、硬件和项目的固定性能倍率。十五、同语义真机实验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 handleUE 对 UObject 强、弱、软引用及普通 shared/unique owner 分组。经过场景/关卡卸载和显式 GC/资源流程后用 Memory Profiler/Unreal Insights 与对象工具验证谁仍被引用。目的不是比较 GC 速度而是验证 owner 契约。15.4 Jobs/TaskGraph相同纯数据 kernel、相同 worker/实体数分别测调度、输入准备、执行、同步和输出提交。Unity 记录 Burst 开关、Safety Checks、Job batchUE 记录 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 深度使用指南
返回列表