refactor: optimize query execution and improve change tracking

- Optimize `QueryExecutor` to use the smallest sparse set as a driver
  for multi-component queries, reducing iteration overhead.
- Implement automatic cleanup of unused `Subject` instances in
  `ChangeBuffer` using a subscriber count.
- Improve `ChangeSet` deduplication to correctly handle rapid
  add/remove transitions for the same component.
- Add validation to ensure `IRelationship` components are added to
  their declared `Source` entity.
- Update documentation to reflect correct `Entity` bit layout.
This commit is contained in:
hypercross 2026-07-18 21:42:08 +08:00
parent 339e850e10
commit 32f4aa3e35
5 changed files with 450 additions and 71 deletions

View File

@ -45,8 +45,8 @@ signature) and sparse sets (one dense array per component type).
**Context:** Entities need to be cheap to copy, comparable, and safe against **Context:** Entities need to be cheap to copy, comparable, and safe against
use-after-free (accessing a recycled entity ID). use-after-free (accessing a recycled entity ID).
**Decision:** `readonly struct Entity` wrapping a `uint`. Upper 24 bits are the **Decision:** `readonly struct Entity` wrapping a `uint`. Lower 24 bits are the
ID, lower 8 bits are the version. ID, upper 8 bits are the version.
**Rationale:** **Rationale:**

View File

@ -14,8 +14,18 @@ internal class ChangeBuffer
private readonly ChangeSet _pending = new(); private readonly ChangeSet _pending = new();
private readonly Subject<EntityChange> _entitySubject = new(); private readonly Subject<EntityChange> _entitySubject = new();
private readonly Dictionary<Type, Subject<EntityChange>> _componentSubjects = new(); private readonly Dictionary<Type, TrackedSubject> _componentSubjects = new();
private readonly Dictionary<QueryDescriptor, Subject<EntityChange>> _querySubjects = new(); private readonly Dictionary<QueryDescriptor, TrackedSubject> _querySubjects = new();
/// <summary>
/// Wraps a <see cref="Subject{T}"/> with a subscriber count so that
/// subjects with no remaining subscribers can be cleaned up.
/// </summary>
private sealed class TrackedSubject
{
public readonly Subject<EntityChange> Subject = new();
public int SubscriberCount;
}
/// <summary> /// <summary>
/// The change set currently accumulating. Cleared after each <see cref="Post"/>. /// The change set currently accumulating. Cleared after each <see cref="Post"/>.
@ -40,18 +50,18 @@ internal class ChangeBuffer
// Push to component-specific subjects. // Push to component-specific subjects.
if (change.ComponentType != null) if (change.ComponentType != null)
{ {
if (_componentSubjects.TryGetValue(change.ComponentType, out var compSubject)) if (_componentSubjects.TryGetValue(change.ComponentType, out var compTracked))
{ {
compSubject.OnNext(change); compTracked.Subject.OnNext(change);
} }
} }
// Push to matching query subjects. // Push to matching query subjects.
foreach (var (query, subject) in _querySubjects) foreach (var (query, tracked) in _querySubjects)
{ {
if (ChangeMatchesQuery(change, query)) if (ChangeMatchesQuery(change, query))
{ {
subject.OnNext(change); tracked.Subject.OnNext(change);
} }
} }
} }
@ -72,12 +82,22 @@ internal class ChangeBuffer
/// </summary> /// </summary>
public Observable<EntityChange> ObserveComponentChanges(Type componentType) public Observable<EntityChange> ObserveComponentChanges(Type componentType)
{ {
if (!_componentSubjects.TryGetValue(componentType, out var subject)) if (!_componentSubjects.TryGetValue(componentType, out var tracked))
{ {
subject = new Subject<EntityChange>(); tracked = new TrackedSubject();
_componentSubjects[componentType] = subject; _componentSubjects[componentType] = tracked;
} }
return subject;
tracked.SubscriberCount++;
return WrapWithCleanup(tracked.Subject, () =>
{
tracked.SubscriberCount--;
if (tracked.SubscriberCount <= 0)
{
tracked.Subject.Dispose();
_componentSubjects.Remove(componentType);
}
});
} }
/// <summary> /// <summary>
@ -85,12 +105,40 @@ internal class ChangeBuffer
/// </summary> /// </summary>
public Observable<EntityChange> ObserveQuery(QueryDescriptor query) public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
{ {
if (!_querySubjects.TryGetValue(query, out var subject)) if (!_querySubjects.TryGetValue(query, out var tracked))
{ {
subject = new Subject<EntityChange>(); tracked = new TrackedSubject();
_querySubjects[query] = subject; _querySubjects[query] = tracked;
} }
return subject;
tracked.SubscriberCount++;
return WrapWithCleanup(tracked.Subject, () =>
{
tracked.SubscriberCount--;
if (tracked.SubscriberCount <= 0)
{
tracked.Subject.Dispose();
_querySubjects.Remove(query);
}
});
}
/// <summary>
/// Wraps an observable so that <paramref name="onLastDispose"/> is called
/// when the last subscriber disposes.
/// </summary>
private static Observable<EntityChange> WrapWithCleanup(
Observable<EntityChange> source, Action onLastDispose)
{
return Observable.Create<EntityChange>(observer =>
{
var subscription = source.Subscribe(observer);
return Disposable.Create(() =>
{
subscription.Dispose();
onLastDispose();
});
});
} }
/// <summary> /// <summary>
@ -99,14 +147,14 @@ internal class ChangeBuffer
public void Dispose() public void Dispose()
{ {
_entitySubject.Dispose(); _entitySubject.Dispose();
foreach (var subject in _componentSubjects.Values) foreach (var tracked in _componentSubjects.Values)
{ {
subject.Dispose(); tracked.Subject.Dispose();
} }
_componentSubjects.Clear(); _componentSubjects.Clear();
foreach (var subject in _querySubjects.Values) foreach (var tracked in _querySubjects.Values)
{ {
subject.Dispose(); tracked.Subject.Dispose();
} }
_querySubjects.Clear(); _querySubjects.Clear();
} }

