ECS实战~生成十万个预制体

文档

一、环境与准备(3 分钟上手)

  • Unity 版本:2022.3 LTS 或以上(推荐 LTS,省心)

  • 必装包(Package Manager)

    • Entities 1.3.14
    • Burst
    • Collections
    • Mathematics
    • (可选)Entities Graphics(需要 GPU 实例化渲染海量实体时)

开启 Burst:Jobs > Burst > Enable Compilation(Play 前点一下)


二、要做什么(Demo 目标)

我们做一个小行星场

  • 启动时批量生成 N 个实体(方块也行)
  • 每帧按朝向位移
  • 支持多线程 + Burst
  • 带寿命自动回收
  • 结构清晰:Authoring → Baker → Components → Aspects → Systems

image.png

flowchart LR A[Authoring MonoBehaviour] --> B[Baker 转换] B --> C[Prefab 实体 + 组件数据IComponentData] C --> D[系统 ISystem 批量处理] D --> E[Job System 并行调度] E --> F[Burst 编译]
sequenceDiagram participant Scene as 场景 participant Baker as Baker(编辑器转换) participant ECS as ECS 世界 participant SpawnSystem as SpawnSystem participant ECB as EntityCommandBuffer Scene->>Baker: 有 SpawnerAuthoring Baker->>ECS: 创建 Spawner 实体 + SpawnSettings 组件 ECS->>SpawnSystem: 检测到 SpawnSettings 存在 SpawnSystem->>ECB: 循环 Instantiate Prefab N 次 ECB->>ECS: Playback 一次性创建全部实体 ECS->>其他系统: MoveSystem / LifetimeSystem 批量处理

流程解读

  1. SpawnerAuthoring
    • 场景里你看到的只是一个空物体挂了 Authoring 脚本
    • 不直接生成东西,只存生成参数
  2. Baker 转换
    • 运行前,Baker 把 Authoring 转成 ECS 组件(SpawnSettings)+ 把 Prefab 转成 ECS 模板实体
  3. ECS 世界运行
    • Spawner 实体只占一个 Entity ID + 一个 SpawnSettings 组件
    • Prefab 实体包含渲染和 Transform 等基础组件,作为模板存在
  4. SpawnSystem 一次性批量生成
    • 用 EntityCommandBuffer 循环 Instantiate(Prefab) N 次
    • 实体按 Archetype(组件组合)被连续存储在 Chunk 里()
    • 同类数据连续放内存 → CPU 缓存命中率极高
  5. 其他 Systems 并行处理
    • MoveSystem 批量处理所有有 MoveSpeed + Heading 的实体
    • LifetimeSystem 批量减少寿命并回收过期实体
flowchart TB subgraph Scene["场景 SubScene 或主场景"] SA[SpawnerAuthoring\n MonoBehaviour]:::scene end subgraph Bake["Baker 转换阶段"] SB[SpawnerBaker\nPrefab→Entity引用]:::baker end subgraph ECSWorld["ECS 世界 运行时"] SS[Spawner 实体\nSpawnSettings组件]:::entity P[Prefab 实体\nMesh+Material 数据]:::prefab subgraph Chunk["Archetype Chunk 内存块"] direction LR E1[实体1: LocalTransform+MoveSpeed+Heading+Lifetime]:::chunk E2[实体2: LocalTransform+MoveSpeed+Heading+Lifetime]:::chunk E3[实体3: LocalTransform+MoveSpeed+Heading+Lifetime]:::chunk EN[实体N: LocalTransform+MoveSpeed+Heading+Lifetime]:::chunk end end subgraph System["运行时 Systems"] SYS[SpawnSystem\n批量 Instantiate Prefab]:::system MOVE[MoveSystem\n批量移动]:::system LIFE[LifetimeSystem\n寿命回收]:::system end SA --> SB --> SS SS -->|Prefab引用| P P --> SYS SYS -->|一次性创建 N 个| Chunk MOVE --> Chunk LIFE --> Chunk

三、数据建模(ECS 组件与 Aspect)

1)纯数据组件(IComponentData)

using Unity.Entities;
using Unity.Mathematics;

public struct MoveSpeed : IComponentData
{
    public float Value;
}

public struct Heading : IComponentData
{
    public float3 Value; // 归一化方向
}

