ECS学习笔记 Lesson2 <使用Job生成Cube>

System生成Cube和Job+CommandBuffer生成Cube的区别

System方式

  • 代码简洁
  • 运行在主线程,生成数量较多时,会引起卡顿
  • 频繁调用api,导致缓存命中降低
  • 存在线程安全隐患

Job+CommandBuffer方式

  • 代码较复杂
  • 并行运行在多个job子线程
  • 数据布局连续缓存命中高
  • CommandBuffer生成指缓存池,在主线程统一提交,保证线程安全

提示:System也可以使用CommandBuffer来保证线程安全。另外使用Job的方式不一定能够带来性能上的提升,因为使用Job存在线程调度本身的开销。需要衡量从生成cube获得的收益以及多线程开销之间的关系,以后的课程中会对这一点进行更深入的探讨

修改Lesson1的代码

添加命名空间

  • 确保所有代码都在Learn.Lesson2.Scripts命名空间下,作者由于ide配置了根据文件夹添加命名空间,且都是在ide中创建代码文件,所以会默认添加上命名空间,目的是避免Lesson2拷贝复用代码后出现冲突,以后的文章都会遵从这个规则

判断System的OnUpdate是否需要运行,避免获取不存在的组件报错。这里采用添加RequireForUpdate方式:

  • 打开SpawnerSystem.cs
  • 给SpawnerSystem添加特性[RequireMatchingQueriesForUpdate],在OnCreate方法中添加
  •    state.RequireForUpdate<RandomCom>(); 
       state.RequireForUpdate<Spawner>();
    

在这里插入图片描述

复用Lesson1的代码,构建Lesson2场景

  • 构建跟Lesson1相同的目录结构,将Lesson1中的代码拷贝到Lesson2中。并将所有代码命名空间修改为Learn.Lesson2.Scripts

  • 将Lesson1/Res的cube拷贝到Lesson2/Res下

  • 在Prject窗口Lesson2/Scene目录下,创建新的场景,名称修改为JobExample

  • 打开JobExample场景,新增子场景。子场景下创建空的节点,命名为Authoring

  • Authoring节点上分别挂载Lesson2的SpawnerAuthoring和RandomAuthoring, 并将Lesson2中的cube关联到SpawnerAuthoring.Prefab字段上,SpawnRate修改为3
    在这里插入图片描述

  • 运行,看到跟Lesson1的结构一致,至此构建Lesson2场景完毕

再次说明,之所以要给Lesson1的代码加上命名空间和给system加上RequireForUpdate,均是避免代码拷贝后运行报错。可以试下,如果不给Learn.Lesson1.Scripts.SpawnerSystem加RequireForUpdate,运行时会报下面错误,获取不到Learn.Lesson1.Scripts.RandomCom组件。而获取这个组件的地方为Learn.Lesson1.Scripts.SpawnerSystem.OnUpdate(),说明这个system一直在运行,这跟传统monobehaviour.Update()不太一样,后者需要挂载到场景中,但是system不需要,system只要代码存在,运行时就会一直在运行,这一点对于初学者来说很容易忽视。通过这里可以学习到,我们不光可以用RequireForUpdate来避免报错,也可以用其来控制system的OnUpdate执行的时机,在不需要的时候不运行,比通过空引用判断性能要更好,这个一个常见的性能优化技巧
在这里插入图片描述

将System方式修改为Job+CommandBuffer方式

