Skip to content

Commit 9aa184d

Browse files
release v3.0.1
- [opt] immediate entity creation/destruction and component addition/removal - [opt] improve groupof performance - [opt] improve tag performance - [opt] improve spatial and temporal locality - [feat] introduce archetype - [feat] introduce batch add/remove components see `migration_guide.md`
1 parent 7a09b92 commit 9aa184d

16 files changed

Lines changed: 153 additions & 151 deletions

EasyEcs.Core/Archetype.cs

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Runtime.CompilerServices;
34
using EasyEcs.Core.Components;
45

@@ -8,6 +9,7 @@ namespace EasyEcs.Core;
89
/// Archetype stores entities with the same component configuration.
910
/// Uses tombstone pattern (-1) for safe iteration during modifications.
1011
/// Uses free list for O(1) tombstone slot reuse.
12+
/// Uses reverse lookup dictionary for O(1) removal.
1113
/// </summary>
1214
internal class Archetype
1315
{
@@ -20,13 +22,17 @@ internal class Archetype
2022
private int[] _freeSlots;
2123
private int _freeCount;
2224

25+
// Reverse lookup: entityId -> index in EntityIds array (O(1) removal)
26+
private readonly Dictionary<int, int> _entityToIndex;
27+
2328
private const int Tombstone = -1;
2429

2530
public Archetype(in Tag componentMask, int initialCapacity = 1024)
2631
{
2732
ComponentMask = componentMask;
2833
EntityIds = new int[initialCapacity];
29-
_freeSlots = new int[initialCapacity / 4]; // Start smaller, grow as needed
34+
_freeSlots = new int[initialCapacity]; // Match capacity to avoid early resizing
35+
_entityToIndex = new Dictionary<int, int>(initialCapacity);
3036
Count = 0;
3137
AliveCount = 0;
3238
_freeCount = 0;
@@ -39,10 +45,12 @@ public Archetype(in Tag componentMask, int initialCapacity = 1024)
3945
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
4046
public void Add(int entityId)
4147
{
48+
int slot;
49+
4250
// Fast path: Reuse free slot from free list (O(1))
4351
if (_freeCount > 0)
4452
{
45-
int slot = _freeSlots[--_freeCount];
53+
slot = _freeSlots[--_freeCount];
4654
EntityIds[slot] = entityId;
4755
AliveCount++;
4856
}
@@ -55,55 +63,67 @@ public void Add(int entityId)
5563
Array.Resize(ref EntityIds, EntityIds.Length * 2);
5664
}
5765

58-
EntityIds[Count++] = entityId;
66+
slot = Count++;
67+
EntityIds[slot] = entityId;
5968
AliveCount++;
6069
}
70+
71+
// Update reverse lookup for O(1) removal
72+
_entityToIndex[entityId] = slot;
6173
}
6274