public struct Lifetime : IComponentData
{
    public float Seconds;
}

// 单例配置(场景内 1 份)
public struct SpawnSettings : IComponentData
{
    public Entity Prefab;
    public int Count;
    public float3 AreaSize; // 生成范围盒
}

2)把常用组合做成 Aspect(写逻辑更顺手)

using Unity.Entities;  
using Unity.Mathematics;  
using Unity.Transforms;  
  
//Aspect is a struct that contains the data required to perform a specific action on an entity.  
public readonly partial struct MovementAspect : IAspect  
{  
    //RefRW is a reference type that allows us to read and write to the entity's components.  
    public readonly RefRW<LocalTransform> Transform;  
    //RefRO is a reference type that allows us to read the entity's components.  
    public readonly RefRO<MoveSpeed> Speed;  
    public readonly RefRO<Heading> Dir;  
  
    public void Move(float dt)  
    {        //We use the ValueRW property to read and write to the entity's components.  
        var t = Transform.ValueRW;  
        t.Position += Dir.ValueRO.Value * Speed.ValueRO.Value * dt;  
        Transform.ValueRW = t;  
    }}

四、从 Authoring 到 Baker(把编辑器设置“烘焙”成 ECS 数据)

Authoring 脚本(挂场景中)

using UnityEngine;

public class SpawnerAuthoring : MonoBehaviour
{
    public GameObject Prefab;
    public int Count = 10000;
    public Vector3 AreaSize = new(200, 0, 200);
}

Baker(转换为 SpawnSettings + 预制体实体)

using Unity.Entities;  
  
//Baker is a generic class that takes a component data authoring  
//class as a parameter and provides a Bake method to create an entity with the component data.  
public class SpawnerBaker : Baker<SpawnerAuthoring>  
{  
    public override void Bake(SpawnerAuthoring authoring)  
    {        
        //TransformUsageFlags.None means that the entity will not be parented to any other entity.  
        var entity = GetEntity(TransformUsageFlags.None);  
        //TransformUsageFlags.Dynamic means that the entity will be parented to another entity.  
        var prefabEntity = GetEntity(authoring.Prefab, TransformUsageFlags.Dynamic);  
  
        AddComponent(entity, new SpawnSettings {  
            Prefab = prefabEntity,  
            Count = authoring.Count,  
            AreaSize = authoring.AreaSize  
        });  
    }}

五、生成系统(一次性跑,批量造实体)

做法ISystem + EntityCommandBuffer(ECB)安全并行创建、设初值

sequenceDiagram participant SYS as SpawnSystem participant ECB as EntityCommandBuffer participant EM as EntityManager participant ECS as ECS 世界 Note over SYS: OnUpdate 开始<br>(本帧逻辑) SYS->>ECB: Instantiate(Prefab)(记录命令1) SYS->>ECB: SetComponent(实体1, Transform)(记录命令2) SYS->>ECB: AddComponent(实体1, MoveSpeed)(记录命令3) SYS->>ECB: ...(记录更多命令) Note over ECB: 命令只记录,不立刻改世界 Note over SYS: 遍历结束<br>安全时机到 SYS->>ECB: Playback(EntityManager) ECB->>EM: 执行命令1(创建实体) ECB->>EM: 执行命令2(设置组件) ECB->>EM: 执行命令3(添加组件) EM->>ECS: 世界状态更新完毕 SYS->>ECB: Dispose() 释放缓冲区

说明:Entities 1.3 里用 SystemAPI 写法最顺手

using Unity.Burst;  
using Unity.Collections;  
using Unity.Entities;  
using Unity.Mathematics;  
using Unity.Transforms;  
  
