Add benchmarks and optimize

Store all data in slices rather than maps.
This commit is contained in:
Trevor Slocum 2021-11-22 11:18:28 -08:00
parent a820927a43
commit 8d4dabd62e
21 changed files with 237 additions and 115 deletions

View File

@ -4,10 +4,10 @@ import (
"sync"
)
var componentMutex sync.RWMutex
var componentMutex sync.Mutex
// ComponentID is a component identifier. Each Component is assigned a unique ID
// via NextComponentID, and implements a ComponentID method which returns the ID.
// via NewComponentID, and implements a ComponentID method returning its ID.
type ComponentID int
// Component represents data for an entity, and how it interacts with the world.
@ -15,31 +15,32 @@ type Component interface {
ComponentID() ComponentID
}
var nextComponentID ComponentID
var maxComponentID ComponentID
// NewComponentID returns the next available ComponentID.
func NewComponentID() ComponentID {
mutex.Lock()
defer mutex.Unlock()
entityMutex.Lock()
defer entityMutex.Unlock()
// NextComponentID returns the next available ComponentID.
func NextComponentID() ComponentID {
componentMutex.Lock()
defer componentMutex.Unlock()
nextComponentID++
return nextComponentID
maxComponentID++
for i := Entity(1); i < maxEntityID; i++ {
gameComponents[i] = append(gameComponents[i], nil)
}
return maxComponentID
}
// AddComponent adds a Component to an Entity.
func (entity EntityID) AddComponent(component Component) {
componentMutex.Lock()
defer componentMutex.Unlock()
if wasRemoved(entity) {
return
}
func (entity Entity) AddComponent(component Component) {
componentID := component.ComponentID()
if gameComponents[entity] == nil {
gameComponents[entity] = make(map[ComponentID]interface{})
}
gameComponents[entity][componentID] = component
entityMutex.Lock()
@ -48,10 +49,7 @@ func (entity EntityID) AddComponent(component Component) {
}
// Component gets a Component of an Entity.
func (entity EntityID) Component(componentID ComponentID) interface{} {
componentMutex.RLock()
defer componentMutex.RUnlock()
func (entity Entity) Component(componentID ComponentID) interface{} {
components := gameComponents[entity]
if components == nil {
return nil

49
component_test.go Normal file
View File

@ -0,0 +1,49 @@
package gohan
import "testing"
var testComponentID = NewComponentID()
type testComponent struct {
X, Y float64
}
func (t testComponent) ComponentID() ComponentID {
return testComponentID
}
func BenchmarkComponent(b *testing.B) {
e := NewEntity()
e.AddComponent(&testComponent{
X: 108,
Y: 0,
})
b.StopTimer()
b.ResetTimer()
b.ReportAllocs()
b.StartTimer()
for i := 0; i < b.N; i++ {
_ = e.Component(testComponentID)
}
}
func BenchmarkAddComponent(b *testing.B) {
e := NewEntity()
c := &testComponent{
X: 108,
Y: 0,
}
b.StopTimer()
b.ResetTimer()
b.ReportAllocs()
b.StartTimer()
for i := 0; i < b.N; i++ {
e.AddComponent(c)
}
}

5
doc.go
View File

@ -1,6 +1,11 @@
/*
Package gohan provides an Entity Component System framework for Ebiten.
An example game is available at /examples/twinstick which may be built by
executing the following command (in /examples/twinstick):
go build -tags example .
Entity
A general-purpose object, which consists of a unique ID, starting with 1.

View File

@ -5,25 +5,36 @@ import (
"time"
)
// EntityID is an entity identifier.
type EntityID int
// Entity is an entity identifier.
type Entity int
var nextEntityID EntityID
var maxEntityID Entity
var entityMutex sync.Mutex
// NextEntityID returns the next available EntityID.
func NextEntityID() EntityID {
// NewEntity returns a new (or previously removed and cleared) Entity. Because
// Gohan reuses removed Entity IDs, a previously removed ID may be returned.
func NewEntity() Entity {
entityMutex.Lock()
defer entityMutex.Unlock()
nextEntityID++
allEntities = append(allEntities, nextEntityID)
return nextEntityID
if len(availableEntityIDs) > 0 {
id := availableEntityIDs[0]
availableEntityIDs = availableEntityIDs[1:]
allEntities = append(allEntities, id)
return id
}
maxEntityID++
allEntities = append(allEntities, maxEntityID)
gameComponents = append(gameComponents, make([]interface{}, maxComponentID+1))
return maxEntityID
}
// RemoveEntity removes the provided Entity, and all of its components.
func RemoveEntity(entity EntityID) {
// Remove removes the provided Entity's components, causing it to no longer be
// handled by any system. Because Gohan reuses removed EntityIDs, applications
// must also remove any internal references to the removed Entity.
func (entity Entity) Remove() {
entityMutex.Lock()
defer entityMutex.Unlock()
@ -36,18 +47,6 @@ func RemoveEntity(entity EntityID) {
}
}
func wasRemoved(entity EntityID) bool {
entityMutex.Lock()
defer entityMutex.Unlock()
for _, e := range allEntities {
if e == entity {
return false
}
}
return true
}
var numEntities int
var numEntitiesT time.Time

60
entity_test.go Normal file
View File

@ -0,0 +1,60 @@
package gohan
import (
"testing"
"time"
)
func TestActiveEntities(t *testing.T) {
t.Parallel()
active := ActiveEntities()
if active != 0 {
t.Fatalf("expected 0 active entities, got %d", active)
}
wait()
active = ActiveEntities()
if active != 0 {
t.Fatalf("expected 0 active entities, got %d", active)
}
// Create entity.
e1 := NewEntity()
wait()
active = ActiveEntities()
if active != 1 {
t.Fatalf("expected 1 active entities, got %d", active)
}
// Create entity.
e2 := NewEntity()
wait()
active = ActiveEntities()
if active != 2 {
t.Fatalf("expected 2 active entities, got %d", active)
}
e1.Remove()
wait()
active = ActiveEntities()
if active != 1 {
t.Fatalf("expected 1 active entities, got %d", active)
}
e2.Remove()
wait()
active = ActiveEntities()
if active != 0 {
t.Fatalf("expected 0 active entities, got %d", active)
}
}
// wait causes the program to wait long enough to expire all duration-based caches.
func wait() {
time.Sleep(2 * time.Second)
}

View File

@ -10,13 +10,13 @@ import (
type BulletComponent struct {
}
var BulletComponentID = gohan.NextComponentID()
var BulletComponentID = gohan.NewComponentID()
func (p *BulletComponent) ComponentID() gohan.ComponentID {
return BulletComponentID
}
func Bullet(e gohan.EntityID) *BulletComponent {
func Bullet(e gohan.Entity) *BulletComponent {
c, ok := e.Component(BulletComponentID).(*BulletComponent)
if !ok {
return nil

View File

@ -11,13 +11,13 @@ type PositionComponent struct {
X, Y float64
}
var PositionComponentID = gohan.NextComponentID()
var PositionComponentID = gohan.NewComponentID()
func (p *PositionComponent) ComponentID() gohan.ComponentID {
return PositionComponentID
}
func Position(e gohan.EntityID) *PositionComponent {
func Position(e gohan.Entity) *PositionComponent {
c, ok := e.Component(PositionComponentID).(*PositionComponent)
if !ok {
return nil

View File

@ -11,13 +11,13 @@ type VelocityComponent struct {
X, Y float64
}
var VelocityComponentID = gohan.NextComponentID()
var VelocityComponentID = gohan.NewComponentID()
func (c *VelocityComponent) ComponentID() gohan.ComponentID {
return VelocityComponentID
}
func Velocity(e gohan.EntityID) *VelocityComponent {
func Velocity(e gohan.Entity) *VelocityComponent {
c, ok := e.Component(VelocityComponentID).(*VelocityComponent)
if !ok {
return nil

View File

@ -20,13 +20,13 @@ type WeaponComponent struct {
BulletSpeed float64
}
var WeaponComponentID = gohan.NextComponentID()
var WeaponComponentID = gohan.NewComponentID()
func (p *WeaponComponent) ComponentID() gohan.ComponentID {
return WeaponComponentID
}
func Weapon(e gohan.EntityID) *WeaponComponent {
func Weapon(e gohan.Entity) *WeaponComponent {
c, ok := e.Component(WeaponComponentID).(*WeaponComponent)
if !ok {
return nil

View File

@ -8,8 +8,8 @@ import (
"code.rocketnine.space/tslocum/gohan/examples/twinstick/component"
)
func NewBullet(x, y, xSpeed, ySpeed float64) gohan.EntityID {
bullet := gohan.NextEntityID()
func NewBullet(x, y, xSpeed, ySpeed float64) gohan.Entity {
bullet := gohan.NewEntity()
bullet.AddComponent(&component.PositionComponent{
X: x,

View File

@ -11,8 +11,8 @@ import (
"code.rocketnine.space/tslocum/gohan/examples/twinstick/component"
)
func NewPlayer() gohan.EntityID {
player := gohan.NextEntityID()
func NewPlayer() gohan.Entity {
player := gohan.NewEntity()
// Set position to -1,-1 to indicate the player has not been assigned a
// position yet. We will place the player in the center of the screen when

View File

@ -20,7 +20,7 @@ import (
type game struct {
w, h int
player gohan.EntityID
player gohan.Entity
op *ebiten.DrawImageOptions
@ -62,7 +62,7 @@ func (g *game) Layout(outsideWidth, outsideHeight int) (int, int) {
g.w, g.h = w, h
g.movementSystem.ScreenW, g.movementSystem.ScreenH = float64(w), float64(h)
position := g.player.Component(component.PositionComponentID).(*component.PositionComponent)
position := component.Position(g.player)
if position.X == -1 && position.Y == -1 {
position.X, position.Y = float64(g.w)/2-16, float64(g.h)/2-16
}

View File

@ -20,19 +20,19 @@ func NewDrawBulletsSystem() *DrawBulletsSystem {
}
}
func (s *DrawBulletsSystem) Matches(entity gohan.EntityID) bool {
func (s *DrawBulletsSystem) Matches(entity gohan.Entity) bool {
position := entity.Component(component.PositionComponentID)
bullet := entity.Component(component.BulletComponentID)
return position != nil && bullet != nil
}
func (s *DrawBulletsSystem) Update(_ gohan.EntityID) error {
func (s *DrawBulletsSystem) Update(_ gohan.Entity) error {
return gohan.ErrSystemWithoutUpdate
}
func (s *DrawBulletsSystem) Draw(entity gohan.EntityID, screen *ebiten.Image) error {
position := entity.Component(component.PositionComponentID).(*component.PositionComponent)
func (s *DrawBulletsSystem) Draw(entity gohan.Entity, screen *ebiten.Image) error {
position := component.Position(entity)
s.op.GeoM.Reset()
s.op.GeoM.Translate(-16, -16)

View File

@ -11,27 +11,27 @@ import (
)
type drawPlayerSystem struct {
player gohan.EntityID
player gohan.Entity
op *ebiten.DrawImageOptions
}
func NewDrawPlayerSystem(player gohan.EntityID) *drawPlayerSystem {
func NewDrawPlayerSystem(player gohan.Entity) *drawPlayerSystem {
return &drawPlayerSystem{
player: player,
op: &ebiten.DrawImageOptions{},
}
}
func (s *drawPlayerSystem) Matches(entity gohan.EntityID) bool {
func (s *drawPlayerSystem) Matches(entity gohan.Entity) bool {
return entity == s.player
}
func (s *drawPlayerSystem) Update(_ gohan.EntityID) error {
func (s *drawPlayerSystem) Update(_ gohan.Entity) error {
return gohan.ErrSystemWithoutUpdate
}
func (s *drawPlayerSystem) Draw(entity gohan.EntityID, screen *ebiten.Image) error {
position := entity.Component(component.PositionComponentID).(*component.PositionComponent)
func (s *drawPlayerSystem) Draw(entity gohan.Entity, screen *ebiten.Image) error {
position := component.Position(entity)
s.op.GeoM.Reset()
s.op.GeoM.Translate(position.X-16, position.Y-16)

View File

@ -18,16 +18,16 @@ func angle(x1, y1, x2, y2 float64) float64 {
}
type fireInputSystem struct {
player gohan.EntityID
player gohan.Entity
}
func NewFireInputSystem(player gohan.EntityID) *fireInputSystem {
func NewFireInputSystem(player gohan.Entity) *fireInputSystem {
return &fireInputSystem{
player: player,
}
}
func (_ *fireInputSystem) Matches(e gohan.EntityID) bool {
func (_ *fireInputSystem) Matches(e gohan.Entity) bool {
weapon := e.Component(component.WeaponComponentID)
return weapon != nil
@ -48,14 +48,14 @@ func (s *fireInputSystem) fire(weapon *component.WeaponComponent, position *comp
_ = bullet
}
func (s *fireInputSystem) Update(_ gohan.EntityID) error {
weapon := s.player.Component(component.WeaponComponentID).(*component.WeaponComponent)
func (s *fireInputSystem) Update(_ gohan.Entity) error {
weapon := component.Weapon(s.player)
if weapon.Ammo <= 0 {
return nil
}
position := s.player.Component(component.PositionComponentID).(*component.PositionComponent)
position := component.Position(s.player)
if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
cursorX, cursorY := ebiten.CursorPosition()
@ -99,6 +99,6 @@ func (s *fireInputSystem) Update(_ gohan.EntityID) error {
return nil
}
func (_ *fireInputSystem) Draw(_ gohan.EntityID, _ *ebiten.Image) error {
func (_ *fireInputSystem) Draw(_ gohan.Entity, _ *ebiten.Image) error {
return gohan.ErrSystemWithoutDraw
}

View File

@ -10,21 +10,21 @@ import (
)
type movementInputSystem struct {
player gohan.EntityID
player gohan.Entity
}
func NewMovementInputSystem(player gohan.EntityID) *movementInputSystem {
func NewMovementInputSystem(player gohan.Entity) *movementInputSystem {
return &movementInputSystem{
player: player,
}
}
func (s *movementInputSystem) Matches(e gohan.EntityID) bool {
func (s *movementInputSystem) Matches(e gohan.Entity) bool {
return e == s.player
}
func (s *movementInputSystem) Update(e gohan.EntityID) error {
velocity := s.player.Component(component.VelocityComponentID).(*component.VelocityComponent)
func (s *movementInputSystem) Update(e gohan.Entity) error {
velocity := component.Velocity(s.player)
if ebiten.IsKeyPressed(ebiten.KeyA) {
velocity.X -= 0.5
if velocity.X < -5 {
@ -52,6 +52,6 @@ func (s *movementInputSystem) Update(e gohan.EntityID) error {
return nil
}
func (s *movementInputSystem) Draw(_ gohan.EntityID, _ *ebiten.Image) error {
func (s *movementInputSystem) Draw(_ gohan.Entity, _ *ebiten.Image) error {
return gohan.ErrSystemWithoutDraw
}

View File

@ -15,21 +15,21 @@ import (
)
type profileSystem struct {
player gohan.EntityID
player gohan.Entity
cpuProfile *os.File
}
func NewProfileSystem(player gohan.EntityID) *profileSystem {
func NewProfileSystem(player gohan.Entity) *profileSystem {
return &profileSystem{
player: player,
}
}
func (s *profileSystem) Matches(e gohan.EntityID) bool {
func (s *profileSystem) Matches(e gohan.Entity) bool {
return e == s.player
}
func (s *profileSystem) Update(e gohan.EntityID) error {
func (s *profileSystem) Update(e gohan.Entity) error {
if ebiten.IsKeyPressed(ebiten.KeyControl) && inpututil.IsKeyJustPressed(ebiten.KeyP) {
if s.cpuProfile == nil {
log.Println("CPU profiling started...")
@ -60,6 +60,6 @@ func (s *profileSystem) Update(e gohan.EntityID) error {
return nil
}
func (s *profileSystem) Draw(_ gohan.EntityID, _ *ebiten.Image) error {
func (s *profileSystem) Draw(_ gohan.Entity, _ *ebiten.Image) error {
return gohan.ErrSystemWithoutDraw
}

View File

@ -13,23 +13,23 @@ type MovementSystem struct {
ScreenW, ScreenH float64
}
func (_ *MovementSystem) Matches(entity gohan.EntityID) bool {
position := entity.Component(component.PositionComponentID)
velocity := entity.Component(component.VelocityComponentID)
func (_ *MovementSystem) Matches(entity gohan.Entity) bool {
position := component.Position(entity)
velocity := component.Velocity(entity)
return position != nil && velocity != nil
}
func (s *MovementSystem) Update(entity gohan.EntityID) error {
position := entity.Component(component.PositionComponentID).(*component.PositionComponent)
velocity := entity.Component(component.VelocityComponentID).(*component.VelocityComponent)
func (s *MovementSystem) Update(entity gohan.Entity) error {
position := component.Position(entity)
velocity := component.Velocity(entity)
bullet := entity.Component(component.BulletComponentID)
// Check for collision.
if position.X+velocity.X < 16 {
if bullet != nil {
gohan.RemoveEntity(entity)
entity.Remove()
return nil
}
@ -37,7 +37,7 @@ func (s *MovementSystem) Update(entity gohan.EntityID) error {
velocity.X = 0
} else if position.X+velocity.X > s.ScreenW-16 {
if bullet != nil {
gohan.RemoveEntity(entity)
entity.Remove()
return nil
}
@ -46,7 +46,7 @@ func (s *MovementSystem) Update(entity gohan.EntityID) error {
}
if position.Y+velocity.Y < 16 {
if bullet != nil {
gohan.RemoveEntity(entity)
entity.Remove()
return nil
}
@ -54,7 +54,7 @@ func (s *MovementSystem) Update(entity gohan.EntityID) error {
velocity.Y = 0
} else if position.Y+velocity.Y > s.ScreenH-16 {
if bullet != nil {
gohan.RemoveEntity(entity)
entity.Remove()
return nil
}
@ -72,6 +72,6 @@ func (s *MovementSystem) Update(entity gohan.EntityID) error {
return nil
}
func (_ *MovementSystem) Draw(_ gohan.EntityID, _ *ebiten.Image) error {
func (_ *MovementSystem) Draw(_ gohan.Entity, _ *ebiten.Image) error {
return gohan.ErrSystemWithoutDraw
}

View File

@ -14,10 +14,10 @@ import (
type printInfoSystem struct {
img *ebiten.Image
op *ebiten.DrawImageOptions
player gohan.EntityID
player gohan.Entity
}
func NewPrintInfoSystem(player gohan.EntityID) *printInfoSystem {
func NewPrintInfoSystem(player gohan.Entity) *printInfoSystem {
p := &printInfoSystem{
img: ebiten.NewImage(200, 100),
op: &ebiten.DrawImageOptions{},
@ -27,15 +27,15 @@ func NewPrintInfoSystem(player gohan.EntityID) *printInfoSystem {
return p
}
func (s *printInfoSystem) Matches(e gohan.EntityID) bool {
func (s *printInfoSystem) Matches(e gohan.Entity) bool {
return e == s.player
}
func (s *printInfoSystem) Update(_ gohan.EntityID) error {
func (s *printInfoSystem) Update(_ gohan.Entity) error {
return gohan.ErrSystemWithoutUpdate
}
func (s *printInfoSystem) Draw(_ gohan.EntityID, screen *ebiten.Image) error {
func (s *printInfoSystem) Draw(_ gohan.Entity, screen *ebiten.Image) error {
s.img.Clear()
ebitenutil.DebugPrint(s.img, fmt.Sprintf("KEY WASD+MOUSE\nENT %d\nUPD %d\nDRA %d\nTPS %0.0f\nFPS %0.0f", gohan.ActiveEntities(), gohan.UpdatedEntities(), gohan.DrawnEntities(), ebiten.CurrentTPS(), ebiten.CurrentFPS()))
screen.DrawImage(s.img, s.op)

View File

@ -12,15 +12,19 @@ import (
)
var (
gameComponents = make(map[EntityID]map[ComponentID]interface{})
gameComponents [][]interface{}
allEntities []EntityID
allEntities []Entity
modifiedEntities []EntityID
removedEntities []EntityID
modifiedEntities []Entity
removedEntities []Entity
// availableEntityIDs is the set of EntityIDs available because they were
// removed from the game.
availableEntityIDs []Entity
gameSystems []System
gameSystemEntities [][]EntityID // Slice of entities matching each system.
gameSystemEntities [][]Entity // Slice of entities matching each system.
gameSystemReceivesUpdate []bool
gameSystemReceivesDraw []bool
@ -39,6 +43,9 @@ var (
)
func init() {
// Pad slices to match IDs starting with 1.
gameComponents = append(gameComponents, nil)
debugEnv := os.Getenv("GOHAN_DEBUG")
debugEnv = strings.TrimSpace(debugEnv)
debugEnv = strings.ToLower(debugEnv)
@ -59,7 +66,7 @@ func attachEntitiesToSystem(system System) {
// This function is always called on a newly added system.
systemID := len(gameSystemEntities) - 1
for entity := EntityID(0); entity < nextEntityID; entity++ {
for entity := Entity(0); entity < maxEntityID; entity++ {
if system.Matches(entity) {
gameSystemEntities[systemID] = append(gameSystemEntities[systemID], entity)
@ -99,12 +106,9 @@ func propagateEntityChanges() {
defer entityMutex.Unlock()
for _, entity := range removedEntities {
delete(gameComponents, entity)
// Remove from attached systems.
REMOVED:
for i := range gameSystemEntities {
delete(gameComponents, entity)
for j, e := range gameSystemEntities[i] {
if e == entity {
gameSystemEntities[i] = append(gameSystemEntities[i][:j], gameSystemEntities[i][j+1:]...)
@ -112,7 +116,14 @@ func propagateEntityChanges() {
}
}
}
// Remove components.
gameComponents[entity] = make([]interface{}, maxComponentID+1)
}
// Mark EntityIDs as available.
availableEntityIDs = append(availableEntityIDs, removedEntities...)
removedEntities = nil
for _, entity := range modifiedEntities {

View File

@ -14,13 +14,13 @@ import (
// See ErrSystemWithoutUpdate and ErrSystemWithoutDraw.
type System interface {
// Matches returns whether the provided entity is handled by this system.
Matches(entity EntityID) bool
Matches(entity Entity) bool
// Update is called once for each matching entity each time the game state is updated.
Update(entity EntityID) error
Update(entity Entity) error
// Draw is called once for each matching entity each time the game is drawn to the screen.
Draw(entity EntityID, screen *ebiten.Image) error
Draw(entity Entity, screen *ebiten.Image) error
}
// Special error values.