From 4b94f411bf023e308666c5e09db1e6437d9ba3e3 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 21 Jul 2026 11:02:53 +0800 Subject: [PATCH] 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. --- OECS/WorldQueryExtensions.cs | 29 +----------- docs/api-surface.md | 85 +++++++++++++++++++++--------------- docs/architecture.md | 62 +++++++++++++++++--------- 3 files changed, 92 insertions(+), 84 deletions(-) diff --git a/OECS/WorldQueryExtensions.cs b/OECS/WorldQueryExtensions.cs index 5d03770..2630c21 100644 --- a/OECS/WorldQueryExtensions.cs +++ b/OECS/WorldQueryExtensions.cs @@ -46,7 +46,7 @@ public static class WorldQueryExtensions return new Select6(world, query); } - // ── FindEntity / FindEntities ──────────────────────────────────── + // ── FindEntity ────────────────────────────────────────────────── public static Entity FindEntity(this World world, Query query = default) where T1 : struct @@ -68,33 +68,6 @@ public static class WorldQueryExtensions using var iter = new Select3(world, query); return iter.MoveNext() ? iter.Entity : Entity.Null; } - - public static List FindEntities(this World world, Query query = default) - where T1 : struct - { - var list = new List(); - using var iter = new Select1(world, query); - while (iter.MoveNext()) list.Add(iter.Entity); - return list; - } - - public static List FindEntities(this World world, Query query = default) - where T1 : struct where T2 : struct - { - var list = new List(); - using var iter = new Select2(world, query); - while (iter.MoveNext()) list.Add(iter.Entity); - return list; - } - - public static List FindEntities(this World world, Query query = default) - where T1 : struct where T2 : struct where T3 : struct - { - var list = new List(); - using var iter = new Select3(world, query); - while (iter.MoveNext()) list.Add(iter.Entity); - return list; - } } // ── Ref struct enumerators ─────────────────────────────────────────── diff --git a/docs/api-surface.md b/docs/api-surface.md index 8da3991..76efe35 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -61,7 +61,7 @@ public class World : IDisposable public void RemoveSingleton() 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 query = default) where T1 : struct; public static Entity FindEntity(...) where T1 : struct where T2 : struct; public static Entity FindEntity(...) where T1 : struct where T2 : struct where T3 : struct; - - // FindEntities — returns all matching entities as List. - public static List FindEntities(...) where T1 : struct; - public static List FindEntities(...) where T1 : struct where T2 : struct; - public static List FindEntities(...) 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()) +// Mutating: use RefN to auto-mark as modified. +foreach (var it in world.Select()) { - 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()) +{ + grid[it.Val1.Row, it.Val1.Col] = it.Val2.Player; } // With Without filter: var query = new Query().Without(); foreach (var it in world.Select(query)) { - // it.Entity, it.Item1 + // it.Ref1, it.Val1 } // Find first entity: var hand = world.FindEntity(); - -// Multi-component: -foreach (var it in world.Select()) -{ - it.Item1.X += it.Item2.X * dt; -} ``` --- @@ -187,25 +185,36 @@ foreach (var it in world.Select()) { ... } ## 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()`) or use the -`WorldQueryExtensions` (`Select`, `FindEntity`, `FindEntities`) with a +`WorldQueryExtensions` (`Select`, `FindEntity`) with a `Query` 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` 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` 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()) { - 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. | \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index f13f059..16bc4f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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()`. **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(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`, `GetSingleton`, and +`RefN` iterator properties are automatically tracked and marked as modified when +the scope ends. Outside a batching scope, explicit `world.MarkModified(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` 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`, `GetSingleton`, 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. ---