4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

NimでECS(Entity Component System)

Last updated at Posted at 2020-11-01

まずECSって何?

Wikiを読んだりググったりしてね

参考: エンティティ・コンポーネント・システム(Wikipedia)

3行で

オブジェクトをクラスで表すんじゃなくて、
Entityって呼ばれるIDにComponentって呼んでる任意のデータを紐付けて
Systemって呼ばれる関数でConponent毎に処理する。

何が嬉しいの?

  • IDにコンポーネントを紐付けてオブジェクトを表すので簡単にいろんなオブジェクトを作れる。
  • コンポーネント毎に処理するので、CPUキャッシュが有効に使えて処理が早くなる(実装による)
  • ゲームみたいな多様なデータをもつオブジェクトを表現する場合、静的なクラスを設計しなくていいので楽

実際どんな感じ?

まずはエンティティやコンポートネントの置き場所となるWorldオブジェクトを作成する。

let world = World()

次にEntityを作成する。

let entity = world.newEntity

Entityにコンポーネントを紐づける

# 位置情報(Position)と名前(Name)を紐づける
entity.with(Position(x: 1, y: 2)).with(Name("Bob"))

Entityからコンポーネントを取得する場合、get関数に型を指定する。

entity.get(Position) == Position(x: 1, y: 2) # True

Entityから一度に複数のコンポーネントを取得できるようにする。

let (pos, name) = entity.getAll(Position, Name)

Entityが指定コンポーネントを持っているかチェックする。

entity.hasAll(Position, Name) # True

Systemを実装するために指定の全てのコンポーネントを処理する。

# 値のみ取得
for pos in world.compornentsOf(Position):
  # pになんかする

# Entityも取得する
for entity, pos in world.compornentsOf(Position):
  # p, eになんかする

使い終わったEntityを削除する。

entity.delete

データ構造

こんな感じになっております。

type
  EntityId = uint64

  Entity* = ref object
    id: EntityId
    world: World

  AbstructComponent = ref object of RootObj
    index: Table[Entity, int]
    freeIndex: seq[int]

  Component*[T] = ref object of AbstructComponent
    components: seq[T]

  World* = ref object
    lastEntityId: EntityId
    components: Table[string, AbstructComponent]

Entity

EntityId自体はただのuint64。
newEntityされるたびにインクリメントしていく。
余程のことがないとオーバーフローはしないと思うので、
使い回しはしない感じでいく。
ただ、Entityを起点として色々操作したいので、
自分が属するWorldの参照を持たせとく。

Component

これはちょっとヒネってる。
管理情報だけをもつAbstructComponentを継承して、
実際のデータリストをもつCompornentをジェネリックで作る。
AbstructComponentのindexはEntityと
紐づいているデータのComponent配列のインデックス情報のテーブル。
freeIndexはComponent配列の空いたところを使い回すようの空きインデックス管理。
単純にEntityとComponentのテーブルにすると、
System関数で回すときにCPUキャッシュの恩恵を受けられそうにないので、
Componentを配列で保持するためにこんな感じになっております。

World

EntityIDの管理とコンポーネントのテーブルを持ってる。
コンポーネントは文字列とAbstructComponentのテーブル。
キーの文字列は型名。使う時はAbstructComponentをキャストして使う。

どうやってんの?

Entity作成

これは単純

proc newEntity*(self: World): Entity =
  self.lastEntityId += 1
  Entity(id: self.lastEntityId, world: self)

Component紐付け

これは面倒
with関数自体はジェネリック関数

proc with*[T](self: Entity, component: T): Entity {.inline, discardable.} =
  self.world.assign(self, component)
  self

Worldのassign関数に丸投げ。
assignは対象のコンポーネントをすでに持ってれば作成。
そのあと、キャストされたComponentを取得して引数のentityとcomponentを追加する。

proc assign*[T](self: World, entity: Entity, component: T) =
  if self.has(T) == false:
    self.newComponent(T)
  self.componentsOf(T).assign(entity, component)

そのコンポーネントを持っているかどうかは以下の関数でチェック

proc has*(self: World, T: typedesc): bool {.inline.} =
  self.components.hasKey(T.type.name)

型自体をtypedescで受け取り、type.nameで型名を文字列に変換し、
componentsテーブルにあるかhasKeyでチェックする。
型名から文字列を取得するname関数はtypetraitsをimportする必要がある。

newComponentは単に指定の型のComponentをテーブルに追加するだけ。

