# dots-tutorials **Repository Path**: xuzimian/dots-tutorials ## Basic Information - **Project Name**: dots-tutorials - **Description**: No description available - **Primary Language**: C# - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2025-09-13 - **Last Updated**: 2025-09-21 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Introduce ## Scene Unity Scene: 1. Single 单个场景 2. Additive 子场景,只是抽象意义上的子场景 DOTs中增加了SubScene,它是DOTs下特有的场景概念: 1. Authoring Scene: 在Editor模式中SubScened打开的模式下,会进入Authoring Scene模式,用来添加Authoring元素. 2. Entity Scene: 在Editor模式中SubScened关闭的模式下,会触发Bake过程,并进入Entity Scene模式 3. 因此在Editor模式中SubScened打开/关闭模式下,Entities Hierarchy窗口会看到不同的呈现. 4. SubScene有 Auto Load Scene选项,可以用于控制运行时SubScene按需加载.当其关闭时,且SubScene关闭时,运行对象是不会加载. ### Scene System #### Scene Streaming **Unload Scene:** - 推荐使用Scene Meta Entity - 不推荐使用Scene GUID,EntitySceneReference - 如果加载同一个场景的多个实例,通过GUID卸载只能卸载其中一个实例 - 默认SceneSystem.UnloadScene只卸载sections内容,并保持场景scene的sections和meta entities - 要卸载content和删除meta entities时应使用UnloadParameters.DestroyMetaEntities调用SceneSystem.UnloadScene - SceneSystem.UnloadScene会导致Structural Changes. ### Section DOTs Scene 结构: - Unity Scene - SubScene - Section(ISharedComponent) SubScene Scene section上Entity的交叉引用关系 - 跨Section的entity的引用 - Section0里的entity可以被其他Section的entity引用 - 单个section内的entity之间可以彼此引用 - 除section0外的其他section间的entity彼此是不能引用的. ## Entity 在 DOTS(Data-Oriented Technology Stack)中,Entity(实体) 是 ECS(实体 - 组件 - 系统)架构的核心概念之一,它本质上是一个 **“空容器” 或 “唯一标识符”** ,用于关联多个 Component(组件)来描述一个游戏对象的属性和行为。与传统 Unity 的GameObject和普通数据类相比,Entity 的设计理念和功能有本质区别。 > GameObject 不能直接作为 Entity:传统GameObject是 Unity 的面向对象产物,而 Entity 是 ECS 的数据导向对象,二者本质不同。 > Baker 的作用是 “转换” 而非 “承载”:通过 Baker(如Baker > ),可以将GameObject上的特定数据(从AuthoringComponent中提取)转换为 > ECS 的IComponentData,并关联到一个新创建的 Entity上。 > “实体” 的核心是 “Entity + 组件集合”,而非单纯的属性转换 > - 一个 “实体”(在 ECS 语境中)的完整定义是:**一个Entity对象 + 它所关联的所有ComponentData。** > - 单纯的 “属性转换为 ComponentData” 并不构成实体,必须有一个Entity作为这些组件的关联载体(通过唯一 ID 绑定)。 > 在 DOTS 的 ECS 架构中,Entity(实体) 和 Component(组件) 是紧密关联的核心要素,二者的关系可以概括为:Entity 是组件的 “容器” > 或 “关联载体”,Component 是实体的数据载体,一个实体通过关联多个组件来定义自身的属性和行为。这种关系是 ECS 实现 “数据与逻辑分离” > 的基础。 **核心关系:“实体 - 组件” 是 “标识符 - 数据” 的绑定** 1. Entity:组件的组织者和唯一标识 Entity 本身不存储任何数据,它本质是一个唯一标识符(类似 ID),用于将多个 Component “绑定” 在一起,形成一个完整的 “游戏对象” 概念。 例如:一个 Entity 可以同时关联 Position(位置)、Velocity(速度)、Health(生命值)三个组件,这三个组件共同描述了 “一个可移动、有生命的实体”(如玩家、敌人)。 2. Component:实体的数据载体 Component 是纯数据结构(通常是struct),仅包含字段(如float3 position、int health),用于描述实体的某一具体属性。一个 Component 无法单独存在,必须 “依附” 于某个 Entity。 例如:Position组件不能单独代表 “一个位置”,只有当它与某个 Entity 关联时,才表示 “该实体的位置”。 3. **一对多关系(一个 Entity 关联多个 Component):** 一个 Entity 可以关联任意数量的 Component(零个或多个),但同一类型的 Component 最多只能关联一个(例如,一个实体不能有两个Position组件)。 4. **动态关联(可随时添加 / 移除 Component)** Entity 与 Component 的关联是动态的,可在运行时通过EntityManager添加或移除组件,实时改变实体的属性和 “类型”。 5. **组件组合定义实体 “类型”** ECS 中没有传统意义上的 “类继承”,实体的 “类型” 由其关联的组件组合决定。两个实体若关联完全相同的组件组合,则视为 “同类型实体”(由 ECS 自动归类到同一Archetype)。 如: ```C# struct RotateSpeed : IComponentData { public float rotateSpeed; } public class RotateSpeedAuthoring : MonoBehaviour { [Range(0, 360)]public float rotateSpeed = 360.0f; public class Baker : Baker { public override void Bake(RotateSpeedAuthoring authoring) { var entity = GetEntity(TransformUsageFlags.Dynamic); var data = new RotateSpeed { rotateSpeed = math.radians(authoring.rotateSpeed) }; AddComponent(entity,data); } } } ``` **DOTS 中 Entity 的核心特性: ** 1. 无数据、无逻辑:Entity 本身不存储任何数据,也不包含任何方法,仅作为一个 “标签” 或 “ID” 存在,用于关联组件。 2. 组件的集合载体:一个 Entity 通过关联多个 Component(如Position、Health、Velocity)来定义自身的属性(例如,关联Position和Velocity的 Entity 可被视为 “可移动的对象”)。 3. 轻量高效:Entity 的内存占用极小(本质是一个整数 ID),创建和销毁的成本远低于GameObject。 4. 动态组合性:可在运行时动态添加 / 移除组件,改变 Entity 的 “类型”(例如,给 Entity 添加EnemyTag组件使其变为敌人)。 Entity 与 GameObject的区别 1. 与传统GameObject的区别 | 特性 | DOTS 中的 Entity | 传统GameObject | |------|----------------------------------|---------------------------------| | 本质 | 无数据的标识符,仅关联组件 | 包含数据和逻辑的复杂对象,有自身生命周期 | | 内存占用 | 极小(仅 ID),创建 / 销毁成本低 | 较大(包含 Transform、组件列表等),操作成本高 | | 数据存储 | 数据存储在独立的 Component 中,与 Entity 关联 | 数据存储在挂载的MonoBehaviour组件中 | | 逻辑处理 | 由 System 通过查询组件处理逻辑 | 逻辑封装在MonoBehaviour的方法中(如Update) | | 灵活性 | 可动态添加/移除组件改变 “类型” | 组件挂载/卸载成本高,且GameObject本身类型固定 | ### 遍历查询Entity的多种方法 在 DOTS(ECS)中,遍历和查询 Entity 是系统(System)处理数据的核心操作。根据不同的使用场景和性能需求,有五种常用的查询方式,各有其特点和适用场景: 1. SystemAPI.Query(推荐,简洁高效),SystemAPI.Query是 Unity 2022 + 推出的现代查询方式,语法简洁,适合大多数场景,内部会自动处理 Job 调度和线程安全。 - 支持foreach语法,代码可读性高; - 自动生成高效的 EntityQuery,无需手动创建; - 可通过WithAll/WithNone/WithAny筛选组件; - 支持in(只读)、ref(读写)关键字控制访问权限。 ```C# public partial struct MovementSystem : ISystem { public void OnUpdate(ref SystemState state) { float deltaTime = SystemAPI.Time.DeltaTime; // 查询所有包含Position和Velocity组件的实体 foreach (var (pos, vel) in SystemAPI.Query, in Velocity>()) { // 读写Position,只读Velocity pos.ValueRW.Value += vel.Value * deltaTime; } // 带筛选条件:包含EnemyTag,且不含FrozenTag foreach (var health in SystemAPI.Query>() .WithAll() .WithNone()) { health.ValueRW.Current -= 1; // 敌人持续掉血 } } } ``` 2. IJobEntity(Job 级封装,兼顾灵活与性能) 本质:显式定义 Job 结构体,实现IJobEntity接口,手动调度但语法仍较简洁。 - 比SystemAPI.Query更灵活,可自定义 Job 逻辑; - 需手动调用Schedule(),支持依赖管理; - 适合需要在 Job 中添加额外逻辑(如临时变量、复杂计算)的场景。 ```C# // 定义Job public partial struct MoveJob : IJobEntity { public float DeltaTime; // 自动匹配包含Position和Velocity的实体 void Execute(ref Position pos, in Velocity vel) { pos.Value += vel.Value * DeltaTime; } } // 在系统中调度 public partial struct MovementSystem : ISystem { public void OnUpdate(ref SystemState state) { new MoveJob { DeltaTime = SystemAPI.Time.DeltaTime } .Schedule(); // 手动调度 } } ``` 3. Entities.ForEach(传统方式,兼容旧代码): Entities.ForEach是早期 DOTS 的查询方式,在SystemBase中常用,目前仍可使用但推荐优先SystemAPI.Query。 - 基于 lambda 表达式,支持链式调用; - 需要显式调用Schedule()或Run()调度执行; - 适合从SystemBase迁移的旧代码。 ```C# public partial class LegacySystem : SystemBase { protected override void OnUpdate() { float deltaTime = Time.DeltaTime; // 遍历所有带Position和Velocity的实体 Entities .WithAll() .ForEach((ref Position pos, in Velocity vel) => { pos.Value += vel.Value * deltaTime; }) .Schedule(); // 调度到多线程执行 } } ``` 4. IJobChunk(Chunk 级 Job,极致性能),本质:直接操作ArchetypeChunk的底层 Job,完全手动处理 Chunk 和组件数组。 - 性能最优,直接访问连续内存块,适合大规模实体; - 代码最复杂,需手动解析 Chunk 中的组件数据和实体索引; - 适合高频、批量处理(如物理模拟、渲染数据准备)。 ```C# // 定义Chunk级Job public struct MoveChunkJob : IJobChunk { public float DeltaTime; public ComponentTypeHandle PositionHandle; public ComponentTypeHandle VelocityHandle; public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask) { // 获取Chunk内的组件数组 NativeArray positions = chunk.GetNativeArray(ref PositionHandle); NativeArray velocities = chunk.GetNativeArray(ref VelocityHandle); // 遍历Chunk内所有实体 for (int i = 0; i < chunk.Count; i++) { positions[i] = new Position { Value = positions[i].Value + velocities[i].Value * DeltaTime }; } } } // 在系统中调度 public partial struct ChunkSystem : ISystem { public void OnUpdate(ref SystemState state) { // 创建查询 EntityQuery query = SystemAPI.QueryBuilder() .WithAll() .Build(); // 调度Job new MoveChunkJob { DeltaTime = SystemAPI.Time.DeltaTime, PositionHandle = state.GetComponentTypeHandle(false), VelocityHandle = state.GetComponentTypeHandle(true) }.ScheduleParallel(query, state.Dependency); } } ``` 5. EntityCommandBuffer(延迟查询与操作):EntityCommandBuffer(ECB)用于在 Job 中延迟执行实体操作(如创建 / 删除实体、添加 / 移除组件),常配合查询使用。 - 支持在多线程 Job 中安全地修改实体; - 操作会在主线程的特定阶段执行(避免线程冲突); - 适合需要在遍历中动态修改实体结构的场景。 ```C# public partial struct DestroySystem : ISystem { public void OnUpdate(ref SystemState state) { // 创建延迟命令缓冲区 EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp); // 查询所有生命值为0的实体 foreach (var (health, entity) in SystemAPI.Query().WithEntityAccess()) { if (health.Current <= 0) { // 延迟销毁实体(在主线程执行) ecb.DestroyEntity(entity); } } // 执行所有延迟命令 ecb.Playback(state.EntityManager); ecb.Dispose(); } } ``` 6. Manually(手动遍历,极少使用):完全手动获取实体数组和组件数据,不依赖 Job 系统,通常在主线程执行。 - 性能最差,不适合大规模实体(会导致主线程阻塞); - 仅用于特殊场景(如调试、初始化阶段的小规模操作)。 ```C# public partial struct ManualSystem : ISystem { public void OnUpdate(ref SystemState state) { // 手动获取实体和组件数组(主线程执行) EntityQuery query = SystemAPI.QueryBuilder().WithAll().Build(); NativeArray entities = query.ToEntityArray(Allocator.Temp); NativeArray positions = query.ToComponentDataArray(Allocator.Temp); // 手动遍历 for (int i = 0; i < entities.Length; i++) { // 处理逻辑... } // 手动释放内存 entities.Dispose(); positions.Dispose(); } } ``` 五种方法的对比与选择 | 方法 | 优点 | 缺点 | 适用场景 | |--------------------------|--------------------|----------------------|--------------------------| | SystemAPI.Query | 简洁高效,自动调度,推荐首选 | 灵活性略低 | 大多数日常逻辑,中等规模实体 | | EntityQuery (IJobEntity) | 灵活可控,支持复杂筛选,可复用 | 代码稍繁琐 | 复杂查询条件,需要复用查询的场景 | | Entities.ForEach | 兼容旧代码,链式调用 | 性能略逊于SystemAPI.Query | 从SystemBase迁移的旧系统 | | Chunk | 级遍历 性能最优,适合批量处理 | 代码复杂,需手动管理索引 | 大规模实体(如 10000+),高频操作(渲染) | | EntityCommandBuffer | 支持多线程安全修改实体 有一定延迟, | 需手动释放 | 遍历中需要创建 / 删除实体、修改组件的场景 | > **总结** > - 优先使用 **SystemAPI.Query**,平衡简洁性和性能; > - 复杂查询或需要复用时用 **EntityQuery**; > - 大规模实体批量处理用Chunk 级遍历; > - 多线程中修改实体用 **EntityCommandBuffer**; > - 旧代码兼容用 **Entities.ForEach**。 ## Component 在 DOTS(Data-Oriented Technology Stack)中,Component(组件) 是数据的核心载体,也是 ECS(Entity-Component-System)架构的三大支柱之一(Entity、Component、System)。与传统 Unity 中MonoBehaviour不同(既包含数据又包含逻辑),DOTS 的 Component 是纯数据容器,仅存储字段(如位置、速度、生命值等),不包含任何方法,逻辑由 System 单独处理。 **Component 的核心特性:** 1. 数据即组件:Component 本质是 “数据的集合”,用于描述实体(Entity)的某一属性。例如: - Position组件存储实体的三维坐标; - Health组件存储实体的生命值和最大生命值; - Velocity组件存储实体的移动速度和方向。 2. 与实体的关联:一个实体可以关联多个 Component(类似 “标签 + 数据” 的组合),System 通过查询 “拥有特定 Component 组合的实体” 来处理数据。例如: - 一个 “敌人” 实体可能关联Position、Health、EnemyTag、Velocity组件; - 移动系统会查询所有关联Position和Velocity的实体,更新其位置。 3. 非托管类型优先:为了适配 DOTS 的高性能需求,Component 通常定义为非托管struct(满足 blittable 条件),避免 GC(垃圾回收)并支持内存连续存储。 ### Component 的种类划分 #### DOTS 根据功能和使用场景划分 1. IComponentData:核心数据组件 定义:最基础、最常用的组件类型,通过实现IComponentData接口定义,用于存储实体的核心状态数据。 特点: - 必须是struct(值类型),且为非托管类型(不含引用类型字段); - 每个实体最多包含一个同类型的IComponentData实例(如一个实体只能有一个Position组件); - 存储在连续内存块中,适合高频访问和批量处理。 ```C# // 位置组件:存储三维坐标 public struct Position : IComponentData { public float3 Value; // float3是Unity.Mathematics中的非托管类型 } // 速度组件:存储移动速度 public struct Velocity : IComponentData { public float3 Value; } ``` - 适用场景:实体的核心属性(位置、旋转、生命值、速度等),需要被系统频繁读写的关键数据。 2. ISharedComponentData:共享数据组件 定义:实现ISharedComponentData接口的组件,用于存储多个实体可共享的数据(如材质、网格等资源引用)。 特点: - 可以是struct或class(但推荐struct以减少 GC); - 多个实体可以共享同一个ISharedComponentData实例(数据在内存中只存储一份); - ECS 会自动将共享相同ISharedComponentData的实体分组,优化查询效率(如渲染系统按材质分组渲染)。 ```C# // 渲染数据组件:存储模型和材质(多个实体可共享) public struct RenderData : ISharedComponentData { public Mesh Mesh; // 模型资源 public Material Material; // 材质资源 } ``` - **注意:ISharedComponentData的修改成本较高(会触发实体重新分组),适合不频繁变更的数据。** - 适用场景:资源引用(模型、材质、音效)、批量实体的共同配置(如同一阵营的敌人共享阵营数据)。 3. IEnableableComponent:可启用 / 禁用组件 定义:继承自IComponentData和IEnableableComponent的组件,支持动态启用 / 禁用状态,无需删除 / 重建组件。 特点: - 本质是特殊的IComponentData,增加了 “启用状态” 标记; - 通过EntityManager.SetComponentEnabled()控制状态,比删除组件更高效(避免内存重分配); - 系统查询时可通过WithEnabled()筛选启用 / 禁用的组件。 ```C# // 可启用的攻击范围组件 public struct AttackRange : IComponentData, IEnableableComponent { public float Radius; // 攻击范围半径 } // 在系统中查询启用的AttackRange组件 Entities .WithEnabled() // 只处理启用状态的组件 .ForEach((in AttackRange range) => { ... }) .Schedule(); ``` - 适用场景:需要临时激活 / 关闭的功能(如角色的技能状态、敌人的攻击模式)。 4. Tag Component(标签组件):无数据标识 定义:不包含任何字段的IComponentData,仅作为 “标签” 标识实体的类型或状态(类似枚举的作用,但更灵活)。 特点: - 结构为空(无字段),仅用于分类实体; - 系统通过查询 “是否包含某标签组件” 来筛选实体。 ```C# // 敌人标签:标识实体为敌人 public struct EnemyTag : IComponentData { } // 玩家标签:标识实体为玩家 public struct PlayerTag : IComponentData { } // 系统中筛选所有敌人实体 Entities .WithAll() // 只处理包含EnemyTag的实体 .ForEach((ref Health health) => { ... }) // 攻击敌人 .Schedule(); ``` - 适用场景:实体分类(玩家 / 敌人 / 道具)、状态标记(是否可交互、是否已死亡)。 5. BufferComponent:动态长度数据组件 定义:通过IBufferElementData接口定义的组件,用于存储动态长度的数组数据(如路径点、伤害记录)。 特点: - 本质是 “动态缓冲区”,可像数组一样添加 / 删除元素; - 通过DynamicBuffer访问,支持高效的动态数据管理; - 内存布局优化,避免传统List的 GC 开销。 ```C# // 路径点缓冲区元素 public struct Waypoint : IBufferElementData { public float3 Position; // 路径点坐标 } // 在系统中使用缓冲区 public void OnUpdate(ref SystemState state) { foreach (var waypoints in SystemAPI.Query>()) { // 遍历路径点 for (int i = 0; i < waypoints.Length; i++) { float3 point = waypoints[i].Position; // 处理路径点逻辑 } } } ``` - 适用场景:动态长度数据(路径点列表、背包物品、伤害记录)。 6. Cleanup Component:自动清理组件 定义:继承自ICleanupComponentData的组件,当实体的其他组件被删除时,会自动被清理。 特点: - 用于 “临时关联” 的数据,依赖其他组件存在; - 当实体失去某组件时,Cleanup Component 会被自动移除(无需手动处理)。 ```C# // 临时速度加成:依赖Speed组件存在 public struct SpeedBoost : ICleanupComponentData { public float Multiplier; // 速度倍率 } ``` 当实体的Speed组件被删除时,SpeedBoost会自动被清理。 - 适用场景:临时效果(buff/debuff)、依赖其他组件存在的辅助数据。 #### DOTS 按组件数据的访问粒度划分 **核心背景:ECS 的 Chunk 存储机制** 在 DOTS 中,组件数据并非零散存储,而是按Chunk(块) 组织: - 一个 Chunk 是连续的内存块,包含相同组件组合的实体数据(例如,所有同时拥有Position、Rotation、Velocity组件的实体,会被分配到同一类 Chunk 中)。 - 每个 Chunk 内,相同组件的所有数据会集中存储(如 Chunk 内所有实体的Position组件连续排列, followed by Rotation组件,以此类推)。 - 一个 Chunk 可容纳多个实体(通常最多 128 个,取决于组件总大小)。 > 这种存储方式直接决定了数据的访问粒度 —— 从细到粗可分为:Element 级 → Entity 级 → Chunk 级。 1. 按 Element 访问(元素级访问) 定义:直接访问组件中的单个字段(元素),是最细粒度的访问。 特点: - 针对组件内的具体字段(如Position.Value.x、Health.Current)进行读写; - 适合需要精确修改某一属性的场景,但频繁的零散访问可能降低缓存利用率。 ```C# // 只修改Position的x坐标(元素级访问) foreach (var pos in SystemAPI.Query>()) { pos.ValueRW.Value.x += 1.0f; // 仅访问x字段 } ``` 2. 按 Entity 访问(实体级访问) 定义:访问单个实体关联的所有组件数据,是最常用的访问粒度。 特点: - 针对 “一个实体” 的完整组件集合(如同时访问某实体的Position、Velocity、Health); - 通过EntityQuery筛选符合条件的实体,逐个处理其组件; - 适合大多数业务逻辑(如移动、攻击、AI 决策),平衡了灵活性和性能。 ```C# // 访问单个实体的多个组件(实体级访问) foreach (var (pos, vel, health) in SystemAPI.Query, in Velocity, RefRW>()) { // 处理一个实体的位置、速度、生命值 pos.ValueRW.Value += vel.Value * deltaTime; if (pos.ValueRO.Value.y < 0) health.ValueRW.Current -= 10; } ``` 3. 按 Chunk 访问(块级访问) 定义:直接访问整个 Chunk 的批量数据,是最粗粒度的访问,充分利用 ECS 的内存布局特性。 特点: - 一次性处理 Chunk 内所有实体的组件数据(同一 Chunk 内实体拥有相同组件组合); - 数据在内存中连续存储,可最大化 CPU 缓存利用率,适合批量操作(如渲染、物理模拟); - 需通过ArchetypeChunk API 直接操作 Chunk,代码较复杂,但性能最优。 ```C# // 按Chunk访问(批量处理整个Chunk的实体) public partial struct ChunkBasedSystem : ISystem { public void OnUpdate(ref SystemState state) { // 获取包含Position和Velocity组件的Chunk查询 EntityQuery query = SystemAPI.QueryBuilder() .WithAll() .Build(); // 遍历所有符合条件的Chunk foreach (ArchetypeChunk chunk in query.ToArchetypeChunks(ref state.WorldUpdateAllocator)) { // 获取Chunk内的Position组件数组(连续内存) NativeArray positions = chunk.GetNativeArray(); // 获取Chunk内的Velocity组件数组(连续内存) NativeArray velocities = chunk.GetNativeArray(); // 批量处理Chunk内所有实体(0到chunk.Count-1) for (int i = 0; i < chunk.Count; i++) { positions[i] = new Position { Value = positions[i].Value + velocities[i].Value * SystemAPI.Time.DeltaTime }; } } } } ``` **三种访问粒度的对比与适用场景:** | 访问粒度 | 优势 | 劣势 | 适用场景 | |-----------|---------------------|----------------------|-----------------------| | Element 级 | 精确操作单个字段 | 缓存利用率低,频繁访问易导致性能损耗 | 局部属性修改(如仅调整位置的 x 轴) | | Entity 级 | 平衡灵活性与性能,代码简洁易读 | 相比 Chunk 级,批量处理效率略低 | 大多数业务逻辑(移动、攻击、状态更新) | | Chunk 级 | 连续内存访问,缓存利用率最高,性能最优 | 代码复杂,需手动处理 Chunk 和索引 | 批量操作(渲染、物理模拟、大规模数据更新) | **为什么这种划分重要?** ECS 的核心性能优势来自连续内存布局,而访问粒度直接影响对这一优势的利用程度: - Chunk 级访问充分利用了 “相同组件连续存储” 的特性,CPU 缓存可一次性加载大量数据,适合高频、大规模的批量操作(如渲染系统处理所有实体的位置和旋转)。 - Entity 级访问是日常开发的折中方案,兼顾了代码可读性和性能,适合大多数逻辑。 - Element 级访问应尽量避免在高频逻辑中使用,尤其是零散修改多个实体的不同字段时,容易导致缓存未命中。 ## System System 是 DOTS 中执行具体逻辑的最小单位,它通过查询符合条件的实体(拥有特定组件的实体),对其组件数据进行读取或修改。 ### ISystem:基于接口的现代实现 ISystem 是 Unity 2022+ 随Entities 1.0 推出的新接口,旨在简化系统定义并提升性能,是当前推荐的方式。 特点: - 实现接口:可定义为struct(推荐)或class,无需partial关键字。 - 精简生命周期:核心方法只有OnUpdate(),其他生命周期通过扩展接口实现: - ISystem:仅需实现OnUpdate()。 - ISystemStartStop:额外实现OnStartRunning()和OnStopRunning()。 - ISystemInitialize:额外实现OnInitialize()(替代OnCreate())。 - ISystemDestroy:额外实现OnDestroy()。 - 自动调度:通过[UpdateInGroup]等特性指定执行时机,无需显式调用Schedule()。 - 性能优化:struct实现的系统无需堆分配,无 GC 开销,更适合高频执行。 **为何推荐ISystem:** - 内存效率:struct实现的ISystem实例存储在栈上(或作为值类型嵌入其他结构),避免了class(SystemBase)的堆分配和 GC 压力。 - 代码简洁性:ISystem 配合SystemAPI(如SystemAPI.Query、SystemAPI.Time)简化了数据访问和 Job 调度,减少模板代码。 - 扩展性:通过扩展接口(ISystemInitialize等)按需添加生命周期方法,而非强制继承所有虚方法,更符合 “最小接口” 原则。 - 未来兼容性:Unity 明确表示ISystem是 DOTS 系统的未来方向,新特性(如自动并行化)将优先支持ISystem。 ### SystemBase SystemBase:基于抽象类的传统实现 SystemBase 是早期 DOTS 中定义系统的主要方式,通过继承抽象类并 override 关键方法实现逻辑。 特点: - 继承抽象类:必须定义为partial class(因 DOTS 代码生成需要)。 - 生命周期方法:提供多个可重写的虚方法,覆盖系统完整生命周期: - OnCreate():系统创建时调用(初始化资源、查询等)。 - OnStartRunning():系统开始运行时调用(首次执行前)。 - OnUpdate():每帧(或按频率)执行的核心逻辑。 - OnStopRunning():系统停止运行时调用。 - OnDestroy():系统销毁时调用(释放资源)。 - Job 集成:通过Entities.ForEach()或Job.WithCode()定义逻辑,需显式调用Schedule()调度。 **推荐使用场景 旧项目兼容,复杂系统(需多生命周期)** ### SystemBase 和 ISystem 差异 SystemBase 和 ISystem 在 Burst 编译支持、线程执行 和 托管 / 非托管类型处理 上的差异,本质与 DOTS 的演进和性能优化目标相关。 1. [BurstCompile] 支持的差异 - ISystem 可以直接标记 [BurstCompile] ISystem(尤其是 struct 实现)的 OnUpdate 方法可以直接添加 [BurstCompile] 特性,让 Burst 编译器将其编译为高度优化的机器码,大幅提升性能。 示例: ```C# [BurstCompile] public struct MovementSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { // 逻辑会被Burst优化编译 } } ``` 原因:ISystem 的 OnUpdate 方法参数 ref SystemState 是 blittable 类型(非托管),且 struct 实现本身无托管状态,符合 Burst 对 “纯非托管代码” 的要求。 - SystemBase 不能直接标记 [BurstCompile] SystemBase 的 OnUpdate 是虚方法,且 SystemBase 本身是 class(托管类型),其内部可能包含托管状态(如 List、string 等)。Burst 编译器无法优化包含托管类型或虚方法调用的代码,因此 SystemBase 的 OnUpdate 不能直接用 [BurstCompile] 标记。 但注意:SystemBase 中 内部的 Job 可以单独用 [BurstCompile],例如: ```C# public partial class MySystem : SystemBase { protected override void OnUpdate() { // 内部Job可以被Burst编译 Entities .ForEach([BurstCompile] (ref Translation pos) => { pos.Value.x += 1; }) .Schedule(); } } ``` 这里 Burst 优化的是 ForEach 中的 lambda 表达式(Job),而非 OnUpdate 本身。 2. 托管 / 非托管类型处理的差异 - ISystem 更严格限制托管类型 当 ISystem 用 struct 实现时,若包含托管类型字段(如 List、string),会导致编译错误或运行时异常。这是因为 struct ISystem 设计为 “纯数据导向”,需避免任何托管状态(防止 GC 和线程安全问题)。 示例(错误): ```C# public struct BadSystem : ISystem { private List _managedList; // 错误:struct ISystem 不能包含托管类型 public void OnUpdate(ref SystemState state) { } } ``` - SystemBase 对托管类型限制宽松 SystemBase 是 class(托管类型),可以包含托管字段(如 List、GameObject 引用),但这会引入 GC 开销,且可能导致线程安全问题(因为 SystemBase 的 OnUpdate 可能在主线程执行,而内部 Job 可能在其他线程)。 示例(不推荐,但允许) ```C# public partial class BadSystem : SystemBase { private List _managedList = new List(); // 允许,但会引发GC protected override void OnUpdate() { // 风险:若在Job中访问_managedList,会导致线程安全问题 } } ``` 3. 线程执行的差异(是否仅限主线程?) - SystemBase 不一定只在主线程执行 SystemBase 的 OnUpdate 方法默认在主线程执行,但内部的 Job(如 Entities.ForEach)可以通过 Schedule() 调度到 worker 线程并行执行。 关键:SystemBase 自身的方法(OnUpdate、OnCreate 等)在主线程,但它可以启动多线程 Job。 - ISystem 支持全线程执行 ISystem 的 OnUpdate 方法本身可以在 worker 线程执行(如果系统被标记为可并行),配合 Burst 编译后,能实现更高的并行效率。 原因:struct ISystem 无托管状态,且 SystemState 是线程安全的访问接口,因此可以安全地在多线程中执行。 示例:ISystem 自动并行执行 ```C# [UpdateInGroup(typeof(SimulationSystemGroup))] public struct ParallelSystem : ISystem { public void OnUpdate(ref SystemState state) { // 此方法可在worker线程执行(由DOTS自动调度) foreach (var pos in SystemAPI.Query>()) { pos.ValueRW.x += 1; } } } ``` ### SystemGroup SystemGroup 是system的 “容器”,用于组织多个 System,定义它们的执行顺序和依赖关系。 管理系统的执行流程(如更新顺序、是否并行执行),避免不同系统间的数据竞争,优化性能。 SystemGroup 本质是 “系统的集合”,它的核心功能是定义系统的执行顺序和管理系统间的依赖关系。 特点: - 层级结构:SystemGroup 可以嵌套(如SimulationSystemGroup包含PhysicsSystemGroup),形成树状执行流程。 - 执行顺序控制:同一 Group 内的系统按注册顺序执行;不同 Group 按层级顺序执行(父 Group 先于子 Group)。 - 内置默认 Group:Unity 提供了多个预定义的 SystemGroup,如InitializationSystemGroup(初始化)、SimulationSystemGroup(模拟逻辑)、PresentationSystemGroup(渲染相关)等。 **为什么需要 SystemGroup?** - 逻辑模块化:将相关系统(如物理相关、UI 相关)归类到 Group,便于管理。 - 避免数据竞争:通过控制执行顺序,确保 “写数据的系统” 先于 “读数据的系统” 执行。 - 性能优化:同一 Group 内的系统可批量调度,减少线程切换开销;不同 Group 可设置不同的更新频率(如渲染 Group 每帧更新,AI Group 每 2 帧更新)。 > 总结 > - System 是执行具体逻辑的单元,专注于 “处理什么数据”; > - SystemGroup 是组织系统的容器,专注于 “何时执行” 和 “如何排序”。 #### SystemGroup的一些限制 由于SystemGroup(实际类型为ComponentSystemGroup)继承自SystemBase,继承了SystemBase的诸多特性限制,包括线程执行范围、Burst 编译支持和GC 风险. 1. SystemGroup 只能在主线程执行 SystemGroup的OnUpdate()方法(用于调度子系统执行)始终在主线程运行,无法像ISystem那样被调度到 worker 线程。 原因: - SystemGroup需要管理子系统的执行顺序、处理系统间的依赖关系(如UpdateBefore/UpdateAfter),并在必要时插入 Job 同步点(确保前序系统的 Job 完成后再执行后续系统)。这些操作涉及全局状态管理,必须在主线程串行执行才能保证安全性。 - 子系统的OnUpdate()可能在 worker 线程执行(如ISystem),但调度这些子系统的SystemGroup本身必须在主线程协调,否则会导致线程安全问题(如并发修改系统列表)。 2. SystemGroup 不能使用 [BurstCompile] 与SystemBase一致,SystemGroup的OnUpdate()方法无法直接标记[BurstCompile],且其自身逻辑也不会被 Burst 编译器优化。 原因: - SystemGroup是class类型(托管类型),其内部包含大量托管状态(如子系统列表、依赖关系表等),而 Burst 编译器无法优化包含托管类型的代码。 - SystemGroup的核心逻辑是 “调度其他系统”,涉及复杂的托管对象交互(如调用子系统的OnUpdate()、处理 Job 依赖),这些操作无法被 Burst 编译为纯机器码。 3. SystemGroup 可能引入 GC,但风险较低 SystemGroup作为class(托管类型),其自身实例存储在托管堆上,理论上存在 GC 开销,但实际风险较低: - 自身实例的 GC 影响:SystemGroup的生命周期与World一致(通常伴随游戏全程),不会频繁创建 / 销毁,因此其自身的 GC 压力可忽略。 - 内部状态的 GC 风险:SystemGroup内部维护子系统列表(如List),这些列表是托管类型,在系统注册 / 卸载时可能触发内存重分配(导致 GC)。但在正常流程中,系统注册通常集中在初始化阶段,运行时很少变动,因此 GC 影响有限。 **总结:SystemGroup 的特性限制源于其 “调度器” 角色** SystemGroup继承SystemBase的限制(主线程执行、无 Burst 支持、潜在 GC),本质是由其核心职责决定的: - 它需要协调所有子系统的执行流程,这种 “全局管理” 工作必须在主线程串行处理,无法并行化。 - 它需要处理复杂的系统依赖和生命周期,不可避免地使用托管容器(如列表)来管理状态,因此无法完全摆脱托管类型。 > 这些限制是合理的权衡 ——SystemGroup本身并不直接处理实体数据(性能敏感操作),而是专注于 > “组织调度”,因此其自身的性能开销对整体影响极小。真正的性能优化重心仍在ISystem实现的业务逻辑中(通过 Burst 编译和多线程执行提升效率)。 #### SystemGroup 的OnUpdate()本质 ComponentSystemGroup的OnUpdate()方法(简化逻辑)大致如下: ```C# public override void OnUpdate() { // 1. 排序内部的子系统(根据UpdateBefore/UpdateAfter等规则) SortSystems(); // 2. 依次执行所有子系统的OnUpdate() foreach (var system in m_systems) { system.Update(); // 调用子System/Group的OnUpdate() } // 3. 处理Job的同步(确保子系统的Job执行完成) CompleteJobs(); } ``` 可以看到,Group 的OnUpdate()不处理具体实体数据,而是调度子系统的执行—— 这正是它作为 “特殊 System” 的核心逻辑。 SystemGroup 是特殊的 System:继承自SystemBase,拥有 System 的基础特性,但职责是管理其他 System 的执行。 设计目的:通过复用 System 的底层机制(World 注册、OnUpdate()执行、依赖管理),实现系统的层级化组织和有序调度,让复杂的 ECS 逻辑能按可预测的顺序高效执行。 ### System 复杂度拆分策略 1. 多个处理相同事务的System耗时远远小于1ms时,可以考虑将这些System合并为一个System 2. 如果一个System处理的事务远远大于1ms时,考虑使用Job并行或burst编译优化,优化后如果仍然远远大于1ms,可以考虑将System做业务拆分 3. 耗时过低的System影响其中Job的并行程度,耗时过高的System影响CPU调度 ## Job 在 DOTS 中,IJob(以及其派生的IJobEntity、IJobChunk等)和ISystem是两个核心概念,它们都用于处理实体数据,但职责、使用方式和适用场景有本质区别。 ISystem是ECS 架构中的 “逻辑单元容器”,负责定义 “何时” 以及 “如何” 处理实体数据。它本身不直接执行具体的数据处理逻辑,而是作为调度器和组织者,决定何时启动 Job、如何处理依赖关系,以及如何与其他系统协作。 - 定义系统的生命周期(初始化、更新、销毁); - 声明实体查询条件(通过EntityQuery或SystemAPI.Query); - 调度 Job(如IJobEntity、IJobChunk)执行具体数据处理; - 管理系统间的依赖关系(如[UpdateBefore]/[UpdateAfter])。 IJob(包括其派生接口IJobEntity、IJobChunk、IJobParallelFor等)是具体的数据处理单元,负责实现 “如何处理数据” 的逻辑。它是 DOTS 中多线程并行的基本单位,由 Job System 调度到工作线程执行。 - 实现具体的业务逻辑(如移动实体、计算伤害、更新状态); - 通过Execute方法定义单步处理逻辑; - 支持多线程并行执行(需保证数据访问安全)。 | 维度 | ISystem | IJob(及派生接口) | |------|----------------------------------------|--------------------------| | 本质 | 系统逻辑的容器,负责调度和组织 | 具体的数据处理任务,负责执行逻辑 | | 生命周期 | 与World绑定,有OnInitialize/OnUpdate等生命周期方法 | 临时对象,执行一次后销毁(或复用) | | 线程执行 | 可在主线程或工作线程执行(由调度器决定) | 主要在工作线程执行(并行化核心) | | 数据访问 | 通常通过SystemAPI或EntityQuery间接访问 | 直接访问组件数据(需显式声明访问权限) | | 依赖管理 | 管理系统间的依赖(通过特性或Dependency) | 管理 Job 间的依赖(通过JobHandle) | | 代码组织 | 定义 “何时做”(调度时机) | 定义 “做什么”(具体逻辑) | > **ISystem的适用场景** > - 系统级逻辑组织:当需要定义一套完整的 “处理流程”(如移动系统、攻击系统、渲染驱动系统)时,使用ISystem。 > - 生命周期管理:需要初始化资源(如创建查询、注册事件)、处理系统启停逻辑时,ISystem的生命周期方法(OnInitialize/OnDestroy)是核心。 > - 调度 Job 和管理依赖:当需要协调多个 Job 的执行顺序,或与其他系统(如物理系统、渲染系统)同步时,ISystem作为调度中心。 > **IJob(及派生接口)的适用场景** > - 具体数据处理:当需要实现 “遍历实体并修改组件” 的逻辑(如移动实体、计算伤害、更新动画状态)时,使用IJob派生接口。 > - 并行化处理:当需要利用多线程加速大规模数据处理(如 10000 个实体的位置更新)时,IJobEntity(按实体并行)或IJobChunk(按块并行)是最佳选择。 > - 细粒度逻辑拆分:当系统逻辑可拆分为多个独立步骤(如先计算路径、再移动位置)时,可拆分为多个 Job 由ISystem统一调度。 > ISystem 既不需要强制依赖 IJob,也不会默认自动使用 IJob 处理业务。二者的关系是 “协作可选” 而非 “强制绑定”——ISystem > 可以直接在自身的 > OnUpdate 方法中处理逻辑,也可以选择调度 IJob 来处理,具体取决于性能需求和代码设计。 > - ISystem 的 OnUpdate 方法本身可以直接编写业务逻辑,无需借助 IJob。这种方式适合简单逻辑或对并行化要求不高的场景。 > - 对于复杂逻辑或需要并行加速的场景(如大规模实体处理),ISystem 可以创建并调度 IJob(如 IJobEntity、IJobChunk),将具体数据处理委托给 IJob。 > - SystemAPI.Query 是高层封装,当使用它配合 foreach 遍历时,DOTS 会隐式生成 IJob 并调度,无需手动定义 IJob 结构体。 ### IJobParallelFor IJobParallelFor用于按索引遍历数组的场景(如处理NativeArray中的元素),自动将数组拆分到多个线程并行执行。 - 需指定数组长度(Length),Job 系统自动划分任务片; - Execute(int index)方法按索引处理单个元素,确保线程安全; - 适合处理连续内存的数组(如粒子位置、网格顶点) ```C# public struct ArrayProcessingJob : IJobParallelFor { [ReadOnly] public NativeArray input; public NativeArray output; // 数组长度(自动匹配input的长度) public int Length => input.Length; public void Execute(int index) { output[index] = input[index] * 2; // 并行处理每个元素 } } // 使用方式 public void ProcessArray() { var input = new NativeArray(1000, Allocator.TempJob); var output = new NativeArray(1000, Allocator.TempJob); // 调度Job,自动拆分到多线程 new ArrayProcessingJob { input = input, output = output } .ScheduleParallel(); // 并行执行 // 等待完成并释放资源 input.Dispose(); output.Dispose(); } ``` ### Transform 组件专用:IJobParallelForTransform IJobParallelForTransform是专门处理 ECS 变换组件(LocalTransform) 的并行 Job,用于高效更新大量实体的位置、旋转或缩放。 - 针对LocalTransform组件优化,避免普通 Job 的组件访问开销; - 通过TransformAccessArray关联需要更新的实体变换; - 适合批量更新角色、道具的位置(如动画驱动、路径跟随)。 ```C# public struct MoveTransformsJob : IJobParallelForTransform { public float deltaTime; public void Execute(int index, TransformAccess transform) { // 并行更新每个实体的位置(向前移动) transform.Position += transform.Forward * deltaTime * 5f; } } // 使用方式 public void UpdateTransforms(EntityManager em, NativeArray entities) { // 创建变换访问数组(关联实体的LocalTransform) var transformAccess = new TransformAccessArray(entities.Length); for (int i = 0; i < entities.Length; i++) { transformAccess[i] = em.GetComponentData(entities[i]); } // 调度Job更新变换 new MoveTransformsJob { deltaTime = Time.deltaTime } .Schedule(transformAccess); // 等待完成并释放 transformAccess.Dispose(); } ``` ### 带依赖的并行 Job:IJobParallelForDefer IJobParallelForDefer是IJobParallelFor的变体,支持延迟指定数组长度,适合数组长度在调度时才能确定的场景(如动态生成的数据)。 - 无需提前指定Length,调度时通过Schedule参数传入; - 适合处理长度动态变化的数组(如运行时生成的路径点、临时计算结果)。 ```C# public struct DynamicArrayJob : IJobParallelForDefer { public NativeArray data; public void Execute(int index) { data[index] = index * 2; } } // 使用方式 public void ProcessDynamicData(NativeArray dynamicData) { // 调度时才指定长度(dynamicData的长度) new DynamicArrayJob { data = dynamicData } .Schedule(dynamicData.Length); // 延迟传入长度 } ``` ### IJobEntity 按实体组件查询并处理 ```C# // 1. 定义处理具体逻辑的IJobEntity public partial struct DamageJob : IJobEntity { public void Execute(ref Health health) { health.Current -= 1; } } // 2. ISystem调度Job public partial struct DamageSystem : ISystem { public void OnUpdate(ref SystemState state) { // 显式创建并调度Job new DamageJob() .Schedule(); // 由Job System分配到多线程执行 } } ``` ## Aspect ## Entities Graphics ### BatchRendererGroup ## Unity Physics ## 设计 & 实践 ### 数据查询的和设计的一些原则 1. **entity的查询原则:** 询的entity数量多时用Job,查询的数量少时用Query,随机访问不要滥用,其他方式最好不用. 2. **entity修改:** entity都是值类型,查询出来的都是值拷贝,需要修改的情况下,使用RefRO只读, 读写RefRW来区分和获取需要修改的数据 3. **数据查询遍历原则:** 简单数据CSD(Component,ShardComponent,DynamicBuffer),复杂查询封装进Aspect,遍历查询定义好读写,一次查询避免间接随机. 4. **数据分类(分组):** 如果entity数量很少,是可以给entity一个Id或者分组的标签值(不变的属性值),然后在system中通过if分支来执行不同逻辑. 如果entity数量很多,推荐使用SharedComponent来处理分类的Id或标签.使所有的同一分组的entity共享相同SharedComponent( ID或标签) ```C# public struct ColonyID : ISharedComponentData { public int id; } // Entity Bake时调用,添加SharedComponent AddSharedComponent(entity, new ColonyID { id = -1 }); // or 代码实例化时,设置SharedComponent var ants = state.EntityManager.Instantiate(settings.ValueRO.antPrefab, settings.ValueRO.antCount, Allocator.Temp); ecb.SetSharedComponent(ants, new ColonyID { id = colonyID }); // 通过WithSharedComponentFilter一次性过滤所有ColonyID为指定id值的entity SystemAPI.Query, RefRW, RefRW, RefRW>().WithAll().WithSharedComponentFilter(new ColonyID{ id = colonyID })) ``` ### DOTs程序的两种模式 1. Hybrid混合模式: 混合使用托管和非托管组件 2. Pure纯净模式: 只使用非托管数据组件 - 该模式需要在Project Settings -> Player -> Script Define Symbols: 添加 UNITY_DISABLE_MANAGED_COMPONENTS - 设置了上面这个设置后,它会删除Entities包中所有托管组件对象,并在项目代码中申明了托管组件对象时报错来阻止使用托管组件. ### DOTs调式宏 1. ENABLE_UNITY_COLLECTIONS_CHECKS (更全面) 2. UNITY_DOTS_DEBUG - 以上两个设置对性能有影响,但是在开发阶段,有助于代码调试. 3. **可以在System与Job代码中使用Debug.Log,但由于Debug.Log是主线程函数,会导致Job的调度发生在主线程.** 4. Project -> Editor -> Enter Play Mode Options: 不勾选 Reload Domain 和 Reload Scene两个选项,加快编辑器开发模式下,进入游戏模式的时间. - 不 Reload Domain 的限制: 静态可变数据不会被重新初始化,避免在DOTs代码中使用静态static数据变量 5. Project -> Burst AOT Settings 选项 Force Debug Information 开启: 会为发布版本生成DOTs代码的调试信息,当出现报错时,能够答应出报错代码行号等具体信息方便追踪生产版本的bug. ### 创建Entity的方案推荐 1. 静态场景用子场景烘焙(Bake),动态场景对象用主线程创建 2. 多线程创建只在对象初始化需要大量额外计算时使用,即便这样也推荐优先做主线程创建然后做性能对比 3. 通过Prefab实例化Entity的方式会在场景中额外保存一个Entity,其Archetype与实例化后的Entity不同,如果有海量的Prefab对象,需要关注Archetype数量对效率的影响. 4. 完全通过脚本化生成Entity方式尽量避免使用. ### EntityManager接口添加Component性能 1. 使用EntityQuery查询添加组件是最高效的方式. 2. 缓存Entity数组无论是访问还是添加删除都没必要,性能差 3. 多次调用单个Entity添加组件接口会导致多次Structural Change 4. 如果为单个Entity添加多个组件,尽量通过以下两个接口 - CreateArchetype - AddComponent(EntityQuery,ComponentTypeset) 5. 先添加普通Component,再添加Enableable修饰的Component比放过来效率更高. 6. 添加IComponent组件(数据组件)要比添加标签组件开销大 7. 添加Enableable修饰的数组组件要比添加普通的数据组件开销大. 8. 通过Enableable禁用和启用组件要比直接添加和删除组件性能要高 9. 禁用组件比启用组件性能开销要高十倍左右 10. 禁用启用数据组件比禁用启用标签组件开销要高 11. 创建Entity时添加Enableable组件,是运行时禁用/启用避免Structural Change的有效手段. ### Hybrid 数据交互分类 | 交互方向/类型 | 核心实现逻辑 | 优点 | 缺点 | 典型适用场景 | |------------------------|-----------------------------------|------------------------------|-----------------------|-----------------------------| | 1. MonoBehaviour → ECS | 命令缓冲(EntityCommandBuffer) | 线程安全(适配 ECS 多线程)、支持延迟执行 | 有一定代码复杂度、延迟1帧生效 | UI 触发 ECS 行为(如按钮创建敌人、技能释放) | | 2. ECS → MonoBehaviour | 单例中介(MonoSingleton) | 逻辑清晰(解耦 ECS 和 Mono)、支持复杂数据传递 | 单例可能导致耦合、需手动同步 | ECS 状态更新 UI(如血量、分数、任务进度) | | 3. 双向实时同步 | 共享内存容器(NativeArray/NativeHashMap) | 零拷贝(性能极高)、实时无延迟 | 需手动管理内存(避免内存泄漏)、代码较复杂 | 物理交互(如 ECS 物理同步到 Mono 碰撞反馈) | | 4. 双向静态共享 | 静态类/静态字段 | 零开销、代码极简、实时同步 | 无线程安全、易导致全局耦合 | 全局配置(速度、血量)、全局状态(暂停、计数) | --- | 交互类型 | 核心实现 | 线程安全 | 典型场景 | 代码示例亮点 | |-----------------------|---------------------|-----------|-----------------------|-----------------| | 1. MonoBehaviour→ECS | EntityCommandBuffer | ✅ | UI 触发 ECS 行为(如创建实体) | 延迟执行,适配多线程 | | 2. ECS→MonoBehaviour | MonoSingleton中介 | ✅(主线程) | ECS 状态更新 UI(如血量) | 解耦 ECS 与 Mono | | 3. 双向实时同步 | NativeArray共享内存 | ✅(需手动控制) | 物理数据交互(如碰撞结果) | 零拷贝,高性能 | | 4. 静态共享(普通) | 静态类 / 字段 | ❌ | 仅主线程访问的全局配置 | 代码极简,无额外开销 | | 5. 静态共享(线程安全) | SharedStatic | ✅ | 多线程与主线程共享的全局状态(如分数 ) | 自动处理并发,兼顾安全与性能 | #### ECS → MonoBehaviour 通过SystemBase/ISystem读取ISystem更新的非托管数据,通过托管对象设置或GameObject侧预先注册回调函数传递数据. #### 静态共享例子 ```C# // 静态状态类:存储会动态变更的全局状态(Mono和ECS都能读写) public static class GameState { // 全局击杀数(ECS的EnemyDeathSystem会写,Mono的ScoreUI会读) public static int TotalKillCount = 0; // 游戏暂停开关(Mono的PauseButton会写,ECS的MovementSystem会读来暂停移动) public static bool IsGamePaused = false; // 玩家当前分数(ECS的ScoreSystem会写,Mono的ScoreBoard会读) public static int PlayerScore = 0; } // MonoBehaviour 读写静态数据:直接通过静态类访问,比如 UI 面板显示配置参数、按钮修改全局状态。 public class PauseButton : MonoBehaviour { public void OnPauseClick() { // Mono写静态数据:修改游戏暂停状态 GameState.IsGamePaused = !GameState.IsGamePaused; // Mono读静态数据:根据配置显示提示(如“暂停速度:5m/s”) Debug.Log($"当前移动速度配置:{GameConfig.PlayerMoveSpeed}"); } } // ECS System 读写静态数据:在 System 的OnUpdate中直接访问,比如根据全局状态决定是否执行逻辑、读取配置参数计算数值。 public partial class PlayerMovementSystem : SystemBase { protected override void OnUpdate() { // ECS读静态状态:如果游戏暂停,不执行移动逻辑 if (GameState.IsGamePaused) return; // ECS读静态配置:获取玩家移动速度 float moveSpeed = GameConfig.PlayerMoveSpeed; // 执行移动逻辑(使用读取到的静态配置) Entities.WithAll() .ForEach((ref Translation trans, in Velocity vel) => { trans.Value += vel.Value * moveSpeed * Time.DeltaTime; }).Schedule(); } } ``` > Notes: - 静态共享数据的线程安全问题:如果 ECS 的并行 Job(如IJobParallelForEntity)读写静态字段,会导致 “数据竞争”(多个线程同时修改同一份数据),引发程序崩溃或数据错误。 - 解决方案:若需在并行 Job 中使用静态数据,需用[NativeDisableParallelForRestriction]标记(仅读场景),或用SpinLock(写入场景)保证线程安全。 - 避免过度依赖静态数据:静态数据是 “全局变量” 的一种,过度使用会导致代码耦合度极高(如所有系统都依赖GameState),后续维护困难。建议仅用于 “真正全局、低变更” 的数据,局部共享数据优先用 ECS 组件或 Mono 成员变量。 - 命令缓冲的延迟问题:EntityCommandBuffer的指令会在 “当前帧结束后、下一帧开始前” 执行,因此 Mono 向 ECS 发指令后,ECS 不会立即响应。若需要 “即时生效”,可改用EntityManager直接操作(但仅能在主线程使用,不推荐多线程场景)。 #### 线程安全的静态数据共享: struct ShardStatic SharedStatic本质是带有线程安全保障的全局静态存储容器,由 DOTS 提供,支持在 ECS 的多线程 Job 和 MonoBehaviour 中安全读写,解决了普通静态字段的 “数据竞争” 问题。 **核心特性:** - 线程安全:内部通过原子操作或锁机制保证多线程读写安全; - 类型安全:通过泛型指定存储的数据类型; - 全局访问:可被 ECS 的 System/Job 和 MonoBehaviour 共同访问; - 零 GC:基于非托管内存,无垃圾回收开销。 ```C# using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; // 1. 定义SharedStatic容器(通常在静态类中声明) public class SharedData { // 声明一个存储int类型的SharedStatic(全局唯一) public static readonly SharedStatic TotalScore = SharedStatic.GetOrCreate(); // 声明一个存储float类型的SharedStatic(用于配置参数) public static readonly SharedStatic PlayerSpeed = SharedStatic.GetOrCreate(); // 内部标记类型(用于区分不同的SharedStatic实例) private class TotalScoreKey {} private class PlayerSpeedKey {} } // ECS System中调度并行Job,使用SharedStatic public partial struct ScoreSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { // 从SharedStatic读取玩家速度配置(只读,线程安全) float speed = SharedData.PlayerSpeed.Data; // 定义一个并行Job,累加分数(写入SharedStatic) new AddScoreJob { DeltaTime = SystemAPI.Time.DeltaTime }.Schedule(); } [BurstCompile] public partial struct AddScoreJob : IJobEntity { public float DeltaTime; public void Execute(in PlayerTag tag, in ScorePerSecond scorePerSecond) { // 线程安全地累加分数(SharedStatic自动处理并发) SharedData.TotalScore.Value += (int)(scorePerSecond.Value * DeltaTime); } } } // 辅助组件:每秒得分 public struct ScorePerSecond : IComponentData { public int Value; } // UI面板显示分数(MonoBehaviour访问SharedStatic) public class ScoreUI : MonoBehaviour { private TMPro.TextMeshProUGUI scoreText; private void Awake() { scoreText = GetComponent(); } private void Update() { // 从SharedStatic读取分数并更新UI scoreText.text = $"总分:{SharedData.TotalScore.Value}"; } // 按钮点击修改玩家速度配置 public void OnSpeedUpClick() { // Mono中修改SharedStatic(主线程安全) SharedData.PlayerSpeed.Value += 1f; } } ``` **SharedStatic与其他静态共享方式的对比:** | 静态共享方式 | 线程安全 | 适用场景 | 性能开销 | 代码复杂度 | |----------------|-------|----------------------------|---------|-------| | 普通静态字段 | ❌ 不安全 | 仅主线程访问的静态数据 | 极低 | 极简 | | SharedStatic | ✅ 安全 | 多线程(ECS Job)与主线程(Mono)共享数据 | 低(原子操作) | 中等 | | 带SpinLock的静态字段 | ✅ 安全 | 需自定义同步逻辑的场景 | 中(锁开销) | 较高 | ## 其他 ### 在通过Authoring Component(挂载在GameObject上的MonoBehaviour)向 ECS 转换数据的流程中,这类MonoBehaviour的Update方法不会被执行,也不会产生额外性能开销,原因如下: 1. 即使开发者误写了Update方法,在 DOTS 的转换流程中,这类MonoBehaviour会被自动禁用或销毁,其生命周期方法不会被调用。 2. 当进入运行时(或构建时),DOTS 的Baker会完成数据转换: - 将Authoring Component中的数据提取出来,创建对应的 ECSEntity和ComponentData; - 转换完成后,原始GameObject会被标记为 “已转换”(通过GameObjectConversionUtility),其身上的MonoBehaviour(包括 Authoring Component)会被自动禁用(enabled = false) 或直接从场景中移除。 ### Script Templates ScriptTemplates下的脚本方便在Unity3d Editor菜单中直接创建DOTs相关的模板类 ### ComponentLookUp & EntityStorageInfoLookUp ### EntityCommandBuffer & EntityCommandBufferSystem ### DynamicBufferComponent ### EnableableComponent ### SharedComponent ### BlobAsset & BlobAssetReference #### BlobAssetReference vs SharedComponent ### CleanupComponent ### ChunkComponent #### ChunkComponent vs SharedComponent