refactor: implement auto-tracking for component modifications

Introduce nested batching scopes to automate component dirty-marking
and defer structural mutations.

- Replace manual `MarkModified` with auto-tracking via `RefN` properties
  in `Select` iterators and `GetComponent`/`GetSingleton` calls.
- Implement nested batching scopes: `SystemGroup` (tick level),
  `ISystem.Run`
  (system level), `CommandQueue.ExecuteAll` (command level), and
  `Select`
  (iteration level).
- Update `ISystem` and `ITickedSystem` to use `RunImpl` to separate
  implementation from the batching-aware `Run` extension method.
- Add `ValN` properties to iterators for read-only, non-tracked access.
- Update documentation to reflect the new iteration and batching model.
This commit is contained in:
hypercross 2026-07-21 11:02:53 +08:00
parent 1e8e4e1b38
commit 4b94f411bf
3 changed files with 92 additions and 84 deletions

View File

@ -46,7 +46,7 @@ public static class WorldQueryExtensions
return new Select6<T1, T2, T3, T4, T5, T6>(world, query);
}
// ── FindEntity / FindEntities ────────────────────────────────────
// ── FindEntity ──────────────────────────────────────────────────
public static Entity FindEntity<T1>(this World world, Query<T1> query = default)
where T1 : struct
@ -68,33 +68,6 @@ public static class WorldQueryExtensions
using var iter = new Select3<T1, T2, T3>(world, query);
return iter.MoveNext() ? iter.Entity : Entity.Null;
}
public static List<Entity> FindEntities<T1>(this World world, Query<T1> query = default)
where T1 : struct
{
var list = new List<Entity>();
using var iter = new Select1<T1>(world, query);
while (iter.MoveNext()) list.Add(iter.Entity);
return list;
}
public static List<Entity> FindEntities<T1, T2>(this World world, Query<T1, T2> query = default)
where T1 : struct where T2 : struct
{
var list = new List<Entity>();
using var iter = new Select2<T1, T2>(world, query);
while (iter.MoveNext()) list.Add(iter.Entity);
return list;
}
public static List<Entity> FindEntities<T1, T2, T3>(this World world, Query<T1, T2, T3> query = default)
where T1 : struct where T2 : struct where T3 : struct
{
var list = new List<Entity>();
using var iter = new Select3<T1, T2, T3>(world, query);
while (iter.MoveNext()) list.Add(iter.Entity);
return list;
}
}
// ── Ref struct enumerators ───────────────────────────────────────────

View File

