|
## Archetype ECS vs MI-AoS Benchmark |
|
## ================================== |
|
## Two approaches for a mixin-based entity simulation with cross-entity refs: |
|
## |
|
## 1. ECS-Archetype — SoA columns within archetype buckets, no signature scan |
|
## 2. MI-AoS — per-archetype value arrays (composition), direct field access |
|
## |
|
## Scenario: Tactical field simulation |
|
## Building {pos, sprite} — static, can be a parent |
|
## Unit {pos, worldPos, vel, hp, sprite, |
|
## parent→Building, target→Unit} — mobile, fights target |
|
## Projectile {pos, vel, lifetime, target→Unit} — ephemeral, homing |
|
## |
|
## Systems (each stresses different access patterns): |
|
## move — velocity integration (sequential scan) |
|
## hierarchy — worldPos = parent.pos + localPos (random access to parent) |
|
## combat — if dist(self,target) < range: dmg (random access to target) |
|
## cull — count visible in viewport (sequential scan + branch) |
|
## spawn — create 1000 projectiles (allocation) |
|
## despawn — swap-remove 1000 projectiles (deallocation) |
|
|
|
import std/[times, monotimes, strformat, strutils] |
|
|
|
const |
|
NBuildings = 15_000 |
|
NUnits = 25_000 |
|
NProjectiles = 10_000 |
|
N = NBuildings + NUnits + NProjectiles |
|
NParented = NUnits div 2 |
|
Frames = 500 |
|
SpawnBatch = 1_000 |
|
SpawnIters = 100 |
|
Dt = 0.016'f32 |
|
ScreenW = 1920'f32 |
|
ScreenH = 1080'f32 |
|
CombatRangeSq = 10_000.0'f32 |
|
CombatDamage = 5.0'f32 |
|
ProjLifetime = 2.0'f32 |
|
|
|
var sink: float32 = 0.0 |
|
|
|
var rngState = 42'u32 |
|
proc rng(): int = |
|
rngState = rngState * 1664525'u32 + 1013904223'u32 |
|
result = int(rngState shr 16) |
|
|
|
type |
|
Vec2f = tuple[x, y: float32] |
|
SpriteC = tuple[color: uint32, size: float32] |
|
HpC = tuple[cur, maxv: float32] |
|
|
|
template v2(x, y: float32): Vec2f = (x, y) |
|
|
|
# ================================================================ |
|
# 1. ECS-Archetype — SoA columns within archetype buckets |
|
# ================================================================ |
|
|
|
type |
|
ComponentKind = enum |
|
ckTransform, ckVelocity, ckHealth, ckTarget, ckSprite, ckParent, ckLifetime |
|
|
|
ArchetypeKind = enum |
|
BuildingArch, UnitArch, ProjectileArch |
|
|
|
TransformC = object |
|
pos: Vec2f |
|
worldPos: Vec2f |
|
VelocityC = object |
|
vel: Vec2f |
|
HealthC = object |
|
hp: HpC |
|
TargetC = object |
|
targetIdx: int32 |
|
SpriteCol = object |
|
sprite: SpriteC |
|
ParentC = object |
|
parentIdx: int32 |
|
LifetimeC = object |
|
lifetime: float32 |
|
|
|
Archetype = object |
|
mask: set[ComponentKind] |
|
columns: array[ComponentKind, pointer] |
|
|
|
ArchWorld = object |
|
archetypes: array[ArchetypeKind, Archetype] |
|
|
|
proc newColumn[T](): pointer = |
|
result = create(seq[T]) |
|
|
|
proc destroyColumn[T](column: pointer) = |
|
if column != nil: |
|
`=destroy`(cast[ptr seq[T]](column)[]) |
|
dealloc(column) |
|
|
|
proc `=destroy`(x: var Archetype) = |
|
if ckTransform in x.mask: destroyColumn[TransformC](x.columns[ckTransform]) |
|
if ckVelocity in x.mask: destroyColumn[VelocityC](x.columns[ckVelocity]) |
|
if ckHealth in x.mask: destroyColumn[HealthC](x.columns[ckHealth]) |
|
if ckTarget in x.mask: destroyColumn[TargetC](x.columns[ckTarget]) |
|
if ckSprite in x.mask: destroyColumn[SpriteCol](x.columns[ckSprite]) |
|
if ckParent in x.mask: destroyColumn[ParentC](x.columns[ckParent]) |
|
if ckLifetime in x.mask: destroyColumn[LifetimeC](x.columns[ckLifetime]) |
|
|
|
proc `=wasMoved`(x: var Archetype) = |
|
x.mask = {} |
|
for ck in ComponentKind: x.columns[ck] = nil |
|
|
|
proc `=copy`(dest: var Archetype; src: Archetype) {.error.} |
|
proc `=dup`(src: Archetype): Archetype {.error.} |
|
|
|
proc `=destroy`(x: var ArchWorld) = |
|
for kind in ArchetypeKind: `=destroy`(x.archetypes[kind]) |
|
|
|
proc `=wasMoved`(x: var ArchWorld) = |
|
for kind in ArchetypeKind: `=wasMoved`(x.archetypes[kind]) |
|
|
|
proc `=copy`(dest: var ArchWorld; src: ArchWorld) {.error.} |
|
proc `=dup`(src: ArchWorld): ArchWorld {.error.} |
|
|
|
template col(w, kind, ck, T): untyped = |
|
cast[ptr seq[T]](w.archetypes[kind].columns[ck])[] |
|
|
|
template colData(w, kind, ck, T): untyped = |
|
cast[ptr UncheckedArray[T]](addr col(w, kind, ck, T)[0]) |
|
|
|
template colLen(w, kind, ck, T): untyped = |
|
col(w, kind, ck, T).len |
|
|
|
proc initArchetype(mask: set[ComponentKind]): Archetype = |
|
result = Archetype(mask: mask) |
|
if ckTransform in mask: result.columns[ckTransform] = newColumn[TransformC]() |
|
if ckVelocity in mask: result.columns[ckVelocity] = newColumn[VelocityC]() |
|
if ckHealth in mask: result.columns[ckHealth] = newColumn[HealthC]() |
|
if ckTarget in mask: result.columns[ckTarget] = newColumn[TargetC]() |
|
if ckSprite in mask: result.columns[ckSprite] = newColumn[SpriteCol]() |
|
if ckParent in mask: result.columns[ckParent] = newColumn[ParentC]() |
|
if ckLifetime in mask: result.columns[ckLifetime] = newColumn[LifetimeC]() |
|
|
|
proc reserve(w: var ArchWorld; kind: ArchetypeKind; cap: int) = |
|
template reserveCol(ck, T): untyped = |
|
if ck in w.archetypes[kind].mask: |
|
cast[ptr seq[T]](w.archetypes[kind].columns[ck])[] = newSeqOfCap[T](cap) |
|
reserveCol(ckTransform, TransformC) |
|
reserveCol(ckVelocity, VelocityC) |
|
reserveCol(ckHealth, HealthC) |
|
reserveCol(ckTarget, TargetC) |
|
reserveCol(ckSprite, SpriteCol) |
|
reserveCol(ckParent, ParentC) |
|
reserveCol(ckLifetime, LifetimeC) |
|
|
|
proc initArchWorld(capBuild, capUnit, capProj: int): ArchWorld = |
|
result.archetypes[BuildingArch] = initArchetype({ckTransform, ckSprite}) |
|
result.archetypes[UnitArch] = initArchetype({ckTransform, ckVelocity, ckHealth, ckTarget, ckSprite, ckParent}) |
|
result.archetypes[ProjectileArch] = initArchetype({ckTransform, ckVelocity, ckTarget, ckLifetime}) |
|
result.reserve(BuildingArch, capBuild) |
|
result.reserve(UnitArch, capUnit) |
|
result.reserve(ProjectileArch, capProj) |
|
|
|
proc populate(w: var ArchWorld) = |
|
rngState = 42'u32 |
|
for i in 0..<NBuildings: |
|
col(w, BuildingArch, ckTransform, TransformC).add( |
|
TransformC(pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)), |
|
worldPos: v2(0'f32, 0'f32))) |
|
col(w, BuildingArch, ckSprite, SpriteCol).add( |
|
SpriteCol(sprite: (0xFF0000FF'u32, 10'f32))) |
|
for i in 0..<NUnits: |
|
col(w, UnitArch, ckTransform, TransformC).add( |
|
TransformC(pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)), |
|
worldPos: v2(0'f32, 0'f32))) |
|
col(w, UnitArch, ckVelocity, VelocityC).add( |
|
VelocityC(vel: v2(float32(rng() mod 10) * 0.1'f32, float32(rng() mod 10) * 0.1'f32))) |
|
col(w, UnitArch, ckHealth, HealthC).add(HealthC(hp: (100'f32, 100'f32))) |
|
col(w, UnitArch, ckTarget, TargetC).add( |
|
TargetC(targetIdx: int32(rng() mod NUnits))) |
|
col(w, UnitArch, ckSprite, SpriteCol).add( |
|
SpriteCol(sprite: (0x00FF00FF'u32, 20'f32))) |
|
col(w, UnitArch, ckParent, ParentC).add( |
|
ParentC(parentIdx: if i < NParented: int32(rng() mod NBuildings) else: -1'i32)) |
|
for i in 0..<NProjectiles: |
|
col(w, ProjectileArch, ckTransform, TransformC).add( |
|
TransformC(pos: v2(0'f32, 0'f32), worldPos: v2(0'f32, 0'f32))) |
|
col(w, ProjectileArch, ckVelocity, VelocityC).add(VelocityC(vel: v2(2'f32, 1'f32))) |
|
col(w, ProjectileArch, ckTarget, TargetC).add( |
|
TargetC(targetIdx: int32(rng() mod NUnits))) |
|
col(w, ProjectileArch, ckLifetime, LifetimeC).add( |
|
LifetimeC(lifetime: ProjLifetime)) |
|
|
|
proc archMove(w: var ArchWorld, dt: float32) = |
|
let n = colLen(w, UnitArch, ckTransform, TransformC) |
|
let transforms = colData(w, UnitArch, ckTransform, TransformC) |
|
let velocities = colData(w, UnitArch, ckVelocity, VelocityC) |
|
for i in 0..<n: |
|
transforms[i].pos.x += velocities[i].vel.x * dt |
|
transforms[i].pos.y += velocities[i].vel.y * dt |
|
let pn = colLen(w, ProjectileArch, ckTransform, TransformC) |
|
let ptransforms = colData(w, ProjectileArch, ckTransform, TransformC) |
|
let pvelocities = colData(w, ProjectileArch, ckVelocity, VelocityC) |
|
for i in 0..<pn: |
|
ptransforms[i].pos.x += pvelocities[i].vel.x * dt |
|
ptransforms[i].pos.y += pvelocities[i].vel.y * dt |
|
|
|
proc archHierarchy(w: var ArchWorld) = |
|
let n = colLen(w, UnitArch, ckTransform, TransformC) |
|
let transforms = colData(w, UnitArch, ckTransform, TransformC) |
|
let parents = colData(w, UnitArch, ckParent, ParentC) |
|
let buildingTransforms = colData(w, BuildingArch, ckTransform, TransformC) |
|
for i in 0..<n: |
|
let pi = parents[i].parentIdx |
|
if pi >= 0: |
|
transforms[i].worldPos.x = buildingTransforms[pi].pos.x + transforms[i].pos.x |
|
transforms[i].worldPos.y = buildingTransforms[pi].pos.y + transforms[i].pos.y |
|
else: |
|
transforms[i].worldPos = transforms[i].pos |
|
|
|
proc archCombat(w: var ArchWorld) = |
|
let n = colLen(w, UnitArch, ckTransform, TransformC) |
|
let transforms = colData(w, UnitArch, ckTransform, TransformC) |
|
let targets = colData(w, UnitArch, ckTarget, TargetC) |
|
let healths = colData(w, UnitArch, ckHealth, HealthC) |
|
for i in 0..<n: |
|
let ti = targets[i].targetIdx |
|
if ti >= 0: |
|
let dx = transforms[i].pos.x - transforms[ti].pos.x |
|
let dy = transforms[i].pos.y - transforms[ti].pos.y |
|
if dx*dx + dy*dy < CombatRangeSq: |
|
healths[ti].hp.cur -= CombatDamage |
|
if healths[ti].hp.cur < 0'f32: healths[ti].hp.cur = 0'f32 |
|
|
|
proc archCull(w: var ArchWorld) = |
|
let bn = colLen(w, BuildingArch, ckTransform, TransformC) |
|
let btransforms = colData(w, BuildingArch, ckTransform, TransformC) |
|
let bsprites = colData(w, BuildingArch, ckSprite, SpriteCol) |
|
for i in 0..<bn: |
|
let p = btransforms[i].pos |
|
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH: |
|
sink += bsprites[i].sprite.size |
|
let un = colLen(w, UnitArch, ckTransform, TransformC) |
|
let utransforms = colData(w, UnitArch, ckTransform, TransformC) |
|
let usprites = colData(w, UnitArch, ckSprite, SpriteCol) |
|
for i in 0..<un: |
|
let p = utransforms[i].worldPos |
|
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH: |
|
sink += usprites[i].sprite.size |
|
|
|
proc archSpawn(w: var ArchWorld, n: int) = |
|
for j in 0..<n: |
|
col(w, ProjectileArch, ckTransform, TransformC).add( |
|
TransformC(pos: v2(0'f32, 0'f32), worldPos: v2(0'f32, 0'f32))) |
|
col(w, ProjectileArch, ckVelocity, VelocityC).add(VelocityC(vel: v2(2'f32, 1'f32))) |
|
col(w, ProjectileArch, ckTarget, TargetC).add( |
|
TargetC(targetIdx: int32(rng() mod NUnits))) |
|
col(w, ProjectileArch, ckLifetime, LifetimeC).add( |
|
LifetimeC(lifetime: ProjLifetime)) |
|
|
|
proc archDespawn(w: var ArchWorld, n: int) = |
|
let ptf = addr col(w, ProjectileArch, ckTransform, TransformC) |
|
for j in 0..<n: |
|
let len = ptf[].len |
|
if len > 0: |
|
let idx = rng() mod len |
|
let last = len - 1 |
|
if idx != last: |
|
col(w, ProjectileArch, ckTransform, TransformC)[idx] = col(w, ProjectileArch, ckTransform, TransformC)[last] |
|
col(w, ProjectileArch, ckVelocity, VelocityC)[idx] = col(w, ProjectileArch, ckVelocity, VelocityC)[last] |
|
col(w, ProjectileArch, ckTarget, TargetC)[idx] = col(w, ProjectileArch, ckTarget, TargetC)[last] |
|
col(w, ProjectileArch, ckLifetime, LifetimeC)[idx] = col(w, ProjectileArch, ckLifetime, LifetimeC)[last] |
|
col(w, ProjectileArch, ckTransform, TransformC).setLen(last) |
|
col(w, ProjectileArch, ckVelocity, VelocityC).setLen(last) |
|
col(w, ProjectileArch, ckTarget, TargetC).setLen(last) |
|
col(w, ProjectileArch, ckLifetime, LifetimeC).setLen(last) |
|
|
|
# ================================================================ |
|
# 2. MI-AoS — per-archetype value arrays (composition) |
|
# ================================================================ |
|
|
|
type |
|
Building = object |
|
pos: Vec2f |
|
sprite: SpriteC |
|
Unit = object |
|
pos: Vec2f |
|
worldPos: Vec2f |
|
vel: Vec2f |
|
hp: HpC |
|
sprite: SpriteC |
|
parentIdx: int32 |
|
targetIdx: int32 |
|
Projectile = object |
|
pos: Vec2f |
|
vel: Vec2f |
|
lifetime: float32 |
|
targetIdx: int32 |
|
AoSWorld = object |
|
buildings: seq[Building] |
|
units: seq[Unit] |
|
projectiles: seq[Projectile] |
|
|
|
proc populate(w: var AoSWorld) = |
|
rngState = 42'u32 |
|
w.buildings = newSeq[Building](NBuildings) |
|
for i in 0..<NBuildings: |
|
w.buildings[i] = Building( |
|
pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)), |
|
sprite: (0xFF0000FF'u32, 10'f32)) |
|
w.units = newSeq[Unit](NUnits) |
|
for i in 0..<NUnits: |
|
w.units[i] = Unit( |
|
pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)), |
|
worldPos: v2(0'f32, 0'f32), |
|
vel: v2(float32(rng() mod 10) * 0.1'f32, float32(rng() mod 10) * 0.1'f32), |
|
hp: (100'f32, 100'f32), |
|
sprite: (0x00FF00FF'u32, 20'f32), |
|
parentIdx: if i < NParented: int32(rng() mod NBuildings) else: -1'i32, |
|
targetIdx: int32(rng() mod NUnits)) |
|
w.projectiles = newSeq[Projectile](NProjectiles) |
|
for i in 0..<NProjectiles: |
|
w.projectiles[i] = Projectile( |
|
pos: v2(0'f32, 0'f32), vel: v2(2'f32, 1'f32), |
|
lifetime: ProjLifetime, targetIdx: int32(rng() mod NUnits)) |
|
|
|
proc aosMove(w: var AoSWorld, dt: float32) = |
|
for i in 0..<w.units.len: |
|
w.units[i].pos.x += w.units[i].vel.x * dt |
|
w.units[i].pos.y += w.units[i].vel.y * dt |
|
for i in 0..<w.projectiles.len: |
|
w.projectiles[i].pos.x += w.projectiles[i].vel.x * dt |
|
w.projectiles[i].pos.y += w.projectiles[i].vel.y * dt |
|
|
|
proc aosHierarchy(w: var AoSWorld) = |
|
for i in 0..<w.units.len: |
|
let pi = w.units[i].parentIdx |
|
if pi >= 0: |
|
w.units[i].worldPos.x = w.buildings[pi].pos.x + w.units[i].pos.x |
|
w.units[i].worldPos.y = w.buildings[pi].pos.y + w.units[i].pos.y |
|
else: |
|
w.units[i].worldPos = w.units[i].pos |
|
|
|
proc aosCombat(w: var AoSWorld) = |
|
for i in 0..<w.units.len: |
|
let ti = w.units[i].targetIdx |
|
if ti >= 0: |
|
let dx = w.units[i].pos.x - w.units[ti].pos.x |
|
let dy = w.units[i].pos.y - w.units[ti].pos.y |
|
if dx*dx + dy*dy < CombatRangeSq: |
|
w.units[ti].hp.cur -= CombatDamage |
|
if w.units[ti].hp.cur < 0'f32: w.units[ti].hp.cur = 0'f32 |
|
|
|
proc aosCull(w: var AoSWorld) = |
|
for i in 0..<w.buildings.len: |
|
let p = w.buildings[i].pos |
|
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH: |
|
sink += w.buildings[i].sprite.size |
|
for i in 0..<w.units.len: |
|
let p = w.units[i].worldPos |
|
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH: |
|
sink += w.units[i].sprite.size |
|
|
|
proc aosSpawn(w: var AoSWorld, n: int) = |
|
for j in 0..<n: |
|
w.projectiles.add(Projectile( |
|
pos: v2(0'f32, 0'f32), vel: v2(2'f32, 1'f32), |
|
lifetime: ProjLifetime, targetIdx: int32(rng() mod NUnits))) |
|
|
|
proc aosDespawn(w: var AoSWorld, n: int) = |
|
for j in 0..<n: |
|
if w.projectiles.len > 0: |
|
let idx = rng() mod w.projectiles.len |
|
w.projectiles[idx] = w.projectiles[w.projectiles.high] |
|
w.projectiles.setLen(w.projectiles.high) |
|
|
|
# ================================================================ |
|
# 3. MI-AoS-Split — hot/cold split per archetype |
|
# ================================================================ |
|
|
|
type |
|
BuildingHot = object |
|
pos: Vec2f |
|
BuildingCold = object |
|
sprite: SpriteC |
|
|
|
UnitHot = object # 24 bytes — touched by move + hierarchy + combat |
|
pos: Vec2f |
|
worldPos: Vec2f |
|
vel: Vec2f |
|
UnitCold = object # 24 bytes — touched only by combat + cull |
|
hp: HpC |
|
sprite: SpriteC |
|
parentIdx: int32 |
|
targetIdx: int32 |
|
|
|
SplitWorld = object |
|
buildingHot: seq[BuildingHot] |
|
buildingCold: seq[BuildingCold] |
|
unitHot: seq[UnitHot] |
|
unitCold: seq[UnitCold] |
|
projectiles: seq[Projectile] |
|
|
|
proc populate(w: var SplitWorld) = |
|
rngState = 42'u32 |
|
w.buildingHot = newSeq[BuildingHot](NBuildings) |
|
w.buildingCold = newSeq[BuildingCold](NBuildings) |
|
for i in 0..<NBuildings: |
|
w.buildingHot[i] = BuildingHot(pos: v2(float32(rng() mod 1920), float32(rng() mod 1080))) |
|
w.buildingCold[i] = BuildingCold(sprite: (0xFF0000FF'u32, 10'f32)) |
|
w.unitHot = newSeq[UnitHot](NUnits) |
|
w.unitCold = newSeq[UnitCold](NUnits) |
|
for i in 0..<NUnits: |
|
w.unitHot[i] = UnitHot( |
|
pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)), |
|
worldPos: v2(0'f32, 0'f32), |
|
vel: v2(float32(rng() mod 10) * 0.1'f32, float32(rng() mod 10) * 0.1'f32)) |
|
w.unitCold[i] = UnitCold( |
|
hp: (100'f32, 100'f32), |
|
sprite: (0x00FF00FF'u32, 20'f32), |
|
parentIdx: if i < NParented: int32(rng() mod NBuildings) else: -1'i32, |
|
targetIdx: int32(rng() mod NUnits)) |
|
w.projectiles = newSeq[Projectile](NProjectiles) |
|
for i in 0..<NProjectiles: |
|
w.projectiles[i] = Projectile( |
|
pos: v2(0'f32, 0'f32), vel: v2(2'f32, 1'f32), |
|
lifetime: ProjLifetime, targetIdx: int32(rng() mod NUnits)) |
|
|
|
proc splitMove(w: var SplitWorld, dt: float32) = |
|
for i in 0..<w.unitHot.len: # only touches 24 bytes, not 48 |
|
w.unitHot[i].pos.x += w.unitHot[i].vel.x * dt |
|
w.unitHot[i].pos.y += w.unitHot[i].vel.y * dt |
|
for i in 0..<w.projectiles.len: |
|
w.projectiles[i].pos.x += w.projectiles[i].vel.x * dt |
|
w.projectiles[i].pos.y += w.projectiles[i].vel.y * dt |
|
|
|
proc splitHierarchy(w: var SplitWorld) = |
|
for i in 0..<w.unitHot.len: |
|
let pi = w.unitCold[i].parentIdx |
|
if pi >= 0: |
|
w.unitHot[i].worldPos.x = w.buildingHot[pi].pos.x + w.unitHot[i].pos.x |
|
w.unitHot[i].worldPos.y = w.buildingHot[pi].pos.y + w.unitHot[i].pos.y |
|
else: |
|
w.unitHot[i].worldPos = w.unitHot[i].pos |
|
|
|
proc splitCombat(w: var SplitWorld) = |
|
for i in 0..<w.unitHot.len: |
|
let ti = w.unitCold[i].targetIdx |
|
if ti >= 0: |
|
let dx = w.unitHot[i].pos.x - w.unitHot[ti].pos.x |
|
let dy = w.unitHot[i].pos.y - w.unitHot[ti].pos.y |
|
if dx*dx + dy*dy < CombatRangeSq: |
|
w.unitCold[ti].hp.cur -= CombatDamage |
|
if w.unitCold[ti].hp.cur < 0'f32: w.unitCold[ti].hp.cur = 0'f32 |
|
|
|
proc splitCull(w: var SplitWorld) = |
|
for i in 0..<w.buildingHot.len: |
|
let p = w.buildingHot[i].pos |
|
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH: |
|
sink += w.buildingCold[i].sprite.size |
|
for i in 0..<w.unitHot.len: |
|
let p = w.unitHot[i].worldPos |
|
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH: |
|
sink += w.unitCold[i].sprite.size |
|
|
|
proc splitSpawn(w: var SplitWorld, n: int) = |
|
for j in 0..<n: |
|
w.projectiles.add(Projectile( |
|
pos: v2(0'f32, 0'f32), vel: v2(2'f32, 1'f32), |
|
lifetime: ProjLifetime, targetIdx: int32(rng() mod NUnits))) |
|
|
|
proc splitDespawn(w: var SplitWorld, n: int) = |
|
for j in 0..<n: |
|
if w.projectiles.len > 0: |
|
let idx = rng() mod w.projectiles.len |
|
w.projectiles[idx] = w.projectiles[w.projectiles.high] |
|
w.projectiles.setLen(w.projectiles.high) |
|
|
|
# ================================================================ |
|
# Benchmark harness |
|
# ================================================================ |
|
|
|
template bench(title: string, body: untyped) = |
|
for _ in 0..<10: body |
|
let t0 = getMonoTime() |
|
for _ in 0..<Frames: body |
|
let t1 = getMonoTime() |
|
let dur = t1 - t0 |
|
let ns = dur.inNanoseconds.float64 / Frames.float64 / N.float64 |
|
let ms = dur.inMilliseconds.float64 |
|
echo title.align(12) & " " & ns.formatFloat(ffDecimal, 3).align(9) & |
|
" ns/e " & ms.formatFloat(ffDecimal, 1).align(8) & " ms" |
|
|
|
template benchLifeCycle(spawnBody, despawnBody: untyped) = |
|
for _ in 0..<3: |
|
spawnBody |
|
despawnBody |
|
var totalSpawn = 0'i64 |
|
var totalDespawn = 0'i64 |
|
for _ in 0..<SpawnIters: |
|
let s0 = getMonoTime(); spawnBody; let s1 = getMonoTime() |
|
totalSpawn += (s1 - s0).inNanoseconds |
|
let d0 = getMonoTime(); despawnBody; let d1 = getMonoTime() |
|
totalDespawn += (d1 - d0).inNanoseconds |
|
let spMs = totalSpawn.float64 / SpawnIters.float64 / 1e6 |
|
let dpMs = totalDespawn.float64 / SpawnIters.float64 / 1e6 |
|
echo "spawn " & spMs.formatFloat(ffDecimal, 3).align(9) & " ms/batch" |
|
echo "despawn " & dpMs.formatFloat(ffDecimal, 3).align(9) & " ms/batch" |
|
|
|
when isMainModule: |
|
echo "Archetype ECS vs MI-AoS Benchmark" |
|
echo "=================================" |
|
echo fmt"Buildings: {NBuildings} Units: {NUnits} Projectiles: {NProjectiles} Total: {N}" |
|
echo fmt"Frames: {Frames} Spawn/despawn batch: {SpawnBatch} x {SpawnIters}" |
|
echo "" |
|
|
|
# ---- ECS-Archetype ---- |
|
var arch = initArchWorld(NBuildings, NUnits, NProjectiles + SpawnBatch + 16) |
|
arch.populate() |
|
echo "--- ECS-Archetype (SoA columns per archetype) ---" |
|
bench("move", arch.archMove(Dt)) |
|
bench("hierarchy", arch.archHierarchy()) |
|
bench("combat", arch.archCombat()) |
|
bench("cull", arch.archCull()) |
|
rngState = 999'u32 |
|
echo " spawn/despawn:" |
|
benchLifeCycle(arch.archSpawn(SpawnBatch), arch.archDespawn(SpawnBatch)) |
|
echo "" |
|
|
|
# ---- MI-AoS ---- |
|
var aos: AoSWorld |
|
aos.projectiles = newSeqOfCap[Projectile](NProjectiles + SpawnBatch + 16) |
|
aos.populate() |
|
echo "--- MI-AoS (per-archetype value arrays) ---" |
|
bench("move", aos.aosMove(Dt)) |
|
bench("hierarchy", aos.aosHierarchy()) |
|
bench("combat", aos.aosCombat()) |
|
bench("cull", aos.aosCull()) |
|
rngState = 999'u32 |
|
echo " spawn/despawn:" |
|
benchLifeCycle(aos.aosSpawn(SpawnBatch), aos.aosDespawn(SpawnBatch)) |
|
echo "" |
|
|
|
# ---- MI-AoS-Split ---- |
|
var spl: SplitWorld |
|
spl.projectiles = newSeqOfCap[Projectile](NProjectiles + SpawnBatch + 16) |
|
spl.populate() |
|
echo "--- MI-AoS-Split (hot/cold split per archetype) ---" |
|
bench("move", spl.splitMove(Dt)) |
|
bench("hierarchy", spl.splitHierarchy()) |
|
bench("combat", spl.splitCombat()) |
|
bench("cull", spl.splitCull()) |
|
rngState = 999'u32 |
|
echo " spawn/despawn:" |
|
benchLifeCycle(spl.splitSpawn(SpawnBatch), spl.splitDespawn(SpawnBatch)) |
|
echo "" |
|
|
|
# ---- Memory ---- |
|
echo "--- Memory footprint (data only) ---" |
|
let archMem = NBuildings * (sizeof(TransformC) + sizeof(SpriteCol)) + |
|
NUnits * (sizeof(TransformC) + sizeof(VelocityC) + sizeof(HealthC) + |
|
sizeof(TargetC) + sizeof(SpriteCol) + sizeof(ParentC)) + |
|
NProjectiles * (sizeof(TransformC) + sizeof(VelocityC) + |
|
sizeof(TargetC) + sizeof(LifetimeC)) |
|
let aosMem = NBuildings * sizeof(Building) + NUnits * sizeof(Unit) + |
|
NProjectiles * sizeof(Projectile) |
|
let splMem = NBuildings * (sizeof(BuildingHot) + sizeof(BuildingCold)) + |
|
NUnits * (sizeof(UnitHot) + sizeof(UnitCold)) + |
|
NProjectiles * sizeof(Projectile) |
|
echo fmt" ECS-Archetype: {archMem.float64 / 1e6:6.1f} MB (SoA columns, only needed comps)" |
|
echo fmt" MI-AoS: {aosMem.float64 / 1e6:6.1f} MB (struct arrays per archetype)" |
|
echo fmt" MI-AoS-Split: {splMem.float64 / 1e6:6.1f} MB (hot/cold struct arrays)" |
|
echo "" |
|
echo fmt"sink: {sink}" |