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<T1>`, `Select<T1, T2>`, etc.)
to support manual iteration with early-exit capabilities.

As part of these changes, component access via `GetComponent<T>` during
iteration now automatically marks components as modified.
This commit is contained in:
hypercross 2026-07-18 21:22:23 +08:00
parent 9aa2a71257
commit f1eddc75ae
5 changed files with 467 additions and 79 deletions

View File

@ -20,20 +20,20 @@ public struct PlaceMarkCommand : ICommand
if (state.Status != GameStatus.Playing) if (state.Status != GameStatus.Playing)
return; 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. // 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<Cell>().Without<Mark>().Build(); var query = world.Query().With<Cell>().Without<Mark>().Build();
Entity? target = null; Entity? target = null;
world.ForEach(query, (Entity entity, ref Cell cell) => using var iter = world.Select<Cell>(query);
while (iter.MoveNext())
{ {
if (cell.Row == row && cell.Col == col) if (iter.Current1.Row == Row && iter.Current1.Col == Col)
target = entity; {
}); target = iter.CurrentEntity;
break;
}
}
if (target == null) if (target == null)
return; // Cell already occupied or invalid position. return; // Cell already occupied or invalid position.

View File

@ -24,12 +24,14 @@ public class RenderSystem : ISystem
for (int c = 0; c < 3; c++) for (int c = 0; c < 3; c++)
grid[r, c] = '.'; grid[r, c] = '.';
// Fill in placed marks. // Fill in placed marks using the iterator API.
var markQuery = world.Query().With<Cell>().With<Mark>().Build(); var markQuery = world.Query().With<Cell>().With<Mark>().Build();
world.ForEach(markQuery, (Entity entity, ref Cell cell, ref Mark mark) => using var markIter = world.Select<Cell, Mark>(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();
Console.WriteLine(" Tic-Tac-Toe"); Console.WriteLine(" Tic-Tac-Toe");

View File

@ -22,13 +22,14 @@ public class WinCheckSystem : ISystem
if (state.Status != GameStatus.Playing) if (state.Status != GameStatus.Playing)
return; 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]; var grid = new Player[3, 3];
world.ForEach(Query, (Entity entity, ref Cell cell, ref Mark mark) => using var iter = world.Select<Cell, Mark>(Query);
while (iter.MoveNext())
{ {
grid[cell.Row, cell.Col] = mark.Player; grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;
}); }
// Check rows. // Check rows.
for (int r = 0; r < 3; r++) for (int r = 0; r < 3; r++)

238
src/OECS/EntityIterator.cs Normal file
View File

@ -0,0 +1,238 @@
using System.Runtime.CompilerServices;
namespace OECS;
/// <summary>
/// Provides zero-allocation ref struct iterators for querying entities.
/// Use with <c>foreach</c> or manual <c>while (iter.MoveNext())</c>.
/// Supports <c>break</c>, <c>continue</c>, and early returns.
/// </summary>
public static class EntityIterator
{
/// <summary>
/// Returns an iterator over entities matching the query, with ref access
/// to one component type.
/// </summary>
public static Select1<T1> Select<T1>(
this World world, QueryDescriptor query)
where T1 : struct
{
return new Select1<T1>(world, query);
}
/// <summary>
/// Returns an iterator over entities matching the query, with ref access
/// to two component types.
/// </summary>
public static Select2<T1, T2> Select<T1, T2>(
this World world, QueryDescriptor query)
where T1 : struct where T2 : struct
{
return new Select2<T1, T2>(world, query);
}
/// <summary>
/// Returns an iterator over entities matching the query, with ref access
/// to three component types.
/// </summary>
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world, QueryDescriptor query)
where T1 : struct where T2 : struct where T3 : struct
{
return new Select3<T1, T2, T3>(world, query);
}
}
/// <summary>
/// Ref struct iterator for queries with one component type.
/// </summary>
public ref struct Select1<T1> where T1 : struct
{
private readonly World _world;
private readonly SparseSet<T1>? _set;
private readonly ComponentStore _store;
private readonly IReadOnlySet<Type> _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<T1>;
_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<T1> 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;
}
}
/// <summary>
/// Ref struct iterator for queries with two component types.
/// </summary>
public ref struct Select2<T1, T2>
where T1 : struct where T2 : struct
{
private readonly World _world;
private readonly SparseSet<T1>? _set1;
private readonly SparseSet<T2>? _set2;
private readonly ComponentStore _store;
private readonly IReadOnlySet<Type> _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<T1>;
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
_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<T1, T2> 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;
}
}
/// <summary>
/// Ref struct iterator for queries with three component types.
/// </summary>
public ref struct Select3<T1, T2, T3>
where T1 : struct where T2 : struct where T3 : struct
{
private readonly World _world;
private readonly SparseSet<T1>? _set1;
private readonly SparseSet<T2>? _set2;
private readonly SparseSet<T3>? _set3;
private readonly ComponentStore _store;
private readonly IReadOnlySet<Type> _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<T1>;
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
_set3 = _store.GetSet(typeof(T3)) as SparseSet<T3>;
_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<T1, T2, T3> 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;
}
}

View File

@ -24,13 +24,28 @@ public class World : IDisposable
private bool _singletonCreated; private bool _singletonCreated;
private bool _disposed; private bool _disposed;
#if DEBUG // Deferred structural mutation support: when iterating entities,
// Tracks entities whose components were accessed via GetComponent<T> // AddComponent, RemoveComponent, and DestroyEntity are buffered and
// but never had MarkModified<T> called. Used to warn about missing // applied after the iteration completes.
// MarkModified calls after a system runs. private int _iterationDepth;
private readonly List<PendingMutation> _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<T> are tracked. When the iteration scope ends,
// all accessed components are automatically marked as modified.
// Outside of iteration, MarkModified<T> must be called explicitly.
private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = new(); private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = new();
private readonly HashSet<(Entity Entity, Type ComponentType)> _markedModified = new(); private readonly HashSet<(Entity Entity, Type ComponentType)> _markedModified = new();
#endif
public World() public World()
{ {
@ -75,6 +90,21 @@ public class World : IDisposable
/// from the source entities. /// from the source entities.
/// </summary> /// </summary>
public void DestroyEntity(Entity entity) 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)) if (!_allocator.IsAlive(entity))
return; return;
@ -135,6 +165,23 @@ public class World : IDisposable
/// the reverse index is updated automatically. /// the reverse index is updated automatically.
/// </summary> /// </summary>
public void AddComponent<T>(Entity entity, T component) where T : struct public void AddComponent<T>(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<T>(Entity entity, T component) where T : struct
{ {
ThrowIfNotAlive(entity); ThrowIfNotAlive(entity);
@ -170,6 +217,22 @@ public class World : IDisposable
/// the reverse index is updated automatically. /// the reverse index is updated automatically.
/// </summary> /// </summary>
public void RemoveComponent<T>(Entity entity) where T : struct public void RemoveComponent<T>(Entity entity) where T : struct
{
if (_iterationDepth > 0)
{
_pendingMutations.Add(new PendingMutation
{
Kind = PendingMutationKind.RemoveComponent,
Entity = entity,
ComponentType = typeof(T)
});
return;
}
RemoveComponentImpl<T>(entity);
}
private void RemoveComponentImpl<T>(Entity entity) where T : struct
{ {
// Update relationship index before removal. // Update relationship index before removal.
if (typeof(IRelationship).IsAssignableFrom(typeof(T))) if (typeof(IRelationship).IsAssignableFrom(typeof(T)))
@ -192,15 +255,15 @@ public class World : IDisposable
/// Returns a reference to the component of type <typeparamref name="T"/> /// Returns a reference to the component of type <typeparamref name="T"/>
/// for the given entity. Throws if the entity does not have the component. /// for the given entity. Throws if the entity does not have the component.
/// ///
/// After mutating the component through this reference, you must call /// During iteration (inside <see cref="ForEach"/> or a <c>Select</c> loop),
/// <see cref="MarkModified{T}"/> so that observers are notified. /// components accessed through this method are automatically marked as
/// In DEBUG builds, a warning is logged if you forget. /// modified when the iteration scope ends. Outside of iteration,
/// <see cref="MarkModified{T}"/> must be called explicitly.
/// </summary> /// </summary>
public ref T GetComponent<T>(Entity entity) where T : struct public ref T GetComponent<T>(Entity entity) where T : struct
{ {
#if DEBUG if (_iterationDepth > 0)
_accessedComponents.Add((entity, typeof(T))); _accessedComponents.Add((entity, typeof(T)));
#endif
return ref _components.Get<T>(entity); return ref _components.Get<T>(entity);
} }
@ -226,16 +289,14 @@ public class World : IDisposable
/// <summary> /// <summary>
/// Marks a component of type <typeparamref name="T"/> on the given entity /// Marks a component of type <typeparamref name="T"/> on the given entity
/// as modified. Must be called after mutating a component via /// as modified. Only needed outside of iteration scopes — during
/// <see cref="GetComponent{T}"/> so that observers are notified. /// <see cref="ForEach"/> or a <c>Select</c> loop, components accessed
/// via <see cref="GetComponent{T}"/> are auto-marked.
/// </summary> /// </summary>
public void MarkModified<T>(Entity entity) where T : struct public void MarkModified<T>(Entity entity) where T : struct
{ {
_changes.Pending.MarkComponentModified(entity, typeof(T)); _changes.Pending.MarkComponentModified(entity, typeof(T));
#if DEBUG
_markedModified.Add((entity, typeof(T))); _markedModified.Add((entity, typeof(T)));
#endif
} }
/// <summary> /// <summary>
@ -245,9 +306,6 @@ public class World : IDisposable
/// </summary> /// </summary>
public void PostChanges() public void PostChanges()
{ {
#if DEBUG
WarnUnmarkedModifications();
#endif
_changes.Post(); _changes.Post();
} }
@ -371,14 +429,9 @@ public class World : IDisposable
/// <summary> /// <summary>
/// Iterates all entities matching the query, providing ref access to /// Iterates all entities matching the query, providing ref access to
/// one component type. /// one component type. Structural mutations (AddComponent, RemoveComponent,
/// /// DestroyEntity) made during iteration are deferred and applied after
/// <b>Caution:</b> Adding or removing components (including destroying /// the iteration completes.
/// 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.
/// </summary> /// </summary>
public void ForEach<T1>( public void ForEach<T1>(
QueryDescriptor query, QueryDescriptor query,
@ -386,12 +439,20 @@ public class World : IDisposable
where T1 : struct where T1 : struct
{ {
ValidateQueryTypes(query, typeof(T1)); ValidateQueryTypes(query, typeof(T1));
QueryExecutor.ForEach(_components, query, action); BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
} }
/// <summary> /// <summary>
/// Iterates all entities matching the query, providing ref access to /// Iterates all entities matching the query, providing ref access to
/// two component types. /// two component types. Structural mutations are deferred.
/// </summary> /// </summary>
public void ForEach<T1, T2>( public void ForEach<T1, T2>(
QueryDescriptor query, QueryDescriptor query,
@ -399,12 +460,20 @@ public class World : IDisposable
where T1 : struct where T2 : struct where T1 : struct where T2 : struct
{ {
ValidateQueryTypes(query, typeof(T1), typeof(T2)); ValidateQueryTypes(query, typeof(T1), typeof(T2));
QueryExecutor.ForEach(_components, query, action); BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
} }
/// <summary> /// <summary>
/// Iterates all entities matching the query, providing ref access to /// Iterates all entities matching the query, providing ref access to
/// three component types. /// three component types. Structural mutations are deferred.
/// </summary> /// </summary>
public void ForEach<T1, T2, T3>( public void ForEach<T1, T2, T3>(
QueryDescriptor query, QueryDescriptor query,
@ -412,12 +481,20 @@ public class World : IDisposable
where T1 : struct where T2 : struct where T3 : struct where T1 : struct where T2 : struct where T3 : struct
{ {
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3)); ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3));
QueryExecutor.ForEach(_components, query, action); BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
} }
/// <summary> /// <summary>
/// Iterates all entities matching the query, providing ref access to /// Iterates all entities matching the query, providing ref access to
/// four component types. /// four component types. Structural mutations are deferred.
/// </summary> /// </summary>
public void ForEach<T1, T2, T3, T4>( public void ForEach<T1, T2, T3, T4>(
QueryDescriptor query, QueryDescriptor query,
@ -425,12 +502,20 @@ public class World : IDisposable
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T1 : struct where T2 : struct where T3 : struct where T4 : struct
{ {
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4)); ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4));
QueryExecutor.ForEach(_components, query, action); BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
} }
/// <summary> /// <summary>
/// Iterates all entities matching the query, providing ref access to /// Iterates all entities matching the query, providing ref access to
/// five component types. /// five component types. Structural mutations are deferred.
/// </summary> /// </summary>
public void ForEach<T1, T2, T3, T4, T5>( public void ForEach<T1, T2, T3, T4, T5>(
QueryDescriptor query, 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 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)); 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();
}
} }
/// <summary> /// <summary>
/// Iterates all entities matching the query, providing ref access to /// Iterates all entities matching the query, providing ref access to
/// six component types. /// six component types. Structural mutations are deferred.
/// </summary> /// </summary>
public void ForEach<T1, T2, T3, T4, T5, T6>( public void ForEach<T1, T2, T3, T4, T5, T6>(
QueryDescriptor query, 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 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)); 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();
}
}
/// <summary>
/// Begins an iteration scope. Structural mutations are buffered
/// until <see cref="EndIteration"/> is called.
/// </summary>
internal void BeginIteration()
{
_iterationDepth++;
}
/// <summary>
/// Ends an iteration scope. When the outermost scope ends, all
/// buffered structural mutations are applied and auto-tracked
/// component modifications are posted.
/// </summary>
internal void EndIteration()
{
_iterationDepth--;
if (_iterationDepth == 0)
{
// Auto-mark all components accessed via GetComponent<T>
// 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<T>.
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 ─────────────────────────────────────────────── // ── Internal Access ───────────────────────────────────────────────
@ -512,28 +683,4 @@ public class World : IDisposable
_disposed = true; _disposed = true;
_changes.Dispose(); _changes.Dispose();
} }
#if DEBUG
/// <summary>
/// Logs warnings for any components that were accessed via
/// <see cref="GetComponent{T}"/> but never had
/// <see cref="MarkModified{T}"/> called.
/// </summary>
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
} }