Entity-Component-System (ECS) in Go für die Spieleentwicklung

Das Entity-Component-System (ECS) ist ein Pattern in der Spieleentwicklung, das dazu dient, die Komplexität des Codes zu reduzieren, mehr Flexibilität in der Gameplay-Logik zu ermöglichen und die Leistung zu steigern. Es zerlegt Spielobjekte in einfache Komponenten. Dadurch ist es möglich, die Spiellogik von den Daten zu trennen. Jedes Objekt besteht nur aus einem Identifikator, meist einer Zahl. Die Daten werden in Komponenten gespeichert, mit dem Identifier verknüpft und von Systemen verarbeitet. Ein System ist eine Funktion, die auf die Komponenten zugreift und die Logik ausführt. Systeme können nacheinander oder parallel in einer Aktualisierungsschleife ausgeführt werden.

In Go benutze ich dafür das Framework Donburi von yohamta. Es funktioniert sehr gut mit Ebiten, einem 2D Game Framework für Go, kann aber auch ohne Ebiten auf dem Server verwendet werden.

Durch die Aufteilung der Objekte in Komponenten und Systeme kann der Code leichter zwischen Server und Client aufgeteilt werden. Rendering- und Spiellogik können zusätzlich auf dem Client verwendet werden.

Da die Logik in Systemen sequentiell ausgeführt wird, kommt es sehr selten vor, dass ein Objekt an zwei Stellen parallel verändert wird. Durch die Verwendung des Identifiers bei der Referenzierung kann ein Objekt ohne Probleme gelöscht werden.

Komponenten link

Der Umbau von klassichen Objekten zu Komponenten ist sehr einfach. Es wird eine Datenstruktur erstellt, in der die Daten der Komponente gespeichert werden. Diese Datenstruktur wird dann mit donburi.NewComponentType in einen Komponententyp umgewandelt.

import (
    "github.com/yohamta/donburi"
	dmath "github.com/yohamta/donburi/features/math"
)

type VelocityData struct {
    Velocity dmath.Vec2
}

// Velocity ist ein neuer Komponententyp mit VelocityData als Datenstruktur
var Velocity = donburi.NewComponentType[VelocityData]()

Donburi liefert eine Transform Komponente, die die Position, Rotation und Skalierung eines Objekts speichert. Zusätzlich können Objekte hierarchisch angeordnet werden und Unterobjekte bewegen sich mit dem übergeordneten Objekt mit.

import "github.com/yohamta/donburi/features/transform"

var Transform = donburi.NewComponentType[transform.TransformData]()

Es ist auch möglich, eine eigene Implementierung für die Position zu verwenden.

Das Rendering wird ebenfalls in Systeme ausgelagert. Die notwendigen Informationen können z.B. in einer Rendering-Komponente gespeichert werden.

import (
    "github.com/yohamta/donburi"
)

type SpriteData struct {
    Texture *ebiten.Image
}

var Sprite = donburi.NewComponentType[SpriteData]()

Systeme link

Systeme sind Funktionen, die auf Komponenten zugreifen und die Logik ausführen. Sie werden im Update Loop ausgeführt. Donburi stellt Abfragen zur Verfügung, die Komponenten filtern können. Zum Beispiel kann ein System nur auf Objekte zugreifen, die eine bestimmte Komponente haben.

type VelocitySystem struct {
    query *query.Query
}

func NewVelocitySystem() *VelocitySystem {
    return &VelocitySystem{
        query: query.NewQuery(filter.Contains(components.Velocity, components.Transform)),
    }
}

func (s *VelocitySystem) Update(w donburi.World) error {
	s.query.Each(w, func(e *donburi.Entry) {
		velocity := components.Velocity.Get(e)
		transformData := transform.GetTransform(e)

		transformData.LocalPosition = transformData.LocalPosition.Add(velocity.Velocity)
	})
}

Das VelocitySystem bewegt alle Objekte, die eine Velocity und eine Transform Komponente haben. Es ist nicht mehr notwendig, Code für jeden Objekttyp zu schreiben.

