Repository: mlange-42/arche (go)

Issue:
Add more examples demonstrating features
* [x] Generic access with `Map[T]` (#95)
* [x] Event listeners (#83)
* [x] Delaying modifications that are not possible in a locked world (#83)
* [x] ~~Initilization with components~~ Used in most examples anyway
* [x] World stats (#95)

Pull request description:
More examples, more stats
Adds examples, adds reserved memory to world and archetype stats.

Fixes #80

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b9452d..f37ba89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@
 * Ensure 100% test coverage by adding a CI check for it (#68)
 * `World.RemEntity(Entity)` is now `World.RemoveEntity(Entity)` (#87)
 * Optimization of adding/removing components, with 2-3x speedup and vast reduction of (number of) allocations (#93)
+* More examples as user documentation (#83, #95)
 
 ## [[v0.3.1]](https://github.com/mlange-42/arche/compare/v0.3.0...v0.3.1)
 
diff --git a/benchmark/profile/iter/main.go b/benchmark/profile/iter/main.go
index d6effcc..303a0c1 100644
--- a/benchmark/profile/iter/main.go
+++ b/benchmark/profile/iter/main.go
@@ -45,8 +45,7 @@ func run(rounds, iters, entities int) {
 		rotID := ecs.ComponentID[rotation](&world)
 
 		for j := 0; j < entities; j++ {
-			entity := world.NewEntity()
-			world.Add(entity, posID, rotID)
+			_ = world.NewEntity(posID, rotID)
 		}
 
 		for j := 0; j < iters; j++ {
diff --git a/benchmark/profile/world/main.go b/benchmark/profile/world/main.go
new file mode 100644
index 0000000..ef72126
--- /dev/null
+++ b/benchmark/profile/world/main.go
@@ -0,0 +1,59 @@
+package main
+
+// Profiling:
+// go build ./benchmark/profile/world
+// world
+// go tool pprof -http=":8000" -nodefraction=0.001 world cpu.pprof
+// go tool pprof -http=":8000" -nodefraction=0.001 world mem.pprof
+
+import (
+	"github.com/mlange-42/arche/ecs"
+	"github.com/pkg/profile"
+)
+
+type position struct {
+	X int
+	Y int
+}
+
+type rotation struct {
+	Angle int
+}
+
+func main() {
+
+	count := 250
+	iters := 10000
+	entities := 1000
+
+	stop := profile.Start(profile.CPUProfile, profile.ProfilePath("."))
+	run(count, iters, entities)
+	stop.Stop()
+
+	stop = profile.Start(profile.MemProfileAllocs, profile.ProfilePath("."))
+	run(count, iters, entities)
+	stop.Stop()
+}
+
+func run(rounds, iters, entityCount int) {
+	for i := 0; i < rounds; i++ {
+		world := ecs.NewConfig().
+			WithCapacityIncrement(1024).
+			Build()
+
+		posID := ecs.ComponentID[position](&world)
+		rotID := ecs.ComponentID[rotation](&world)
+
+		entities := make([]ecs.Entity, entityCount)
+		for i := 0; i < entityCount; i++ {
+			entities[i] = world.NewEntity(posID, rotID)
+		}
+
+		for j := 0; j < iters; j++ {
+			for _, e := range entities {
+				pos := (*position)(world.Get(e, posID))
+				_ = pos
+			}
+		}
+	}
+}
diff --git a/ecs/archetype.go b/ecs/archetype.go
index 683efd5..29363ee 100644
--- a/ecs/archetype.go
+++ b/ecs/archetype.go
@@ -1,7 +1,10 @@
 package ecs
 
 import (
+	"reflect"
 	"unsafe"
+
+	"github.com/mlange-42/arche/ecs/stats"
 )
 
 // archetype represents an ECS archetype
@@ -161,3 +164,29 @@ func (a *archetype) SetTransitionAdd(id ID, to *archetype) {
 func (a *archetype) SetTransitionRemove(id ID, to *archetype) {
 	a.toRemove[id] = to
 }
+
+// Stats generates statistics for an archetype
+func (a *archetype) Stats(reg *componentRegistry) stats.ArchetypeStats {
+	ids := a.Components()
+	aCompCount := len(ids)
+	aTypes := make([]reflect.Type, aCompCount)
+	for j, id := range ids {
+		aTypes[j] = reg.ComponentType(id)
+	}
+
+	cap := int(a.Cap())
+	memory := cap * int(entitySize)
+	for i := 0; i < len(a.components); i++ {
+		comp := &a.components[i]
+		memory += int(comp.itemSize) * cap
+	}
+
+	return stats.ArchetypeStats{
+		Size:           int(a.Len()),
+		Capacity:       cap,
+		Components:     aCompCount,
+		ComponentIDs:   ids,
+		ComponentTypes: aTypes,
+		Memory:         memory,
+	}
+}
diff --git a/ecs/bitmask64.go b/ecs/bitmask64.go
new file mode 100644
index 0000000..2ce5f19
--- /dev/null
+++ b/ecs/bitmask64.go
@@ -0,0 +1,24 @@
+package ecs
+
+// bitMask64 is there just for performance comparison with the new 128 bit BitMask.
+type bitMask64 uint64
+
+func newBitMask64(ids ...ID) bitMask64 {
+	var mask bitMask64
+	for _, id := range ids {
+		mask.Set(id, true)
+	}
+	return mask
+}
+func (e bitMask64) Get(bit ID) bool {
+	mask := bitMask64(1 << bit)
+	return e&mask == mask
+}
+
+func (e *bitMask64) Set(bit ID, value bool) {
+	if value {
+		*e |= bitMask64(1 << bit)
+	} else {
+		*e &= bitMask64(^(1 << bit))
+	}
+}
diff --git a/ecs/entity.go b/ecs/entity.go
index 09c04a7..e22c168 100644
--- a/ecs/entity.go
+++ b/ecs/entity.go
@@ -1,5 +1,13 @@
 package ecs
 
+import "reflect"
+
+// Size of an Entity in memory.
+var entitySize = reflect.TypeOf(Entity{}).Size()
+
+// Size of an entitySize in memory.
+var entityIndexSize = reflect.TypeOf(entityIndex{}).Size()
+
 // Entity identifier.
 // Holds an entity ID and it's generation for recycling.
 //
diff --git a/ecs/entity_pool.go b/ecs/entity_pool.go
index 7b1b426..3d39970 100644
--- a/ecs/entity_pool.go
+++ b/ecs/entity_pool.go
@@ -64,6 +64,9 @@ func (p *entityPool) Len() int {
 func (p *entityPool) Cap() int {
 	return len(p.entities) - 1
 }
+func (p *entityPool) TotalCap() int {
+	return cap(p.entities)
+}
 func (p *entityPool) Available() int {
 	return int(p.available)
 }
diff --git a/ecs/stats/stats.go b/ecs/stats/stats.go
index 301a963..7b6b721 100644
--- a/ecs/stats/stats.go
+++ b/ecs/stats/stats.go
@@ -18,6 +18,8 @@ type WorldStats struct {
 	Locked bool
 	// Archetype statistics
 	Archetypes []ArchetypeStats
+	// Memory used by entities and components
+	Memory int
 }
 
 // EntityStats provide statistics about [World] entities.
@@ -25,9 +27,11 @@ type EntityStats struct {
 	// Currently used/alive entities
 	Used int
 	// Current capacity of the entity pool
-	Capacity int
+	Total int
 	// Recycled/available entities
 	Recycled int
+	// Current capacity of the entities list
+	Capacity int
 }
 
 // ArchetypeStats provide statistics for an archetype.
@@ -42,12 +46,17 @@ type ArchetypeStats struct {
 	ComponentIDs []uint8
 	// Component types for ComponentIDs
 	ComponentTypes []reflect.Type
+	// Total reserved memory for entities and components, in bytes
+	Memory int
 }
 
 func (s *WorldStats) String() string {
 	b := strings.Builder{}
 
-	fmt.Fprintf(&b, "World -- Components: %d, Archetypes: %d, Locked: %t\n", s.ComponentCount, len(s.Archetypes), s.Locked)
+	fmt.Fprintf(
+		&b, "World -- Components: %d, Archetypes: %d, Memory: %.1f kb, Locked: %t\n",
+		s.ComponentCount, len(s.Archetypes), float64(s.Memory)/1024.0, s.Locked,
+	)
 
 	typeNames := make([]string, len(s.ComponentTypes))
 	for i, tp := range s.ComponentTypes {
@@ -64,7 +73,7 @@ func (s *WorldStats) String() string {
 }
 
 func (s *EntityStats) String() string {
-	return fmt.Sprintf("Entities -- Used: %d, Recycled: %d, Capacity: %d\n", s.Used, s.Recycled, s.Capacity)
+	return fmt.Sprintf("Entities -- Used: %d, Recycled: %d, Total: %d, Capacity: %d\n", s.Used, s.Recycled, s.Total, s.Capacity)
 }
 
 func (s *ArchetypeStats) String() string {
@@ -73,7 +82,7 @@ func (s *ArchetypeStats) String() string {
 		typeNames[i] = tp.Name()
 	}
 	return fmt.Sprintf(
-		"Archetype -- Components: %d, Entities: %d, Capacity: %d\n  Components: %s\n",
-		s.Components, s.Size, s.Capacity, strings.Join(typeNames, ", "),
+		"Archetype -- Components: %2d, Entities: %6d, Capacity: %6d, Memory: %7.1f kb\n  Components: %s\n",
+		s.Components, s.Size, s.Capacity, float64(s.Memory)/1024.0, strings.Join(typeNames, ", "),
 	)
 }
diff --git a/ecs/world.go b/ecs/world.go
index 889340e..9e2a390 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -193,8 +193,7 @@ func (w *World) Alive(entity Entity) bool {
 //
 // See also [github.com/mlange-42/arche/generic.Map.Get] for a generic variant.
 func (w *World) Get(entity Entity, comp ID) unsafe.Pointer {
-	index := w.entities[entity.id]
-
+	index := &w.entities[entity.id]
 	return index.arch.Get(index.index, comp)
 }
 
@@ -378,33 +377,19 @@ func (w *World) RegisterListener(listener func(e ChangeEvent)) {
 func (w *World) Stats() *stats.WorldStats {
 	entities := stats.EntityStats{
 		Used:     w.entityPool.Len(),
-		Capacity: w.entityPool.Cap(),
+		Total:    w.entityPool.Cap(),
 		Recycled: w.entityPool.Available(),
+		Capacity: w.entityPool.TotalCap(),
 	}
 
 	compCount := len(w.registry.Components)
 	types := append([]reflect.Type{}, w.registry.Types[:compCount]...)
 
+	memory := cap(w.entities)*int(entityIndexSize) + w.entityPool.TotalCap()*int(entitySize)
 	archetypes := make([]stats.ArchetypeStats, w.archetypes.Len())
 	for i := 0; i < w.archetypes.Len(); i++ {
-		arch := w.archetypes.Get(i)
-
-		ids := arch.Components()
-		aCompCount := len(ids)
-		aTypes := make([]reflect.Type, aCompCount)
-		for j, id := range ids {
-			aTypes[j] = w.registry.ComponentType(id)
-		}
-
-		stats := stats.ArchetypeStats{
-			Size:           int(arch.Len()),
-			Capacity:       int(arch.Cap()),
-			Components:     aCompCount,
-			ComponentIDs:   ids,
-			ComponentTypes: aTypes,
-		}
-
-		archetypes[i] = stats
+		archetypes[i] = w.archetypes.Get(i).Stats(&w.registry)
+		memory += archetypes[i].Memory
 	}
 
 	return &stats.WorldStats{
@@ -413,6 +398,7 @@ func (w *World) Stats() *stats.WorldStats {
 		ComponentTypes: types,
 		Locked:         w.IsLocked(),
 		Archetypes:     archetypes,
+		Memory:         memory,
 	}
 }
 
diff --git a/examples/random_access/main.go b/examples/random_access/main.go
new file mode 100644
index 0000000..200b845
--- /dev/null
+++ b/examples/random_access/main.go
@@ -0,0 +1,49 @@
+// Demonstrates generic access to arbitrary entities.
+package main
+
+import (
+	"github.com/mlange-42/arche/ecs"
+	"github.com/mlange-42/arche/generic"
+)
+
+// Linked component
+type Linked struct {
+	Prev ecs.Entity
+	Next ecs.Entity
+}
+
+func main() {
+	// Create a World.
+	world := ecs.NewWorld()
+
+	// Create a component mapper.
+	mapper := generic.NewMap1[Linked](&world)
+
+	var prev ecs.Entity
+	// Create entities.
+	for i := 0; i < 1000; i++ {
+		// Create a new Entity with components.
+		_, link := mapper.NewEntity()
+		// Make it an implicit linked list
+		link.Prev = prev
+	}
+
+	// Create a generic filter.
+	filter := generic.NewFilter1[Linked]()
+
+	// Get a fresh query iterator.
+	query := filter.Query(&world)
+	for query.Next() {
+		entity := query.Entity()
+
+		link := query.Get()
+		if link.Prev.IsZero() {
+			continue
+		}
+
+		// Get a component from another entity than the one of the current iteration.
+		prevLink := mapper.Get(link.Prev)
+		// Make it a double-linked list
+		prevLink.Next = entity
+	}
+}
diff --git a/examples/world_stats/main.go b/examples/world_stats/main.go
new file mode 100644
index 0000000..34b05e9
--- /dev/null
+++ b/examples/world_stats/main.go
@@ -0,0 +1,58 @@
+// Demonstrates the core API that uses component IDs for access.
+package main
+
+import (
+	"fmt"
+	"math/rand"
+
+	"github.com/mlange-42/arche/ecs"
+)
+
+// Position component
+type Position struct {
+	X float64
+	Y float64
+}
+
+// Velocity component
+type Velocity struct {
+	X float64
+	Y float64
+}
+
+// Rotation component
+type Rotation struct {
+	A float64
+}
+
+func main() {
+	// Create a World.
+	world :=
+		ecs.NewConfig().
+			WithCapacityIncrement(4096).
+			Build()
+
+	ids := []ecs.ID{
+		ecs.ComponentID[Position](&world),
+		ecs.ComponentID[Velocity](&world),
+		ecs.ComponentID[Rotation](&world),
+	}
+
+	currIDs := make([]ecs.ID, 0, len(ids))
+	// Create 1 million entities.
+	for i := 0; i < 1000000; i++ {
+		// Create a new Entity.
+		entity := world.NewEntity()
+		// Select some random components
+		for _, id := range ids {
+			if rand.Float64() < 0.7 {
+				currIDs = append(currIDs, id)
+			}
+		}
+		// Add the components
+		world.Add(entity, currIDs...)
+		currIDs = currIDs[:0]
+	}
+
+	fmt.Println(world.Stats().String())
+}


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 65f53a3..eed3660 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -79,4 +79,6 @@ jobs:
           go run ./examples/filter
           go run ./examples/generic
           go run ./examples/locked_world
+          go run ./examples/random_access
           go run ./examples/readme
+          go run ./examples/world_stats
diff --git a/benchmark/iterate/arche_test.go b/benchmark/iterate/arche_test.go
index 013c06b..e696f8b 100644
--- a/benchmark/iterate/arche_test.go
+++ b/benchmark/iterate/arche_test.go
@@ -215,18 +215,15 @@ func runArcheWorld(b *testing.B, count int) {
 	posID := ecs.ComponentID[c.Position](&world)
 	rotID := ecs.ComponentID[c.Rotation](&world)
 
+	entities := make([]ecs.Entity, count)
 	for i := 0; i < count; i++ {
-		_ = world.NewEntity(posID, rotID)
+		entities[i] = world.NewEntity(posID, rotID)
 	}
 	b.StartTimer()
 
 	for i := 0; i < b.N; i++ {
-		b.StopTimer()
-		query := world.Query(ecs.All(posID, rotID))
-		b.StartTimer()
-		for query.Next() {
-			entity := query.Entity()
-			pos := (*c.Position)(world.Get(entity, posID))
+		for _, e := range entities {
+			pos := (*c.Position)(world.Get(e, posID))
 			_ = pos
 		}
 	}
@@ -241,18 +238,15 @@ func runArcheWorldGeneric(b *testing.B, count int) {
 
 	get := generic.NewMap[c.Position](&world)
 
+	entities := make([]ecs.Entity, count)
 	for i := 0; i < count; i++ {
-		_ = world.NewEntity(posID, rotID)
+		entities[i] = world.NewEntity(posID, rotID)
 	}
 	b.StartTimer()
 
 	for i := 0; i < b.N; i++ {
-		b.StopTimer()
-		query := world.Query(ecs.All(posID, rotID))
-		b.StartTimer()
-		for query.Next() {
-			entity := query.Entity()
-			pos := get.Get(entity)
+		for _, e := range entities {
+			pos := get.Get(e)
 			_ = pos
 		}
 	}
diff --git a/ecs/bitmask_test.go b/ecs/bitmask_test.go
index 3121565..3a0e256 100644
--- a/ecs/bitmask_test.go
+++ b/ecs/bitmask_test.go
@@ -1,6 +1,7 @@
 package ecs
 
 import (
+	"math/rand"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -65,3 +66,48 @@ func TestBitMask128(t *testing.T) {
 	assert.True(t, mask.ContainsAny(NewBitMask(ID(6), ID(65), ID(111))))
 	assert.False(t, mask.ContainsAny(NewBitMask(ID(6), ID(66), ID(90))))
 }
+
+func TestBitMask64(t *testing.T) {
+	mask := newBitMask64(ID(1))
+	assert.True(t, mask.Get(ID(1)))
+	for i := 0; i < wordSize; i++ {
+		mask.Set(ID(i), true)
+		assert.True(t, mask.Get(ID(i)))
+		mask.Set(ID(i), false)
+		assert.False(t, mask.Get(ID(i)))
+	}
+}
+
+func BenchmarkBitmask64Get(b *testing.B) {
+	b.StopTimer()
+	mask := newBitMask64()
+	for i := 0; i < MaskTotalBits; i++ {
+		if rand.Float64() < 0.5 {
+			mask.Set(ID(i), true)
+		}
+	}
+	idx := ID(rand.Intn(wordSize))
+	b.StartTimer()
+
+	for i := 0; i < b.N; i++ {
+		v := mask.Get(idx)
+		_ = v
+	}
+}
+
+func BenchmarkBitmask128Get(b *testing.B) {
+	b.StopTimer()
+	mask := NewBitMask()
+	for i := 0; i < MaskTotalBits; i++ {
+		if rand.Float64() < 0.5 {
+			mask.Set(ID(i), true)
+		}
+	}
+	idx := ID(rand.Intn(MaskTotalBits))
+	b.StartTimer()
+
+	for i := 0; i < b.N; i++ {
+		v := mask.Get(idx)
+		_ = v
+	}
+}