ECS与数据布局
全库目录221
本文目录9
一批实体每帧按速度更新位置,循环只用位置、速度,却可能为名字、动画状态和其他字段加载大量内存。ECS把身份、数据和处理逻辑分开,具体存储方式决定这些访问是否连续。本页建立一个按组件组合分块的模型,沿字节地址、查询、位置更新和结构搬迁追踪一次执行。这里的布局是教学实现,具体框架可采用其他存储方式。
热点字段与缓存行
设位置与速度各由三个32位浮点数组成,共24字节。一个实体还带40字节其他数据,形成64字节记录。先假定记录首地址对齐64字节,缓存行也为64字节,位置和速度位于记录前24字节。数组结构(AoS)中实体i的位置地址为base+64*i,速度地址为base+64*i+12。连续处理64个实体会触及64条缓存行,覆盖4096字节,其中本次需要的字段只有1536字节,利用率为37.5%。这还没考虑对象分散分配时的指针读取。
如果把位置与速度各自存成紧凑列,结构数组(SoA)的位置地址为positionBase+12*i,速度地址为velocityBase+12*i。两列分别对齐64字节,64个位置占768字节,即12条缓存行;速度再占12条。循环触及24条缓存行,覆盖1536字节。字段跨行也包含在这个计数里,因为列的所有字节都会使用。
这个计算解释了连续布局节省了哪些访问,但没有预测加速倍数。缓存可能已有数据,写入还有回写流量,处理器可能受计算而非内存限制。实体只有一个时,两列分别占一条缓存行,覆盖128字节,反而比单条AoS记录更多。需要整对象的循环也可能更适合AoS。布局要跟随访问字段与批量规模,不能从名称推出性能。
组件组合与分块布局
Entity提供身份,Component保存数据,System执行处理逻辑。实体4即使换了存储位置,身份仍是4;位置和速度属于组件,更新循环属于系统。为了让同一个系统顺序处理数据,本页把组件组合相同的实体归入同一原型(Archetype),再将每个原型的数据放进若干块(Chunk)。
设P表示位置,V表示速度,H表示生命值,D表示停用标签,位值依次为1、2、4、8。P|V=3与P|V|H=7是不同原型。标签D只参与组合,不占有效载荷字节。这个身份与组合模型使查询可以先选布局,再处理其中的行。
容量与列地址
参考实现使用4096字节块、64字节头区和8字节实体ID。每列起点向上对齐64字节,列内元素紧密排列,P与V的步长均为12字节。给定容量n,依次计算每列起点和末尾,再求能装进4096字节的最大n。
对于P、V组合,n=122时,实体列起点为64,长度976,末尾1040;位置列对齐后从1088开始,长度1464,末尾2552;速度列从2560开始,末尾4024。若尝试123行,速度列末尾会变为4100,超过块大小,因此容量是122。加上4字节H列后,容量成为112,末尾恰好4096。
因此块中第1行位置地址为chunkBase+1088+12,第2行为chunkBase+1088+24。这些地址允许读者直接检查相邻位置是否连续。实现用ctypes保留原始内存所有者,实际分配并对齐缓冲区,再用struct读写小端float32与整数;它没有借用Python对象列表的内存布局来代替这份模型。
列首对齐不能让每个元素都对齐64字节。两个任务分别写相邻行时,仍可能写同一缓存行,发生假共享。位置区间需要独占,任务边界还要结合字节地址安排;结构连续本身不保证线程安全。
查询与批量更新
更新位置要求P、V,排除D。对原型掩码m,匹配条件为(m & (P|V)) == (P|V)且(m & D) == 0。组合3与7均满足,组合1缺少速度,组合11带停用标签,二者跳过。生命值不影响查询是否匹配,因为系统要求的是组件子集。
系统先找匹配原型,然后枚举这些原型当前的非空块,最后按行计算。下面是单线程机制伪代码,位置单位为米,速度为米每秒,dt为秒;第i行的各列属于同一个实体。
for chunk in query(required=P|V, excluded=D):
for i in range(chunk.count):
position[i] = position[i] + velocity[i] * dt实体4的位置为(1,2,3)、速度为(2,-4,6),dt=0.5时得到(2,0,6)。只有位置的实体7保持原值,带D的实体9即使有速度也保持原值。这个输入同时检验计算与过滤,避免一个总数正确的查询掩盖错误匹配。
匹配原型集合可以缓存,但块集合会随实体数量变化。第123个P、V实体需要第二个块,缓存若只保存首次查询得到的块,就会漏掉新实体。参考实现缓存原型掩码,每次再取这些原型的当前块;创建新原型时清除匹配缓存。这样新增块不需要重做组件匹配,新组合出现后也不会永远被漏掉。
结构搬迁与引用失效
顺序访问依赖块中所有行具备相同字段。实体4增加H后,其布局发生变化,必须迁到组合7的块。假设源块实体列为[4,7,9]:先在目标块追加实体4,复制P和V,并将H初始化为80;再用源块末行实体9填补第0行,源块变成[9,7]。
validate all new values
append entity 4 to destination; copy P,V; initialize H
location[4] = (destination, newRow)
if removedRow != lastRow:
copy every source column from lastRow to removedRow
location[source.entity[removedRow]] = (source, removedRow)
source.count -= 1复制全部列很关键。只改实体ID列会让实体9拿到实体4的位置,只复制P而漏掉V会让它用错速度。填洞以后还必须把location[9]从第2行改为第0行;否则通过身份查找它时会读到已失效的行。删除末行时无需填洞,直接减数量,两个分支都要检查。
稳定ID通过位置表间接定位数据,组件地址则直接指向某次布局。迁移前保存的实体4位置地址仍指向源块第0行,现在该行属于实体9。因此引用失效不是抽象规则:沿用旧地址会修改另一个实体。遍历中直接搬迁还可能把尚未访问的末行移到已访问位置,导致漏处理。
结构命令可以先记录实体身份和操作,等所有持有旧引用的任务结束后执行。刷新后重新查询地址。示例通过迭代作用域直接拒绝结构修改,让错误在发生时可见;工程命令缓冲还需定义冲突顺序、实体销毁和新建规则。本模型没有实现并发调度或带世代计数的实体复用,C++组件还需处理构造、析构与搬迁约束。
输入验证也必须发生在移除源行之前。参考实现先检查生命值能否编码成int32,超范围时抛错并保持原位置。如果先删除源数据再尝试初始化新组件,失败会让实体丢失。完整存储系统还需按异常与内存分配策略设计提交和回滚。
布局收益与工作负载
结构变化把顺序读写的收益转移到了另一条成本路径。每帧反复增删H,会持续复制P、V并修补位置;若H只是一个频繁变化的逻辑状态,可以保留组件、改状态字段,再让系统过滤。这样减少搬迁,却增加字段常驻和分支。停用标签D在本模型中也是结构变化;框架提供的启用位可能采用另一种过滤实现,需按目标版本核对。
大量低占用原型还会留下许多半空块。例如容量112的块只装一行时,连续布局没有形成足够批量;遍历很多小块又增加切换成本。与对象系统的逐实体桥接同样可能重新引入随机访问。评估时固定实体规模、组合分布和结构变化次数,同时测位置循环、查询维护、搬迁与桥接的总耗时,才能判断这一工作负载是否受益。
验证与证据
在仓库根目录执行python -B 知识库/examples/mechanism_depth/ecs/test_ecs.py。2026年10月23日本机11项测试通过,覆盖实际缓冲区地址、最大容量与对齐、查询子集与排除、新块和新原型、首行和末行搬迁、输入失败保留源数据、迭代期拒绝结构修改,以及位置积分。随机测试还让160个实体经历300次增删H,与按实体身份保存的独立数据逐项比较,每次检查位置表和全部有效行一致。
读者可把末行填洞后的location更新删去,再运行迁移测试;第一次填洞后就会出现位置不变量失败。也可把查询缓存改成固定块列表,观察第123个实体漏查。两个失败直接对应正文的数据变化。缓存覆盖函数统计假定行宽下触及的缓存行集合,并未测量真实CPU缓存未命中、引擎性能或并发正确性。
相关主题
参考资料
- Unity Entities 1.0.16结构变化:组件增删、块间迁移、末行填洞、直接引用失效与同步点。2026年10月23日核读Add or remove components与Sync points;本页4096字节为教学布局。
- 本页显式字节缓冲区实现与测试:支撑本页数值、地址、查询和迁移推演。