@ -61,7 +61,7 @@ public class World : IDisposable
public void RemoveSingleton<T>() where T : struct;
// --- Queries ---
// See QueryExtensions for Select / FindEntity / FindEntities.
// See WorldQueryExtensions for Select / FindEntity.
// --- Commands ---
public CommandQueue Commands { get; }
@ -93,7 +93,7 @@ public class World : IDisposable
## WorldQueryExtensions
Extension methods on `World` providing `foreach`-compatible iteration and
entity lookup. Select returns `ref struct` enumerators for zero-allocation
entity lookup. `Select` returns `ref struct` enumerators for zero-allocation
iteration with `ref` access to components.
```csharp
@ -113,44 +113,42 @@ public static class WorldQueryExtensions
this World world, Query<T1> query = default) where T1 : struct;
public static Entity FindEntity<T1, T2>(...) where T1 : struct where T2 : struct;
public static Entity FindEntity<T1, T2, T3>(...) where T1 : struct where T2 : struct where T3 : struct;
// FindEntities — returns all matching entities as List<Entity>.
public static List<Entity> FindEntities<T1>(...) where T1 : struct;
public static List<Entity> FindEntities<T1, T2>(...) where T1 : struct where T2 : struct;
public static List<Entity> FindEntities<T1, T2, T3>(...) where T1 : struct where T2 : struct where T3 : struct;
}
```
Each ref struct (Select1 through Select6) exposes:
- `Entity Entity` — the current entity handle.
- `ref T1 Item1`, `ref T2 Item2`, ... — read-write references to components.
- `ref T1 Ref1`, `ref T2 Ref2`, ... — mutable references to components.
Access is tracked for auto-dirty-marking during a batching scope.
- `ref readonly T1 Val1`, `ref readonly T2 Val2`, ... — read-only references.
Access is NOT tracked — use these when you only read the component.
- `bool MoveNext()` — advances and returns true while items remain.
- `GetEnumerator()` — returns `this` for `foreach` compatibility.
- `Dispose()` — ends the iteration scope (flushes deferred mutations).
- `Dispose()` — ends the batching scope (flushes deferred mutations).
Usage:
```csharp
// Simple scan: all entities with Position.
foreach (var it in world.Select<Position>())
// Mutating: use RefN to auto-mark as modified.
foreach (var it in world.Select<Position, Velocity>())
{
it.Item1.X += 1; // ref mutates component in-place
it.Ref1.X += it.Val2.X * dt; // Position tracked, Velocity not
}
// Read-only: use ValN everywhere.
foreach (var it in world.Select<Cell, Mark>())
{
grid[it.Val1.Row, it.Val1.Col] = it.Val2.Player;
}
// With Without filter:
var query = new Query<Position>().Without<Frozen>();
foreach (var it in world.Select(query))
{
// it.Entity, it.Item1
// it.Ref1, it.Val1
}
// Find first entity:
var hand = world.FindEntity<PlayerHand>();
// Multi-component:
foreach (var it in world.Select<Position, Velocity>())
{
it.Item1.X += it.Item2.X * dt;
}
```
---
@ -187,25 +185,36 @@ foreach (var it in world.Select<Card>()) { ... }
## ISystem
A system is a unit of logic that runs during a tick. It receives the `World`
and decides what to do — typically reading singletons, iterating queries,
or enqueuing commands.
A system is a unit of logic that runs during a tick. Implement `RunImpl` with
the system logic. Use the `Run` extension method to execute with automatic
batching — or register with `SystemGroup` which handles this automatically.
```csharp
namespace OECS;
public interface ISystem
{
void Run(World world);
void RunImpl(World world);
}
```
Systems do **not** declare a query on the interface. Instead, they either
read singletons directly (`world.ReadSingleton<T>()`) or use the
`WorldQueryExtensions` (`Select`, `FindEntity`, `FindEntities`) with a
`WorldQueryExtensions` (`Select`, `FindEntity`) with a
`Query<T1..T6>` they build inline. This keeps the interface minimal and
gives systems full flexibility over what they inspect at runtime.
The `Run` extension method wraps `RunImpl` in a batching scope so component
accesses via `GetComponent<T>` and `RefN` are auto-tracked for modification:
```csharp
public static class SystemExtensions
{
public static void Run(this ISystem system, World world);
public static void Run(this ITickedSystem system, World world, Tick tick);
}
```
---
## ITickedSystem
@ -218,13 +227,13 @@ namespace OECS;
public interface ITickedSystem : ISystem
{
void Run(World world, Tick tick);
void RunImpl(World world, Tick tick);
}
```
`SystemGroup` checks each system at runtime: if it implements
`ITickedSystem`, the `Run(World, Tick)` overload is called; otherwise
`Run(World)` is called. Both overloads must be implemented.
`ITickedSystem` must also implement `ISystem.RunImpl(World)` (typically
delegating to the ticked overload with a default tick). `SystemGroup` checks
each system at runtime and calls the appropriate `Run` extension.
---
@ -267,8 +276,10 @@ public class SystemGroup
}
```
Systems execute in registration order. `SystemGroup` automatically drains
commands and posts changes after each system and after the full tick.
Systems execute in registration order. `SystemGroup` wraps the entire tick in
a batching scope, and each system's `Run` extension adds a nested scope.
Commands are drained, pending mutations are flushed, and changes are posted
after each system and after the full tick.
---
@ -305,7 +316,9 @@ public class CommandQueue
```
`Enqueue<T>` uses a constrained generic to avoid boxing at the call site.
Commands enqueued during `ExecuteAll` are processed in the same drain cycle.
`ExecuteAll` wraps the drain in a batching scope — mutations are deferred and
component accesses are auto-tracked. Pending mutations are flushed after each
command so chained commands see each other's changes within the same drain cycle.
---
@ -445,16 +458,16 @@ public record struct Velocity
// Define a system
public class MovementSystem : ITickedSystem
{
public void Run(World world) => Run(world, Tick.Logical());
public void RunImpl(World world) => RunImpl(world, Tick.Logical());
public void Run(World world, Tick tick)
public void RunImpl(World world, Tick tick)
{
float dt = tick.DeltaTime;
foreach (var it in world.Select<Position, Velocity>())
{
it.Item1.X += it.Item2.X * dt;
it.Item1.Y += it.Item2.Y * dt;
it.Ref1.X += it.Val2.X * dt;
it.Ref1.Y += it.Val2.Y * dt;
}
}
}

View File

@ -80,16 +80,16 @@ ID, upper 8 bits are the version.
**Context:** Systems need to iterate entities matching a component signature.
Two API styles exist: auto-injection (the framework calls the system with the
right components) and explicit iteration (the system calls `ForEach`).
right components) and explicit iteration (the system calls `foreach`).
**Decision:** Use explicit iteration via `world.ForEach(query, action)`.
**Decision:** Use explicit iteration via `world.Select<T1..T6>()`.
**Rationale:**
- Auto-injection hides the iteration cost. A system that looks like a simple
method is actually O(N) — this is surprising.
- Explicit iteration makes the performance model visible. The system author
sees the `ForEach` call and understands they're iterating.
sees the `foreach` call and understands they're iterating.
- Auto-injection requires either code generation or reflection to match
parameters to component types. Explicit iteration uses generics, which are
resolved at compile time.
@ -133,7 +133,7 @@ graph.
---
## ADR-005: Manual Marking for Component Modifications
## ADR-005: Auto-Tracking for Component Modifications in Batching Scopes
**Status:** Accepted
@ -141,19 +141,26 @@ graph.
so it can notify observers. Structural changes (add/remove) are detectable, but
in-place mutations via `ref T` are not.
**Decision:** Require explicit `world.MarkModified<T>(entity)` calls after
mutating a component. Provide a debug-mode warning when a `ref T` is obtained
but never marked.
**Decision:** Inside a batching scope (system run, command drain, foreach
iteration), component accesses via `GetComponent<T>`, `GetSingleton<T>`, and
`RefN` iterator properties are automatically tracked and marked as modified when
the scope ends. Outside a batching scope, explicit `world.MarkModified<T>(entity)`
is still required.
Batching scopes nest: `SystemGroup` wraps the entire tick, each system's `Run`
extension adds a nested scope, and each `foreach` iteration adds another. Only
the outermost scope flush triggers auto-marking.
**Rationale:**
- C# structs returned by `ref` have no built-in change detection. Wrapping them
in a property-change-notifying container would break `ref` semantics and add
overhead.
- Auto-detection via `IEquatable<T>` comparison is possible but expensive:
every component would be compared every tick, even if unchanged.
- Manual marking puts the cost on the author, where it belongs. The debug
warning catches the most common mistake (forgetting to mark).
- C# structs returned by `ref` have no built-in change detection. Auto-tracking
within known scopes eliminates the most common source of forgotten
`MarkModified` calls.
- The `RefN`/`ValN` iterator property pattern lets the user opt in to tracking
per-component: `RefN` tracks, `ValN` does not. This avoids false positives
from read-only iterations.
- Outside batching scopes, manual marking is still required — but these are
rare (one-off mutations outside systems).
**Alternatives considered:**
@ -166,22 +173,34 @@ but never marked.
**Consequences:**
- System authors must remember to call `MarkModified`. The debug warning
mitigates this.
- System authors rarely need to call `MarkModified` — only for mutations outside
batching scopes.
- `ValN` accessors on iterators are the safe default for read-only access.
- No per-component memory or CPU overhead for change detection.
---
## ADR-006: Deferred Change Posting
## ADR-006: Deferred Mutation Batching and Change Posting
**Status:** Accepted
**Context:** Changes made during a system's `Run` need to be communicated to
observers. Posting immediately would interleave observer callbacks with system
logic, leading to reentrancy bugs.
logic, leading to reentrancy bugs. Structural mutations (add/remove component,
destroy entity) during iteration also need to be deferred to avoid invalidating
iterators.
**Decision:** Accumulate changes during `Run`, post them after `Run` completes.
Post once more after the full tick.
**Decision:** Use nested batching scopes (`BeginBatching`/`EndBatching`).
`SystemGroup` wraps the entire tick in a batching scope. Each system's `Run`
extension, each command drain, and each `foreach` iteration add nested scopes.
Within a batching scope:
- Structural mutations are buffered and applied when the outermost scope ends.
- Component accesses via `GetComponent<T>`, `GetSingleton<T>`, and `RefN`
iterator properties are tracked for auto-dirty-marking.
Pending mutations are flushed between systems so each system sees the prior
system's changes. Changes are posted after each system and after the full tick.
**Rationale:**
@ -189,12 +208,15 @@ Post once more after the full tick.
- Allows batching: multiple changes to the same entity/component are collapsed
into one notification.
- Matches the mental model of "the tick is the atomic unit of work."
- Nesting means `foreach` loops inside systems are safe — adding/removing
components mid-iteration is deferred.
**Consequences:**
- Observers always see state after a complete system or tick, never during.
- If an observer needs to react mid-tick, they must split their logic into
multiple systems.
- `FlushPendingMutations` between systems ensures chained work is visible.
---