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); 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) public static Entity FindEntity<T1>(this World world, Query<T1> query = default)
where T1 : struct where T1 : struct
@ -68,33 +68,6 @@ public static class WorldQueryExtensions
using var iter = new Select3<T1, T2, T3>(world, query); using var iter = new Select3<T1, T2, T3>(world, query);
return iter.MoveNext() ? iter.Entity : Entity.Null; 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 ─────────────────────────────────────────── // ── Ref struct enumerators ───────────────────────────────────────────

View File

@ -61,7 +61,7 @@ public class World : IDisposable
public void RemoveSingleton<T>() where T : struct; public void RemoveSingleton<T>() where T : struct;
// --- Queries --- // --- Queries ---
// See QueryExtensions for Select / FindEntity / FindEntities. // See WorldQueryExtensions for Select / FindEntity.
// --- Commands --- // --- Commands ---
public CommandQueue Commands { get; } public CommandQueue Commands { get; }
@ -93,7 +93,7 @@ public class World : IDisposable
## WorldQueryExtensions ## WorldQueryExtensions
Extension methods on `World` providing `foreach`-compatible iteration and 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. iteration with `ref` access to components.
```csharp ```csharp
@ -113,44 +113,42 @@ public static class WorldQueryExtensions
this World world, Query<T1> query = default) where T1 : struct; 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>(...) where T1 : struct where T2 : struct;
public static Entity FindEntity<T1, T2, T3>(...) where T1 : struct where T2 : struct where T3 : 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: Each ref struct (Select1 through Select6) exposes:
- `Entity Entity` — the current entity handle. - `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. - `bool MoveNext()` — advances and returns true while items remain.
- `GetEnumerator()` — returns `this` for `foreach` compatibility. - `GetEnumerator()` — returns `this` for `foreach` compatibility.
- `Dispose()` — ends the iteration scope (flushes deferred mutations). - `Dispose()` — ends the batching scope (flushes deferred mutations).
Usage: Usage:
```csharp ```csharp
// Simple scan: all entities with Position. // Mutating: use RefN to auto-mark as modified.
foreach (var it in world.Select<Position>()) 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: // With Without filter:
var query = new Query<Position>().Without<Frozen>(); var query = new Query<Position>().Without<Frozen>();
foreach (var it in world.Select(query)) foreach (var it in world.Select(query))
{ {
// it.Entity, it.Item1 // it.Ref1, it.Val1
} }
// Find first entity: // Find first entity:
var hand = world.FindEntity<PlayerHand>(); 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 ## ISystem
A system is a unit of logic that runs during a tick. It receives the `World` A system is a unit of logic that runs during a tick. Implement `RunImpl` with
and decides what to do — typically reading singletons, iterating queries, the system logic. Use the `Run` extension method to execute with automatic
or enqueuing commands. batching — or register with `SystemGroup` which handles this automatically.
```csharp ```csharp
namespace OECS; namespace OECS;
public interface ISystem public interface ISystem
{ {
void Run(World world); void RunImpl(World world);
} }
``` ```
Systems do **not** declare a query on the interface. Instead, they either Systems do **not** declare a query on the interface. Instead, they either
read singletons directly (`world.ReadSingleton<T>()`) or use the 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 `Query<T1..T6>` they build inline. This keeps the interface minimal and
gives systems full flexibility over what they inspect at runtime. 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 ## ITickedSystem
@ -218,13 +227,13 @@ namespace OECS;
public interface ITickedSystem : ISystem 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` must also implement `ISystem.RunImpl(World)` (typically
`ITickedSystem`, the `Run(World, Tick)` overload is called; otherwise delegating to the ticked overload with a default tick). `SystemGroup` checks
`Run(World)` is called. Both overloads must be implemented. 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 Systems execute in registration order. `SystemGroup` wraps the entire tick in
commands and posts changes after each system and after the full tick. 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. `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 // Define a system
public class MovementSystem : ITickedSystem 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; float dt = tick.DeltaTime;
foreach (var it in world.Select<Position, Velocity>()) foreach (var it in world.Select<Position, Velocity>())
{ {
it.Item1.X += it.Item2.X * dt; it.Ref1.X += it.Val2.X * dt;
it.Item1.Y += it.Item2.Y * dt; it.Ref1.Y += it.Val2.Y * dt;
} }
} }
} }
@ -493,4 +506,4 @@ These types are implementation details and may change without notice:
| `EntityAllocator` | Free-list + bump allocator for entity IDs. | | `EntityAllocator` | Free-list + bump allocator for entity IDs. |
| `WorldQueryExtensions` | Extension methods with ref struct iterators and entity lookup. | | `WorldQueryExtensions` | Extension methods with ref struct iterators and entity lookup. |
| `ComponentRegistry` | Source-generated registry of all component types for serialization. | | `ComponentRegistry` | Source-generated registry of all component types for serialization. |
| `ComponentDescriptor` | Source-generated per-type descriptor with serialize/deserialize callbacks. | | `ComponentDescriptor` | Source-generated per-type descriptor with serialize/deserialize callbacks. |

View File

@ -80,16 +80,16 @@ ID, upper 8 bits are the version.
**Context:** Systems need to iterate entities matching a component signature. **Context:** Systems need to iterate entities matching a component signature.
Two API styles exist: auto-injection (the framework calls the system with the 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:** **Rationale:**
- Auto-injection hides the iteration cost. A system that looks like a simple - Auto-injection hides the iteration cost. A system that looks like a simple
method is actually O(N) — this is surprising. method is actually O(N) — this is surprising.
- Explicit iteration makes the performance model visible. The system author - 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 - Auto-injection requires either code generation or reflection to match
parameters to component types. Explicit iteration uses generics, which are parameters to component types. Explicit iteration uses generics, which are
resolved at compile time. 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 **Status:** Accepted
@ -141,19 +141,26 @@ graph.
so it can notify observers. Structural changes (add/remove) are detectable, but so it can notify observers. Structural changes (add/remove) are detectable, but
in-place mutations via `ref T` are not. in-place mutations via `ref T` are not.
**Decision:** Require explicit `world.MarkModified<T>(entity)` calls after **Decision:** Inside a batching scope (system run, command drain, foreach
mutating a component. Provide a debug-mode warning when a `ref T` is obtained iteration), component accesses via `GetComponent<T>`, `GetSingleton<T>`, and
but never marked. `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:** **Rationale:**
- C# structs returned by `ref` have no built-in change detection. Wrapping them - C# structs returned by `ref` have no built-in change detection. Auto-tracking
in a property-change-notifying container would break `ref` semantics and add within known scopes eliminates the most common source of forgotten
overhead. `MarkModified` calls.
- Auto-detection via `IEquatable<T>` comparison is possible but expensive: - The `RefN`/`ValN` iterator property pattern lets the user opt in to tracking
every component would be compared every tick, even if unchanged. per-component: `RefN` tracks, `ValN` does not. This avoids false positives
- Manual marking puts the cost on the author, where it belongs. The debug from read-only iterations.
warning catches the most common mistake (forgetting to mark). - Outside batching scopes, manual marking is still required — but these are
rare (one-off mutations outside systems).
**Alternatives considered:** **Alternatives considered:**
@ -166,22 +173,34 @@ but never marked.
**Consequences:** **Consequences:**
- System authors must remember to call `MarkModified`. The debug warning - System authors rarely need to call `MarkModified` — only for mutations outside
mitigates this. batching scopes.
- `ValN` accessors on iterators are the safe default for read-only access.
- No per-component memory or CPU overhead for change detection. - 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 **Status:** Accepted
**Context:** Changes made during a system's `Run` need to be communicated to **Context:** Changes made during a system's `Run` need to be communicated to
observers. Posting immediately would interleave observer callbacks with system 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. **Decision:** Use nested batching scopes (`BeginBatching`/`EndBatching`).
Post once more after the full tick. `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:** **Rationale:**
@ -189,12 +208,15 @@ Post once more after the full tick.
- Allows batching: multiple changes to the same entity/component are collapsed - Allows batching: multiple changes to the same entity/component are collapsed
into one notification. into one notification.
- Matches the mental model of "the tick is the atomic unit of work." - 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:** **Consequences:**
- Observers always see state after a complete system or tick, never during. - 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 - If an observer needs to react mid-tick, they must split their logic into
multiple systems. multiple systems.
- `FlushPendingMutations` between systems ensures chained work is visible.
--- ---