feat: add reactivity via change buffering and R3 observables

This commit is contained in:
hypercross 2026-07-18 19:50:31 +08:00
parent 48f3319615
commit 01eb5aa5f6
7 changed files with 700 additions and 2 deletions

131
src/OECS/ChangeBuffer.cs Normal file
View File

@ -0,0 +1,131 @@
using R3;
namespace OECS;
/// <summary>
/// Buffers changes during system execution and posts them to R3 subjects
/// when <see cref="Post"/> is called.
///
/// Subscribers receive batched changes: all changes accumulated since the
/// last <see cref="Post"/> call are pushed in a single burst.
/// </summary>
internal class ChangeBuffer
{
private readonly ChangeSet _pending = new();
private readonly Subject<EntityChange> _entitySubject = new();
private readonly Dictionary<Type, Subject<EntityChange>> _componentSubjects = new();
private readonly Dictionary<QueryDescriptor, Subject<EntityChange>> _querySubjects = new();
/// <summary>
/// The change set currently accumulating. Cleared after each <see cref="Post"/>.
/// </summary>
public ChangeSet Pending => _pending;
/// <summary>
/// Pushes all pending changes to R3 subjects, then clears the pending set.
/// </summary>
public void Post()
{
if (_pending.Count == 0)
return;
var changes = _pending.Changes;
foreach (var change in changes)
{
// Push to the global entity subject.
_entitySubject.OnNext(change);
// Push to component-specific subjects.
if (change.ComponentType != null)
{
if (_componentSubjects.TryGetValue(change.ComponentType, out var compSubject))
{
compSubject.OnNext(change);
}
}
// Push to matching query subjects.
foreach (var (query, subject) in _querySubjects)
{
if (ChangeMatchesQuery(change, query))
{
subject.OnNext(change);
}
}
}
_pending.Clear();
}
/// <summary>
/// Returns an observable that emits all entity changes.
/// </summary>
public Observable<EntityChange> ObserveEntityChanges()
{
return _entitySubject;
}
/// <summary>
/// Returns an observable that emits changes for a specific component type.
/// </summary>
public Observable<EntityChange> ObserveComponentChanges(Type componentType)
{
if (!_componentSubjects.TryGetValue(componentType, out var subject))
{
subject = new Subject<EntityChange>();
_componentSubjects[componentType] = subject;
}
return subject;
}
/// <summary>
/// Returns an observable that emits changes matching the given query.
/// </summary>
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
{
if (!_querySubjects.TryGetValue(query, out var subject))
{
subject = new Subject<EntityChange>();
_querySubjects[query] = subject;
}
return subject;
}
/// <summary>
/// Disposes all R3 subjects.
/// </summary>
public void Dispose()
{
_entitySubject.Dispose();
foreach (var subject in _componentSubjects.Values)
{
subject.Dispose();
}
_componentSubjects.Clear();
foreach (var subject in _querySubjects.Values)
{
subject.Dispose();
}
_querySubjects.Clear();
}
private static bool ChangeMatchesQuery(EntityChange change, QueryDescriptor query)
{
// Entity-level changes: match if the entity was added/removed and
// the query has any "with" types (we can't know component state for
// removed entities, so we only match added entities that would satisfy
// the query — but we don't have component data here).
// For simplicity, we only match component-level changes against queries.
if (change.ComponentType == null)
return false;
// A component change matches a query if the component type is in the
// query's With set and not in the Without set.
if (query.Without.Contains(change.ComponentType))
return false;
return query.With.Contains(change.ComponentType);
}
}

33
src/OECS/ChangeKind.cs Normal file
View File

@ -0,0 +1,33 @@
namespace OECS;
/// <summary>
/// The kind of change that occurred in the ECS world.
/// </summary>
public enum ChangeKind
{
/// <summary>
/// A new entity was created.
/// </summary>
EntityAdded,
/// <summary>
/// An entity was destroyed.
/// </summary>
EntityRemoved,
/// <summary>
/// A component was added to an entity.
/// </summary>
ComponentAdded,
/// <summary>
/// A component was removed from an entity.
/// </summary>
ComponentRemoved,
/// <summary>
/// A component's value was modified. Must be explicitly marked
/// via <see cref="World.MarkModified{T}"/>.
/// </summary>
ComponentModified
}

