From f1eddc75ae655d3dd2494b58acf36192f5112dfd Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 18 Jul 2026 21:22:23 +0800 Subject: [PATCH] feat: add deferred structural mutations and entity iterators Implement deferred structural mutations (AddComponent, RemoveComponent, DestroyEntity) to allow safe modification of the world during iteration. Additionally, introduce a new `EntityIterator` API providing zero- allocation `ref struct` iterators (`Select`, `Select`, etc.) to support manual iteration with early-exit capabilities. As part of these changes, component access via `GetComponent` during iteration now automatically marks components as modified. --- .../TicTacToe/Commands/PlaceMarkCommand.cs | 18 +- examples/TicTacToe/Systems/RenderSystem.cs | 10 +- examples/TicTacToe/Systems/WinCheckSystem.cs | 9 +- src/OECS/EntityIterator.cs | 238 +++++++++++++++ src/OECS/World.cs | 271 ++++++++++++++---- 5 files changed, 467 insertions(+), 79 deletions(-) create mode 100644 src/OECS/EntityIterator.cs diff --git a/examples/TicTacToe/Commands/PlaceMarkCommand.cs b/examples/TicTacToe/Commands/PlaceMarkCommand.cs index 27d39e6..f2f1d4b 100644 --- a/examples/TicTacToe/Commands/PlaceMarkCommand.cs +++ b/examples/TicTacToe/Commands/PlaceMarkCommand.cs @@ -20,20 +20,20 @@ public struct PlaceMarkCommand : ICommand if (state.Status != GameStatus.Playing) return; - // Copy to locals so the lambda in ForEach can capture them - // (this is a struct, so `this` cannot be captured directly). - int row = Row; - int col = Col; - // Find the cell entity at (Row, Col) that has no Mark. + // Use the iterator API with early-exit via break. var query = world.Query().With().Without().Build(); Entity? target = null; - world.ForEach(query, (Entity entity, ref Cell cell) => + using var iter = world.Select(query); + while (iter.MoveNext()) { - if (cell.Row == row && cell.Col == col) - target = entity; - }); + if (iter.Current1.Row == Row && iter.Current1.Col == Col) + { + target = iter.CurrentEntity; + break; + } + } if (target == null) return; // Cell already occupied or invalid position. diff --git a/examples/TicTacToe/Systems/RenderSystem.cs b/examples/TicTacToe/Systems/RenderSystem.cs index df1f30e..28d9c7a 100644 --- a/examples/TicTacToe/Systems/RenderSystem.cs +++ b/examples/TicTacToe/Systems/RenderSystem.cs @@ -24,12 +24,14 @@ public class RenderSystem : ISystem for (int c = 0; c < 3; c++) grid[r, c] = '.'; - // Fill in placed marks. + // Fill in placed marks using the iterator API. var markQuery = world.Query().With().With().Build(); - world.ForEach(markQuery, (Entity entity, ref Cell cell, ref Mark mark) => + using var markIter = world.Select(markQuery); + while (markIter.MoveNext()) { - grid[cell.Row, cell.Col] = mark.Player == Player.X ? 'X' : 'O'; - }); + grid[markIter.Current1.Row, markIter.Current1.Col] = + markIter.Current2.Player == Player.X ? 'X' : 'O'; + } Console.WriteLine(); Console.WriteLine(" Tic-Tac-Toe"); diff --git a/examples/TicTacToe/Systems/WinCheckSystem.cs b/examples/TicTacToe/Systems/WinCheckSystem.cs index 0d1ce53..185d2d1 100644 --- a/examples/TicTacToe/Systems/WinCheckSystem.cs +++ b/examples/TicTacToe/Systems/WinCheckSystem.cs @@ -22,13 +22,14 @@ public class WinCheckSystem : ISystem if (state.Status != GameStatus.Playing) return; - // Build a 3×3 grid of marks. + // Build a 3×3 grid of marks using the iterator API. var grid = new Player[3, 3]; - world.ForEach(Query, (Entity entity, ref Cell cell, ref Mark mark) => + using var iter = world.Select(Query); + while (iter.MoveNext()) { - grid[cell.Row, cell.Col] = mark.Player; - }); + grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player; + } // Check rows. for (int r = 0; r < 3; r++) diff --git a/src/OECS/EntityIterator.cs b/src/OECS/EntityIterator.cs new file mode 100644 index 0000000..1f57166 --- /dev/null +++ b/src/OECS/EntityIterator.cs @@ -0,0 +1,238 @@ +using System.Runtime.CompilerServices; + +namespace OECS; + +/// +/// Provides zero-allocation ref struct iterators for querying entities. +/// Use with foreach or manual while (iter.MoveNext()). +/// Supports break, continue, and early returns. +/// +public static class EntityIterator +{ + /// + /// Returns an iterator over entities matching the query, with ref access + /// to one component type. + /// + public static Select1 Select( + this World world, QueryDescriptor query) + where T1 : struct + { + return new Select1(world, query); + } + + /// + /// Returns an iterator over entities matching the query, with ref access + /// to two component types. + /// + public static Select2 Select( + this World world, QueryDescriptor query) + where T1 : struct where T2 : struct + { + return new Select2(world, query); + } + + /// + /// Returns an iterator over entities matching the query, with ref access + /// to three component types. + /// + public static Select3 Select( + this World world, QueryDescriptor query) + where T1 : struct where T2 : struct where T3 : struct + { + return new Select3(world, query); + } +} + +/// +/// Ref struct iterator for queries with one component type. +/// +public ref struct Select1 where T1 : struct +{ + private readonly World _world; + private readonly SparseSet? _set; + private readonly ComponentStore _store; + private readonly IReadOnlySet _without; + private int _index; + private readonly int _count; + + internal Select1(World world, QueryDescriptor query) + { + _world = world; + _store = world.Components; + _without = query.Without; + _set = _store.GetSet(typeof(T1)) as SparseSet; + _count = _set?.Count ?? 0; + _index = -1; + + _world.BeginIteration(); + } + + public Entity CurrentEntity { get; private set; } + public ref T1 Current1 => ref _set!.Get(CurrentEntity); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool MoveNext() + { + if (_set == null) return false; + while (++_index < _count) + { + CurrentEntity = _set.DenseEntities[_index]; + if (CurrentEntity.Id == 1) continue; // Skip singleton. + if (!PassesWithout()) continue; + return true; + } + return false; + } + + public Select1 GetEnumerator() => this; + + public void Dispose() + { + _world.EndIteration(); + } + + private bool PassesWithout() + { + foreach (var type in _without) + { + var set = _store.GetSet(type); + if (set != null && set.Contains(CurrentEntity)) + return false; + } + return true; + } +} + +/// +/// Ref struct iterator for queries with two component types. +/// +public ref struct Select2 + where T1 : struct where T2 : struct +{ + private readonly World _world; + private readonly SparseSet? _set1; + private readonly SparseSet? _set2; + private readonly ComponentStore _store; + private readonly IReadOnlySet _without; + private int _index; + private readonly int _count; + + internal Select2(World world, QueryDescriptor query) + { + _world = world; + _store = world.Components; + _without = query.Without; + _set1 = _store.GetSet(typeof(T1)) as SparseSet; + _set2 = _store.GetSet(typeof(T2)) as SparseSet; + _count = _set1?.Count ?? 0; + _index = -1; + + _world.BeginIteration(); + } + + public Entity CurrentEntity { get; private set; } + public ref T1 Current1 => ref _set1!.Get(CurrentEntity); + public ref T2 Current2 => ref _set2!.Get(CurrentEntity); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool MoveNext() + { + if (_set1 == null || _set2 == null) return false; + while (++_index < _count) + { + CurrentEntity = _set1.DenseEntities[_index]; + if (CurrentEntity.Id == 1) continue; + if (!_set2.Contains(CurrentEntity)) continue; + if (!PassesWithout()) continue; + return true; + } + return false; + } + + public Select2 GetEnumerator() => this; + + public void Dispose() + { + _world.EndIteration(); + } + + private bool PassesWithout() + { + foreach (var type in _without) + { + var set = _store.GetSet(type); + if (set != null && set.Contains(CurrentEntity)) + return false; + } + return true; + } +} + +/// +/// Ref struct iterator for queries with three component types. +/// +public ref struct Select3 + where T1 : struct where T2 : struct where T3 : struct +{ + private readonly World _world; + private readonly SparseSet? _set1; + private readonly SparseSet? _set2; + private readonly SparseSet? _set3; + private readonly ComponentStore _store; + private readonly IReadOnlySet _without; + private int _index; + private readonly int _count; + + internal Select3(World world, QueryDescriptor query) + { + _world = world; + _store = world.Components; + _without = query.Without; + _set1 = _store.GetSet(typeof(T1)) as SparseSet; + _set2 = _store.GetSet(typeof(T2)) as SparseSet; + _set3 = _store.GetSet(typeof(T3)) as SparseSet; + _count = _set1?.Count ?? 0; + _index = -1; + + _world.BeginIteration(); + } + + public Entity CurrentEntity { get; private set; } + public ref T1 Current1 => ref _set1!.Get(CurrentEntity); + public ref T2 Current2 => ref _set2!.Get(CurrentEntity); + public ref T3 Current3 => ref _set3!.Get(CurrentEntity); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool MoveNext() + { + if (_set1 == null || _set2 == null || _set3 == null) return false; + while (++_index < _count) + { + CurrentEntity = _set1.DenseEntities[_index]; + if (CurrentEntity.Id == 1) continue; + if (!_set2.Contains(CurrentEntity)) continue; + if (!_set3.Contains(CurrentEntity)) continue; + if (!PassesWithout()) continue; + return true; + } + return false; + } + + public Select3 GetEnumerator() => this; + + public void Dispose() + { + _world.EndIteration(); + } + + private bool PassesWithout() + { + foreach (var type in _without) + { + var set = _store.GetSet(type); + if (set != null && set.Contains(CurrentEntity)) + return false; + } + return true; + } +} \ No newline at end of file diff --git a/src/OECS/World.cs b/src/OECS/World.cs index ab0432c..badbf23 100644 --- a/src/OECS/World.cs +++ b/src/OECS/World.cs @@ -24,13 +24,28 @@ public class World : IDisposable private bool _singletonCreated; private bool _disposed; -#if DEBUG - // Tracks entities whose components were accessed via GetComponent - // but never had MarkModified called. Used to warn about missing - // MarkModified calls after a system runs. + // Deferred structural mutation support: when iterating entities, + // AddComponent, RemoveComponent, and DestroyEntity are buffered and + // applied after the iteration completes. + private int _iterationDepth; + private readonly List _pendingMutations = new(); + + private enum PendingMutationKind { AddComponent, RemoveComponent, DestroyEntity } + + private struct PendingMutation + { + public PendingMutationKind Kind; + public Entity Entity; + public object? Component; // boxed struct for AddComponent + public Type ComponentType; + } + + // Auto-dirty-marking: during iteration, component accesses via + // GetComponent are tracked. When the iteration scope ends, + // all accessed components are automatically marked as modified. + // Outside of iteration, MarkModified must be called explicitly. private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = new(); private readonly HashSet<(Entity Entity, Type ComponentType)> _markedModified = new(); -#endif public World() { @@ -75,6 +90,21 @@ public class World : IDisposable /// from the source entities. /// public void DestroyEntity(Entity entity) + { + if (_iterationDepth > 0) + { + _pendingMutations.Add(new PendingMutation + { + Kind = PendingMutationKind.DestroyEntity, + Entity = entity + }); + return; + } + + DestroyEntityImpl(entity); + } + + private void DestroyEntityImpl(Entity entity) { if (!_allocator.IsAlive(entity)) return; @@ -135,6 +165,23 @@ public class World : IDisposable /// the reverse index is updated automatically. /// public void AddComponent(Entity entity, T component) where T : struct + { + if (_iterationDepth > 0) + { + _pendingMutations.Add(new PendingMutation + { + Kind = PendingMutationKind.AddComponent, + Entity = entity, + Component = component, + ComponentType = typeof(T) + }); + return; + } + + AddComponentImpl(entity, component); + } + + private void AddComponentImpl(Entity entity, T component) where T : struct { ThrowIfNotAlive(entity); @@ -170,6 +217,22 @@ public class World : IDisposable /// the reverse index is updated automatically. /// public void RemoveComponent(Entity entity) where T : struct + { + if (_iterationDepth > 0) + { + _pendingMutations.Add(new PendingMutation + { + Kind = PendingMutationKind.RemoveComponent, + Entity = entity, + ComponentType = typeof(T) + }); + return; + } + + RemoveComponentImpl(entity); + } + + private void RemoveComponentImpl(Entity entity) where T : struct { // Update relationship index before removal. if (typeof(IRelationship).IsAssignableFrom(typeof(T))) @@ -192,15 +255,15 @@ public class World : IDisposable /// Returns a reference to the component of type /// for the given entity. Throws if the entity does not have the component. /// - /// After mutating the component through this reference, you must call - /// so that observers are notified. - /// In DEBUG builds, a warning is logged if you forget. + /// During iteration (inside or a Select loop), + /// components accessed through this method are automatically marked as + /// modified when the iteration scope ends. Outside of iteration, + /// must be called explicitly. /// public ref T GetComponent(Entity entity) where T : struct { -#if DEBUG - _accessedComponents.Add((entity, typeof(T))); -#endif + if (_iterationDepth > 0) + _accessedComponents.Add((entity, typeof(T))); return ref _components.Get(entity); } @@ -226,16 +289,14 @@ public class World : IDisposable /// /// Marks a component of type on the given entity - /// as modified. Must be called after mutating a component via - /// so that observers are notified. + /// as modified. Only needed outside of iteration scopes — during + /// or a Select loop, components accessed + /// via are auto-marked. /// public void MarkModified(Entity entity) where T : struct { _changes.Pending.MarkComponentModified(entity, typeof(T)); - -#if DEBUG _markedModified.Add((entity, typeof(T))); -#endif } /// @@ -245,9 +306,6 @@ public class World : IDisposable /// public void PostChanges() { -#if DEBUG - WarnUnmarkedModifications(); -#endif _changes.Post(); } @@ -371,14 +429,9 @@ public class World : IDisposable /// /// Iterates all entities matching the query, providing ref access to - /// one component type. - /// - /// Caution: Adding or removing components (including destroying - /// entities) during iteration may cause entities to be skipped or - /// processed twice. This is a consequence of the sparse set's - /// swap-remove strategy. If you need to make structural changes, - /// collect the affected entities first, then apply changes after - /// the iteration. + /// one component type. Structural mutations (AddComponent, RemoveComponent, + /// DestroyEntity) made during iteration are deferred and applied after + /// the iteration completes. /// public void ForEach( QueryDescriptor query, @@ -386,12 +439,20 @@ public class World : IDisposable where T1 : struct { ValidateQueryTypes(query, typeof(T1)); - QueryExecutor.ForEach(_components, query, action); + BeginIteration(); + try + { + QueryExecutor.ForEach(_components, query, action); + } + finally + { + EndIteration(); + } } /// /// Iterates all entities matching the query, providing ref access to - /// two component types. + /// two component types. Structural mutations are deferred. /// public void ForEach( QueryDescriptor query, @@ -399,12 +460,20 @@ public class World : IDisposable where T1 : struct where T2 : struct { ValidateQueryTypes(query, typeof(T1), typeof(T2)); - QueryExecutor.ForEach(_components, query, action); + BeginIteration(); + try + { + QueryExecutor.ForEach(_components, query, action); + } + finally + { + EndIteration(); + } } /// /// Iterates all entities matching the query, providing ref access to - /// three component types. + /// three component types. Structural mutations are deferred. /// public void ForEach( QueryDescriptor query, @@ -412,12 +481,20 @@ public class World : IDisposable where T1 : struct where T2 : struct where T3 : struct { ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3)); - QueryExecutor.ForEach(_components, query, action); + BeginIteration(); + try + { + QueryExecutor.ForEach(_components, query, action); + } + finally + { + EndIteration(); + } } /// /// Iterates all entities matching the query, providing ref access to - /// four component types. + /// four component types. Structural mutations are deferred. /// public void ForEach( QueryDescriptor query, @@ -425,12 +502,20 @@ public class World : IDisposable where T1 : struct where T2 : struct where T3 : struct where T4 : struct { ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4)); - QueryExecutor.ForEach(_components, query, action); + BeginIteration(); + try + { + QueryExecutor.ForEach(_components, query, action); + } + finally + { + EndIteration(); + } } /// /// Iterates all entities matching the query, providing ref access to - /// five component types. + /// five component types. Structural mutations are deferred. /// public void ForEach( QueryDescriptor query, @@ -438,12 +523,20 @@ public class World : IDisposable where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct { ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5)); - QueryExecutor.ForEach(_components, query, action); + BeginIteration(); + try + { + QueryExecutor.ForEach(_components, query, action); + } + finally + { + EndIteration(); + } } /// /// Iterates all entities matching the query, providing ref access to - /// six component types. + /// six component types. Structural mutations are deferred. /// public void ForEach( QueryDescriptor query, @@ -451,7 +544,85 @@ public class World : IDisposable where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct { ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5), typeof(T6)); - QueryExecutor.ForEach(_components, query, action); + BeginIteration(); + try + { + QueryExecutor.ForEach(_components, query, action); + } + finally + { + EndIteration(); + } + } + + /// + /// Begins an iteration scope. Structural mutations are buffered + /// until is called. + /// + internal void BeginIteration() + { + _iterationDepth++; + } + + /// + /// Ends an iteration scope. When the outermost scope ends, all + /// buffered structural mutations are applied and auto-tracked + /// component modifications are posted. + /// + internal void EndIteration() + { + _iterationDepth--; + if (_iterationDepth == 0) + { + // Auto-mark all components accessed via GetComponent + // during the iteration as modified. + foreach (var (entity, componentType) in _accessedComponents) + { + if (!_markedModified.Contains((entity, componentType))) + { + _changes.Pending.MarkComponentModified(entity, componentType); + } + } + _accessedComponents.Clear(); + _markedModified.Clear(); + + FlushPendingMutations(); + } + } + + private void FlushPendingMutations() + { + if (_pendingMutations.Count == 0) + return; + + foreach (var m in _pendingMutations) + { + switch (m.Kind) + { + case PendingMutationKind.AddComponent: + // Use reflection to call AddComponentImpl. + var addMethod = typeof(World).GetMethod( + nameof(AddComponentImpl), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var genericAdd = addMethod.MakeGenericMethod(m.ComponentType); + genericAdd.Invoke(this, [m.Entity, m.Component]); + break; + + case PendingMutationKind.RemoveComponent: + var removeMethod = typeof(World).GetMethod( + nameof(RemoveComponentImpl), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var genericRemove = removeMethod.MakeGenericMethod(m.ComponentType); + genericRemove.Invoke(this, [m.Entity]); + break; + + case PendingMutationKind.DestroyEntity: + DestroyEntityImpl(m.Entity); + break; + } + } + + _pendingMutations.Clear(); } // ── Internal Access ─────────────────────────────────────────────── @@ -512,28 +683,4 @@ public class World : IDisposable _disposed = true; _changes.Dispose(); } - -#if DEBUG - /// - /// Logs warnings for any components that were accessed via - /// but never had - /// called. - /// - private void WarnUnmarkedModifications() - { - foreach (var (entity, componentType) in _accessedComponents) - { - if (!_markedModified.Contains((entity, componentType))) - { - Debug.WriteLine( - $"[OECS] Warning: Component '{componentType.Name}' on {entity} " + - $"was accessed via GetComponent but MarkModified was never called. " + - $"Observers will not be notified of the change."); - } - } - - _accessedComponents.Clear(); - _markedModified.Clear(); - } -#endif }