Specifically, I'm curious if tuples could take the place of structs, and equivalents for registry.assign<position> and registry.view<position, velocity>.
I can imagine registry as a seq[int], with each entity being represented by a unique integer, but I don't know how to assign tuples to an int.
I really like the ability to filter entities to only those with the specific components of interest. Is there a way to achieve the same in Nim?
assign shouldn't be too difficult... just add a subclass of Component to the seq.
But filtering for registry.view? I can't figure that one out yet. Templates, maybe?
Thank you, Araq and doofenstein. I'm going to try to develop a VERY simple ECS today in Nim.
Somehow, I had totally missed (or forgotten) varargs and set. Those two should help make this first attempt successful.
I thought I had something good last night:
type Position = object
x: int
y: int
type Velocity = object
dx: int
dy: int
dz: int
type Component = Position | Velocity
proc showProps(p: Position) =
echo p.x, ", ", p.y
proc showProps(v: Velocity) =
echo v.dx, ", ", v.dy, ", ", v.dz
let p = Position(x: 9, y: 9)
let v = Velocity(dx: 1, dy: 1, dz: 3)
showProps(p)
showProps(v)
While tedious, it was good.
Then I learned this morning that seq[Component] fails. sigh
type Position = object
x: int
y: int
type Velocity = object
dx: int
dy: int
dz: int
type Component = Position | Velocity
proc showProps(p: Position) =
echo p.x, ", ", p.y
proc showProps(v: Velocity) =
echo v.dx, ", ", v.dy, ", ", v.dz
let p = Position(x: 9, y: 9)
let v = Velocity(dx: 1, dy: 1, dz: 3)
var s: seq[Component]
s.add(p)
s.add(v)
echo s
Then I learned this morning that seq[Component] fails.
That really should be obvious.
A seq in Nim is similar to vectors in C++, that is basically a plain C array that automatically resizes when necessary, where resize is new allocation and copying elements. So all elements must have same type and same size as in C arrays -- as all refs and pointers have the same size, we can have different ref types in a seq and can test for exact type with "of" operator at runtime.
Note that we have sum types (objects variants) in Nim.
Ideally, I'm hoping for something comparable to Golang interfaces.
I avoided C since the early '90s due to early compiler incompatibilities. Only recently, in the past few months, have I looked at C/C++ seriously.
Thank you for pointing to variant. I'm going to look at entt's source more closely and try to find a good equivalent in Nim. (I couldn't seem to make anything useful out of sequtils; Araq, I'm curious as to what you had in mind.)
I guess this would be much easier to do in nim?
Yes, this where templates shine. You want to use obj.field syntax but the storage changed to fields[obj]:
template field(x): untyped = fields[obj]
The fields array must be in the scope for this to work, but that's very easy to do.
Imho ECS is mostly about "adding stuff to stuff without knowing beforehand what stuff is or can be".
I've also done a toy ESC: https://github.com/enthus1ast/ecs
As I said, I'm not aiming to get the maximum performance. for synthetic performance measurement, I used bunnymark from raylib_now examples. the measurement showed that the performance dropped by 90%, but after some optimizations, this figure reached 30%, which was already quite satisfactory to me.
in this case, ecs allows you to build an architecture, rather than achieve maximum speed. (:
@araq, I think I get what you are suggesting but I'm not sure it does exactly what I had in mind. What I believe an AOS - SOA transform should do is turn:
my_object[n].my_field
into
my_object.my_field[n]
rather than into:
my_field[my_object]
BTW, I just happened to run into this related C++20 library which does something similar but IMHO it feels much less elegant that what I think you could do in nim (https://github.com/celtera/ahsohtoa).
It looks possible with a macro
type
SoA = object
field: seq[int]
import macros
template `[]`*(s: SoA, i: int): (SoA, int) = (s, i)
template declareSoAField(T, field: untyped) =
macro field*(o: (T, int)): untyped =
expectKind(o, nnkTupleConstr)
result = nnkBracketExpr.newTree(nnkDotExpr.newTree(o[0], ident astToStr(field)), o[1])
echo repr(result)
declareSoAField(SoA, field)
let soa = SoA(field: @[2])
assert soa[0].field == 2
As Nim's perversion knows no bounds, one can use a macro to generate these accessor macros from the initial object
import macros
template declareSoAField(T, field: untyped) =
macro field(o: (T, int)): untyped =
expectKind(o, nnkTupleConstr)
result = nnkBracketExpr.newTree(nnkDotExpr.newTree(o[0], ident astToStr(field)), o[1])
#echo repr(result)
template declareSoAaccessors(T: untyped, f1: untyped) =
#proc toSoA*(o: oldT, result: var newT):
# for f in fields(o)
template `[]`*(s: T , i: int): (T, int) = (s, i)
proc len*(s: T): int = s.f1.len
template declareSoASetLen(s, newLen, T, body: untyped) =
proc setLen*(s: var T, newLen: int) = body
template declareSoAToSoA(s, old, j, T, oldT, body: untyped) =
proc toSoA*(old: sink openArray[oldT], s: var T) =
setLen(s, old.len)
for i in 0..<old.len:
let j = i
body
proc toSoA*(old: sink openArray[oldT]): T = toSoA(old, result)
template declareSoAtoOldT(s, old, T, oldT, toOldT, body: untyped) =
template toOldT*(s: (T, int)): oldT =
var old: oldT
body
old
macro declareSoAfromObject*(T: typedesc[object], newT: untyped): untyped =
let rec = T.getImpl[2][2]
var fields: seq[tuple[f: NimNode, typ: NimNode, exported: bool]]
for c in rec.items:
if c.kind == nnkIdentDefs:
for i in 0..<c.len-2:
fields.add ((if c[i].kind == nnkPostFix: c[i][1] else: c[i]), c[^2], c[i].kind == nnkPostFix)
if fields.len > 1:
let newRec = nnkRecList.newTree()
for f in fields:
newRec.add nnkIdentDefs.newTree(if f.exported: nnkPostFix.newTree(ident"*", f[0]) else: f[0], nnkBracketExpr.newTree(ident("seq"), f[1]), newEmptyNode())
result = newStmtList()
result.add nnkTypeSection.newTree(
nnkTypeDef.newTree(
newT,
newEmptyNode(),
nnkObjectTy.newTree(newEmptyNode(), newEmptyNode(), newRec)
)
)
var setLenBody, toSoABody, toOldTBody = newStmtList()
let paramId = ident"s"
let newLenId = ident"newLen"
let oldId = ident"old"
let iId = ident"j"
for f in fields:
result.add getAst declareSoAField(newT, f[0])
if f.exported:
result.add nnkExportStmt.newTree(f[0])
toSoABody.add newAssignment(nnkBracketExpr.newTree(nnkDotExpr.newTree(paramId, f[0]), iId), nnkDotExpr.newTree(nnkBracketExpr.newTree(oldId, iId), f[0]))
setLenBody.add newCall(ident"setLen", nnkDotExpr.newTree(paramId, f[0]), ident"newLen")
toOldTBody.add newAssignment(nnkDotExpr.newTree(oldId, f[0]), newCall(f[0], paramId))
result.add getAst declareSoAaccessors(newT,fields[0][0])
result.add getAst declareSoASetLen(paramId, newLenId, newT, setLenBody)
result.add getAst declareSoAToSoA(paramId, oldId, iId, newT, T, toSoABody)
let tooldT = ident("to" & $T)
result.add getAst declareSoAtoOldT(paramId, oldId, newT, T, toOldT, toOldTBody)
# echo repr(result)
type
MyObj* = object
field1*: int
field2*: float
declareSoAfromObject(MyObj, MyObjSoA)
export MyObjSoA
var aos = @[MyObj(field1: 1, field2: 1.2), MyObj(field1: 2, field2: 2.1)]
var soa = toSoA(aos)
assert soa[0].field1 == 1
assert soa[0].field2 == 1.2
assert soa[1].field1 == 2
assert soa[1].field2 == 2.1
# As soa[i] is a tuple used to propagate the index to accessors at compile-time, soa[i] should not be used directly
assert soa[0].toMyObj == aos[0]
assert soa[1].toMyObj == aos[1]
Note: this code is largely untested and could use some refactoring with macros.quote for clarity
This is very cool, thanks!
I’d be very cool if something like this was made into a library or even into the stdlib.
The stdlib has higher standards and lower scope than before, and would IMO need a mature version (what about generics/case objects etc…) in the nimbleverse first. This can start here: https://github.com/guibar64/aossoa. I will publish it in nim-lang/packages soon.
Feel free to open issues to discuss naming/API etc…