实现ProcessSpawnerJob

  • 打开SpawnerSystem.cs

  • 定义修饰符为partial的结构体ProcessSpawnerJob,并继承IJobEntity。IJobEntity可以替代SystemAPI.Query,是job中用来query数据的另一种方式

  • 增加字段public RefRW Random用来算随机数,注意需要在前面添加特性[NativeDisableUnsafePtrRestriction]。因为RefRW是ECS提供的引用包装器,其内部直接通过指针操作组件内存地址,绕过常规内存安全检查,是一个unsafe Ptr。为了保证访问安全,Job默认限制使用unsafe Ptr,运行时会报错,报错信息见下图。但如果我们能确保多个job同时对unsafe Ptr的访问是安全的,那么可以加[NativeDisableUnsafePtrRestriction]来解除这个限制。而Unity.Mathematics.Random本身是线程安全的,所以我们加上这个特性。在以后的项目中,这个特性也会经常使用到,用来提升性能,但是需要确保数据访问的安全性
    在这里插入图片描述

  • 增加字段public EntityCommandBuffer.ParallelWriter Ecb。在Job中,是不允许进行structual change操作的,例如创建和销毁entity,或者增加删除component等。但ECS为我们提供了EntityCommandBuffer,其为ECS处理structual change的核心机制,举具备两个特征:

    • 允许在Job中记录实体创建/销毁等结构性变更命令,通过主线程Playback统一执行,解决Job中无法创建entity等问题
    • 将分散的结构性变更合并为批量操作,减少因频繁调用EntityManager引发的同步等待,提升性能
  • 增加系统时间字段public double ElapsedTime

EntityCommandBuffer.ParallelWriter和EntityCommandBuffer的区别

EntityCommandBuffer.ParallelWriter

  • 按内存块(Chunk)划分Job
  • 每个Chunk生成一个Job,映射到多核并行处理
  • 适合大规模数据,减少调度开销
  • Job被ScheduleParallel()多线程调用时使用
  • 不需要手动调用Playback和释放内存

EntityCommandBuffer

  • 按迭代次数划分Job
  • 每个迭代生成独立Job
  • 适合少量迭代
  • Job被Schedule()调用时使用
  • 需要手动调用Playback和释放内存

实现Execute方法

  • Execute方法是Job的核心执行入口。在Job被Schedule()或ScheduleParallel()调用后,Execute方法会被工作线程分块执行,如何分块可参考EntityCommandBuffer.ParallelWriter和EntityCommandBuffer的区别。由于我们采用EntityCommandBuffer.ParallelWriter方式,则每个块为一个Chunk。这样可以保证数据访问和写入的安全性,避免竞争
  • 增加参数[ChunkIndexInQuery] int chunkIndex,其即为当前执行的chunk的索引。增加这个的目的是,让ECB(EntityCommandBuffer)记录每次api是被哪个chunk调用的,在playback的时候,也会按照这个索引顺序来执行,保证时序上的正确性,避免因并行乱序导致逻辑错误(如先删除再创建同一实体)。这个参数也只在调用EntityCommandBuffer.ParallelWriter的api时需要,调用EntityCommandBuffer的api则不需要
  • 增加参数ref Spawner spawner,表明按照Spawner组件查询生成Job
  • 使用Ecb.Instantiate和Ecb.SetComponent替代state.EntityManager.Instantiate和state.EntityManager.SetComponentData。这个替换不光可以在Job中使用,也可以在system中使用。比如在system中,有前后几次不连续的EntityManager api调用,因为这发生在主线程,每次调用都会产生一个同步点(sync points)。如果使用ECB替代EntityManager,将多个同步点汇总成一个,就可以减少性能开销,这也是性能优化的常见技巧

同步点(sync points)是 ECS 架构中强制主线程等待所有已调度 Job 完成的机制,用于确保数据一致性。当发生结构性修改(Structural Changes)时,ECS 必须暂停并行任务执行,并在主线程同步数据状态

修改SpawnerSystem

增加方法GetEntiyCommandBuffer

  • 获取BeginSimulationEntityCommandBufferSystem组件的全局唯一实例,BeginSimulationEntityCommandBufferSystem是EntityCommandBufferSystem的子类,其属于SimulationSystemGroup。SimulationSystemGroup在每帧模拟阶段开始时执行,关于SystemGroup更多信息以后会讲到,现在可以理解为在一个unity生命周期内,用来管理system执行时机的工具。所有在 SimulationSystemGroup中注册的 ECB 命令都会在模拟逻辑前完成执行,一般用在处理需要在物理/逻辑计算前完成的结构性变更(如实体创建、组件增删)
  • 通过BeginSimulationEntityCommandBufferSystem实例创建一个ECB
  • 将ECB转换成ParallelWriter返回

修改OnUpdate方法

  • 删除foreach代码块
  • 调用GetEntiyCommandBuffer获取EntityCommandBuffer.ParallelWriter
  • new一个ProcessSpawnerJob,并使用ScheduleParallel进行调用