83
src/OECS/ChangeSet.cs Normal file
View File

@ -0,0 +1,83 @@
namespace OECS;
/// <summary>
/// Accumulates <see cref="EntityChange"/> entries during a system run.
///
/// Deduplication: if the same (entity, kind, componentType) change is marked
/// multiple times, only one entry is kept. Entity-level changes (Added/Removed)
/// take precedence over component-level changes for the same entity.
/// </summary>
internal class ChangeSet
{
private readonly List<EntityChange> _changes = new();
private readonly HashSet<(Entity Entity, ChangeKind Kind, Type? ComponentType)> _dedup = new();
/// <summary>
/// All accumulated changes in insertion order.
/// </summary>
public IReadOnlyList<EntityChange> Changes => _changes;
/// <summary>
/// Number of changes in this set.
/// </summary>
public int Count => _changes.Count;
/// <summary>
/// Records that an entity was created.
/// </summary>
public void MarkEntityAdded(Entity entity)
{
TryAdd(new EntityChange(entity, ChangeKind.EntityAdded));
}
/// <summary>
/// Records that an entity was destroyed.
/// </summary>
public void MarkEntityRemoved(Entity entity)
{
TryAdd(new EntityChange(entity, ChangeKind.EntityRemoved));
}
/// <summary>
/// Records that a component was added to an entity.
/// </summary>
public void MarkComponentAdded(Entity entity, Type componentType)
{
TryAdd(new EntityChange(entity, ChangeKind.ComponentAdded, componentType));
}
/// <summary>
/// Records that a component was removed from an entity.
/// </summary>
public void MarkComponentRemoved(Entity entity, Type componentType)
{
TryAdd(new EntityChange(entity, ChangeKind.ComponentRemoved, componentType));
}
/// <summary>
/// Records that a component's value was modified.
/// Must be called explicitly by user code via <see cref="World.MarkModified{T}"/>.
/// </summary>
public void MarkComponentModified(Entity entity, Type componentType)
{
TryAdd(new EntityChange(entity, ChangeKind.ComponentModified, componentType));
}
/// <summary>
/// Clears all accumulated changes.
/// </summary>
public void Clear()
{
_changes.Clear();
_dedup.Clear();
}
private void TryAdd(EntityChange change)
{
var key = (change.Entity, change.Kind, change.ComponentType);
if (_dedup.Add(key))
{
_changes.Add(change);
}
}
}

45
src/OECS/EntityChange.cs Normal file
View File

@ -0,0 +1,45 @@
namespace OECS;
/// <summary>
/// Describes a single change that occurred in the ECS world.
/// </summary>
public readonly struct EntityChange : IEquatable<EntityChange>
{
/// <summary>
/// The entity that was affected.
/// </summary>
public Entity Entity { get; }
/// <summary>
/// The kind of change.
/// </summary>
public ChangeKind Kind { get; }
/// <summary>
/// The component type involved, or null for entity-level changes
/// (<see cref="ChangeKind.EntityAdded"/> / <see cref="ChangeKind.EntityRemoved"/>).
/// </summary>
public Type? ComponentType { get; }
internal EntityChange(Entity entity, ChangeKind kind, Type? componentType = null)
{
Entity = entity;
Kind = kind;
ComponentType = componentType;
}
public bool Equals(EntityChange other)
{
return Entity.Equals(other.Entity)
&& Kind == other.Kind
&& ComponentType == other.ComponentType;
}
public override bool Equals(object? obj) => obj is EntityChange other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Entity, Kind, ComponentType);
public override string ToString() => ComponentType == null
? $"{Kind} {Entity}"
: $"{Kind} {ComponentType.Name} on {Entity}";
}

View File

@ -72,9 +72,16 @@ public class SystemGroup
// Drain commands after each system so subsequent systems // Drain commands after each system so subsequent systems
// see the effects of commands enqueued by prior systems. // see the effects of commands enqueued by prior systems.
_world.ExecuteCommands(); _world.ExecuteCommands();
// Post changes after each system so subscribers see
// incremental updates.
_world.PostChanges();
} }
// Drain any remaining commands (e.g., those enqueued outside systems). // Drain any remaining commands (e.g., those enqueued outside systems).
_world.ExecuteCommands(); _world.ExecuteCommands();
// Post any remaining changes (e.g., from command execution).
_world.PostChanges();
} }
} }

