尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ECS随笔3

ECS随笔3 文章目录ECS 存储与 Command Buffer 简单例子共同例子Archetype 存储按组件组合给实体分组简单实现示意它解决什么问题Sparse Setkv变数组它解决什么问题SoA 连续数组对象字段变数组它解决什么问题Command Buffer为什么不能直接改用 Command Buffer它解决什么问题四者之间的关系例如一个成熟 ECS 可能是建议ECS 存储与 Command Buffer 简单例子archetype存储组件组合类型sparse setSoA 连续数组Structure of Arrayscommand bufferECS 框架底层为了“查得快、遍历快、修改安全”使用的几个工程手段共同例子//假设游戏里有这些组件type EntityIdnumber;interfaceTransform{x:number;y:number;}interfaceHealth{hp:number;maxHp:number;}interfaceVelocity{vx:number;vy:number;}interfaceSkill{skillIds:number[];}//有 4 个实体// 英雄entity1:TransformHealthSkill// 小兵entity2:TransformHealthVelocity// 子弹entity3:TransformVelocity// 塔entity4:TransformHealthSkillArchetype 存储按组件组合给实体分组archetype 的意思是“组件组合类型”。拥有相同组件组合的实体会被放到同一个组里。上面的实体可以分成 3 组// Archetype A: Transform Health Skillentities:[1,4]// 英雄、塔// Archetype B: Transform Health Velocityentities:[2]// 小兵// Archetype C: Transform Velocityentities:[3]// 子弹//如果某个系统要处理所有“有血量的实体”那么命中的 archetype 是Archetype A和B简单实现示意type ComponentNameTransform|Health|Velocity|Skill;interfaceArchetype{key:string;// 例如 Health|Skill|Transformentities:EntityId[];components:MapComponentName,unknown[];}functionmakeArchetypeKey(componentNames:ComponentName[]):string{returncomponentNames.sort().join(|);}//实体 1 和实体 4 都有 Transform Health Skill所以它们进入同一个 archetypeconstkeymakeArchetypeKey([Transform,Health,Skill]);// key Health|Skill|Transform它解决什么问题简单理解archetype 是按组件组合给实体分组方便系统少做无效判断。for(constarchetypeofarchetypesWithTransformAndHealth){for(constentityofarchetype.entities){// 处理如果没有 archetype系统可能要遍历所有实体然后一个个判断有没有组件for(constentityofallEntities){if(has(entity,Transform)has(entity,Health)){// 处理Sparse Setkv变数组sparse set 是一种常见的高效存储结构主要用于快速判断某个实体有没有某个组件。快速通过 entityId 找到组件数据。快速增删组件。遍历时只遍历拥有该组件的实体。以 Health 组件为例拥有 Health 的实体是entity1:Health entity2:Health entity4:Health//可以这样存classSparseSetT{// kv替换为双数组sparse记录k和v在数组中的索引denseEntities:EntityId[][];denseValues:T[][];sparse:number[][];add(entity:EntityId,value:T):void{constindexthis.denseEntities.length;this.denseEntities.push(entity);this.denseValues.push(value);this.sparse[entity]index;}has(entity:EntityId):boolean{constindexthis.sparse[entity];returnthis.denseEntities[index]entity;}get(entity:EntityId):T|undefined{if(!this.has(entity)){returnundefined;}returnthis.denseValues[this.sparse[entity]];}}//添加血量consthealthStorenewSparseSetHealth();healthStore.add(1,{hp:100,maxHp:100});healthStore.add(2,{hp:30,maxHp:30});healthStore.add(4,{hp:500,maxHp:500});// 内部大概是denseEntities[1,2,4]denseValues[{hp:100,maxHp:100},{hp:30,maxHp:30},{hp:500,maxHp:500},]// sparse[entityId] dense 数组下标sparse[1]0sparse[2]1sparse[4]2//查询实体 4 的血量constindexsparse[4];// 2consthealthdenseValues[index];// { hp: 500, maxHp: 500 }它解决什么问题普通 Map 也能做const healthMap new MapEntityId, Health();但 ECS 里组件数据通常很多且每帧频繁遍历。sparse set 的好处是1、denseValues 是紧凑数组遍历快。2、sparse 可以快速从 entityId 找下标。3、删除时可以用最后一个元素补位避免数组中间空洞。简单理解sparse set 是“既能快速查 entityId又能紧凑遍历组件”的结构。SoA 连续数组对象字段变数组SoA(s) 是 Structure of Arrays意思是“数组的结构”。多个数组表示对象SoA 和普通写法 AoS(Array of Structures) 相对一个数组里每个元素是一个结构。一个数组表示多个对象例把位置和速度都拆成连续数组constx:number[][10,30,50];consty:number[][20,40,60];constvx:number[][1,-1,0];constvy:number[][0,0,1];//移动系统每帧只关心如下functionmovementSystem(dt:number):void{for(leti0;ix.length;i){x[i]vx[i]*dt;y[i]vy[i]*dt;}}它解决什么问题SoA 的目标是让系统遍历时访问连续内存。简单理解SoA 是把组件字段拆成连续数组让系统每帧批量处理时更高效。对移动系统来说它只需要所有 x所有 y所有 vx所有 vy它不需要读取血量技能Buff模型 IDSoA 可以减少无关数据混在一起带来的访问浪费。Command Buffercommand buffer 是“命令缓冲区”。它解决的问题是System 遍历实体时不要立刻创建、删除实体或增删组件。为什么不能直接改假设死亡系统正在遍历所有有 Health 的实体如果 destroyEntity(entity) 会立刻删除组件就可能改变当前正在遍历的集合导致跳过某些实体。遍历下标错乱。Query 缓存失效。同一帧系统顺序出现难以排查的问题。for(constentityofquery(Health)){consthealthget(entity,Health);if(health.hp0){destroyEntity(entity);}}用 Command Buffer正确做法是先记录命令classCommandBuffer{privatecommands:(()void)[][];destroyEntity(entity:EntityId):void{this.commands.push((){world.destroyEntityNow(entity);});}addComponentT(entity:EntityId,type:ComponentTypeT,value:T):void{this.commands.push((){world.addComponentNow(entity,type,value);});}flush():void{for(constcommandofthis.commands){command();}this.commands.length0;}}死亡系统只记录销毁请求functiondeathSystem(world:World,commandBuffer:CommandBuffer):void{for(constentityofworld.query(Health)){consthealthworld.get(entity,Health);if(health.hp0){commandBuffer.destroyEntity(entity);}}}等这一批系统跑完再统一执行deathSystem(world,commandBuffer);buffSystem(world,commandBuffer);cleanupSystem(world,commandBuffer);commandBuffer.flush();它解决什么问题简单理解Command Buffer 是“这一帧先记账等安全的时候再统一结算”核心作用如下避免边遍历边修改集合。让实体创建、销毁、组件增删在固定时机发生。让 System 之间的执行顺序更稳定。更容易做回放、调试、帧同步。四者之间的关系这 4 个概念各自解决的问题不同它们可以组合使用archetype 存储按组件组合分组方便 query。【同原型分组】sparse set按组件类型紧凑存储方便快速查找和遍历。【kv变数组】SoA 连续数组把字段拆成连续数组提高批量遍历性能。【对象变数组】command buffer延迟执行结构修改保证遍历安全。【命令缓冲区】例如一个成熟 ECS 可能是World ArchetypeA:TransformHealthSkillentities:[1,4]Transform 使用 SoA:x[],y[]Health 使用 SoA:hp[],maxHp[]Skill 使用普通数组 ArchetypeB:TransformHealthVelocityentities:[2]Transform 使用 SoA:x[],y[]Health 使用 SoA:hp[],maxHp[]Velocity 使用 SoA:vx[],vy[]CommandBuffer 本帧结束后统一创建/删除实体、增删组件建议第一版不需要全部实现。从 0 写你ECS建议优先顺序是先做最简单的 World MapComponentType, MapEntityId, Component。加 query(…)。加 command buffer保证遍历安全。加 changed flag减少 View 刷新。性能不够时再考虑 sparse set。实体规模很大时再考虑 archetype 和 SoA。这样会更稳不容易一开始就陷入 ECS 框架细节。
返回列表