[BurstCompile]  
public partial struct SpawnSystem : ISystem  
{  
    // 系统初始化  
    [BurstCompile]  
    public void OnCreate(ref SystemState state)  
    {        //只有存在SpawnSettings组件的实体才会被激活  
        state.RequireForUpdate<SpawnSettings>();  
    }  
    // 系统更新  
    [BurstCompile]  
    public void OnUpdate(ref SystemState state)  
    {        var settings = SystemAPI.GetSingleton<SpawnSettings>();  
        var ecb = new EntityCommandBuffer(Allocator.Temp);  
  
        var rnd = new Unity.Mathematics.Random(  
            (uint)SystemAPI.Time.ElapsedTime * 1664525u + 1013904223u);// 随机种子  
  
        for (int i = 0; i < settings.Count; i++)  
        {            
            // 实例化ECS实体预制体,发生在内存连续的ArchetypeChunk中  
            var e = ecb.Instantiate(settings.Prefab);  
  
            float3 pos = new float3(  
                rnd.NextFloat(-settings.AreaSize.x * .5f, settings.AreaSize.x * .5f),  
                rnd.NextFloat(-settings.AreaSize.y * .5f, settings.AreaSize.y * .5f),  
                rnd.NextFloat(-settings.AreaSize.z * .5f, settings.AreaSize.z * .5f));  
            float3 dir = math.normalize(rnd.NextFloat3Direction());  
            dir.y = 0;  
  
            ecb.SetComponent(e, LocalTransform.FromPositionRotationScale(  
                pos, quaternion.LookRotationSafe(dir, math.up()), 1f));  
  
            ecb.AddComponent(e, new Heading { Value = dir });  
            ecb.AddComponent(e, new MoveSpeed { Value = rnd.NextFloat(2f, 8f) });  
            ecb.AddComponent(e, new Lifetime { Seconds = rnd.NextFloat(10f, 25f) });  
        }  
                ecb.Playback(state.EntityManager);  
        ecb.Dispose();  
  
        // 只生成一次  
        state.Enabled = false;  
    }}

流程解释

  1. 记录阶段(Record Phase)
    • OnUpdate 或 Job 中,用 ecb.Instantiate()ecb.SetComponent() 等方法把改动写进 ECB 缓冲区
    • 这个阶段不改动 ECS 世界,所以遍历时不会冲突。
  2. 回放阶段(Playback Phase)
    • 遍历结束后,在安全时机调用 ecb.Playback(state.EntityManager)
    • ECB 会按记录顺序执行所有命令(创建实体、改组件、加/删组件等)。
    • 这时 ECS 世界(EntityManager)才真正被修改。
  3. 销毁阶段(Dispose)
    • 用完 ECB 必须 Dispose() 释放分配的内存(除非用的是系统自带的 Persistent ECB)。

六、移动系统(IJobEntity 并行 + Burst)

写法 1:IJobEntity(更易并行化)

using Unity.Burst;
using Unity.Entities;
using Unity.Jobs;
using Unity.Transforms;

[BurstCompile]
public partial struct MoveJob : IJobEntity
{
    public float DeltaTime;

    void Execute(MovementAspect aspect)
    {
        aspect.Move(DeltaTime);
    }
}

[BurstCompile]
public partial struct MoveSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        new MoveJob { DeltaTime = SystemAPI.Time.DeltaTime }
            .ScheduleParallel();
    }
}

写法 2:SystemAPI.Query(最直接,易读)

[BurstCompile]
public partial struct MoveSystem_Alt : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        float dt = SystemAPI.Time.DeltaTime;
        foreach (var aspect in SystemAPI.Query<MovementAspect>())
        {
            aspect.Move(dt);
        }
    }
}

提示:想要最大化吞吐,倾向 IJobEntity + ScheduleParallel;想快速验证逻辑用 foreach 也行。


七、寿命与回收(无 GC 的“对象池”味道)

using Unity.Burst;
using Unity.Entities;

[BurstCompile]
public partial struct LifetimeSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var ecb = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>()
                           .CreateCommandBuffer(state.WorldUnmanaged);

        foreach (var (life, entity) in SystemAPI.Query<RefRW<Lifetime>>().WithEntityAccess())
        {
            life.ValueRW.Seconds -= SystemAPI.Time.DeltaTime;
            if (life.ValueRO.Seconds <= 0f)
                ecb.DestroyEntity(entity);
        }
    }
}

想做“复用而非销毁”的对象池:把 Destroy 换成移出可见区 + 重置组件即可。

🚨EndSimulationEntityCommandBufferSystem 就是 Unity 内置的“一帧收单 → 一次性执行 → 清空收银台”机制,保证结构性更改安全、批量、高效,同时不让你手动管理生命周期。