View File

@ -1,3 +1,6 @@
using System.Diagnostics;
using R3;
namespace OECS; namespace OECS;
/// <summary> /// <summary>
@ -12,6 +15,15 @@ public class World : IDisposable
private readonly ComponentStore _components; private readonly ComponentStore _components;
private readonly CommandQueue _commands; private readonly CommandQueue _commands;
private readonly RelationshipIndex _relationships; private readonly RelationshipIndex _relationships;
private readonly ChangeBuffer _changes;
#if DEBUG
// Tracks entities whose components were accessed via GetComponent<T>
// but never had MarkModified<T> called. Used to warn about missing
// MarkModified calls after a system runs.
private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = new();
private readonly HashSet<(Entity Entity, Type ComponentType)> _markedModified = new();
#endif
public World() public World()
{ {
@ -19,16 +31,20 @@ public class World : IDisposable
_components = new ComponentStore(); _components = new ComponentStore();
_commands = new CommandQueue(); _commands = new CommandQueue();
_relationships = new RelationshipIndex(); _relationships = new RelationshipIndex();
_changes = new ChangeBuffer();
} }
// ── Entity Management ──────────────────────────────────────────── // ── Entity Management ────────────────────────────────────────────
/// <summary> /// <summary>
/// Creates a new entity and returns its handle. /// Creates a new entity and returns its handle.
/// Automatically marks an <see cref="ChangeKind.EntityAdded"/> change.
/// </summary> /// </summary>
public Entity CreateEntity() public Entity CreateEntity()
{ {
return _allocator.Allocate(); var entity = _allocator.Allocate();
_changes.Pending.MarkEntityAdded(entity);
return entity;
} }
/// <summary> /// <summary>
@ -63,6 +79,8 @@ public class World : IDisposable
_components.RemoveAll(entity); _components.RemoveAll(entity);
_allocator.Free(entity); _allocator.Free(entity);
_changes.Pending.MarkEntityRemoved(entity);
} }
/// <summary> /// <summary>
@ -96,6 +114,7 @@ public class World : IDisposable
} }
} }
bool isNew = !_components.Has<T>(entity);
_components.Add(entity, component); _components.Add(entity, component);
// Update relationship index. // Update relationship index.
@ -103,6 +122,10 @@ public class World : IDisposable
{ {
_relationships.OnAdded(typeof(T), entity, rel.Target); _relationships.OnAdded(typeof(T), entity, rel.Target);
} }
// Mark change.
if (isNew)
_changes.Pending.MarkComponentAdded(entity, typeof(T));
} }
/// <summary> /// <summary>
@ -124,15 +147,26 @@ public class World : IDisposable
} }
} }
bool existed = _components.Has<T>(entity);
_components.Remove<T>(entity); _components.Remove<T>(entity);
if (existed)
_changes.Pending.MarkComponentRemoved(entity, typeof(T));
} }
/// <summary> /// <summary>
/// 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
/// <see cref="MarkModified{T}"/> so that observers are notified.
/// In DEBUG builds, a warning is logged if you forget.
/// </summary> /// </summary>
public ref T GetComponent<T>(Entity entity) where T : struct public ref T GetComponent<T>(Entity entity) where T : struct
{ {
#if DEBUG
_accessedComponents.Add((entity, typeof(T)));
#endif
return ref _components.Get<T>(entity); return ref _components.Get<T>(entity);
} }
@ -144,6 +178,63 @@ public class World : IDisposable
return _components.Has<T>(entity); return _components.Has<T>(entity);
} }
// ── Change Tracking ──────────────────────────────────────────────
/// <summary>
/// Marks a component of type <typeparamref name="T"/> on the given entity
/// as modified. Must be called after mutating a component via
/// <see cref="GetComponent{T}"/> so that observers are notified.
/// </summary>
public void MarkModified<T>(Entity entity) where T : struct
{
_changes.Pending.MarkComponentModified(entity, typeof(T));
#if DEBUG
_markedModified.Add((entity, typeof(T)));
#endif
}
/// <summary>
/// Posts all pending changes to R3 subscribers, then clears the pending set.
/// Called automatically by <see cref="SystemGroup"/> after each system
/// and after the full tick.
/// </summary>
public void PostChanges()
{
#if DEBUG
WarnUnmarkedModifications();
#endif
_changes.Post();
}
/// <summary>
/// Returns an observable that emits all entity changes (adds, removes,
/// component adds, component removes, component modifications).
/// </summary>
public Observable<EntityChange> ObserveEntityChanges()
{
return _changes.ObserveEntityChanges();
}
/// <summary>
/// Returns an observable that emits changes for a specific component type
/// <typeparamref name="T"/> (added, removed, or modified).
/// </summary>
public Observable<EntityChange> ObserveComponentChanges<T>() where T : struct
{
return _changes.ObserveComponentChanges(typeof(T));
}
/// <summary>
/// Returns an observable that emits changes matching the given query.
/// Only component-level changes whose component type is in the query's
/// "with" set and not in the "without" set are emitted.
/// </summary>
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
{
return _changes.ObserveQuery(query);
}
// ── Relationships ──────────────────────────────────────────────── // ── Relationships ────────────────────────────────────────────────
/// <summary> /// <summary>
@ -273,6 +364,30 @@ public class World : IDisposable
public void Dispose() public void Dispose()
{ {
// Future: dispose R3 subscriptions, etc. _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
} }

