Repository: mlange-42/arche
Pull request #327 (2024-01-12 23:31:55)

Reduce archetype size by storing layouts in a growing slice
Use a growing slice to hold archetype mem layouts, instead of a fixes-size array.

Reduces archetype size from 4 kB to 88 B, plus the heap-allocated slice. The slice starts with size 16 (i.e. 16 component types) and grows in steps of 16 up to 256.

coveralls: ## Pull Request Test Coverage Report for [Build 7508582964](https://coveralls.io/builds/65053169)


* **0** of **0**   changed or added relevant lines in **0** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage remained the same at **100.0%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/65053169/badge)](https://coveralls.io/builds/65053169) |
| :-- | --: |
| Change from base [Build 7503377325](https://coveralls.io/builds/65041412): |  0.0% |
| Covered Lines: | 4863 |
| Relevant Lines: | 4863 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8db91f7..e58f219 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +11,7 @@
 ### Breaking changes
 
 * `MaskTotalBits` changed from 128 to 256 (#313)
-* Removed `Mask.Lo` and `Mask.Hi`, internal mask representation is now private (#313)
+* Removes `Mask.Lo` and `Mask.Hi`, internal mask representation is now private (#313)
 * `Filters.Matches(Mask)` became `Filters.Matches(*Mask)`; same for all `Filter` implementations (#313)  
 This change was necessary to get the same performance as before, despite the more heavyweight implementation of the now 256 bits `Mask`.
 
@@ -19,9 +19,14 @@ This change was necessary to get the same performance as before, despite the mor
 
 * Adds functions `ComponentInfo(*World, ID)` and `ResourceType(*World, ResID)` (#315, #318)
 * Adds methods `World.Ids(Entity)` and `Query.Ids()` to get component IDs for an entity (#315, #325)
-* Entities support JSON marshalling/unmarshalling (#319)
+* Entities support JSON marshalling and unmarshalling (#319)
+* Adds methods `Entity.ID()` and `Entity.Gen()` for serialization purposes (#319)
 * The world's entity state can be extracted and re-established via `World.DumpEntities()` and `World.LoadEntities()` (#319, #326)
 
+### Performance
+
+* Reduces archetype memory footprint by using a dynamically sized slice for storage lookup (#327)
+
 ### Other
 
 * Entity generation data type changed from `uint16` to `uint32` (#317)
diff --git a/README.md b/README.md
index fa408e9..91196dd 100644
--- a/README.md
+++ b/README.md
@@ -112,10 +112,10 @@ func main() {
 
 Several tools for *Arche* are provided in separate modules:
 
+* [arche-serde](https://github.com/mlange-42/arche-serde) provides JSON serialization and deserialization for *Arche*'s World.
 * [arche-model](https://github.com/mlange-42/arche-model) provides a wrapper around *Arche*, and some common systems and resources.
 It's purpose is to get started with prototyping and developing simulation models immediately, focussing on the model logic.
 * [arche-pixel](https://github.com/mlange-42/arche-pixel) provides OpenGL graphics and live plots for *Arche* using the [Pixel](https://github.com/gopxl/pixel) game engine.
-* [arche-serde](https://github.com/mlange-42/arche-serde) provides JSON serialization and deserialization for *Arche*'s World.
 
 ## Design
 
diff --git a/ecs/archetype.go b/ecs/archetype.go
index 6256be4..0dded56 100644
--- a/ecs/archetype.go
+++ b/ecs/archetype.go
@@ -8,6 +8,8 @@ import (
 	"github.com/mlange-42/arche/ecs/stats"
 )
 
+const layoutChunkSize uint8 = 16
+
 // layoutSize is the size of an archetype column layout in bytes.
 var layoutSize uint32 = uint32(unsafe.Sizeof(layout{}))
 
@@ -69,15 +71,15 @@ type archetype struct {
 }
 
 type archetypeData struct {
-	layouts      [MaskTotalBits]layout // Column layouts by ID.
-	indices      idMap[uint32]         // Mapping from IDs to buffer indices.
-	buffers      []reflect.Value       // Reflection arrays containing component data.
-	entityBuffer reflect.Value         // Reflection array containing entity data.
-	index        int32                 // Index of the archetype in the world.
+	layouts      []layout        // Column layouts by ID.
+	indices      idMap[uint32]   // Mapping from IDs to buffer indices.
+	buffers      []reflect.Value // Reflection arrays containing component data.
+	entityBuffer reflect.Value   // Reflection array containing entity data.
+	index        int32           // Index of the archetype in the world.
 }
 
 // Init initializes an archetype
-func (a *archetype) Init(node *archNode, data *archetypeData, index int32, forStorage bool, relation Entity) {
+func (a *archetype) Init(node *archNode, data *archetypeData, index int32, forStorage bool, layouts uint8, relation Entity) {
 	if !node.IsActive {
 		node.IsActive = true
 	}
@@ -86,6 +88,7 @@ func (a *archetype) Init(node *archNode, data *archetypeData, index int32, forSt
 	a.buffers = make([]reflect.Value, len(node.Ids))
 	a.indices = newIDMap[uint32]()
 	a.index = index
+	a.layouts = make([]layout, layouts)
 
 	cap := 1
 	if forStorage {
@@ -261,6 +264,16 @@ func (a *archetype) Activate(target Entity, index int32) {
 	a.RelationTarget = target
 }
 
+func (a *archetype) ExtendLayouts(count uint8) {
+	if len(a.layouts) >= int(count) {
+		return
+	}
+	temp := a.layouts
+	a.layouts = make([]layout, count)
+	copy(a.layouts, temp)
+	a.archetypeAccess.basePointer = unsafe.Pointer(&a.layouts[0])
+}
+
 // IsActive returns whether the archetype is active.
 // Otherwise, it is eligible for re-use.
 func (a *archetype) IsActive() bool {
diff --git a/ecs/archetype_node.go b/ecs/archetype_node.go
index 0c6445f..247a1c6 100644
--- a/ecs/archetype_node.go
+++ b/ecs/archetype_node.go
@@ -115,7 +115,7 @@ func (a *archNode) SetArchetype(arch *archetype) {
 }
 
 // CreateArchetype creates a new archetype in nodes with relation component.
-func (a *archNode) CreateArchetype(target Entity) *archetype {
+func (a *archNode) CreateArchetype(layouts uint8, target Entity) *archetype {
 	var arch *archetype
 	var archIndex int32
 	lenFree := len(a.freeIndices)
@@ -129,12 +129,26 @@ func (a *archNode) CreateArchetype(target Entity) *archetype {
 		a.archetypeData.Add(archetypeData{})
 		archIndex := a.archetypes.Len() - 1
 		arch = a.archetypes.Get(archIndex)
-		arch.Init(a, a.archetypeData.Get(archIndex), archIndex, true, target)
+		arch.Init(a, a.archetypeData.Get(archIndex), archIndex, true, layouts, target)
 	}
 	a.archetypeMap[target] = arch
 	return arch
 }
 
+func (a *archNode) ExtendArchetypeLayouts(count uint8) {
+	if !a.HasRelation {
+		a.archetype.ExtendLayouts(count)
+		return
+	}
+
+	lenArches := a.archetypes.Len()
+	var j int32
+	for j = 0; j < lenArches; j++ {
+		arch := a.archetypes.Get(j)
+		arch.ExtendLayouts(count)
+	}
+}
+
 // RemoveArchetype de-activates an archetype.
 // The archetype will be re-used by CreateArchetype.
 func (a *archNode) RemoveArchetype(arch *archetype) {
diff --git a/ecs/id_map.go b/ecs/id_map.go
index bdc8940..d7d1e1a 100644
--- a/ecs/id_map.go
+++ b/ecs/id_map.go
@@ -1,8 +1,8 @@
 package ecs
 
 const (
-	chunkSize = 16
-	numChunks = MaskTotalBits / chunkSize
+	idMapChunkSize = 16
+	idMapChunks    = MaskTotalBits / idMapChunkSize
 )
 
 // idMap maps component IDs to values.
@@ -23,9 +23,9 @@ type idMap[T any] struct {
 // newIDMap creates a new idMap
 func newIDMap[T any]() idMap[T] {
 	return idMap[T]{
-		chunks:    make([][]T, numChunks),
+		chunks:    make([][]T, idMapChunks),
 		used:      Mask{},
-		chunkUsed: make([]uint8, numChunks),
+		chunkUsed: make([]uint8, idMapChunks),
 	}
 }
 
@@ -34,7 +34,7 @@ func (m *idMap[T]) Get(index uint8) (T, bool) {
 	if !m.used.Get(index) {
 		return m.zeroValue, false
 	}
-	return m.chunks[index/chunkSize][index%chunkSize], true
+	return m.chunks[index/idMapChunkSize][index%idMapChunkSize], true
 }
 
 // Get returns a pointer to the value at the given key and whether the key is present.
@@ -42,16 +42,16 @@ func (m *idMap[T]) GetPointer(index uint8) (*T, bool) {
 	if !m.used.Get(index) {
 		return nil, false
 	}
-	return &m.chunks[index/chunkSize][index%chunkSize], true
+	return &m.chunks[index/idMapChunkSize][index%idMapChunkSize], true
 }
 
 // Set sets the value at the given key.
 func (m *idMap[T]) Set(index uint8, value T) {
-	chunk := index / chunkSize
+	chunk := index / idMapChunkSize
 	if m.chunks[chunk] == nil {
-		m.chunks[chunk] = make([]T, chunkSize)
+		m.chunks[chunk] = make([]T, idMapChunkSize)
 	}
-	m.chunks[chunk][index%chunkSize] = value
+	m.chunks[chunk][index%idMapChunkSize] = value
 	m.used.Set(index, true)
 	m.chunkUsed[chunk]++
 }
@@ -59,7 +59,7 @@ func (m *idMap[T]) Set(index uint8, value T) {
 // Remove removes the value at the given key.
 // It de-allocates empty chunks.
 func (m *idMap[T]) Remove(index uint8) {
-	chunk := index / chunkSize
+	chunk := index / idMapChunkSize
 	m.used.Set(index, false)
 	m.chunkUsed[chunk]--
 	if m.chunkUsed[chunk] == 0 {
diff --git a/ecs/registry.go b/ecs/registry.go
index 7942245..b8035dc 100644
--- a/ecs/registry.go
+++ b/ecs/registry.go
@@ -1,6 +1,9 @@
 package ecs
 
-import "reflect"
+import (
+	"fmt"
+	"reflect"
+)
 
 // componentRegistry keeps track of component IDs.
 type componentRegistry[T uint8] struct {
@@ -21,11 +24,12 @@ func newComponentRegistry[T uint8]() componentRegistry[T] {
 }
 
 // ComponentID returns the ID for a component type, and registers it if not already registered.
-func (r *componentRegistry[T]) ComponentID(tp reflect.Type) T {
+// The second return value indicates if it is a newly created ID.
+func (r *componentRegistry[T]) ComponentID(tp reflect.Type) (T, bool) {
 	if id, ok := r.Components[tp]; ok {
-		return id
+		return id, false
 	}
-	return r.registerComponent(tp, MaskTotalBits)
+	return r.registerComponent(tp, MaskTotalBits), true
 }
 
 // ComponentType returns the type of a component by ID.
@@ -33,12 +37,18 @@ func (r *componentRegistry[T]) ComponentType(id T) (reflect.Type, bool) {
 	return r.Types[id], r.Used.Get(uint8(id))
 }
 
+// ComponentType returns the type of a component by ID.
+func (r *componentRegistry[T]) Count() int {
+	return len(r.Components)
+}
+
 // registerComponent registers a components and assigns an ID for it.
 func (r *componentRegistry[T]) registerComponent(tp reflect.Type, totalBits int) T {
-	id := T(len(r.Components))
-	if int(id) >= totalBits {
-		panic("maximum of 128 component types exceeded")
+	val := len(r.Components)
+	if val >= totalBits {
+		panic(fmt.Sprintf("maximum of %d component types exceeded", totalBits))
 	}
+	id := T(val)
 	r.Components[tp], r.Types[id] = id, tp
 	r.Used.Set(uint8(id), true)
 	if r.isRelation(tp) {
@@ -47,6 +57,15 @@ func (r *componentRegistry[T]) registerComponent(tp reflect.Type, totalBits int)
 	return id
 }
 
+func (r *componentRegistry[T]) unregisterLastComponent() {
+	id := T(len(r.Components) - 1)
+	tp, _ := r.ComponentType(id)
+	delete(r.Components, tp)
+	r.Types[id] = nil
+	r.Used.Set(uint8(id), false)
+	r.IsRelation.Set(uint8(id), false)
+}
+
 func (r *componentRegistry[T]) isRelation(tp reflect.Type) bool {
 	if tp.NumField() == 0 {
 		return false
diff --git a/ecs/util.go b/ecs/util.go
index e499020..8a39219 100644
--- a/ecs/util.go
+++ b/ecs/util.go
@@ -17,6 +17,19 @@ func capacity(size, increment int) int {
 	return cap
 }
 
+// Calculates the capacity required for size, given an increment.
+// Always returns a value greater than zero.
+func capacityNonZero(size, increment int) int {
+	if size == 0 {
+		return increment
+	}
+	cap := increment * (size / increment)
+	if size%increment != 0 {
+		cap += increment
+	}
+	return cap
+}
+
 // Calculates the capacity required for size, given an increment.
 func capacityU32(size, increment uint32) uint32 {
 	cap := increment * (size / increment)
diff --git a/ecs/world.go b/ecs/world.go
index 9795593..b872188 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -11,7 +11,11 @@ import (
 // ComponentID returns the [ID] for a component type via generics.
 // Registers the type if it is not already registered.
 //
-// The number of unique component types per [World] is limited to [MaskTotalBits].
+// The number of unique component types per [World] is limited to 256 ([MaskTotalBits]).
+//
+// Panics if called on a locked world and the type is not registered yet.
+//
+// ⚠️ Warning: Using IDs that are outside of the range of registered IDs anywhere in [World] or other places will result in undefined behavior!
 func ComponentID[T any](w *World) ID {
 	tp := reflect.TypeOf((*T)(nil)).Elem()
 	return w.componentID(tp)
@@ -1420,14 +1424,16 @@ func (w *World) createArchetypeNode(mask Mask, relation int8) *archNode {
 // and with a capacity of 1 otherwise.
 func (w *World) createArchetype(node *archNode, target Entity, forStorage bool) *archetype {
 	var arch *archetype
+	layouts := capacityNonZero(w.registry.Count(), int(layoutChunkSize))
+
 	if node.HasRelation {
-		arch = node.CreateArchetype(target)
+		arch = node.CreateArchetype(uint8(layouts), target)
 	} else {
 		w.archetypes.Add(archetype{})
 		w.archetypeData.Add(archetypeData{})
 		archIndex := w.archetypes.Len() - 1
 		arch = w.archetypes.Get(archIndex)
-		arch.Init(node, w.archetypeData.Get(archIndex), archIndex, forStorage, target)
+		arch.Init(node, w.archetypeData.Get(archIndex), archIndex, forStorage, uint8(layouts), target)
 		node.SetArchetype(arch)
 	}
 	w.filterCache.addArchetype(arch)
@@ -1498,14 +1504,34 @@ func (w *World) removeArchetype(arch *archetype) {
 	w.Cache().removeArchetype(arch)
 }
 
+// Extend the number of access layouts in archetypes.
+func (w *World) extendArchetypeLayouts(count uint8) {
+	len := w.nodes.Len()
+	var i int32
+	for i = 0; i < len; i++ {
+		w.nodes.Get(i).ExtendArchetypeLayouts(count)
+	}
+}
+
 // componentID returns the ID for a component type, and registers it if not already registered.
 func (w *World) componentID(tp reflect.Type) ID {
-	return w.registry.ComponentID(tp)
+	id, newID := w.registry.ComponentID(tp)
+	if newID {
+		if w.IsLocked() {
+			w.registry.unregisterLastComponent()
+			panic("attempt to register a new component in a locked world")
+		}
+		if id > 0 && id%layoutChunkSize == 0 {
+			w.extendArchetypeLayouts(id + layoutChunkSize)
+		}
+	}
+	return id
 }
 
 // resourceID returns the ID for a resource type, and registers it if not already registered.
 func (w *World) resourceID(tp reflect.Type) ResID {
-	return w.resources.registry.ComponentID(tp)
+	id, _ := w.resources.registry.ComponentID(tp)
+	return id
 }
 
 // closeQuery closes a query and unlocks the world.


Test patch:
diff --git a/ecs/archetype_test.go b/ecs/archetype_test.go
index d0afcbe..e3bc2ad 100644
--- a/ecs/archetype_test.go
+++ b/ecs/archetype_test.go
@@ -16,7 +16,7 @@ func TestArchetype(t *testing.T) {
 	node := newArchNode(All(0, 1), &nodeData{}, -1, 32, comps)
 	arch := archetype{}
 	data := archetypeData{}
-	arch.Init(&node, &data, 0, false, Entity{})
+	arch.Init(&node, &data, 0, false, 16, Entity{})
 
 	arch.Add(
 		newEntity(0),
@@ -75,12 +75,12 @@ func TestNewArchetype(t *testing.T) {
 	node := newArchNode(All(0, 1), &nodeData{}, -1, 32, comps)
 	arch := archetype{}
 	data := archetypeData{}
-	arch.Init(&node, &data, 0, true, Entity{})
+	arch.Init(&node, &data, 0, true, 16, Entity{})
 	assert.Equal(t, 32, int(arch.Cap()))
 
 	arch = archetype{}
 	data = archetypeData{}
-	arch.Init(&node, &data, 0, false, Entity{})
+	arch.Init(&node, &data, 0, false, 16, Entity{})
 	assert.Equal(t, 1, int(arch.Cap()))
 
 	comps = []componentType{
@@ -91,7 +91,7 @@ func TestNewArchetype(t *testing.T) {
 		node := newArchNode(All(0, 1), &nodeData{}, -1, 32, comps)
 		arch := archetype{}
 		data := archetypeData{}
-		arch.Init(&node, &data, 0, true, Entity{})
+		arch.Init(&node, &data, 0, true, 16, Entity{})
 	})
 }
 
@@ -104,7 +104,7 @@ func TestArchetypeExtend(t *testing.T) {
 	node := newArchNode(All(0, 1), &nodeData{}, -1, 8, comps)
 	arch := archetype{}
 	data := archetypeData{}
-	arch.Init(&node, &data, 0, true, Entity{})
+	arch.Init(&node, &data, 0, true, 16, Entity{})
 
 	assert.Equal(t, 8, int(arch.Cap()))
 	assert.Equal(t, 0, int(arch.Len()))
@@ -119,6 +119,37 @@ func TestArchetypeExtend(t *testing.T) {
 	assert.Equal(t, 24, int(arch.Cap()))
 }
 
+func TestArchetypeExtendLayouts(t *testing.T) {
+	comps := []componentType{
+		{ID: 0, Type: reflect.TypeOf(Position{})},
+		{ID: 1, Type: reflect.TypeOf(rotation{})},
+		{ID: 2, Type: reflect.TypeOf(relationComp{})},
+	}
+	entity := newEntity(1)
+
+	node := newArchNode(All(0, 1), &nodeData{}, -1, 8, comps[:2])
+	arch := archetype{}
+	data := archetypeData{}
+	arch.Init(&node, &data, 0, true, 16, Entity{})
+	node.SetArchetype(&arch)
+
+	assert.Equal(t, len(arch.layouts), 16)
+	node.ExtendArchetypeLayouts(16)
+	assert.Equal(t, len(arch.layouts), 16)
+	node.ExtendArchetypeLayouts(32)
+	assert.Equal(t, len(arch.layouts), 32)
+
+	node = newArchNode(All(0, 1), &nodeData{}, 2, 8, comps)
+	node.CreateArchetype(16, entity)
+	arch2 := node.GetArchetype(entity)
+
+	assert.Equal(t, len(arch2.layouts), 16)
+	node.ExtendArchetypeLayouts(16)
+	assert.Equal(t, len(arch2.layouts), 16)
+	node.ExtendArchetypeLayouts(32)
+	assert.Equal(t, len(arch2.layouts), 32)
+}
+
 func TestArchetypeAlloc(t *testing.T) {
 	comps := []componentType{
 		{ID: 0, Type: reflect.TypeOf(Position{})},
@@ -127,7 +158,7 @@ func TestArchetypeAlloc(t *testing.T) {
 	node := newArchNode(All(0, 1), &nodeData{}, -1, 8, comps)
 	arch := archetype{}
 	data := archetypeData{}
-	arch.Init(&node, &data, 0, true, Entity{})
+	arch.Init(&node, &data, 0, true, 16, Entity{})
 
 	assert.Equal(t, 8, int(arch.Cap()))
 	assert.Equal(t, 0, int(arch.Len()))
@@ -153,7 +184,7 @@ func TestArchetypeAddGetSet(t *testing.T) {
 	node := newArchNode(All(0, 1), &nodeData{}, -1, 1, comps)
 	a := archetype{}
 	data := archetypeData{}
-	a.Init(&node, &data, 0, true, Entity{})
+	a.Init(&node, &data, 0, true, 16, Entity{})
 
 	assert.Equal(t, 1, int(a.Cap()))
 	assert.Equal(t, 0, int(a.Len()))
@@ -189,7 +220,7 @@ func TestArchetypeReset(t *testing.T) {
 	node := newArchNode(All(0, 1), &nodeData{}, -1, 32, comps)
 	arch := archetype{}
 	data := archetypeData{}
-	arch.Init(&node, &data, 0, false, Entity{})
+	arch.Init(&node, &data, 0, false, 16, Entity{})
 
 	arch.Add(
 		newEntity(0),
@@ -236,7 +267,7 @@ func TestArchetypeZero(t *testing.T) {
 	node := newArchNode(All(0, 1), &nodeData{}, -1, 32, comps)
 	arch := archetype{}
 	data := archetypeData{}
-	arch.Init(&node, &data, 0, false, Entity{})
+	arch.Init(&node, &data, 0, false, 16, Entity{})
 
 	arch.Alloc(newEntity(0))
 	arch.Alloc(newEntity(1))
@@ -269,7 +300,7 @@ func BenchmarkIterArchetype_1000(b *testing.B) {
 	node := newArchNode(All(0), &nodeData{}, -1, 32, comps)
 	arch := archetype{}
 	data := archetypeData{}
-	arch.Init(&node, &data, 0, true, Entity{})
+	arch.Init(&node, &data, 0, true, 16, Entity{})
 
 	for i := 0; i < 1000; i++ {
 		arch.Alloc(newEntity(eid(i)))
diff --git a/ecs/registry_test.go b/ecs/registry_test.go
index c8c60f4..f025614 100644
--- a/ecs/registry_test.go
+++ b/ecs/registry_test.go
@@ -15,8 +15,10 @@ func TestComponentRegistry(t *testing.T) {
 
 	reg.registerComponent(posType, MaskTotalBits)
 
-	assert.Equal(t, ID(0), reg.ComponentID(posType))
-	assert.Equal(t, ID(1), reg.ComponentID(rotType))
+	id0, _ := reg.ComponentID(posType)
+	id1, _ := reg.ComponentID(rotType)
+	assert.Equal(t, ID(0), id0)
+	assert.Equal(t, ID(1), id1)
 
 	t1, _ := reg.ComponentType(ID(0))
 	t2, _ := reg.ComponentType(ID(1))
@@ -62,10 +64,10 @@ func TestRegistryRelations(t *testing.T) {
 	assert.False(t, registry.isRelation(noRelCompTp2))
 	assert.False(t, registry.isRelation(noRelCompTp3))
 
-	id1 := registry.ComponentID(relCompTp)
-	id2 := registry.ComponentID(noRelCompTp1)
-	id3 := registry.ComponentID(noRelCompTp2)
-	id4 := registry.ComponentID(noRelCompTp3)
+	id1, _ := registry.ComponentID(relCompTp)
+	id2, _ := registry.ComponentID(noRelCompTp1)
+	id3, _ := registry.ComponentID(noRelCompTp2)
+	id4, _ := registry.ComponentID(noRelCompTp3)
 
 	assert.True(t, registry.IsRelation.Get(id1))
 	assert.False(t, registry.IsRelation.Get(id2))
diff --git a/ecs/resources_test.go b/ecs/resources_test.go
index f417e26..fd0741d 100644
--- a/ecs/resources_test.go
+++ b/ecs/resources_test.go
@@ -11,8 +11,8 @@ import (
 func TestResources(t *testing.T) {
 	res := newResources()
 
-	posID := res.registry.ComponentID(reflect.TypeOf(Position{}))
-	rotID := res.registry.ComponentID(reflect.TypeOf(rotation{}))
+	posID, _ := res.registry.ComponentID(reflect.TypeOf(Position{}))
+	rotID, _ := res.registry.ComponentID(reflect.TypeOf(rotation{}))
 
 	assert.False(t, res.Has(posID))
 	assert.Nil(t, res.Get(posID))
@@ -40,8 +40,8 @@ func TestResources(t *testing.T) {
 func TestResourcesReset(t *testing.T) {
 	res := newResources()
 
-	posID := res.registry.ComponentID(reflect.TypeOf(Position{}))
-	rotID := res.registry.ComponentID(reflect.TypeOf(rotation{}))
+	posID, _ := res.registry.ComponentID(reflect.TypeOf(Position{}))
+	rotID, _ := res.registry.ComponentID(reflect.TypeOf(rotation{}))
 
 	res.Add(posID, &Position{1, 2})
 	res.Add(rotID, &rotation{5})
diff --git a/ecs/types_test_test.go b/ecs/types_test_test.go
index b130faa..f101248 100644
--- a/ecs/types_test_test.go
+++ b/ecs/types_test_test.go
@@ -35,3 +35,9 @@ type testStruct7 struct{ val int32 }
 type testStruct8 struct{ val int32 }
 type testStruct9 struct{ val int32 }
 type testStruct10 struct{ val int32 }
+type testStruct11 struct{ val int32 }
+type testStruct12 struct{ val int32 }
+type testStruct13 struct{ val int32 }
+type testStruct14 struct{ val int32 }
+type testStruct15 struct{ val int32 }
+type testStruct16 struct{ val int32 }
diff --git a/ecs/util_test.go b/ecs/util_test.go
index 3e5b007..67baffb 100644
--- a/ecs/util_test.go
+++ b/ecs/util_test.go
@@ -14,6 +14,13 @@ func TestCapacity(t *testing.T) {
 	assert.Equal(t, 16, capacity(9, 8))
 }
 
+func TestCapacityNonZero(t *testing.T) {
+	assert.Equal(t, 8, capacityNonZero(0, 8))
+	assert.Equal(t, 8, capacityNonZero(1, 8))
+	assert.Equal(t, 8, capacityNonZero(8, 8))
+	assert.Equal(t, 16, capacityNonZero(9, 8))
+}
+
 func TestCapacityU32(t *testing.T) {
 	assert.Equal(t, 0, int(capacityU32(0, 8)))
 	assert.Equal(t, 8, int(capacityU32(1, 8)))
diff --git a/ecs/world_test.go b/ecs/world_test.go
index 63eaef3..48f60ab 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -1612,6 +1612,12 @@ func Test1000Archetypes(t *testing.T) {
 	_ = testStruct8{1}
 	_ = testStruct9{1}
 	_ = testStruct10{1}
+	_ = testStruct11{1}
+	_ = testStruct12{1}
+	_ = testStruct13{1}
+	_ = testStruct14{1}
+	_ = testStruct15{1}
+	_ = testStruct16{1}
 
 	w := NewWorld()
 
@@ -1650,29 +1656,6 @@ func Test1000Archetypes(t *testing.T) {
 	assert.Equal(t, 256, cnt)
 }
 
-func TestTypeSizes(t *testing.T) {
-	printTypeSize[Entity]()
-	printTypeSize[entityIndex]()
-	printTypeSize[Mask]()
-	printTypeSize[World]()
-	printTypeSizeName[pagedSlice[archetype]]("pagedArr32")
-	printTypeSize[archetype]()
-	printTypeSize[archetypeAccess]()
-	printTypeSize[archetypeData]()
-	printTypeSize[archNode]()
-	printTypeSize[nodeData]()
-	printTypeSize[layout]()
-	printTypeSize[entityPool]()
-	printTypeSizeName[componentRegistry[ID]]("componentRegistry")
-	printTypeSize[bitPool]()
-	printTypeSize[Query]()
-	printTypeSize[Resources]()
-	printTypeSizeName[reflect.Value]("reflect.Value")
-	printTypeSize[EntityEvent]()
-	printTypeSize[Cache]()
-	printTypeSizeName[idMap[uint32]]("idMap")
-}
-
 func TestWorldEntityDump(t *testing.T) {
 	w := NewWorld()
 
@@ -1739,6 +1722,82 @@ func TestWorldEntityDumpFail(t *testing.T) {
 	})
 }
 
+func TestWorldExtendLayouts(t *testing.T) {
+	w := NewWorld()
+
+	id0 := ComponentID[testStruct0](&w)
+	_ = ComponentID[testStruct1](&w)
+	_ = ComponentID[testStruct2](&w)
+	_ = ComponentID[testStruct3](&w)
+	_ = ComponentID[testStruct4](&w)
+	_ = ComponentID[testStruct5](&w)
+	_ = ComponentID[testStruct6](&w)
+	_ = ComponentID[testStruct7](&w)
+	_ = ComponentID[testStruct8](&w)
+	_ = ComponentID[testStruct9](&w)
+	_ = ComponentID[testStruct10](&w)
+	_ = ComponentID[testStruct11](&w)
+	_ = ComponentID[testStruct12](&w)
+	_ = ComponentID[testStruct13](&w)
+	_ = ComponentID[testStruct14](&w)
+	_ = ComponentID[testStruct15](&w)
+
+	e := w.NewEntity(id0)
+	t0 := (*testStruct0)(w.Get(e, id0))
+	t0.Val = 100
+
+	t0 = (*testStruct0)(w.Get(e, id0))
+	assert.Equal(t, t0.Val, int32(100))
+
+	assert.Equal(t, 16, len(w.archetypes.Get(0).layouts))
+	assert.Equal(t, 16, len(w.archetypes.Get(1).layouts))
+
+	lock := w.lock()
+	assert.Panics(t, func() {
+		ComponentID[testStruct16](&w)
+	})
+	w.unlock(lock)
+
+	id16 := ComponentID[testStruct16](&w)
+	_ = id16
+
+	assert.Equal(t, 32, len(w.archetypes.Get(0).layouts))
+	assert.Equal(t, 32, len(w.archetypes.Get(1).layouts))
+
+	t0 = (*testStruct0)(w.Get(e, id0))
+	assert.Equal(t, int32(100), t0.Val)
+
+	query := w.Query(All(id0))
+	assert.Equal(t, 1, query.Count())
+	for query.Next() {
+		t0 := (*testStruct0)(query.Get(id0))
+		assert.Equal(t, int32(100), t0.Val)
+	}
+}
+
+func TestTypeSizes(t *testing.T) {
+	printTypeSize[Entity]()
+	printTypeSize[entityIndex]()
+	printTypeSize[Mask]()
+	printTypeSize[World]()
+	printTypeSizeName[pagedSlice[archetype]]("pagedArr32")
+	printTypeSize[archetype]()
+	printTypeSize[archetypeAccess]()
+	printTypeSize[archetypeData]()
+	printTypeSize[archNode]()
+	printTypeSize[nodeData]()
+	printTypeSize[layout]()
+	printTypeSize[entityPool]()
+	printTypeSizeName[componentRegistry[ID]]("componentRegistry")
+	printTypeSize[bitPool]()
+	printTypeSize[Query]()
+	printTypeSize[Resources]()
+	printTypeSizeName[reflect.Value]("reflect.Value")
+	printTypeSize[EntityEvent]()
+	printTypeSize[Cache]()
+	printTypeSizeName[idMap[uint32]]("idMap")
+}
+
 func printTypeSize[T any]() {
 	tp := reflect.TypeOf((*T)(nil)).Elem()
 	fmt.Printf("%18s: %5d B\n", tp.Name(), tp.Size())