运行,结果跟Lesson1一致

在这里插入图片描述

修改后完整的System代码

using Unity.Entities;
using Unity.Burst;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Mathematics;
using Unity.Transforms;

namespace Learn.Lesson2.Scripts
{
    [BurstCompile]
    [RequireMatchingQueriesForUpdate]
    public partial struct SpawnerSystem : ISystem
    {
        public void OnCreate(ref SystemState state)
        {
            state.RequireForUpdate<RandomCom>();
            state.RequireForUpdate<Spawner>();
            state.RequireForUpdate<BeginSimulationEntityCommandBufferSystem.Singleton>();
        }

        public void OnDestroy(ref SystemState state)
        {
        }

        public void OnUpdate(ref SystemState state)
        {
            // 获取RandomCom组件
            var random = SystemAPI.GetSingletonRW<RandomCom>();
            
            EntityCommandBuffer.ParallelWriter ecb = GetEntiyCommandBuffer(ref state);

            new ProcessSpawnerJob
            {
                Random = random,
                Ecb = ecb,
                ElapsedTime = SystemAPI.Time.ElapsedTime
            }.ScheduleParallel();
        }

        private EntityCommandBuffer.ParallelWriter GetEntiyCommandBuffer(ref SystemState state)
        {
            var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
            var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
            return ecb.AsParallelWriter();
        }
    }

    [BurstCompile]
    public partial struct ProcessSpawnerJob : IJobEntity
    {
        [NativeDisableUnsafePtrRestriction]public RefRW<RandomCom> Random;
        public EntityCommandBuffer.ParallelWriter Ecb;
        public double ElapsedTime;
        
        private void Execute([ChunkIndexInQuery] int chunkIndex, ref Spawner spawner)
        {
            if (spawner.NextSpawnTime < ElapsedTime)
            {
                Entity newEntity = Ecb.Instantiate(chunkIndex, spawner.Prefab);
                float3 pos = new float3(Random.ValueRW.random.NextFloat(-5f,5f), Random.ValueRW.random.NextFloat(-3f,3f),0);
                Ecb.SetComponent(chunkIndex, newEntity, LocalTransform.FromPosition(pos));
                spawner.NextSpawnTime = (float)ElapsedTime + spawner.SpawnRate;
            }
        }
    }
}

GitHub路径

  • https://github.com/BenTuLX/LearnECS

参考资料

  • unity官方文档