八、渲染与可视化(Entities Graphics 可选)

  • 如果装了 Entities Graphics:准备一个带 MeshRenderer 的 Prefab,在 Baker 里 GetEntity(prefab, TransformUsageFlags.Dynamic) 即可批量实例化。
  • 没装也没事:可用 Hybrid Renderer 或者简单 Gizmos 验证。

九、性能与调试(一分钟自检)

  • Burst:确保菜单已启用;看 Console 首帧会打印启用信息
  • Profiler:添加 “Entities” 模块,观察 Archetype ChunkSystem 时间
  • 查询筛选WithAll/WithNone/WithDisabled 控制查询集合
  • Chunk 顺序:把常访问的组件放一起(减少跨 Chunk)
  • 只读标注RefRO<>/in 参数,帮助调度并行
  • ECB:结构变化(增删组件、创建销毁)放到 CommandBuffer,避免结构性竞争

十、从 MonoBehaviour 迁移的 4 步法

  1. 拆数据:把脚本里的字段抽成 IComponentData(只留数据)
  2. 提算法:把 Update() 逻辑搬进 ISystem/IJobEntity
  3. 做 Baker:把 Prefab/初始参数从 Authoring 转成组件
  4. 小步并行:先 foreach 跑通 → 换 IJobEntity.ScheduleParallel → 最后 Burst 化

十二、完整文件清单(拷就能跑)

Scripts/
  Components/
    MoveSpeed.cs
    Heading.cs
    Lifetime.cs
    SpawnSettings.cs
    MovementAspect.cs
  Authoring/
    SpawnerAuthoring.cs
    SpawnerBaker.cs
  Systems/
    SpawnSystem.cs
    MoveSystem.cs
    LifetimeSystem.cs

十三、常见坑位清单

  • 没装 Burst未启用 → 性能上不去
  • 在 Job 里用 UnityEngine API → 会报错/不兼容
  • 结构改动太频繁(增删组件、创建销毁)→ 用 ECB + 批量合并
  • 随机数:别用 UnityEngine.Random;用 Unity.Mathematics.Random,Job 内要用 ref 储存与回写
  • 多场景:用 SubScene 存 ECS 物体,避免转化期抖动

十四、加餐:以 IJobEntity 改写随机游走(并行刷新朝向)

using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;

[BurstCompile]
public partial struct WanderJob : IJobEntity
{
    public float DeltaTime;
    public uint Seed;

    void Execute(ref Heading heading)
    {
        var rnd = new Unity.Mathematics.Random(Seed ^ (uint)heading.GetHashCode());
        // 轻微抖动朝向
        var dir = heading.Value + rnd.NextFloat3Direction() * 0.1f * DeltaTime;
        dir.y = 0;
        heading.Value = math.normalize(dir);
    }
}

[BurstCompile]
public partial struct WanderSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        new WanderJob {
            DeltaTime = SystemAPI.Time.DeltaTime,
            Seed = (uint)(SystemAPI.Time.ElapsedTime * 1000003)
        }.ScheduleParallel();
    }
}

十五、5 道 Unity / DOTS 面试题(含解析)

  1. :为什么 ECS 比 MonoBehaviour 快?
    数据局部性 + 批量处理 + 并行 + Burst。相同组件连续存储,缓存命中率高;系统一次性遍历同类组件,减少函数与对象跳转;Job System 并行调度;Burst 生成向量化本地码。

  2. IJobEntitySystemAPI.Query 的取舍?
    IJobEntity 更适合并行和 Burst,写明依赖可自动调度;Query 更直接易读,验证逻辑快。性能优先用 IJobEntity,原型优先用 Query

  3. :为何结构性更改要用 EntityCommandBuffer
    :创建/销毁/增删组件会改 Archetype,直接改可能与其他系统并行冲突。ECB 记录更改,统一安全回放,避免结构竞争与同步成本。

  4. :如何在 Job 中使用随机数?
    :使用 Unity.Mathematics.Random非静态、可复制的结构体。为每个 Job/实体准备独立种子,避免竞态;完成后写回状态。

  5. :Burst 有哪些限制?
    :不能用 UnityEngine.*、反射、GC 分配、异常等托管高级特性;数据结构需 blittable;函数尽量 static/可内联;readonly 有助于优化。


posted @ 2025-08-21 10:13  世纪末の魔术师  阅读(7)  评论(0)    收藏  举报