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:
parent
1e8e4e1b38
commit
4b94f411bf
|
|
@ -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 ───────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -493,4 +506,4 @@ These types are implementation details and may change without notice:
|
|||
| `EntityAllocator` | Free-list + bump allocator for entity IDs. |
|
||||
| `WorldQueryExtensions` | Extension methods with ref struct iterators and entity lookup. |
|
||||
| `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. |
|
||||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue