ARTICLE DETAIL

资讯详情

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

EntityComponentSystemSamples 数据驱动核心指南:Entity、Archetype、Chunk 与组件体系详解

EntityComponentSystemSamples 数据驱动核心指南:Entity、Archetype、Chunk 与组件体系详解 EntityComponentSystemSamples 数据驱动核心指南Entity、Archetype、Chunk 与组件体系详解【免费下载链接】EntityComponentSystemSamples项目地址: https://gitcode.com/GitHub_Trending/en/EntityComponentSystemSamples本文以 EntitiesSamples 项目为背景系统讲解 Unity ECSEntity Component System中实体Entity与组件Component的完整技术体系从 Entity 的本质、World/EntityManager 的管理职责到 Archetype、Chunk 的内存布局、EntityQuery 查询机制再到IComponentData、DynamicBuffer 与 Aspect 的实战定义方式。读完本文你将能够理解 Entities 包的数据存储与访问原理并在 EntitiesSamples 仓库源码的印证下写出高性能、结构正确的 ECS 组件与查询代码。概述Entity 与 GameObject 的本质区别在 Unity ECS 中Entity 是 GameObject 的轻量级非托管替代品。两者在职责上有些相似但存在关键差异Entity 不是托管对象而是一个唯一的标识数字ID。它本身不携带任何数据所有数据都存放在与它关联的组件component里。与 Entity 关联的组件通常是结构体struct值而非托管类实例。一个 Entity 对任意给定类型只能拥有一个组件。例如一个 Entity 不能同时持有两个Foo类型的组件。虽然组件的类型上可以定义方法但一般不推荐这么做——组件应当尽量保持为纯数据。Entity 没有内建的父子parenting概念。层级关系是通过标准的Parent组件包含对另一个 Entity 的引用来形成的由此构建出 Entity 变换层级。Entity 组件类型通过实现特定的接口来定义下表列出了 ECS 中最基本的几种组件种类组件种类说明IComponentData定义最常见、最基础的组件类型IBufferElementData定义动态缓冲区可增长的数组组件类型ISharedComponent定义共享组件类型其值可被多个 Entity 共享ICleanupComponent定义清理组件类型用于资源的正确初始化和销毁此外还有两个补充接口ICleanupSharedComponent与ICleanupBufferElementData以及 Chunk 组件用IComponentData定义但通过另一套方法增删值归属于整个 chunk 而非某个 Entity。用IComponentData或IBufferElementData定义的组件类型还可以通过额外实现IEnableableComponent使其变成可启停组件enableable component即对每个 Entity 单独启用或禁用该组件从而让查询跳过禁用组件对应的 Entity。关于 Enableable、Shared、Cleanup、Chunk 组件以及 Blob asset 和版本号机制本文档的姊妹篇 additional-entities-features.md 有更详细的展开这里只聚焦基础组件体系。World 与 EntityManager实体的管理容器World世界是 Entity 的集合容器。一个 Entity 的 ID 只在它所在的 World 内唯一——一个 World 中 ID 为某个值的 Entity与另一个 World 中相同 ID 的 Entity 完全无关。每个 World 还拥有一组系统System系统是运行在主线程上的代码单元通常每帧执行一次本仓库的 systems.md 对此有系统化讲解。World 中的 Entity 通常只被本 World 的系统以及这些系统调度的 Job 访问但这并非强制限制。World 中所有 Entity 的创建、销毁与修改都通过该 World 的EntityManager完成。核心方法如下方法说明CreateEntity()创建一个新的 EntityInstantiate()基于已有 Entity 复制其全部组件创建新 EntityDestroyEntity()销毁一个已有 EntityAddComponentT()为已有 Entity 添加类型为 T 的组件RemoveComponentT()从已有 Entity 移除类型为 T 的组件HasComponentT()返回该 Entity 当前是否拥有类型为 T 的组件GetComponentT()读取 Entity 上类型为 T 的组件值SetComponentT()覆写 Entity 上类型为 T 的组件值注意CreateEntity、Instantiate、DestroyEntity、AddComponent、RemoveComponent都属于**结构性变更structural change**操作会引发内存搬移应谨慎使用。在仓库示例代码 ComponentsSystems.cs 中可以看到一个典型的ISystem通过state.EntityManager访问实体OnCreate在系统创建时调用一次、OnUpdate每帧执行、OnDestroy在销毁时调用并用[BurstCompile]标记为 Burst 入口点。Archetype按组件组合分桶存储Archetype原型代表 World 中一种特定的组件类型组合World 中所有拥有相同组件类型集合的 Entity 会被存储到同一个 Archetype 中。例如所有同时拥有组件类型A、B、C的 Entity存储在一个 Archetype 中只拥有A、B没有C的 Entity存储在第二个 Archetype 中拥有B、D的 Entity存储在第三个 Archetype 中。增删组件会改变 Entity 所属的 Archetype。例如一个拥有X、Y、Z组件的 Entity 被移除Y后EntityManager会把它移动到只有X、Z的 Archetype 中如果该 Archetype 尚不存在则会自动创建。Archetype 由EntityManager在创建、修改实体时自动维护无需手动创建。即使一个 Archetype 中的实体被全部清空它也会一直保留到所属 World 被销毁。⚠️重要频繁在 Archetype 之间大量搬移实体会累积成可观的性能开销。因此结构性变更应尽量避免高频执行这通常是 ECS 性能优化的第一原则。Chunk16KiB 的内存块Archetype 中的 Entity 存储在归属于该 Archetype 的16KiB 内存块chunk中。每个 chunk 最多存储128 个 Entity具体数量取决于 Archetype 中组件类型的数量与大小。在 chunk 内部实体 ID 与每种类型的组件各自存放在独立的数组中。例如对于拥有组件类型A和B的 Archetype每个 chunk 会存储三个数组一个存放实体 ID 的数组一个存放A组件的数组一个存放B组件的数组。chunk 中第一个实体对应的 ID 和各组件值位于这些数组的下标 0第二个实体位于下标 1以此类推。chunk 内的数组始终保持紧凑排列新实体加入时存放在数组中第一个空闲下标处实体被移除时销毁或迁移到其他 Archetypechunk 中最后一个实体会被搬移过来填补空位。chunk 的创建与销毁由EntityManager统一管理仅当向一个所有 chunk 都已满的 Archetype 添加实体时才会新建 chunk仅当某个 chunk 的最后一个实体被移除时才会销毁该 chunk。任何在 chunk 内增删、搬移实体的操作都被称为结构性变更。结构性变更一般只应在主线程进行不应在 Job 中直接执行——不过可以通过EntityCommandBuffer作为变通方案Job 中记录命令主线程延迟回放。EntityQuery高效查找实体集合EntityQuery可以高效地找出所有拥有指定组件类型集合的实体。例如一个查找同时拥有A和B组件的查询会收集所有包含A与B的 Archetype 的 chunk而不关心这些 Archetype 还包含哪些其他组件——因此该查询不仅能匹配拥有A、B的实体也会匹配拥有A、B、C的实体。注意匹配查询的 Archetype 集合会被缓存直到 World 中新增 Archetype 时才失效。由于 World 中 Archetype 集合通常在程序早期就趋于稳定这个缓存通常能让查询变得非常廉价。查询还可以指定要排除的组件类型。例如查找拥有A、B但不拥有C的实体的查询会匹配拥有A、B的实体但不会匹配拥有A、B、C的实体。在仓库的查询示例中可以看到从底层 chunk 级访问到高层SystemAPI.Query的完整链路见 ComponentsSystems.csEntityQuery myQuery SystemAPI.QueryBuilder().WithAllFoo, Bar, Apple().WithNoneBanana().Build(); // 方式一取整块数据的副本ToComponentDataArray 返回的是 chunk 数据的拷贝 NativeArrayApple apples myQuery.ToComponentDataArrayApple(Allocator.Temp); NativeArrayEntity entities myQuery.ToEntityArray(Allocator.Temp); // 方式二遍历 chunk直接读写 chunk 内部数组 NativeArrayArchetypeChunk chunks myQuery.ToArchetypeChunkArray(Allocator.Temp); for (int i 0; i chunks.Length; i) { NativeArrayFoo foos chunks[i].GetNativeArray(ref fooHandle); NativeArrayEntity es chunks[i].GetNativeArray(entityHandle); for (int j 0; j chunks[i].Count; j) { foos[j] new Foo { }; // 注意GetNativeArray 返回的是 chunk 内的真实数组不能 Dispose } } // 方式三SystemAPI.Query 提供最便捷的 foreach 写法源码生成会将其翻译为等价的手写循环 foreach (var (foo, bar, entity) in SystemAPI.QueryRefRWFoo, RefROBar() .WithAllApple().WithNoneBanana().WithEntityAccess()) { foo.ValueRW new Foo { }; }对应的组件定义见 ExampleCode.csFoo、Bar携带float ValueApple、Banana是空标签组件SystemAPI.Query中ref参数代表读写访问、in参数代表只读访问。Entity ID 与版本号机制Entity ID 由结构体Entity表示。为了按 ID 查找实体World 的EntityManager维护着一个实体元数据数组每个 Entity ID 包含一个索引值index指向元数据数组中的某个槽位槽位中存储一个指向实体所在 chunk 的指针以及实体在 chunk 内的下标当某个索引处不存在实体时该槽位的 chunk 指针为null。为了让实体被销毁后索引可以被复用每个 Entity ID 还包含一个版本号version当实体被销毁时其索引处的版本号会递增。因此如果某个 ID 携带的版本号与当前存储的不一致就说明该 ID 指向的实体要么已被销毁要么从未存在过——这正是悬垂引用检测的底层机制。IComponentData最基础的组件类型最常用、最基础的组件类型是实现IComponentData接口的结构体。IComponentData结构体被要求是**非托管unmanaged**的因此不能包含任何托管字段类型。允许的字段类型包括Blittable 类型bool、charBlobAssetReferenceT对 Blob 数据结构的引用Collections.FixedString定长字符缓冲区Collections.FixedList定长数组仅允许在unsafe上下文中符合上述限制的其他结构体类型。仓库示例中典型的组件定义如下ComponentsSystems.cspublic struct EnergyShield : IComponentData { public int HitPoints; public int MaxHitPoints; public float RechargeDelay; public float RechargeRate; } public struct OnFire : IComponentData { // 没有字段的组件称为标签组件tag component // 不占用存储空间但可以像普通组件一样被查询、添加和移除。 }没有字段的IComponentData结构体称为标签组件tag component。虽然不存储数据但它可以像其他组件一样被增删这对查询非常有用——例如给所有怪物实体加上Monster标签组件后针对Monster类型的查询就能匹配到全部怪物实体。托管managedIComponentData组件用类class实现IComponentData即得到托管组件类型。与 unmanaged 结构体组件不同托管组件可以存储任意托管对象。但托管组件通常只应在确实必要时使用因为它们相比 unmanaged 组件有不少昂贵代价与所有托管对象一样托管组件不能用于 Burst 编译的代码托管对象通常不能安全地用于 Job托管组件不直接存储在 chunk 中World 中所有托管组件存放在一个巨大的数组中chunk 里只存储该数组的索引创建托管组件会产生垃圾回收GC开销。如果托管组件类型实现了ICloneable则实例被复制时其内部资源可以被正确复制如果实现了IDisposable则实例从实体移除或实体被销毁时可以正确释放其内部资源。DynamicBuffer 组件可增长的缓冲区DynamicBuffer是一种可调整大小的数组组件类型。要定义一个 DynamicBuffer 组件类型需要创建一个实现IBufferElementData接口的结构体。每个实体的缓冲区存储Length、Capacity与一个指针Length缓冲区中的元素数量。初始为0追加元素时递增。Capacity缓冲区的存储容量。初始值与内部缓冲区容量一致默认128 / sizeof(元素类型)可通过在IBufferElementData结构体上添加[InternalBufferCapacity]特性来指定。设置Capacity会调整缓冲区大小。指针指向缓冲区内容的存储位置。初始为null表示内容直接存储在 chunk 内。如果容量被设置得超过内部缓冲区容量则会在 chunk 外部分配更大的数组把内容复制过去并让指针指向这个外部数组若Length再超过外部数组容量则会再复制到另一个更大的外部数组并释放旧数组。缓冲区也可以被缩小。内部缓冲区容量以及可能存在的外部容量都会在EntityManager销毁 chunk 时一并释放。仓库示例中的 DynamicBuffer 定义与使用见 ComponentsSystems.cs// 定义 DynamicBufferWaypoint 组件类型可增长的 Waypoint 数组。 // InternalBufferCapacity 指定每个实体直接存储在 chunk 内的元素数量默认 8。 [InternalBufferCapacity(20)] public struct Waypoint : IBufferElementData { public float3 Value; } // 在系统中创建并访问 DynamicBuffer Entity entity state.EntityManager.CreateEntity(); DynamicBufferWaypoint waypoints state.EntityManager.AddBufferWaypoint(entity); // 添加组件并返回新缓冲区 waypoints.Length 100; // 设置大于当前容量的长度会自动扩容 for (int i 0; i waypoints.Length; i) { waypoints[i] new Waypoint { Value new float3() }; }注意当动态缓冲区存储在 chunk 外部时内部容量会被浪费且访问内容需要多一次指针跳转。若保证内部容量不被突破则可避免这些代价但往往需要过大的内部容量另一个方案是把内部容量设为 0——此时任何非空缓冲区都会存储在 chunk 外部虽然访问总要多跟一次指针但避免了 chunk 内空间的浪费。DynamicBuffer 的 EntityManager 方法方法说明AddComponentT()为实体添加类型 T 的组件T 可以是动态缓冲区组件类型AddBufferT()为实体添加类型 T 的动态缓冲区组件并返回新的DynamicBufferTRemoveComponentT()从实体移除类型 T 的组件T 可以是动态缓冲区组件类型HasBufferT()返回实体当前是否拥有类型 T 的动态缓冲区组件GetBufferT()将实体的类型 T 动态缓冲区组件作为DynamicBufferT返回DynamicBufferT的关键属性与方法属性或方法说明Length获取或设置缓冲区的长度Capacity获取或设置缓冲区的容量Item[Int32]获取或设置指定索引处的元素Add()在缓冲区末尾追加元素必要时扩容Insert()在指定索引处插入元素必要时扩容RemoveAt()移除指定索引处的元素安全句柄、失效与重解释为了保证 Job 安全DynamicBufferT持有安全句柄safety handle只要有访问同一缓冲区组件类型的 Job 尚未完成主线程就不能访问该DynamicBuffer的内容不过如果未完成的 Job 对该缓冲区组件类型全部只是只读访问主线程仍可读取缓冲区。任何结构性变更都会使DynamicBuffer安全句柄失效之后调用其所有方法都会抛出异常。要再次使用缓冲区必须重新获取实例见示例代码中GetBufferT重新获取的用法。DynamicBufferT还可以被重解释reinterpret但目标重解释元素类型的大小必须与 T 相同。示例中Waypoint含一个float312 字节可以被重解释为float3缓冲区两者指向相同内容DynamicBufferfloat3 floatsBuffer myBuff.Reinterpretfloat3(); floatsBuffer[2] new float3(1, 2, 3); // 等价于 myBuff[2] new Waypoint { Value new float3(1, 2, 3) };此外在 Job 中可以通过EntityCommandBuffer记录对动态缓冲区的操作AddBuffer、SetBuffer、AppendToBuffer、RemoveComponent在回放时统一生效详见 entity-command-buffers.md。Aspects组件子集的对象化封装Aspect 是对实体组件子集的类对象化封装wrapper用于简化查询和组件相关代码。例如TransformAspect把标准变换组件LocalTransform、ParentTransform、WorldTransform组合在一起。在查询中包含一个 Aspect等同于包含该 Aspect 包装的所有组件——例如包含TransformAspect的查询就包含标准变换矩阵组件。Aspect 定义为实现IAspect的只读 partial 结构体其字段可以包含以下类型字段类型说明Entity被包装实体的实体 IDRefRWT或RefROT对被包装实体 T 组件的引用读-写 / 只读EnabledRefRWT与EnabledRefROT对被包装实体 T 组件启用状态的引用DynamicBufferT被包装实体的动态缓冲区 T 组件另一个 Aspect 类型包含该 aspect 的字段会嵌入被包含的 aspect即容纳其全部字段仓库中的 Aspect 定义示例ComponentsSystems.cspublic readonly partial struct MyAspect : IAspect { // 包含实体 ID只读值类型公开字段无风险 public readonly Entity Entity; // 以读-写方式包含 Foo 组件 readonly RefRWFoo foo; // 通过属性暴露对 Foo 组件的读写 public float3 Foo { get foo.ValueRO.Value; set foo.ValueRW.Value value; } // 包含 Bar 组件的启用状态 public readonly EnabledRefRWBar BarEnabled; }创建 Aspect 实例的方法方法说明EntityManager.GetAspectT(entity)返回包装该实体的类型 T 的 AspectEntityManager.GetAspectROT(entity)返回只读 Aspect使用任何试图修改底层组件的方法或属性都会抛异常此外Aspect 实例也可以通过SystemAPI.GetAspectRWT/SystemAPI.GetAspectROT获取并直接用在IJobEntity的Execute参数或SystemAPI.Query循环中Jobs.cs 展示了IJobEntity的完整写法。⚠️重要获取 Aspect 实例通常应通过SystemAPI而非EntityManager——与EntityManager方法不同SystemAPI方法会把 Aspect 的底层组件类型注册到系统中这是系统正确调度 Job 全部依赖所必需的详见 entities-jobs.md。从文档到源码如何继续深入本文档的完整原文位于 EntitiesSamples/Docs/entities-components.md配套的补充特性Enableable、Shared、Cleanup、Chunk 组件、Blob asset、版本号见 additional-entities-features.md与组件强相关的 Job 访问、同步点与依赖机制见 entities-jobs.md可直接运行、带完整注释的示例代码集中在 EntitiesSamples/Assets/ExampleCode组件与系统示例在 ComponentsSystems.csJob 示例在 Jobs.cs更多可运行的完整样例项目Baking、Boids、Graphical、Streaming 等见 EntitiesSamples/README.md。掌握 Entity 的内存组织Archetype Chunk、组件体系IComponentData/IBufferElementData/ 共享 / 清理组件与查询方式EntityQuery/SystemAPI.Query/ Aspect之后你便拥有了读写 Entities 包一切数据的核心知识底座——无论是批量迭代、Job 并行还是 Baking 与流式加载场景都建立在这套基础之上。【免费下载链接】EntityComponentSystemSamples项目地址: https://gitcode.com/GitHub_Trending/en/EntityComponentSystemSamples创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表