Skip to content

[Feature] Better systems managing #31

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ ecs.MapN(world, func(id ecs.Id, a *ComponentA, /*... */, n *ComponentN) {
})
```

### Advanced queries
#### Advanced queries
You can also filter your queries for more advanced usage:
```
// Returns a view of Position and Velocity, but only if the entity also has the `Rotation` component.
Expand All @@ -78,6 +78,68 @@ query := ecs.Query2[Position, Velocity](world, ecs.With(Rotation))
query := ecs.Query2[Position, Velocity](world, ecs.Optional(Velocity))
```

### Systems
There are several types of systems:
1. Realtime - should be runned as fast as possible (without any deplays). Usefull for Rendering or collecting inputs from user.
2. Fixed - should be runned ones a specified period of time. Usefull for physics.
3. Step - can only be runned by manually triggering. Usefull for simulations, like stable updates in P2P games.

To create system, implement one of the interfaces `RealtimeSystem`, `FixedSystem`, `StepSystem`:
```
type Position struct {
X, Y float64
}
type Velocity struct {
X, Y float64
}

// --- world initialization here ---

type movementSystem struct {}

func (s *movementSystem) GetName() {
return "physics::movement"
}

func (s *movementSystem) RunFixed(delta time.Duration) {
query := ecs.Query2[Position, Velocity](world)
query.MapId(func(id ecs.Id, pos *Position, vel *Velocity) {
// No need to adjust here to delta time between updates, because
// physics steps should always be the same. Delta time is still available to systems, where
// it can be important (f.e. if we want to detect "throttling")
pos.X += vel.X
pos.Y += vel.Y

// Add some drag
vel.X *= 0.99
vel.Y *= 0.99
})
}
```

To shedule the system, we need to have a system group of one of the types `RealtimeGroup`, `FixedGroup`, `StepGroup`. There are several rules regarding system groups:
1. One system cant belong to multiple groups.
2. Groups are running in parallel and there is no synchronization between them.
3. All the systems in the group can be automatically runned in parallel.
4. Groups share lock to the components on the systems level, so there will be no race conditions while accessing components data.
5. Group can only contain systems with theirs type.

```
componentsGuard = group.NewComponentsGuard()
physics = group.NewFixedGroup("physics", 50*time.Millisecond, componentsGuard)
physics.AddSystem(&movementSystem{})
physics.Build()
physics.StartFixed()
defer physics.StopFixed()
```

#### Order
You can specify systems order by implementing `system.RunBeforeSystem` or/and `system.RunAfterSystem` interface for the system. You cant implement order between systems from multiple groups.

#### Automatic parallelism
In order for sheduler to understand which systems can be runed in parallel, you have to specify components used by systems. By default, if component will not be specified, system will be marked as `exclusive` and will only be runned by locking entire ECS world.
You can do this by implementing `system.ReadComponentsSystem` or/and `system.WriteComponentsSystem` interface for the system. Make sure to use `system.ReadComponentsSystem` as much as possible, because with read access, multiple systems can be runned at the same time.

### Commands

Commands will eventually replace `ecs.Write(...)` once I figure out how their usage will work. Commands essentially buffer some work on the ECS so that the work can be executed later on. You can use them in loop safe ways by calling `Execute()` after your loop has completed. Right now they work like this:
Expand All @@ -90,7 +152,7 @@ cmd.Execute()

### Still In Progress
- [ ] Improving iterator performance: See: https://github.com/golang/go/discussions/54245
- [ ] Automatic multithreading
- [+] Automatic multithreading
- [ ] Without() filter

### Videos
Expand Down
51 changes: 21 additions & 30 deletions arch.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
)

// This is the identifier for entities in the world
//
//cod:struct
type Id uint32

Expand All @@ -28,17 +29,17 @@ func (s *componentSlice[T]) Write(index int, val T) {

// TODO: Rename, this is kind of like an archetype header
type lookupList struct {
index *internalMap[Id,int] // A mapping from entity ids to array indices
id []Id // An array of every id in the arch list (essentially a reverse mapping from index to Id)
holes []int // List of indexes that have ben deleted
mask archetypeMask
index *internalMap[Id, int] // A mapping from entity ids to array indices
id []Id // An array of every id in the arch list (essentially a reverse mapping from index to Id)
holes []int // List of indexes that have ben deleted
mask archetypeMask
}

// Adds ourselves to the last available hole, else appends
// Returns the index
func (l *lookupList) addToEasiestHole(id Id) int {
if len(l.holes) > 0 {
lastHoleIndex := len(l.holes)-1
lastHoleIndex := len(l.holes) - 1
index := l.holes[lastHoleIndex]
l.id[index] = id
l.index.Put(id, index)
Expand All @@ -54,7 +55,6 @@ func (l *lookupList) addToEasiestHole(id Id) int {
}
}


type storage interface {
ReadToEntity(*Entity, archetypeId, int) bool
ReadToRawEntity(*RawEntity, archetypeId, int) bool
Expand Down Expand Up @@ -105,25 +105,21 @@ func (s *componentSliceStorage[T]) print(amount int) {

// Provides generic storage for all archetypes
type archEngine struct {
generation int
generation int
// archCounter archetypeId

lookup []*lookupList // Indexed by archetypeId
compSliceStorage []storage // Indexed by componentId
dcr *componentRegistry

// TODO - using this makes things not thread safe inside the engine
archCount map[archetypeId]int
lookup []*lookupList // Indexed by archetypeId
compSliceStorage []storage // Indexed by componentId
dcr *componentRegistry
}

func newArchEngine() *archEngine {
return &archEngine{
generation: 1, // Start at 1 so that anyone with the default int value will always realize they are in the wrong generation
generation: 1, // Start at 1 so that anyone with the default int value will always realize they are in the wrong generation

lookup: make([]*lookupList, 0, DefaultAllocation),
compSliceStorage: make([]storage, maxComponentId + 1),
compSliceStorage: make([]storage, maxComponentId+1),
dcr: newComponentRegistry(),
archCount: make(map[archetypeId]int),
}
}

Expand All @@ -133,10 +129,10 @@ func (e *archEngine) newArchetypeId(archMask archetypeMask) archetypeId {
archId := archetypeId(len(e.lookup))
e.lookup = append(e.lookup,
&lookupList{
index: newMap[Id,int](0),
index: newMap[Id, int](0),
id: make([]Id, 0, DefaultAllocation),
holes: make([]int, 0, DefaultAllocation),
mask: archMask,
mask: archMask,
},
)

Expand All @@ -163,7 +159,7 @@ func (e *archEngine) getGeneration() int {
// }

func (e *archEngine) count(anything ...any) int {
comps := make([]componentId, len(anything))
comps := make([]ComponentId, len(anything))
for i, c := range anything {
comps[i] = name(c)
}
Expand All @@ -190,29 +186,24 @@ func (e *archEngine) getArchetypeId(comp ...Component) archetypeId {
}

// TODO - map might be slower than just having an array. I could probably do a big bitmask and then just do a logical OR
func (e *archEngine) FilterList(archIds []archetypeId, comp []componentId) []archetypeId {
func (e *archEngine) FilterList(archIds []archetypeId, comp []ComponentId) []archetypeId {
// TODO: could I maybe do something more optimal with archetypeMask?
// New way: With archSets that are just slices
// Logic: Go thorugh and keep track of how many times we see each archetype. Then only keep the archetypes that we've seen an amount of times equal to the number of components. If we have 5 components and see 5 for a specific archId, it means that each component has that archId
// TODO: this may be more efficient to use a slice?

// Clearing Optimization: https://go.dev/doc/go1.11#performance-compiler
for k := range e.archCount {
delete(e.archCount, k)
}

archCount := make([]int, len(e.lookup))
for _, compId := range comp {
for _, archId := range e.dcr.archSet[compId] {
e.archCount[archId] = e.archCount[archId] + 1
archCount[archId]++
}
}

numComponents := len(comp)

archIds = archIds[:0]
for archId, count := range e.archCount {
for archId, count := range archCount {
if count >= numComponents {
archIds = append(archIds, archId)
archIds = append(archIds, archetypeId(archId))

// // TODO: How tight do I want my tolerances?
// if count > numComponents {
Expand All @@ -231,7 +222,7 @@ func getStorage[T any](e *archEngine) *componentSliceStorage[T] {
}

// Note: This will panic if the wrong compId doesn't match the generic type
func getStorageByCompId[T any](e *archEngine, compId componentId) *componentSliceStorage[T] {
func getStorageByCompId[T any](e *archEngine, compId ComponentId) *componentSliceStorage[T] {
ss := e.compSliceStorage[compId]
if ss == nil {
ss = &componentSliceStorage[T]{
Expand Down
34 changes: 16 additions & 18 deletions bundle.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package ecs

type Bundle[T any] struct {
compId componentId
compId ComponentId
storage *componentSliceStorage[T]
// world *ecs.World //Needed?
}
Expand All @@ -11,14 +11,14 @@ func NewBundle[T any](world *World) Bundle[T] {
var t T
compId := name(t)
return Bundle[T]{
compId: compId,
compId: compId,
storage: getStorageByCompId[T](world.engine, compId),
}
}

func (c Bundle[T]) New(comp T) Box[T] {
return Box[T]{
Comp: comp,
Comp: comp,
compId: c.compId,
// storage: c.storage,
}
Expand All @@ -28,17 +28,17 @@ func (b Bundle[T]) write(engine *archEngine, archId archetypeId, index int, comp
writeArch[T](engine, archId, index, b.storage, comp)
}

func (b Bundle[T]) id() componentId {
func (b Bundle[T]) id() ComponentId {
return b.compId
}

type Bundle4[A, B, C, D any] struct {
compId componentId
boxA *Box[A]
boxB *Box[B]
boxC *Box[C]
boxD *Box[D]
comps []Component
compId ComponentId
boxA *Box[A]
boxB *Box[B]
boxC *Box[C]
boxD *Box[D]
comps []Component
}

// Createst the boxed component type
Expand All @@ -56,16 +56,15 @@ func NewBundle4[A, B, C, D any]() Bundle4[A, B, C, D] {
}

return Bundle4[A, B, C, D]{
boxA: boxA,
boxB: boxB,
boxC: boxC,
boxD: boxD,
boxA: boxA,
boxB: boxB,
boxC: boxC,
boxD: boxD,
comps: comps,
}
}


func (bun Bundle4[A,B,C,D]) Write(world *World, id Id, a A, b B, c C, d D) {
func (bun Bundle4[A, B, C, D]) Write(world *World, id Id, a A, b B, c C, d D) {
// bun.boxA.Comp = a
// bun.boxB.Comp = b
// bun.boxC.Comp = c
Expand All @@ -86,7 +85,6 @@ func (bun Bundle4[A,B,C,D]) Write(world *World, id Id, a A, b B, c C, d D) {
bun.boxD.Comp = d

Write(world, id,
bun.comps...
bun.comps...,
)
}

26 changes: 13 additions & 13 deletions component.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ import (
// "sort"
)

type componentId uint16
type ComponentId uint16

type Component interface {
write(*archEngine, archetypeId, int)
id() componentId
Id() ComponentId
}

// This type is used to box a component with all of its type info so that it implements the component interface. I would like to get rid of this and simplify the APIs
type Box[T any] struct {
Comp T
compId componentId
compId ComponentId
}

// Createst the boxed component type
Expand All @@ -26,10 +26,10 @@ func C[T any](comp T) Box[T] {
}
}
func (c Box[T]) write(engine *archEngine, archId archetypeId, index int) {
store := getStorageByCompId[T](engine, c.id())
store := getStorageByCompId[T](engine, c.Id())
writeArch[T](engine, archId, index, store, c.Comp)
}
func (c Box[T]) id() componentId {
func (c Box[T]) Id() ComponentId {
if c.compId == invalidComponentId {
c.compId = name(c.Comp)
}
Expand All @@ -40,20 +40,20 @@ func (c Box[T]) Get() T {
return c.Comp
}


// Note: you can increase max component size by increasing maxComponentId and archetypeMask
// TODO: I should have some kind of panic if you go over maximum component size
const maxComponentId = 255

// Supports maximum 256 unique component types
type archetypeMask [4]uint64 // TODO: can/should I make this configurable?
func buildArchMask(comps ...Component) archetypeMask {
var mask archetypeMask
for _, comp := range comps {
// Ranges: [0, 64), [64, 128), [128, 192), [192, 256)
c := comp.id()
c := comp.Id()
idx := c / 64
offset := c - (64 * idx)
mask[idx] |= (1<<offset)
mask[idx] |= (1 << offset)
}
return mask
}
Expand All @@ -69,14 +69,14 @@ func (m archetypeMask) bitwiseOr(a archetypeMask) archetypeMask {
// TODO: You should move to this (ie archetype graph (or bitmask?). maintain the current archetype node, then traverse to nodes (and add new ones) based on which components are added): https://ajmmertens.medium.com/building-an-ecs-2-archetypes-and-vectorization-fe21690805f9
// Dynamic component Registry
type componentRegistry struct {
archSet [][]archetypeId // Contains the set of archetypeIds that have this component
archMask map[archetypeMask]archetypeId // Contains a mapping of archetype bitmasks to archetypeIds
archSet [][]archetypeId // Contains the set of archetypeIds that have this component
archMask map[archetypeMask]archetypeId // Contains a mapping of archetype bitmasks to archetypeIds
}

func newComponentRegistry() *componentRegistry {
r := &componentRegistry{
archSet: make([][]archetypeId, maxComponentId + 1), // TODO: hardcoded to max component
archMask: make(map[archetypeMask]archetypeId),
archSet: make([][]archetypeId, maxComponentId+1), // TODO: hardcoded to max component
archMask: make(map[archetypeMask]archetypeId),
}
return r
}
Expand All @@ -102,7 +102,7 @@ func (r *componentRegistry) getArchetypeId(engine *archEngine, comps ...Componen

// Add this archetypeId to every component's archList
for _, comp := range comps {
compId := comp.id()
compId := comp.Id()
r.archSet[compId] = append(r.archSet[compId], archId)
}
}
Expand Down
Loading