View File

@ -4,13 +4,14 @@ namespace OECS;
/// Accumulates <see cref="EntityChange"/> entries during a system run. /// Accumulates <see cref="EntityChange"/> entries during a system run.
/// ///
/// Deduplication: if the same (entity, kind, componentType) change is marked /// Deduplication: if the same (entity, kind, componentType) change is marked
/// multiple times, only one entry is kept. Entity-level changes (Added/Removed) /// multiple times, only one entry is kept. However, if a structurally opposed
/// take precedence over component-level changes for the same entity. /// change arrives (Added vs Removed) for the same (entity, componentType),
/// the old entry is removed so observers see the full state transition.
/// </summary> /// </summary>
internal class ChangeSet internal class ChangeSet
{ {
private readonly List<EntityChange> _changes = new(); private readonly List<EntityChange> _changes = new();
private readonly HashSet<(Entity Entity, ChangeKind Kind, Type? ComponentType)> _dedup = new(); private readonly Dictionary<(Entity Entity, ChangeKind Kind, Type? ComponentType), int> _dedup = new();
/// <summary> /// <summary>
/// All accumulated changes in insertion order. /// All accumulated changes in insertion order.
@ -75,9 +76,31 @@ internal class ChangeSet
private void TryAdd(EntityChange change) private void TryAdd(EntityChange change)
{ {
var key = (change.Entity, change.Kind, change.ComponentType); var key = (change.Entity, change.Kind, change.ComponentType);
if (_dedup.Add(key)) if (_dedup.ContainsKey(key))
{ {
_changes.Add(change); // Same (entity, kind, componentType) already recorded — deduplicate.
return;
} }
// When a component is re-added after being removed within the same
// batch, remove the old ComponentRemoved entry so observers see the
// full Add → Remove → Add sequence.
if (change.Kind == ChangeKind.ComponentAdded && change.ComponentType != null)
{
var removedKey = (change.Entity, ChangeKind.ComponentRemoved, change.ComponentType);
if (_dedup.Remove(removedKey, out int oldIndex))
{
_changes.RemoveAt(oldIndex);
// Adjust indices for all entries that shifted down.
foreach (var k in _dedup.Keys.ToList())
{
if (_dedup[k] > oldIndex)
_dedup[k]--;
}
}
}
_dedup[key] = _changes.Count;
_changes.Add(change);
} }
} }