proc newComponent*(self: World, T: typedesc) {.inline.} =
  self.components[T.type.name] = Component[T]()

componentsOfはcomponentsテーブルから指定された型の文字列をキーに
AbstructComponentを持ってきてそれを指定された型にcastして返す。

proc componentsOf*(self: World, T: typedesc): Component[T] {.inline.} =
  cast[Component[T]](self.components[T.type.name])

Componentのassignはこんな感じ。

proc assign*[T](self: Component[T], entity: Entity, component: T) =
  if self.index.hasKey(entity):
    self.components[self.index[entity]] = component
    return
  if self.freeIndex.len > 0:
    let index = self.freeIndex.pop
    self.index[entity] = index
    self.components[index] = component
    return
  self.index[entity] = self.components.len
  self.components.add(component)

既に割り当てられてたら置換え。
空きindexがあればそこに格納。
そうでなければ配列を拡張して追加。

Componentの取得

Entity経由での取得はWorldに丸投げ。
型はtypedescで指定する。

proc get*(self: Entity, T: typedesc): T {.inline.} =
  self.world.get(self, T)

WorldからComponentリストを取得して、
Componentから指定したentityのコンポーネントを取得する。

proc get*(self: World, entity: Entity, T: typedesc): T {.inline.} =
  self.componentsOf(T).get(entity)

Componentから指定されたEntityのデータ取得
単にentityをキーにindexテーブルからインデックスを取得して、
components配列からデータを取り出してくるだけ。

proc get*[T](self: Component[T], entity: Entity): T {.inline.} =
  return self.components[self.index[entity]]

ちなみにEntityはオブジェクトなので、ハッシュのキーにする場合は
hash関数の定義が必要。

import hashes
proc hash*(self: Entity): Hash {.inline.} =
  self.id.hash

複数取得はマクロを使って実装。
やってることはgetを並べてるだけ。

macro getAll*(self: Entity, types: varargs[typed]): untyped =
  var body = ""
  for t in types:
    if len(body) > 0:
      body.add(", ")
    body.add(fmt"{repr(self)}.get({repr(t)})")
  parseStmt(fmt"({body})")

Componentを持っているかチェック

worldが持っててかつEntityが持ってれば真を返す感じ。

proc has*(self: Entity, T: typedesc): bool {.inline.} =
  self.world.has(T) and self.world.componentsOf(T).has(self)

Componentのチェックは単純にhasKeyで。

proc has(self: AbstructComponent, entity: Entity): bool {.inline.} =
  self.index.hasKey(entity)

複数の型を一気にチェックできた方が便利なので
これもマクロで実装。hasを並べてるだけ。

macro hasAll*(self: Entity, types: varargs[typed]): untyped =
  var body = ""
  for t in types:
    if len(body) > 0:
      body.add(" and ")
    body.add(fmt"{repr(self)}.has({repr(t)})")
  parseStmt(body)

Component全処理

Componentにitmesとpairsイテレーターを実装すれば
Component、Entity、Componentのペアを
forループでぶん回せる。

iterator items*[T](self: Component[T]): T =
  for i in self.index.values:
    yield self.components[i]

iterator pairs*[T](self: Component[T]): tuple[key: Entity, val: T] =
  for e, i in self.index.pairs:
    yield (e, self.components[i])

Entityの削除

worldから対象のEntityを削除して
念のためidに無効なidを設定する。

let InvalidEntityId: EntityId = 0

proc delete*(self: Entity) =
  self.world.deleteEntity(self)
  self.id = InvalidEntityId

Entityが有効かチェックする関数も定義しとくと良いかもしんない。

proc isValid*(self: Entity): bool {.inline.} =
  self.id != InvalidEntityId

worldからの削除は全Componentから対象Entityのデータを削除する。

proc deleteEntity(self: World, entity: Entity) =
  for c in self.components.values:
    c.remove(entity)

Componentからの削除はEntityが持っていれば、
データ自体の削除処理はしないで対象EntityのインデックスをfreeIndexに移動するだけ。

proc remove(self: AbstructComponent, entity: Entity) =
  if self.has(entity):
    self.freeIndex.add(self.index[entity])
    self.index.del(entity)

最後に

ECSを知って興味が出てNimで実装してみただけなので、
実際使ったら色々問題あるかもだし、ベンチマークとかも取ってないので遅いかもだけど、
あくまでご参考までに。

以上〜。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?