<think> 我们有一个结构体数组 `shr_ecs_rpt_cmd`,其中包含两个条目:`log` 和 `alert`。 每个条目包含一个命令字符串,该命令字符串通过管道调用了一个lua脚本并使用了sed进行输出过滤。 观察命令字符串: 对于 "log": CMD_PREFIX "syslog.lua rpt | sed ..." 对于 "alert": CMD_PREFIX "syslog.lua alertRpt | sed ..." 这里 CMD_PREFIX 是一个宏,我们不知道具体是什么,但根据上下文,可能是一个命令前缀(比如路径或环境变量设置等)。 但问题要求的是:最终是哪个路径下的哪个lua脚本的哪个函数被执行了? 从命令中可以看出,脚本名是 `syslog.lua`,而函数调用分别是 `rpt` 和 `alertRpt`。 但是,脚本的路径没有直接给出,因为命令中只写了脚本文件名。这意味着脚本可能位于当前工作目录或者PATH环境变量指定的路径中。 然而,在命令中使用了 `CMD_PREFIX`,它可能包含路径信息。例如,如果 `CMD_PREFIX` 定义为 `/usr/bin/`,那么完整的命令就是: `/usr/bin/syslog.lua rpt ...` 但是,通常lua脚本不是直接可执行文件,所以这里可能实际上是通过lua解释器来运行的。因此,更合理的解释是 `CMD_PREFIX` 可能是一个包含lua解释器的命令前缀,例如: CMD_PREFIX 可能是 "lua " 或者 "/usr/bin/lua " 但是,我们注意到命令中直接使用了 `syslog.lua` 作为可执行文件,这要求该脚本必须具有可执行权限并且第一行指定了解释器(如#!/usr/bin/env lua),或者通过lua解释器显式调用。 由于命令字符串中没有明确出现lua解释器,因此我们只能假设: 1. 如果 `CMD_PREFIX` 包含了解释器调用(比如 `lua /path/to/`),那么脚本的路径就是`/path/to/syslog.lua`。 2. 如果 `CMD_PREFIX` 只是路径,那么脚本位于该路径下,并且该脚本本身是可执行的(使用shebang指定解释器)。 但是,题目没有给出 `CMD_PREFIX` 的定义,所以我们无法确定绝对路径。因此,我们只能根据命令字符串中的内容推断: 执行的脚本:`syslog.lua` 执行的函数:对于键"log",执行的是`rpt`函数;对于键"alert",执行的是`alertRpt`函数。 注意:命令中直接使用了 `syslog.lua rpt` 和 `syslog.lua alertRpt`,这意味着在脚本内部,应该通过命令行参数来调用相应的函数。 例如,在 `syslog.lua` 脚本中,可能会根据命令行参数来决定调用哪个函数: 如果参数是 "rpt",则调用rpt函数 如果参数是 "alertRpt",则调用alertRpt函数 因此,总结: 脚本:`syslog.lua`(具体路径取决于CMD_PREFIX和系统环境,但脚本名为syslog.lua) 函数: 当传入参数为 "rpt" 时,调用rpt函数(对应log) 当传入参数为 "alertRpt" 时,调用alertRpt函数(对应alert) 另外,命令后面的sed部分只是对输出进行过滤,与执行哪个函数无关。 所以,最终执行的函数是: 对于log:syslog.lua中的rpt函数 对于alert:syslog.lua中的alertRpt函数 由于没有给出CMD_PREFIX的具体定义,我们无法确定脚本的绝对路径。 </think>### 执行路径与函数解析 #### 最终执行逻辑分析 根据结构体定义和命令模板,最终执行的命令是: ```bash # 对于 "log" 键 ${CMD_PREFIX}syslog.lua rpt | sed -E '/.*<<S>>(.*)<<E>>.*/!d;s/.*<<S>>(.*)<<E>>.*/\\1/' # 对于 "alert" 键 ${CMD_PREFIX}syslog.lua alertRpt | sed -E '/.*<<S>>(.*)<<E>>.*/!d;s/.*<<S>>(.*)<<E>>.*/\\1/' ``` #### 关键组件解析 1. **脚本路径** - `${CMD_PREFIX}` 是预定义的路径前缀变量 - 实际执行的脚本路径:`${CMD_PREFIX}syslog.lua` 2. **执行函数** | 键值 | 调用的Lua函数 | 说明 | |--------|---------------|--------------------------| | `log` | `rpt()` | 基础日志报告函数 | | `alert`| `alertRpt()` | 告警专用报告函数 | 3. **输出处理** 通过 `sed` 命令提取特定标记内容: - 保留 `<<S>>` 和 `<<E>>` 标记之间的内容 - 示例:`<<S>>关键数据<<E>>` → 提取 `"关键数据"` #### 实际执行流程 1. **路径确定** `${CMD_PREFIX}` 可能的值: ```c // 典型定义示例 #define CMD_PREFIX "/opt/app/bin/" ``` - 最终脚本路径:`/opt/app/bin/syslog.lua` 2. **函数调用** Lua 脚本内部结构推测: ```lua -- syslog.lua 内容 function rpt() print("<<S>>日志内容<<E>>") end function alertRpt() print("<<S>>告警内容<<E>>") end -- 根据命令行参数调用对应函数 if arg[1] == "rpt" then rpt() elseif arg[1] == "alertRpt" then alertRpt() end ``` 3. **输出过滤** `sed` 命令的工作流程: ```mermaid graph LR A[原始输出] --> B{包含 <<S>>...<<E>>?} B -- 是 --> C[提取标记间内容] B -- 否 --> D[丢弃] ``` ### 总结 | 组件 | 具体值 | |--------------|-----------------------------| | **脚本路径** | `${CMD_PREFIX}syslog.lua` | | **log函数** | `rpt()` | | **alert函数**| `alertRpt()` | | **输出处理** | `sed` 提取 `<<S>>...<<E>>` |
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值