6375
/// <summary>
6476
/// Remove an entity from this archetype.
77+
/// O(1) removal using reverse lookup dictionary.
6578
/// Marks the slot as tombstone (-1) and adds to free list for O(1) reuse.
6679
/// </summary>
6780
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
6881
public void Remove(int entityId)
6982
{
70-
// Find and tombstone the entity
71-
var span = EntityIds.AsSpan(0, Count);
72-
int idx = span.IndexOf(entityId);
83+
// O(1) lookup using reverse index
84+
if (!_entityToIndex.TryGetValue(entityId, out int idx))
85+
return;
7386

74-
if (idx >= 0)
75-
{
76-
EntityIds[idx] = Tombstone;
77-
AliveCount--;
87+
// Tombstone the slot
88+
EntityIds[idx] = Tombstone;
89+
AliveCount--;
7890

79-
// Add to free list for O(1) reuse
80-
if (_freeCount >= _freeSlots.Length)
81-
{
82-
Array.Resize(ref _freeSlots, _freeSlots.Length * 2);
83-
}
84-
_freeSlots[_freeCount++] = idx;
91+
// Remove from reverse lookup
92+
_entityToIndex.Remove(entityId);
8593

86-
// Note: Auto-compaction is disabled to avoid unpredictable performance spikes mid-frame.
87-
// Call Context.CompactArchetypes() manually during loading screens or maintenance windows
88-
// to remove tombstones and reduce fragmentation.
94+
// Add to free list for O(1) reuse
95+
if (_freeCount >= _freeSlots.Length)
96+
{
97+
Array.Resize(ref _freeSlots, _freeSlots.Length * 2);
8998
}
99+
_freeSlots[_freeCount++] = idx;
100+
101+
// Note: Auto-compaction is disabled to avoid unpredictable performance spikes mid-frame.
102+
// Call Context.CompactArchetypes() manually during loading screens or maintenance windows
103+
// to remove tombstones and reduce fragmentation.
90104
}
91105

92106
/// <summary>
93107
/// Compact the array by removing all tombstones.
94-
/// Clears the free list since all gaps are removed.
108+
/// Clears the free list and rebuilds reverse lookup since all gaps are removed.
95109
/// </summary>
96110
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
97111
public void Compact()
98112
{
99113
int writeIdx = 0;
100114
var span = EntityIds.AsSpan(0, Count);
101115

116+
// Clear and rebuild reverse lookup
117+
_entityToIndex.Clear();
118+
102119
for (int readIdx = 0; readIdx < Count; readIdx++)
103120
{
104-
if (span[readIdx] != Tombstone)
121+
int entityId = span[readIdx];
122+
if (entityId != Tombstone)
105123
{
106-
EntityIds[writeIdx++] = EntityIds[readIdx];
124+
EntityIds[writeIdx] = entityId;
125+
_entityToIndex[entityId] = writeIdx; // Rebuild reverse lookup
126+
writeIdx++;
107127
}
108128
}
109129

EasyEcs.Core/Components/TagRegistry.cs

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
namespace EasyEcs.Core.Components;
66

7-
internal class TagRegistry
7+
/// <summary>
8+
/// Global component type registry. Thread-safe and shared across all Context instances.
9+
/// Component types are registered once globally and persist for the lifetime of the process.
10+
/// </summary>
11+
internal static class TagRegistry
812
{
913
private interface ITypeBitIndex
1014
{
@@ -14,10 +18,9 @@ private interface ITypeBitIndex
1418
private class TypeBitIndex<T> : ITypeBitIndex
1519
{
1620
// Volatile fields for cross-thread visibility
17-
// Static instance shared across all Context instances by design
1821
internal volatile ushort BitIndex; // Supports up to 65536 component types
1922
internal volatile bool IsRegistered;
20-
public static TypeBitIndex<T> Instance = new();
23+
public static readonly TypeBitIndex<T> Instance = new();
2124

2225
public void Reset()
2326
{
@@ -26,31 +29,42 @@ public void Reset()
2629
}
2730
}
2831

29-
// Static lock for thread-safe registration across all Context instances
30-
private static readonly object GlobalRegistrationLock = new();
32+
// Global lock for thread-safe registration
33+
private static readonly object GlobalLock = new();
3134

32-
public int TagCount;
33-
private List<ITypeBitIndex> _tags = new();
35+
// Global tag counter - thread-safe via lock
36+
private static int _tagCount;
3437

35-
public void Clear()
38+
// Track all registered tags for cleanup (primarily for testing scenarios)
39+
private static readonly List<ITypeBitIndex> Tags = new();
40+
41+
/// <summary>
42+
/// Clear all registered component types.
43+
/// WARNING: Only use this in testing scenarios. This will invalidate all existing Tags and Archetypes.
44+
/// </summary>
45+
public static void Clear()
3646
{
37-
// Zero-allocation iteration with direct method call
38-
for (int i = 0; i < _tags.Count; i++)
47+
lock (GlobalLock)
3948
{
40-
_tags[i].Reset();
49+
// Reset all registered types
50+
for (int i = 0; i < Tags.Count; i++)
51+
{
52+
Tags[i].Reset();
53+
}
54+
55+
Tags.Clear();
56+
_tagCount = 0;
4157
}
42-
43-
_tags.Clear();
4458
}
4559

4660
[MethodImpl(MethodImplOptions.AggressiveInlining)]
47-
public bool HasTag<T>() where T : struct
61+
public static bool HasTag<T>() where T : struct
4862
{
4963
return TypeBitIndex<T>.Instance.IsRegistered;
5064
}
5165

5266
[MethodImpl(MethodImplOptions.AggressiveInlining)]
53-
public ushort GetTagBitIndex<T>() where T : struct
67+
public static ushort GetTagBitIndex<T>() where T : struct
5468
{
5569
var instance = TypeBitIndex<T>.Instance;
5670
if (!instance.IsRegistered)
@@ -62,7 +76,7 @@ public ushort GetTagBitIndex<T>() where T : struct
6276
}
6377

6478
[MethodImpl(MethodImplOptions.AggressiveInlining)]
65-
public bool TryGetTagBitIndex<T>(out ushort bitIndex) where T : struct
79+
public static bool TryGetTagBitIndex<T>(out ushort bitIndex) where T : struct
6680
{
6781
var instance = TypeBitIndex<T>.Instance;
6882
if (!instance.IsRegistered)
@@ -78,33 +92,33 @@ public bool TryGetTagBitIndex<T>(out ushort bitIndex) where T : struct
7892
/// <summary>
7993
/// Get existing tag index or register a new one if it doesn't exist.
8094
/// Supports up to 65536 component types (ushort max).
81-
/// Thread-safe: uses static lock to protect shared TypeBitIndex across contexts.
95+
/// Thread-safe: uses global lock for registration.
8296
/// </summary>
8397
[MethodImpl(MethodImplOptions.AggressiveInlining)]
84-
public ushort GetOrRegisterTag<T>() where T : struct
98+
public static ushort GetOrRegisterTag<T>() where T : struct
8599
{
86100
var instance = TypeBitIndex<T>.Instance;
87101

88102
// Fast path: already registered (volatile read ensures visibility)
89103
if (instance.IsRegistered)
90104
return instance.BitIndex;
91105

92-
// Slow path: need to register (lock globally since TypeBitIndex is static)
93-
lock (GlobalRegistrationLock)
106+
// Slow path: need to register
107+
lock (GlobalLock)
94108
{
95109
// Double-check after acquiring lock
96110
if (instance.IsRegistered)
97111
return instance.BitIndex;
98112

99113
// Register new tag - limited to 65536 component types (ushort max)
100-
if (TagCount >= ushort.MaxValue)
114+
if (_tagCount >= ushort.MaxValue)
101115
throw new InvalidOperationException($"Maximum number of component types reached ({ushort.MaxValue})");
102116

103-
ushort bitIndex = (ushort)TagCount;
117+
ushort bitIndex = (ushort)_tagCount;
104118
instance.BitIndex = bitIndex;
105119
instance.IsRegistered = true; // Volatile write ensures visibility
106-
TagCount++;
107-
_tags.Add(instance);
120+
_tagCount++;
121+
Tags.Add(instance);
108122

109123
return bitIndex;
110124
}

EasyEcs.Core/Context.Archetypes.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System;
21
using System.Collections.Generic;
32
using System.Runtime.CompilerServices;
43
using EasyEcs.Core.Components;
@@ -30,8 +29,19 @@ internal Archetype GetOrCreateArchetype(in Tag componentMask)
3029
archetype = new Archetype(in componentMask, initialCapacity: 1024);
3130
Archetypes[componentMask] = archetype;
3231

33-
// Invalidate query cache (new archetype may match existing queries)
34-
_queryCache.Clear();
32+
// Incrementally update query cache: add new archetype to matching queries
33+
// This is smarter than clearing the entire cache
34+
foreach (var kvp in _queryCache)
35+
{
36+
var queryTag = kvp.Key;
37+
var cachedList = kvp.Value;
38+
39+
// Check if new archetype matches this cached query
40+
if ((componentMask & queryTag) == queryTag)
41+
{
42+
cachedList.Add(archetype);
43+
}
44+
}
3545
}
3646

3747
return archetype;

EasyEcs.Core/Context.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ public partial class Context : IAsyncDisposable
2424

2525
// Component storage
2626
internal Array[] Components;
27-
internal readonly TagRegistry TagRegistry = new();
2827

2928
// System storage (zero-allocation priority lists)
3029
private readonly PrioritySystemList<ExecuteSystemWrapper> _executeSystems = new();
@@ -466,7 +465,9 @@ public async ValueTask DisposeAsync()
466465
_initSystems.Clear();
467466
_endSystems.Clear();
468467

469-
TagRegistry.Clear();
468+
// Note: TagRegistry is global and shared across all contexts.
469+
// It is NOT cleared here to avoid breaking other Context instances.
470+
// Only clear TagRegistry in isolated testing scenarios via TagRegistry.Clear().
470471

471472
_activeEntityCount = 0;
472473
_disposed = true;

EasyEcs.Core/Entity.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ public void RemoveComponents<T1, T2, T3, T4, T5, T6, T7, T8, T9>()
321321
[MethodImpl(MethodImplOptions.AggressiveInlining)]
322322
public ComponentRef<T> GetComponent<T>() where T : struct, IComponent
323323
{
324-
var idx = Context.TagRegistry.GetTagBitIndex<T>();
324+
var idx = TagRegistry.GetTagBitIndex<T>();
325325
if (!Tag.HasBit(idx))
326326
throw new InvalidOperationException("Component not found");
327327

@@ -338,7 +338,7 @@ public ComponentRef<T> GetComponent<T>() where T : struct, IComponent
338338
public bool TryGetComponent<T>(out ComponentRef<T> value) where T : struct, IComponent
339339
{
340340
value = default;
341-
if (!Context.TagRegistry.TryGetTagBitIndex<T>(out var idx))
341+
if (!TagRegistry.TryGetTagBitIndex<T>(out var idx))
342342
return false;
343343
if (!Tag.HasBit(idx))
344344
return false;
@@ -355,7 +355,7 @@ public bool TryGetComponent<T>(out ComponentRef<T> value) where T : struct, ICom
355355
[MethodImpl(MethodImplOptions.AggressiveInlining)]
356356
public bool HasComponent<T>() where T : struct, IComponent
357357
{
358-
if (Context.TagRegistry.TryGetTagBitIndex<T>(out var idx))
358+
if (TagRegistry.TryGetTagBitIndex<T>(out var idx))
359359
return Tag.HasBit(idx);
360360
return false;
361361
}

EasyEcs.Core/Enumerators/GroupResultEnumerator.1.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public GroupResultEnumerator(Context context)
3333
_entityIndexInArchetype = 0;
3434
Current = default;
3535

36-
if (context.TagRegistry.TryGetTagBitIndex<T>(out var bitIdx))
36+
if (TagRegistry.TryGetTagBitIndex<T>(out var bitIdx))
3737
{
3838
// Safely access Components array (may not be initialized yet)
3939
if (context.Components != null && bitIdx < context.Components.Length)
@@ -45,7 +45,7 @@ public GroupResultEnumerator(Context context)
4545
queryTag.SetBit(bitIdx);
4646

4747
// Get matching archetypes from cache (O(1) after first access)
48-
_matchingArchetypes = context.GetMatchingArchetypes(queryTag);
48+
_matchingArchetypes = context.GetMatchingArchetypes(in queryTag);
4949
}
5050
}
5151
}

EasyEcs.Core/Enumerators/GroupResultEnumerator.2.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ public GroupResultEnumerator(Context context)
3232
_entityIndexInArchetype = 0;
3333
Current = default;
3434

35-
if (context.TagRegistry.TryGetTagBitIndex<T1>(out var bitIdx1) &&
36-
context.TagRegistry.TryGetTagBitIndex<T2>(out var bitIdx2))
35+
if (TagRegistry.TryGetTagBitIndex<T1>(out var bitIdx1) &&
36+
TagRegistry.TryGetTagBitIndex<T2>(out var bitIdx2))
3737
{
3838
if (context.Components != null &&
3939
bitIdx1 < context.Components.Length &&
@@ -46,7 +46,7 @@ public GroupResultEnumerator(Context context)
4646
queryTag.SetBit(bitIdx1);
4747
queryTag.SetBit(bitIdx2);
4848

49-
_matchingArchetypes = context.GetMatchingArchetypes(queryTag);
49+
_matchingArchetypes = context.GetMatchingArchetypes(in queryTag);
5050
}
5151
}
5252
}

EasyEcs.Core/Enumerators/GroupResultEnumerator.3.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ public GroupResultEnumerator(Context context)
3535
_entityIndexInArchetype = 0;
3636
Current = default;
3737

38-
if (context.TagRegistry.TryGetTagBitIndex<T1>(out var bitIdx1) &&
39-
context.TagRegistry.TryGetTagBitIndex<T2>(out var bitIdx2) &&
40-
context.TagRegistry.TryGetTagBitIndex<T3>(out var bitIdx3))
38+
if (TagRegistry.TryGetTagBitIndex<T1>(out var bitIdx1) &&
39+
TagRegistry.TryGetTagBitIndex<T2>(out var bitIdx2) &&
40+
TagRegistry.TryGetTagBitIndex<T3>(out var bitIdx3))
4141
{
4242
if (context.Components != null &&
4343
bitIdx1 < context.Components.Length &&
@@ -53,7 +53,7 @@ public GroupResultEnumerator(Context context)
5353
queryTag.SetBit(bitIdx2);
5454
queryTag.SetBit(bitIdx3);
5555

56-
_matchingArchetypes = context.GetMatchingArchetypes(queryTag);
56+
_matchingArchetypes = context.GetMatchingArchetypes(in queryTag);
5757
}
5858
}
5959
}

0 commit comments

Comments
 (0)