FEATURED · 精选文章

Unity DOTS 中 World 概念详解:从生命周期到自定义实践

发布时间 / 2026/9/10 1:25:58
来源 / 创域科博编辑部
栏目 / 资讯中心
Unity DOTS 中 World 概念详解:从生命周期到自定义实践 如果你刚接触 Unity DOTS打开官方文档第一眼看到的十有八九是这个词World世界。我最早看 DOTS 文档的时候被“世界”“系统组”“实体”这些词绕得够呛尤其是 World总觉得它像是一个抽象空间上的“宇宙”概念跟自己的项目代码搭不上边。后来在帧同步项目里真正动手用了 DOTS才慢慢明白 World 到底是什么、它管了哪些事、哪些坑是绕不开的。这篇文章我会完全围绕 Unity DOTS 中的 World 概念展开讲清楚它的定位、默认 World 是怎么来的、如何创建自定义 World、生命周期里有哪些容易踩的雷以及 World 在性能层面到底做了什么。适合刚接触 DOTS 但被 World 绕晕的开发者也适合已经写了几个 ECS 示例、想深入理解底层设计的同学参考。1. 先搞清楚World 到底是干什么的1.1 一个装实体、组件和系统的“容器”很多人第一次接触 ECS会觉得 Entity 是主角System 是大脑Component 是数据这三者的关系很好理解。但 World 夹在中间很多人就懵了你到底管什么我用一个偏向实际工程的比喻来解释World 就像一个独立的“办公室”。办公室里有自己的文件柜Archetype 和 Chunk 内存有固定员工System有前台统一管理出入人员EntityManager。不同办公室之间文件是隔离的员工也不能随便跑去别的办公室翻文件。你的游戏世界里可以有很多间这样的办公室每一间都相对独立、有序运转。对应到 DOTS 里World 是实体、组件数据、系统、查询缓存、内存分配器的总容器。每个 Entity 必须归属于某个 World每个 System 也一定运行在某个 World 环境里。你通过World.EntityManager创建实体、添加组件通过World创建和获取系统也是通过 World 把系统的更新排进统一节奏里。这个设计解决了一个很实际的问题当你的项目里有多个逻辑域时比如一个服务端模拟世界、一个客户端表现世界、一个后台批量计算世界如果没有 World 做边界隔离所有实体和系统混在一起数据边界、更新顺序、内存生命周期全都乱套。World 相当于给你划出了清晰的代码和数据的“项目边界”。1.2 World 和 Entity、Component、System 之间的关系这里我把 DOTS 里几个核心概念的依赖关系捋一下方便新手建立整体框架Entity只是一个 ID本身不存任何数据。它标识“世界里有这么个东西”但具体是什么完全由挂载的 Component 决定。Component是真正存数据的地方但数据不是零散存在堆里的而是被紧凑地排列进 Chunk 的内存块里同一个 Archetype实体组件组合类型的实体会被放进同一类 Chunk。System是逻辑处理器它拿到 EntityQuery从 World 管理的 Chunk 里取出批量数据做转换、计算、过滤。World是上面所有东西的大本营。它知道有哪些 Archetype、分配了哪些 Chunk、创建了哪些系统、系统的更新顺序是什么。所以你在代码里写world.EntityManager.CreateEntity()本质上是让这个 World 在它自己的内存体系里分配一个 ID并给这个 ID 挂上组件。你写world.CreateSystemManagedMySystem()这个系统就会被纳入这个 World 的系统列表之后由 World 统一驱动更新。从内存角度看World 是最上层的“持有者”。它持有EntityManager持有 Archetype 列表、Chunk 列表、系统列表、查询缓存。所以销毁一个 World等于一次性释放这个边界里的所有实体数据和系统资源。这也是为什么多 World 管理得当的话模块卸载和重置会非常干净。2. 默认 World 与更新机制2.1 启动时默认会创建哪几个 World只要你装了 Entities 包并进入 Play ModeUnity 会自动创建默认 World 体系。不同版本细节不太一样但大体上你会看到这么几个Default World最核心的世界大部分示例代码都是在这个世界里跑的也是World.DefaultGameObjectInjectionWorld返回的那个世界。如果启用了 NetCode 相关模块还可能自动创建ClientWorld和ServerWorld用于客户端服务端双端逻辑分离。编辑器环境下还有一些用于 Baking实体烘焙和 Conversion 的世界负责把 GameObject 转成 ECS 实体。其中你平时打交道最多的是 Default World。在代码里直接写World.DefaultGameObjectInjectionWorld拿到的就是它。这个静态属性在运行时一般不会为空但在编辑器脚本执行顺序比较早的代码里你可能会拿到 null这个我后面在常见问题里专门说。默认 World 的出现让新手可以完全不用手动创建任何 World直接写[UpdateInGroup(typeof(SimulationSystemGroup))]然后挂个 SystemBase就能跑起来。但这也带来一个副作用很多人写了很久 ECS都没意识到 World 的存在直到同时使用多个 World 时才发现“系统怎么没跑”“查询怎么查不到数据”这类诡异问题。2.2 系统组系统是怎么被排进时间线的World 里系统不是一锅粥乱跑的而是按照系统组SystemGroup分层组织。默认情况下Unity 会把系统排进三大系统组依次每帧更新InitializationSystemGroup初始化相关比如处理BeginInitializationEntityCommandBufferSystem适合放一些重置标记、处理输入等最先执行的逻辑。SimulationSystemGroup核心模拟逻辑大多数 gameplay 系统、物理系统、网络同步都挂在这里。PresentationSystemGroup表现相关比如动画、渲染数据同步。你自定义的系统可以通过[UpdateInGroup(typeof(SimulationSystemGroup))]这类 Attribute 把自己挂进对应分组。World 在更新时会按系统组的嵌套顺序遍历组内系统之间还可以用[UpdateBefore]、[UpdateAfter]控制先后。这个机制对 World 特别重要因为一个 World 的“心跳”就是它内部所有系统和系统组的更新。当你手动创建自定义 World 时你可以选择让这个 World 使用默认的三组结构也可以自定义系统组甚至手动控制某个系统的更新频率。这在做服务端模拟、定步长逻辑、离线批量计算时非常有用。2.3 为什么不要跨 World 访问数据多 World 最大的特点就是数据隔离。你在 World A 里创建的实体World B 的 EntityQuery 是查不到的。原因很简单World B 的内存池里根本没有这些实体和 Chunk它的查询缓存也不会包含 A 的数据。很多新手误以为“实体都是 ECS 里的应该全局统一”结果在 World B 里查询不到任何实体就开始怀疑人生。我用表格把常见跨 World 操作的结果列一下操作场景实际结果原因World B 的 EntityQuery 查询 World A 创建的实体查不到查询只作用于当前 World 的 Chunk用 World A 的 EntityManager 访问 World B 的实体大概率报错或得到无效数据EntityManager 绑定特定 WorldWorld B 里更新一个依赖 World A 数据的系统表现异常数据不完整系统框架默认只访问当前 World 的组件通过EntityManager.Instantiate把一个 A 的实体复制到 B不会自动跨世界复制复制操作仅限同一个 World 内部真要跨 World 传数据正规做法是在系统里用EntityCommandBuffer或者自己写同步逻辑在帧末把需要共享的数据拷贝过去而不是直接操作另一个 World 的内存。这个约束看似麻烦实际上能避免大量因为数据边界不清导致的并发问题。3. 创建属于自己的 World3.1 什么时候值得自定义 World默认 World 够用但很多场景下你会需要第二个甚至更多 World。我根据自己的项目经验把典型的几个场景列出来帧同步/网络同步服务端逻辑放一个独立 World客户端表现放默认 World两套逻辑用固定步长驱动状态只同步必要数据。这样预测、回滚、快照都容易实现。数字孪生或多场景模拟一个场景对应一个 World切换场景时直接销毁重建 World比手动清理一堆实体干净得多。离线批量计算比如寻路、批量碰撞检测、AI 决策模拟可以放一个后台 World自己控制更新节奏不影响主游戏循环。多团队并行开发不同玩法系统分属不同 World减少数据耦合避免团队间频繁改 shared 组件。不过我要提醒一句多 World 会带来额外的内存开销和调度复杂度如果你的项目只有一个世界也够用千万别为了“设计感”硬拆几个 World。3.2 一个可运行的自定义 World 示例下面我以 Entities 1.x 为例写一个最简的自定义 World 创建流程代码可以直接放到一个测试脚本里跑using Unity.Entities; using Unity.Collections; using UnityEngine; public class CustomWorldDemo : MonoBehaviour { private World m_MyWorld; void Start() { // 1. 创建带名字的 World m_MyWorld World.Create(MySimulationWorld); // 2. 创建系统并让它进入默认的 SimulationSystemGroup var systemHandle m_MyWorld.CreateSystemManagedMySampleSystem(); var simGroup m_MyWorld.GetExistingSystemManagedSimulationSystemGroup(); simGroup.AddSystemToUpdateList(systemHandle); // 3. 在自定义世界里创建实体 var entity m_MyWorld.EntityManager.CreateEntity(typeof(MyTagComponent)); // 4. 给实体写点数据 m_MyWorld.EntityManager.SetComponentData(entity, new MyTagComponent { Value 42 }); } void Update() { // 5. 手动驱动这个 World 更新也可以放到 FixedUpdate 做定步长 if (m_MyWorld ! null m_MyWorld.IsCreated) { m_MyWorld.Update(); } } void OnDestroy() { // 6. 销毁 World 会释放内部所有数据 if (m_MyWorld ! null m_MyWorld.IsCreated) { m_MyWorld.Dispose(); m_MyWorld null; } } } public struct MyTagComponent : IComponentData { public int Value; } public partial class MySampleSystem : SystemBase { protected override void OnUpdate() { Entities.ForEach((ref MyTagComponent tag) { tag.Value 1; }).Run(); } }这里有几个细节要注意World.Create(MySimulationWorld)创建出来的是普通 World如果你想要一个“模拟型”世界还可以使用WorldFlags.Simulation之类的标记不同 flag 会影响它在编辑器里是否参与某些默认流程。CreateSystemManaged创建的是托管系统SystemBase。如果你用ISystem要改用CreateSystemT()返回的是SystemHandle操作方式略有不同。自定义 World 不会自动被你MonoBehaviour的 Update 驱动你必须显式调用Update()。不调用的话系统永远不执行。一定要在销毁时调用Dispose()否则 World 里分配的原生内存会泄漏。3.3 自定义 World 里的常用操作创建完 World 后你的大部分操作都集中在World.EntityManager和系统查询上。我把自己项目里常用的几个操作列一下创建实体并添加组件EntityManager.CreateEntity(typeof(ComponentA), typeof(ComponentB))批量创建实体EntityManager.CreateEntity(archetype, count, allocator)或者配合NativeArrayEntity做批量创建性能比单次循环高很多。获取唯一系统World.GetExistingSystemManagedMySystem()如果你不确定系统是否已创建先判断一下返回是否 default。按组件类型做查询在自定义系统里直接用SystemAPI.QueryBuilder()或者手动World.EntityManager.CreateEntityQuery(typeof(MyTagComponent))。控制更新频率如果你的模拟是定步长的比如每秒 20 次可以不用每帧调用Update()而是攒够时间再调用这就是帧同步的常见做法。另外自定义 World 里如果有多个系统也可以用[UpdateInGroup(typeof(SimulationSystemGroup))]在类上声明然后在创建后手动加入对应系统组。这样系统的执行顺序逻辑和默认 World 保持一致代码维护起来更省心。4. World 生命周期里的各种坑4.1 Dispose 顺序与悬挂引用我最初踩过最深的坑就是 World 销毁顺序问题。假设你有两个 WorldA 和 BA 里的系统持有 B 的 EntityManager 引用或者持有从 B 查出来的ComponentLookup。如果 B 先被 Dispose 了A 的系统下一帧一跑直接访问已经释放的原生内存轻则异常重则 Editor 直接崩掉。正确的做法是系统里不要缓存其他 World 的 EntityManager需要时在OnUpdate里临时获取。如果必须缓存跨 World 数据监听目标 World 的销毁事件或者轮询IsCreated。多 World 销毁时严格按照依赖方向逆序释放先销毁依赖方再销毁被依赖方。还有一点World 销毁后World.DefaultGameObjectInjectionWorld可能已经被替换或置空所以系统OnDestroy里别再尝试拿默认世界做清理直接用自己持有的 World 句柄。4.2 DefaultGameObjectInjectionWorld 为空很多刚用 DOTS 的人会遇到“运行时 DefaultGameObjectInjectionWorld 是 null”的问题。通常由这几种情况引起代码执行时机太早比如[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]里就去访问默认世界。某次脚本重编译后世界还没被重新创建。你手动销毁了默认 World又没重建。解决办法也很简单代码里先判空如果确实需要在早期访问可以在自己的MonoBehaviour的Awake或OnEnable里去访问那时候默认世界一般已经就绪。如果你手动销毁了默认 World那就要自己负责重建或者改用自定义 World 作为主世界。4.3 EntityQuery、ComponentLookup 与 World 不匹配这个问题很隐蔽。你把 World A 的EntityQuery传给 World B 的系统用看起来没问题但实际查询结果为空因为 query 对象内部绑定的是 A 的查询缓存和 Chunk 列表。ComponentLookup也有类似问题如果你用WorldA的 lookup 去读 World B 里的实体组件数据大概率不对。排查思路是在任何系统里创建 query 或 lookup 时确认它们来自当前 World。使用SystemAPI.QueryBuilder()或World.EntityManager.CreateEntityQuery()创建不要跨 World 传递查询对象。如果确实需要跨 World 读数据写一个专门的数据同步系统在帧末从源 World 拷贝到目标 World。4.4 World 常见问题速查表常见问题可能原因解决思路自定义 World 里系统不跑没有调用World.Update()在 Update/FixedUpdate 手动调用或挂到某个驱动循环系统重复执行多次同一个系统被加进了多个系统组检查 UpdateInGroup 属性确保只加入一个组Editor 脚本运行时默认 World 为 null访问时机太早延后到 Awake/OnEnable或手动判空兜底Dispose World 后报访问已释放内存其他系统还持有该 World 的引用按依赖顺序释放系统不要缓存跨 World 引用两个 World 间查不到实体数据隔离机制设计同步系统在帧末拷贝共享数据World.Dispose 后主循环还在 Update没有在 OnDestroy 中断言销毁后置空引用更新前检查IsCreated5. World 背后的性能与内存管理5.1 World 内部是怎么管理内存的DOTS 之所以快一个重要原因是内存布局是面向数据设计的。这个布局的最上层组织者就是 World。在每个 World 内部实体按照 Archetype 分类。同一个 Archetype 的实体数据会被连续放进 Chunk 里每个 Chunk 的容量是固定的默认大概 16KB组件的存储位置可以通过 Chunk 的元数据快速定位。World 持有这些 Chunk 的列表、空闲块、Archetype 注册表以及查询缓存。这个设计带来的好处是遍历同类型实体时CPU 缓存命中率高不会像普通 GameObject 那样到处跳地址。批量创建、销毁、Instantiate 很快因为只需要操作内存块。World 级别的一次性释放可以批量归还所有原生内存避免逐实体释放的开销。从资源管理角度看World 是 DOTS 内存分配的“根”。它内部会使用Allocator管理原生内存所以创建一个 World 的成本不是零里面会初始化一堆结构。这也说明你不应该频繁创建销毁 World最好是生命周期和游戏模式关卡、场景、服务器会话保持一致。5.2 用 World 做性能优化的一些实操习惯我总结几个跟 World 强相关的性能优化习惯都是项目里实测有效的不要在 Update 里每帧创建新的 EntityQuery。World 内部有查询缓存但每次显式创建查询对象都会有开销。正确做法是在系统OnCreate时创建一次存字段后续复用。按需更新而不是无脑 World.Update()。如果自定义 World 里只有一个系统需要高频跑其他系统低频跑可以考虑拆分 World或者只更新需要的系统。我做过一个后台寻路 World里面只跑一个寻路系统更新频率完全由业务控制帧率压力小很多。多 World 并行时注意主线程瓶颈。每个 World 的Update()默认由主线程驱动系统内部才能通过 Job 并行。如果你同时有三个 World 都在 Update它们的系统调度本身会占用主线程时间。这种情况下可以把后台 World 的更新放到工作线程或者用EntityCommandBuffer批量提交结果减少主线程等待。善用 Burst 和 Jobs。World 只负责组织数据真正的性能大头在IJobEntity或ISystem里开启 Burst 编译。我用IJobEntity批量处理几十万实体时开了 Burst 之后性能提升非常明显这一点比优化 World 数量更值得投入。调试时关注 World 的 Memory 占用。Unity 的 Profiler 里可以看到每个 World 的堆内存分配情况。我曾经发现自定义 World 里有一个系统每帧都创建 NativeArray 但不释放导致内存曲线直线上升。这种问题如果不按 World 维度去查很难定位是哪个系统漏了 Dispose。写在最后我自己的项目里最常用的套路是默认 World 负责客户端表现自定义一个固定步长的模拟 World 负责核心玩法逻辑两个世界之间通过独立的同步数据结构交换状态。这套架构帮我解决了很多帧同步里头疼的问题也让不同模块的开发边界变得非常清晰。踩过几次坑之后我的体会是World 不是抽象概念它就是一个需要你认真管理生命周期的运行时容器。创建要克制销毁要及时跨 World 的数据交换要走正规同步路径不要为了“复用代码”硬跨世界访问。最后再分享一个小技巧调试多 World 项目时给每个 World 命名时加上业务标识比如SimWorld_FrameSync、BackendPathfinding然后在 Unity 的 Entity Debugger 里按名字筛选能帮你节省大量排查时间。很多人开发到后期光靠世界名字就能一眼看出数据是不是放对了地方。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