|
## Archetype ECS vs MI-AoS — Realistic Game Benchmark |
|
## =================================================== |
|
## A tactical combat arena tick. Tests both uniform batch systems |
|
## (where archetype SoA shines) and per-entity complex logic |
|
## (where AoS struct locality should win). |
|
## |
|
## Systems per frame: |
|
## 1. aiUpdate — per-agent state machine (idle/seek/attack/flee) |
|
## reads: pos, hp, target, cooldown, aiState |
|
## writes: vel, aiState, cooldown |
|
## 2. move — velocity integration + bounds clamp |
|
## reads: pos, vel. writes: pos |
|
## 3. combat — if target in range & cooldown ready: deal damage |
|
## reads: pos(self+target), cooldown. writes: hp(target), cooldown |
|
## 4. projectile — homing steer + lifetime decay + hit detection |
|
## reads: pos, vel, target pos, lifetime. writes: pos, vel, lifetime |
|
## 5. cull — viewport visibility count |
|
## reads: pos, sprite |
|
## 6. spawn — create projectiles |
|
## 7. despawn — remove dead projectiles |
|
|
|
import std/[times, monotimes, strformat, strutils, math] |
|
|
|
const |
|
NBuildings = 1_000 |
|
NAgents = 8_000 |
|
NProjectiles = 2_000 |
|
N = NBuildings + NAgents + NProjectiles |
|
Frames = 500 |
|
SpawnBatch = 200 |
|
SpawnIters = 100 |
|
Dt = 0.016'f32 |
|
ScreenW = 1920'f32 |
|
ScreenH = 1080'f32 |
|
AttackRange = 50.0'f32 |
|
AttackRangeSq = AttackRange * AttackRange |
|
AttackDamage = 8.0'f32 |
|
AttackCooldown = 0.5'f32 |
|
FleeHpThreshold = 0.25'f32 |
|
SeekSpeed = 30.0'f32 |
|
FleeSpeed = 50.0'f32 |
|
ProjHomingStrength = 3.0'f32 |
|
ProjLifetime = 2.0'f32 |
|
ProjSpeed = 200.0'f32 |
|
ArmorReduction = 0.3'f32 # damage *= (1 - armor * 0.1) |
|
|
|
var sink: float32 = 0.0 |
|
var aliveCount: int = 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] |
|
|
|
template v2(x, y: float32): Vec2f = (x, y) |
|
template lenSq(v: Vec2f): float32 = v.x*v.x + v.y*v.y |
|
template norm(v: Vec2f): Vec2f = |
|
let l = sqrt(v.x*v.x + v.y*v.y) |
|
if l > 0.0001'f32: (v.x / l, v.y / l) else: (0'f32, 0'f32) |
|
|
|
# ================================================================ |
|
# 1. ECS-Archetype — SoA columns within archetype buckets |
|
# ================================================================ |
|
|
|
type |
|
ComponentKind = enum |
|
ckTransform, ckVelocity, ckHealth, ckTarget, ckSprite, |
|
ckParent, ckLifetime, ckAI |
|
|
|
ArchetypeKind = enum |
|
BuildingArch, AgentArch, ProjectileArch |
|
|
|
TransformC = object |
|
pos: Vec2f |
|
worldPos: Vec2f |
|
VelocityC = object |
|
vel: Vec2f |
|
HealthC = object |
|
hp: float32 |
|
maxHp: float32 |
|
TargetC = object |
|
targetIdx: int32 |
|
SpriteCol = object |
|
sprite: SpriteC |
|
ParentC = object |
|
parentIdx: int32 |
|
LifetimeC = object |
|
lifetime: float32 |
|
AIC = object |
|
cooldown: float32 |
|
state: uint8 # 0=idle 1=seek 2=attack 3=flee |
|
armor: float32 |
|
|
|
Archetype = object |
|
mask: set[ComponentKind] |
|
columns: array[ComponentKind, pointer] |
|
|
|
ArchWorld = object |
|
archetypes: array[ArchetypeKind, Archetype] |
|
|
|
# --- column memory management --- |
|
proc newColumn[T](): pointer = 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]) |
|
if ckAI in x.mask: destroyColumn[AIC](x.columns[ckAI]) |
|
|
|
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.} |
|
|
|
# --- column access --- |
|
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]() |
|
if ckAI in mask: result.columns[ckAI] = newColumn[AIC]() |
|
|
|
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) |
|
reserveCol(ckAI, AIC) |
|
|
|
proc initArchWorld(capB, capA, capP: int): ArchWorld = |
|
result.archetypes[BuildingArch] = initArchetype({ckTransform, ckSprite}) |
|
result.archetypes[AgentArch] = initArchetype( |
|
{ckTransform, ckVelocity, ckHealth, ckTarget, ckSprite, ckParent, ckAI}) |
|
result.archetypes[ProjectileArch] = initArchetype( |
|
{ckTransform, ckVelocity, ckTarget, ckLifetime}) |
|
result.reserve(BuildingArch, capB) |
|
result.reserve(AgentArch, capA) |
|
result.reserve(ProjectileArch, capP) |
|
|
|
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..<NAgents: |
|
col(w, AgentArch, ckTransform, TransformC).add( |
|
TransformC(pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)), |
|
worldPos: v2(0'f32, 0'f32))) |
|
col(w, AgentArch, ckVelocity, VelocityC).add( |
|
VelocityC(vel: v2(0'f32, 0'f32))) |
|
col(w, AgentArch, ckHealth, HealthC).add( |
|
HealthC(hp: 100'f32, maxHp: 100'f32)) |
|
col(w, AgentArch, ckTarget, TargetC).add( |
|
TargetC(targetIdx: int32(rng() mod NAgents))) |
|
col(w, AgentArch, ckSprite, SpriteCol).add( |
|
SpriteCol(sprite: (0x00FF00FF'u32, 20'f32))) |
|
col(w, AgentArch, ckParent, ParentC).add( |
|
ParentC(parentIdx: if i < NAgents div 2: int32(rng() mod NBuildings) else: -1'i32)) |
|
col(w, AgentArch, ckAI, AIC).add( |
|
AIC(cooldown: 0'f32, state: 1'u8, armor: float32(rng() mod 5) * 0.1'f32)) |
|
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(float32(rng() mod 10), float32(rng() mod 10)))) |
|
col(w, ProjectileArch, ckTarget, TargetC).add( |
|
TargetC(targetIdx: int32(rng() mod NAgents))) |
|
col(w, ProjectileArch, ckLifetime, LifetimeC).add( |
|
LifetimeC(lifetime: ProjLifetime)) |
|
|
|
# --- archetype systems --- |
|
proc archAIUpdate(w: var ArchWorld, dt: float32) = |
|
let n = colLen(w, AgentArch, ckTransform, TransformC) |
|
let transforms = colData(w, AgentArch, ckTransform, TransformC) |
|
let velocities = colData(w, AgentArch, ckVelocity, VelocityC) |
|
let healths = colData(w, AgentArch, ckHealth, HealthC) |
|
let targets = colData(w, AgentArch, ckTarget, TargetC) |
|
let ais = colData(w, AgentArch, ckAI, AIC) |
|
for i in 0..<n: |
|
if healths[i].hp <= 0'f32: continue |
|
ais[i].cooldown -= dt |
|
let hpRatio = healths[i].hp / healths[i].maxHp |
|
# state machine: flee if low hp, attack if in range, else seek |
|
if hpRatio < FleeHpThreshold: |
|
ais[i].state = 3 # flee |
|
let ti = targets[i].targetIdx |
|
if ti >= 0 and healths[ti].hp > 0'f32: |
|
let away = norm(v2(transforms[i].pos.x - transforms[ti].pos.x, |
|
transforms[i].pos.y - transforms[ti].pos.y)) |
|
velocities[i].vel = v2(away.x * FleeSpeed, away.y * FleeSpeed) |
|
else: |
|
let ti = targets[i].targetIdx |
|
if ti >= 0 and healths[ti].hp > 0'f32: |
|
let dx = transforms[ti].pos.x - transforms[i].pos.x |
|
let dy = transforms[ti].pos.y - transforms[i].pos.y |
|
let distSq = dx*dx + dy*dy |
|
if distSq < AttackRangeSq: |
|
ais[i].state = 2 # attack |
|
velocities[i].vel = v2(0'f32, 0'f32) |
|
else: |
|
ais[i].state = 1 # seek |
|
let dir = norm(v2(dx, dy)) |
|
velocities[i].vel = v2(dir.x * SeekSpeed, dir.y * SeekSpeed) |
|
else: |
|
ais[i].state = 0 # idle |
|
velocities[i].vel = v2(0'f32, 0'f32) |
|
|
|
proc archMove(w: var ArchWorld, dt: float32) = |
|
let n = colLen(w, AgentArch, ckTransform, TransformC) |
|
let transforms = colData(w, AgentArch, ckTransform, TransformC) |
|
let velocities = colData(w, AgentArch, 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 |
|
# bounds clamp |
|
if transforms[i].pos.x < 0'f32: transforms[i].pos.x = 0'f32 |
|
elif transforms[i].pos.x > ScreenW: transforms[i].pos.x = ScreenW |
|
if transforms[i].pos.y < 0'f32: transforms[i].pos.y = 0'f32 |
|
elif transforms[i].pos.y > ScreenH: transforms[i].pos.y = ScreenH |
|
let pn = colLen(w, ProjectileArch, ckTransform, TransformC) |
|
let pt = colData(w, ProjectileArch, ckTransform, TransformC) |
|
let pv = colData(w, ProjectileArch, ckVelocity, VelocityC) |
|
for i in 0..<pn: |
|
pt[i].pos.x += pv[i].vel.x * dt |
|
pt[i].pos.y += pv[i].vel.y * dt |
|
|
|
proc archCombat(w: var ArchWorld, dt: float32) = |
|
let n = colLen(w, AgentArch, ckTransform, TransformC) |
|
let transforms = colData(w, AgentArch, ckTransform, TransformC) |
|
let healths = colData(w, AgentArch, ckHealth, HealthC) |
|
let targets = colData(w, AgentArch, ckTarget, TargetC) |
|
let ais = colData(w, AgentArch, ckAI, AIC) |
|
for i in 0..<n: |
|
if healths[i].hp <= 0'f32 or ais[i].state != 2: continue |
|
if ais[i].cooldown > 0'f32: continue |
|
let ti = targets[i].targetIdx |
|
if ti >= 0 and healths[ti].hp > 0'f32: |
|
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 < AttackRangeSq: |
|
let dmg = AttackDamage * (1'f32 - ais[ti].armor * ArmorReduction) |
|
healths[ti].hp -= dmg |
|
ais[i].cooldown = AttackCooldown |
|
if healths[ti].hp < 0'f32: healths[ti].hp = 0'f32 |
|
|
|
proc archProjectile(w: var ArchWorld, dt: float32) = |
|
let pn = colLen(w, ProjectileArch, ckTransform, TransformC) |
|
let pt = colData(w, ProjectileArch, ckTransform, TransformC) |
|
let pv = colData(w, ProjectileArch, ckVelocity, VelocityC) |
|
let ptg = colData(w, ProjectileArch, ckTarget, TargetC) |
|
let pl = colData(w, ProjectileArch, ckLifetime, LifetimeC) |
|
let at = colData(w, AgentArch, ckTransform, TransformC) |
|
let ah = colData(w, AgentArch, ckHealth, HealthC) |
|
for i in 0..<pn: |
|
pl[i].lifetime -= dt |
|
let ti = ptg[i].targetIdx |
|
if ti >= 0 and ah[ti].hp > 0'f32: |
|
let dx = at[ti].pos.x - pt[i].pos.x |
|
let dy = at[ti].pos.y - pt[i].pos.y |
|
let dir = norm(v2(dx, dy)) |
|
# homing steer |
|
pv[i].vel.x += dir.x * ProjHomingStrength * dt * ProjSpeed |
|
pv[i].vel.y += dir.y * ProjHomingStrength * dt * ProjSpeed |
|
let sp = norm(pv[i].vel) |
|
pv[i].vel = v2(sp.x * ProjSpeed, sp.y * ProjSpeed) |
|
# hit detection |
|
if dx*dx + dy*dy < 100'f32: |
|
ah[ti].hp -= AttackDamage * 0.5'f32 |
|
pl[i].lifetime = 0'f32 |
|
|
|
proc archCull(w: var ArchWorld) = |
|
let bn = colLen(w, BuildingArch, ckTransform, TransformC) |
|
let bt = colData(w, BuildingArch, ckTransform, TransformC) |
|
let bs = colData(w, BuildingArch, ckSprite, SpriteCol) |
|
for i in 0..<bn: |
|
let p = bt[i].pos |
|
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH: |
|
sink += bs[i].sprite.size |
|
let an = colLen(w, AgentArch, ckTransform, TransformC) |
|
let at = colData(w, AgentArch, ckTransform, TransformC) |
|
let aspr = colData(w, AgentArch, ckSprite, SpriteCol) |
|
for i in 0..<an: |
|
let p = at[i].pos |
|
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH: |
|
sink += aspr[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(float32(rng() mod 10), float32(rng() mod 10)))) |
|
col(w, ProjectileArch, ckTarget, TargetC).add( |
|
TargetC(targetIdx: int32(rng() mod NAgents))) |
|
col(w, ProjectileArch, ckLifetime, LifetimeC).add( |
|
LifetimeC(lifetime: ProjLifetime)) |
|
|
|
proc archDespawn(w: var ArchWorld, n: int) = |
|
let pt = addr col(w, ProjectileArch, ckTransform, TransformC) |
|
for j in 0..<n: |
|
let len = pt[].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 |
|
|
|
Agent = object |
|
pos: Vec2f |
|
vel: Vec2f |
|
hp: float32 |
|
maxHp: float32 |
|
targetIdx: int32 |
|
parentIdx: int32 |
|
cooldown: float32 |
|
state: uint8 |
|
armor: float32 |
|
sprite: SpriteC |
|
|
|
Projectile = object |
|
pos: Vec2f |
|
vel: Vec2f |
|
lifetime: float32 |
|
targetIdx: int32 |
|
|
|
AoSWorld = object |
|
buildings: seq[Building] |
|
agents: seq[Agent] |
|
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.agents = newSeq[Agent](NAgents) |
|
for i in 0..<NAgents: |
|
w.agents[i] = Agent( |
|
pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)), |
|
vel: v2(0'f32, 0'f32), |
|
hp: 100'f32, maxHp: 100'f32, |
|
targetIdx: int32(rng() mod NAgents), |
|
parentIdx: if i < NAgents div 2: int32(rng() mod NBuildings) else: -1'i32, |
|
cooldown: 0'f32, state: 1'u8, |
|
armor: float32(rng() mod 5) * 0.1'f32, |
|
sprite: (0x00FF00FF'u32, 20'f32)) |
|
w.projectiles = newSeq[Projectile](NProjectiles) |
|
for i in 0..<NProjectiles: |
|
w.projectiles[i] = Projectile( |
|
pos: v2(0'f32, 0'f32), |
|
vel: v2(float32(rng() mod 10), float32(rng() mod 10)), |
|
lifetime: ProjLifetime, |
|
targetIdx: int32(rng() mod NAgents)) |
|
|
|
proc aosAIUpdate(w: var AoSWorld, dt: float32) = |
|
for i in 0..<w.agents.len: |
|
if w.agents[i].hp <= 0'f32: continue |
|
w.agents[i].cooldown -= dt |
|
let hpRatio = w.agents[i].hp / w.agents[i].maxHp |
|
if hpRatio < FleeHpThreshold: |
|
w.agents[i].state = 3 |
|
let ti = w.agents[i].targetIdx |
|
if ti >= 0 and w.agents[ti].hp > 0'f32: |
|
let away = norm(v2(w.agents[i].pos.x - w.agents[ti].pos.x, |
|
w.agents[i].pos.y - w.agents[ti].pos.y)) |
|
w.agents[i].vel = v2(away.x * FleeSpeed, away.y * FleeSpeed) |
|
else: |
|
let ti = w.agents[i].targetIdx |
|
if ti >= 0 and w.agents[ti].hp > 0'f32: |
|
let dx = w.agents[ti].pos.x - w.agents[i].pos.x |
|
let dy = w.agents[ti].pos.y - w.agents[i].pos.y |
|
let distSq = dx*dx + dy*dy |
|
if distSq < AttackRangeSq: |
|
w.agents[i].state = 2 |
|
w.agents[i].vel = v2(0'f32, 0'f32) |
|
else: |
|
w.agents[i].state = 1 |
|
let dir = norm(v2(dx, dy)) |
|
w.agents[i].vel = v2(dir.x * SeekSpeed, dir.y * SeekSpeed) |
|
else: |
|
w.agents[i].state = 0 |
|
w.agents[i].vel = v2(0'f32, 0'f32) |
|
|
|
proc aosMove(w: var AoSWorld, dt: float32) = |
|
for i in 0..<w.agents.len: |
|
w.agents[i].pos.x += w.agents[i].vel.x * dt |
|
w.agents[i].pos.y += w.agents[i].vel.y * dt |
|
if w.agents[i].pos.x < 0'f32: w.agents[i].pos.x = 0'f32 |
|
elif w.agents[i].pos.x > ScreenW: w.agents[i].pos.x = ScreenW |
|
if w.agents[i].pos.y < 0'f32: w.agents[i].pos.y = 0'f32 |
|
elif w.agents[i].pos.y > ScreenH: w.agents[i].pos.y = ScreenH |
|
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 aosCombat(w: var AoSWorld, dt: float32) = |
|
for i in 0..<w.agents.len: |
|
if w.agents[i].hp <= 0'f32 or w.agents[i].state != 2: continue |
|
if w.agents[i].cooldown > 0'f32: continue |
|
let ti = w.agents[i].targetIdx |
|
if ti >= 0 and w.agents[ti].hp > 0'f32: |
|
let dx = w.agents[i].pos.x - w.agents[ti].pos.x |
|
let dy = w.agents[i].pos.y - w.agents[ti].pos.y |
|
if dx*dx + dy*dy < AttackRangeSq: |
|
let dmg = AttackDamage * (1'f32 - w.agents[ti].armor * ArmorReduction) |
|
w.agents[ti].hp -= dmg |
|
w.agents[i].cooldown = AttackCooldown |
|
if w.agents[ti].hp < 0'f32: w.agents[ti].hp = 0'f32 |
|
|
|
proc aosProjectile(w: var AoSWorld, dt: float32) = |
|
for i in 0..<w.projectiles.len: |
|
w.projectiles[i].lifetime -= dt |
|
let ti = w.projectiles[i].targetIdx |
|
if ti >= 0 and w.agents[ti].hp > 0'f32: |
|
let dx = w.agents[ti].pos.x - w.projectiles[i].pos.x |
|
let dy = w.agents[ti].pos.y - w.projectiles[i].pos.y |
|
let dir = norm(v2(dx, dy)) |
|
w.projectiles[i].vel.x += dir.x * ProjHomingStrength * dt * ProjSpeed |
|
w.projectiles[i].vel.y += dir.y * ProjHomingStrength * dt * ProjSpeed |
|
let sp = norm(w.projectiles[i].vel) |
|
w.projectiles[i].vel = v2(sp.x * ProjSpeed, sp.y * ProjSpeed) |
|
if dx*dx + dy*dy < 100'f32: |
|
w.agents[ti].hp -= AttackDamage * 0.5'f32 |
|
w.projectiles[i].lifetime = 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.agents.len: |
|
let p = w.agents[i].pos |
|
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH: |
|
sink += w.agents[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(float32(rng() mod 10), float32(rng() mod 10)), |
|
lifetime: ProjLifetime, |
|
targetIdx: int32(rng() mod NAgents))) |
|
|
|
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) |
|
|
|
# ================================================================ |
|
# 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 / Frames.float64 |
|
let pct = ms / 16.0'f64 * 100.0 |
|
echo title.align(14) & " " & ns.formatFloat(ffDecimal, 3).align(8) & |
|
" ns/e " & ms.formatFloat(ffDecimal, 2).align(7) & |
|
" ms/frame " & pct.formatFloat(ffDecimal, 1).align(5) & "% budget" |
|
|
|
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(8) & " ms/batch" |
|
echo "despawn " & dpMs.formatFloat(ffDecimal, 3).align(8) & " ms/batch" |
|
|
|
when isMainModule: |
|
echo "Archetype ECS vs MI-AoS — Realistic Game Benchmark" |
|
echo "==================================================" |
|
echo fmt"Buildings: {NBuildings} Agents: {NAgents} Projectiles: {NProjectiles} Total: {N}" |
|
echo fmt"Frames: {Frames} dt: {Dt}s Frame budget: 16ms" |
|
echo "" |
|
|
|
# ---- ECS-Archetype ---- |
|
var arch = initArchWorld(NBuildings, NAgents, NProjectiles + SpawnBatch + 16) |
|
arch.populate() |
|
echo "--- ECS-Archetype (SoA columns per archetype) ---" |
|
bench("ai_update", arch.archAIUpdate(Dt)) |
|
bench("move", arch.archMove(Dt)) |
|
bench("combat", arch.archCombat(Dt)) |
|
bench("projectile", arch.archProjectile(Dt)) |
|
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("ai_update", aos.aosAIUpdate(Dt)) |
|
bench("move", aos.aosMove(Dt)) |
|
bench("combat", aos.aosCombat(Dt)) |
|
bench("projectile", aos.aosProjectile(Dt)) |
|
bench("cull", aos.aosCull()) |
|
rngState = 999'u32 |
|
echo " spawn/despawn:" |
|
benchLifeCycle(aos.aosSpawn(SpawnBatch), aos.aosDespawn(SpawnBatch)) |
|
echo "" |
|
|
|
# ---- Memory ---- |
|
echo "--- Memory footprint ---" |
|
let archMem = NBuildings * (sizeof(TransformC) + sizeof(SpriteCol)) + |
|
NAgents * (sizeof(TransformC) + sizeof(VelocityC) + sizeof(HealthC) + |
|
sizeof(TargetC) + sizeof(SpriteCol) + sizeof(ParentC) + sizeof(AIC)) + |
|
NProjectiles * (sizeof(TransformC) + sizeof(VelocityC) + |
|
sizeof(TargetC) + sizeof(LifetimeC)) |
|
let aosMem = NBuildings * sizeof(Building) + NAgents * sizeof(Agent) + |
|
NProjectiles * sizeof(Projectile) |
|
echo fmt" ECS-Archetype: {archMem.float64 / 1e6:6.1f} MB" |
|
echo fmt" MI-AoS: {aosMem.float64 / 1e6:6.1f} MB" |
|
echo fmt" Agent struct: {sizeof(Agent)} bytes ({NAgents} agents = {NAgents * sizeof(Agent)} bytes)" |
|
echo "" |
|
echo fmt"sink: {sink}" |