View File

@ -129,19 +129,95 @@ internal static class QueryExecutor
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>; var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
if (set3 == null) return; if (set3 == null) return;
var dense = set1.Dense; // Pick the smallest set as the driver.
var denseEntities = set1.DenseEntities; int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count;
var count = set1.Count;
if (c2 <= c1 && c2 <= c3)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity));
}
}
else if (c3 <= c1 && c3 <= c2)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity));
}
}
}
private static void IterateThree<TDriver, TA, TB>(
SparseSet<TDriver> driveSet,
SparseSet<TA> setA,
SparseSet<TB> setB,
ComponentStore store,
IReadOnlySet<Type> withoutTypes,
ForEachAction<TA, TDriver, TB> actionWhenDriverIsT2,
ForEachAction<TDriver, TA, TB> actionWhenDriverIsT1,
ForEachAction<TA, TB, TDriver> actionWhenDriverIsT3)
where TDriver : struct where TA : struct where TB : struct
{
var dense = driveSet.Dense;
var denseEntities = driveSet.DenseEntities;
var count = driveSet.Count;
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
var entity = denseEntities[i]; var entity = denseEntities[i];
if (entity.Id == SingletonId) continue; if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue; if (!setA.Contains(entity)) continue;
if (!set3.Contains(entity)) continue; if (!setB.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) if (!PassesWithoutFilter(store, entity, withoutTypes))
continue; continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity));
if (actionWhenDriverIsT2 != null!)
{
// Driver is T2, so order is: T1=setA, T2=driveSet, T3=setB
actionWhenDriverIsT2(entity, ref setA.Get(entity), ref dense[i], ref setB.Get(entity));
}
else if (actionWhenDriverIsT3 != null!)
{
// Driver is T3, so order is: T1=setA, T2=setB, T3=driveSet
actionWhenDriverIsT3(entity, ref setA.Get(entity), ref setB.Get(entity), ref dense[i]);
}
else
{
// Driver is T1, so order is: T1=driveSet, T2=setA, T3=setB
actionWhenDriverIsT1(entity, ref dense[i], ref setA.Get(entity), ref setB.Get(entity));
}
} }
} }
@ -160,20 +236,73 @@ internal static class QueryExecutor
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>; var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
if (set4 == null) return; if (set4 == null) return;
var dense = set1.Dense; // Pick the smallest set as the driver.
var denseEntities = set1.DenseEntities; int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count;
var count = set1.Count; int min = Math.Min(Math.Min(c1, c2), Math.Min(c3, c4));
for (int i = 0; i < count; i++) if (min == c2)
{ {
var entity = denseEntities[i]; var dense = set2.Dense;
if (entity.Id == SingletonId) continue; var denseEntities = set2.DenseEntities;
if (!set2.Contains(entity)) continue; var count = set2.Count;
if (!set3.Contains(entity)) continue; for (int i = 0; i < count; i++)
if (!set4.Contains(entity)) continue; {
if (!PassesWithoutFilter(store, entity, query.Without)) var entity = denseEntities[i];
continue; if (entity.Id == SingletonId) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity)); if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity));
}
}
else if (min == c3)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity));
}
}
else if (min == c4)
{
var dense = set4.Dense;
var denseEntities = set4.DenseEntities;
var count = set4.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity));
}
} }
} }
@ -194,21 +323,94 @@ internal static class QueryExecutor
var set5 = store.GetSet(typeof(T5)) as SparseSet<T5>; var set5 = store.GetSet(typeof(T5)) as SparseSet<T5>;
if (set5 == null) return; if (set5 == null) return;
var dense = set1.Dense; // Pick the smallest set as the driver.
var denseEntities = set1.DenseEntities; int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count, c5 = set5.Count;
var count = set1.Count; int min = Math.Min(Math.Min(Math.Min(c1, c2), Math.Min(c3, c4)), c5);
for (int i = 0; i < count; i++) if (min == c2)
{ {
var entity = denseEntities[i]; var dense = set2.Dense;
if (entity.Id == SingletonId) continue; var denseEntities = set2.DenseEntities;
if (!set2.Contains(entity)) continue; var count = set2.Count;
if (!set3.Contains(entity)) continue; for (int i = 0; i < count; i++)
if (!set4.Contains(entity)) continue; {
if (!set5.Contains(entity)) continue; var entity = denseEntities[i];
if (!PassesWithoutFilter(store, entity, query.Without)) if (entity.Id == SingletonId) continue;
continue; if (!set1.Contains(entity)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity)); if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
}
}
else if (min == c3)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity), ref set5.Get(entity));
}
}
else if (min == c4)
{
var dense = set4.Dense;
var denseEntities = set4.DenseEntities;
var count = set4.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i], ref set5.Get(entity));
}
}
else if (min == c5)
{
var dense = set5.Dense;
var denseEntities = set5.DenseEntities;
var count = set5.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
}
} }
} }
@ -231,22 +433,117 @@ internal static class QueryExecutor
var set6 = store.GetSet(typeof(T6)) as SparseSet<T6>; var set6 = store.GetSet(typeof(T6)) as SparseSet<T6>;
if (set6 == null) return; if (set6 == null) return;
var dense = set1.Dense; // Pick the smallest set as the driver.
var denseEntities = set1.DenseEntities; int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count, c5 = set5.Count, c6 = set6.Count;
var count = set1.Count; int min = Math.Min(Math.Min(Math.Min(c1, c2), Math.Min(c3, c4)), Math.Min(c5, c6));
for (int i = 0; i < count; i++) if (min == c2)
{ {
var entity = denseEntities[i]; var dense = set2.Dense;
if (entity.Id == SingletonId) continue; var denseEntities = set2.DenseEntities;
if (!set2.Contains(entity)) continue; var count = set2.Count;
if (!set3.Contains(entity)) continue; for (int i = 0; i < count; i++)
if (!set4.Contains(entity)) continue; {
if (!set5.Contains(entity)) continue; var entity = denseEntities[i];
if (!set6.Contains(entity)) continue; if (entity.Id == SingletonId) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) if (!set1.Contains(entity)) continue;
continue; if (!set3.Contains(entity)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity)); if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
}
else if (min == c3)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
}
else if (min == c4)
{
var dense = set4.Dense;
var denseEntities = set4.DenseEntities;
var count = set4.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i], ref set5.Get(entity), ref set6.Get(entity));
}
}
else if (min == c5)
{
var dense = set5.Dense;
var denseEntities = set5.DenseEntities;
var count = set5.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref dense[i], ref set6.Get(entity));
}
}
else if (min == c6)
{
var dense = set6.Dense;
var denseEntities = set6.DenseEntities;
var count = set6.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
} }
} }
} }

View File

@ -188,6 +188,17 @@ public class World : IDisposable
// If replacing an existing relationship, remove the old index entry first. // If replacing an existing relationship, remove the old index entry first.
if (component is IRelationship newRel) if (component is IRelationship newRel)
{ {
// Guard against mismatched Source: the relationship must be stored
// on the entity it claims as its Source, otherwise the reverse
// index becomes corrupted.
if (newRel.Source != entity)
{
throw new InvalidOperationException(
$"Relationship of type {typeof(T).Name} has Source={newRel.Source} " +
$"but is being added to entity {entity}. " +
$"The Source must match the entity the component is added to.");
}
if (_components.TryGet<T>(entity, out var old)) if (_components.TryGet<T>(entity, out var old))
{ {
var oldRel = (IRelationship)(object)old; var oldRel = (IRelationship)(object)old;