Auch das Rendering kann optional in mehrere Systeme aufgeteilt werden, z.B. in Systeme, die den Hintergrund und die Objekte rendern. Das RenderSystem benötigt nicht die Velocity-Komponente in der Query, sondern die Sprite-Komponente.

type RenderSystem struct {
    query *query.Query
}

func NewRenderSystem() *RenderSystem {
    return &RenderSystem{
        query: query.NewQuery(filter.Contains(components.Sprite, components.Transform)),
    }
}

func (s *RenderSystem) Draw(w donburi.World, screen *ebiten.Image) {
    s.query.Each(w, func(e *donburi.Entry) {
        spriteData := components.Sprite.Get(e)
        transformData := transform.GetTransform(e)

        op := &ebiten.DrawImageOptions{}
        op.GeoM.Translate(transformData.WorldPosition.X, transformData.WorldPosition.Y)

        screen.DrawImage(spriteData.Texture, op)
    })
}

Das Rendering könnte zusätzlich eine Update-Funktion definieren, in der z.B. Animationszustände aktualisiert werden.

Es kann vorkommen, dass ein Objekt optional eine weitere Komponente hat. Dies kann auch unabhängig von einer Abfrage abgefragt werden.

query.Each(w, func(entry *donburi.Entry) {
    spriteData := components.Sprite.Get(e)
    transformData := transform.GetTransform(e)

    op := &ebiten.DrawImageOptions{}
    op.GeoM.Translate(transformData.WorldPosition.X, transformData.WorldPosition.Y)

    if entry.HasComponent(components.Velocity) {
        // render speed lines
    }

    screen.DrawImage(spriteData.Texture, op)
})

Um Systeme im Client und Server nutzen zu können, nutze ich Interfaces.

type System interface {}

type SystemWithUpdate interface {
    Update(w donburi.World) error
}

type SystemWithDraw interface {
    Draw(w donburi.World, screen *ebiten.Image)
}

Game link

Eine Implementierung des ECS in einem Spiel könnte wie folgt aussehen:

type Client struct {
    world donburi.World

    systems []System
}

var _ ebiten.Game = &Client{}

func NewClient() *Client {
    return &Client{
        world: donburi.NewWorld(),
        systems: []System{
            NewVelocitySystem(),
            NewRenderSystem(),
        },
    }
}

func (c *Client) Update() error {
    for _, system := range c.systems {
        if system, ok := system.(SystemWithUpdate); ok {
            if err := system.Update(c.world); err != nil {
                return err
            }
        }
    }
}

func (c *Client) Draw(screen *ebiten.Image) error {
    for _, system := range c.systems {
        if system, ok := system.(SystemWithDraw); ok {
            system.Draw(c.world, screen)
        }
    }
}

// Layout set the size of the screen. Needed by Ebiten.
func (c *Client) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
    return 800, 600
}

func main() {
    ebiten.SetWindowSize(800, 600)
    ebiten.SetWindowTitle("ECS Network")
    ebiten.SetRunnableOnUnfocused(true)

    client := NewClient()

    if err := ebiten.RunGame(client); err != nil {
        panic(err)
    }
}

Neue Objekte erstellen link

Neue Objekte können mit der world.Create Funktion erstellt werden. Diese Funktion erwartet eine Liste von Komponenten und gibt ein donburi.Entity zurück. Die Entity ist ein eindeutiger Identifier für das Objekt.

Die Komponenten eines Objekts können mit donburi.Entry abgefragt werden.

func NewPlayer(world donburi.World) *donburi.Entry {
    player := world.Entry(world.Create(components.Transform, components.Velocity, components.Sprite))

    // Helper Funktionen für die Positionierung
    transform.SetWorldPosition(player, dmath.Vec2{X: 100, Y: 100})

    velocityData := components.Velocity.Get(player)
    velocityData.Velocity = dmath.Vec2{X: 1, Y: 1}

    spriteData := components.Sprite.Get(player)
    spriteData.Texture = ebiten.NewImage(32, 32)

    return player
}

Dokumentation link

Das README von Donburi ist sehr gut und enthält Beispiele für die meisten Anwendungsfälle. Mein Blogpost deckt nur einen Bruchteil der Möglichkeiten ab.