View File

@ -0,0 +1,284 @@
using FluentAssertions;
using R3;
using Xunit;
namespace OECS.Tests;
public class ReactivityTests
{
private struct Position { public float X; public float Y; }
private struct Health { public int Value; }
private struct Frozen { }
/// <summary>
/// Helper that collects changes into a list and exposes an R3 Observer.
/// </summary>
private sealed class ChangeCollector : IObserver<EntityChange>
{
public List<EntityChange> Changes = new();
public void OnNext(EntityChange value) => Changes.Add(value);
public void OnError(Exception error) { }
public void OnCompleted() { }
}
[Fact]
public void EntityCreation_PostsEntityAdded()
{
var world = new World();
var collector = new ChangeCollector();
using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
var entity = world.CreateEntity();
world.PostChanges();
collector.Changes.Should().ContainSingle()
.Which.Should().Match<EntityChange>(c =>
c.Entity == entity && c.Kind == ChangeKind.EntityAdded);
}
[Fact]
public void EntityDestruction_PostsEntityRemoved()
{
var world = new World();
var entity = world.CreateEntity();
world.PostChanges(); // Clear pending
var collector = new ChangeCollector();
using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
world.DestroyEntity(entity);
world.PostChanges();
collector.Changes.Should().ContainSingle()
.Which.Should().Match<EntityChange>(c =>
c.Entity == entity && c.Kind == ChangeKind.EntityRemoved);
}
[Fact]
public void ComponentAdd_PostsComponentAdded()
{
var world = new World();
var entity = world.CreateEntity();
world.PostChanges(); // Clear pending
var collector = new ChangeCollector();
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
world.AddComponent(entity, new Position { X = 1, Y = 2 });
world.PostChanges();
collector.Changes.Should().ContainSingle()
.Which.Should().Match<EntityChange>(c =>
c.Entity == entity &&
c.Kind == ChangeKind.ComponentAdded &&
c.ComponentType == typeof(Position));
}
[Fact]
public void ComponentRemove_PostsComponentRemoved()
{
var world = new World();
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 1, Y = 2 });
world.PostChanges(); // Clear pending
var collector = new ChangeCollector();
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
world.RemoveComponent<Position>(entity);
world.PostChanges();
collector.Changes.Should().ContainSingle()
.Which.Should().Match<EntityChange>(c =>
c.Entity == entity &&
c.Kind == ChangeKind.ComponentRemoved &&
c.ComponentType == typeof(Position));
}
[Fact]
public void MarkModified_PostsComponentModified()
{
var world = new World();
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 0, Y = 0 });
world.PostChanges(); // Clear pending
var collector = new ChangeCollector();
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
world.MarkModified<Position>(entity);
world.PostChanges();
collector.Changes.Should().ContainSingle()
.Which.Should().Match<EntityChange>(c =>
c.Entity == entity &&
c.Kind == ChangeKind.ComponentModified &&
c.ComponentType == typeof(Position));
}
[Fact]
public void Changes_AreBatchedPerPost()
{
var world = new World();
var collector = new ChangeCollector();
using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
var a = world.CreateEntity();
var b = world.CreateEntity();
world.AddComponent(a, new Position { X = 1, Y = 2 });
world.AddComponent(b, new Health { Value = 100 });
// No Post yet — subscribers should not have received anything.
collector.Changes.Should().BeEmpty();
world.PostChanges();
// All changes arrive in one batch.
collector.Changes.Should().HaveCount(4);
}
[Fact]
public void Subscribers_ReceiveChangesInOrder()
{
var world = new World();
var collector = new ChangeCollector();
using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 0, Y = 0 });
world.MarkModified<Position>(entity);
world.RemoveComponent<Position>(entity);
world.DestroyEntity(entity);
world.PostChanges();
collector.Changes.Select(c => c.Kind).Should().Equal(
ChangeKind.EntityAdded,
ChangeKind.ComponentAdded,
ChangeKind.ComponentModified,
ChangeKind.ComponentRemoved,
ChangeKind.EntityRemoved
);
}
[Fact]
public void DisposingSubscription_StopsNotifications()
{
var world = new World();
var collector = new ChangeCollector();
var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
var entity = world.CreateEntity();
world.PostChanges();
collector.Changes.Should().HaveCount(1);
sub.Dispose();
world.DestroyEntity(entity);
world.PostChanges();
// No new changes after dispose.
collector.Changes.Should().HaveCount(1);
}
[Fact]
public void ObserveComponentChanges_OnlyReceivesMatchingType()
{
var world = new World();
var posCollector = new ChangeCollector();
var hpCollector = new ChangeCollector();
using var sub1 = world.ObserveComponentChanges<Position>().Subscribe(posCollector.ToObserver());
using var sub2 = world.ObserveComponentChanges<Health>().Subscribe(hpCollector.ToObserver());
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 1, Y = 2 });
world.AddComponent(entity, new Health { Value = 100 });
world.PostChanges();
posCollector.Changes.Should().HaveCount(1);
posCollector.Changes[0].ComponentType.Should().Be(typeof(Position));
hpCollector.Changes.Should().HaveCount(1);
hpCollector.Changes[0].ComponentType.Should().Be(typeof(Health));
}
[Fact]
public void ObserveQuery_OnlyReceivesMatchingChanges()
{
var world = new World();
var query = world.Query().With<Position>().Without<Frozen>().Build();
var collector = new ChangeCollector();
using var sub = world.ObserveQuery(query).Subscribe(collector.ToObserver());
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 1, Y = 2 }); // Should match
world.AddComponent(entity, new Frozen()); // Should NOT match (not in With)
world.AddComponent(entity, new Health { Value = 100 }); // Should NOT match (not in With)
world.PostChanges();
collector.Changes.Should().HaveCount(1);
collector.Changes[0].ComponentType.Should().Be(typeof(Position));
}
[Fact]
public void Deduplication_PreventsDuplicateChanges()
{
var world = new World();
var collector = new ChangeCollector();
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 0, Y = 0 });
world.MarkModified<Position>(entity);
world.MarkModified<Position>(entity); // Duplicate
world.MarkModified<Position>(entity); // Duplicate
world.PostChanges();
// Should have exactly 2 changes: ComponentAdded + ComponentModified (deduplicated).
collector.Changes.Should().HaveCount(2);
collector.Changes[0].Kind.Should().Be(ChangeKind.ComponentAdded);
collector.Changes[1].Kind.Should().Be(ChangeKind.ComponentModified);
}
[Fact]
public void Changes_ArePostedAfterEachSystem_InSystemGroup()
{
var world = new World();
var group = new SystemGroup(world);
var collector = new ChangeCollector();
using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
// System that creates an entity.
group.Add(new EntityCreatorSystem(world));
// Before running, no changes should be posted.
collector.Changes.Should().BeEmpty();
group.RunLogical();
// After the system group runs, changes should be posted.
collector.Changes.Should().Contain(c => c.Kind == ChangeKind.EntityAdded);
}
private class EntityCreatorSystem : ISystem
{
private readonly World _world;
public QueryDescriptor Query { get; }
public EntityCreatorSystem(World world)
{
_world = world;
Query = world.Query().With<Position>().Build();
}
public void Run(World world)
{
_world.CreateEntity();
}
}
}