Skip to content

Commit d35ebe9

Browse files
authored
Update README.md
1 parent bc8dac2 commit d35ebe9

File tree

1 file changed

+299
-1
lines changed

1 file changed

+299
-1
lines changed

README.md

Lines changed: 299 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,299 @@
1-
# UniLoop
1+
## UniLoop
2+
3+
`UniLoop` provides high-perfowrmance C# logic execution directly within the Unity **PlayerLoop**, completely bypassing the need for MonoBehaviour.
4+
5+
* Update outside MonoBehaviour — Run cyclic logic in pure C# classes and services without scene objects.
6+
* Zero Allocation — No heap allocations during task registration, execution, or completion.
7+
* Max Performance — Low-level injection directly into the PlayerLoop systems.
8+
* Safe Modification — Add or remove tasks safely during the loop iteration.
9+
* Deterministic Order — Tasks are executed strictly in the order they were registered.
10+
* CancellationToken Support — Built-in integration for modern lifecycle management.
11+
* IDisposable Handles — Precise manual control over running processes.
12+
* Editor Safety — Automatic cleanup of injected systems when exiting Play Mode.
13+
* Zero-Alloc State — Pass data to callbacks without closures or boxing.
14+
15+
### A Note from the Author (Why UniLoop?)
16+
<details>
17+
<summary> Expand </summary>
18+
19+
This library is not an attempt to "beat" the native Update speed or replace it entirely. It was born from a few simple frustrations:
20+
- MonoBehaviour Overhead: I was tired of turning pure C# classes into MonoBehaviour or dragging references just for a single update loop.
21+
- Direct Access: Singletons in the scene are a standard solution, but why access the engine's loop through a game object proxy when you can have direct access?
22+
- Workflow Control: UniTask.Yield solves the problem, but managing its lifecycle isn't always convenient, and its efficiency for mass updates is lower.
23+
24+
In **Unreal Engine**, running logic in the world tick without being tied to an object is a standard practice, and it felt natural to bring that to Unity. With `UniLoop`, I aimed to combine the **Zero-Alloc** philosophy of **UniTask** with the **Fluent API** convenience of **DOTween**.
25+
26+
P.S. Inside the library, you'll find some interesting solutions, such as specialized collections and handle-based management. Feel free to use these components separately in your own projects if they fit. I hope `UniLoop` makes your development life a bit easier! :)
27+
</details>
28+
29+
30+
31+
## Table of Contents
32+
- [Usage](#usage)
33+
- [Architecture and API](#architecture-and-api)
34+
- [Showcase](#showcase)
35+
- [Advanced Optimization](#advanced-optimization)
36+
- [Performance](#performance)
37+
- [Installation](#installation)
38+
- [License](#license)
39+
40+
41+
42+
## Usage
43+
44+
```csharp
45+
// --- 1. Infinite Cycle (Loop) ---
46+
// Fluent API: [Timing] -> [Phase] -> [Type] -> [Schedule]
47+
LoopHandle handle = UniLoop.Update.Loop.Schedule(() => Debug.Log("Tick"));
48+
handle.Dispose(); // Stop the process via handle
49+
50+
// Registration via universal Schedule method
51+
LoopHandle handle = UniLoop.Schedule(() => Debug.Log("Tick"), PlayerLoopTiming.Update, PlayerLoopPhase.Early);
52+
53+
// Adding a callback for process cancellation
54+
LoopHandle handle = UniLoop.FixedUpdate.Loop.Schedule(() => Debug.Log("Tick"))
55+
.SetOnCanceled(() => Debug.Log("Canceled"));
56+
57+
// Registration with CancellationToken
58+
UniLoop.Update.Loop.Schedule(() => Debug.Log("Tick"), _cts.Token);
59+
_cts.Cancel();
60+
61+
62+
// --- 2. Conditional Cycle (While) ---
63+
// Runs as long as the predicate returns true
64+
WhileHandle handle = UniLoop.Update.While.Schedule(() =>
65+
{
66+
_interval -= Time.deltaTime;
67+
return _interval > 0;
68+
});
69+
70+
// While registration with support for completion and cancellation events
71+
UniLoop.Update.While.Schedule(predicateFunc, _cts.Token)
72+
.SetOnCanceled(() => Debug.Log("Canceled")) // Triggered on Dispose/Cancel
73+
.SetOnCompleted(() => Debug.Log("Completed")); // Triggered when predicate returns false
74+
75+
// Using 'using' guarantees stopping when exiting the scope
76+
using (var handle = UniLoop.Update.Loop.Schedule(() => Debug.Log("Tick")))
77+
{
78+
await DoSomethingAsync();
79+
}
80+
```
81+
82+
83+
## Architecture and API
84+
85+
`UniLoop` acts as a unified entry point for running logic within the Unity PlayerLoop by combining **Timings**, **Phases**, and **Process Types**.
86+
87+
### Process Types
88+
- `Loop` (Infinite cycle) — Executes every frame until manually stopped or cancelled via a token.
89+
- `While` (Conditional cycle) — Executes as long as the predicate returns `true`. Supports both cancellation and completion events.
90+
91+
### Execution Points (Timings & Phases)
92+
Precise control over when your code executes within the Unity frame:
93+
- `PlayerLoopTiming` — Select the loop: Update, FixedUpdate, or LateUpdate.
94+
- `PlayerLoopPhase` — Select the execution window: Early (start of the cycle) or Late (end of the cycle).
95+
96+
### Execution Methods
97+
- **Fluent API** — Sequential selection through a call chain:
98+
```cs
99+
UniLoop.Update.Loop.Schedule(() => ...)
100+
```
101+
- **Universal `Schedule` method** — Execution by explicitly passing `PlayerLoopTiming` and `PlayerLoopPhase` parameters.
102+
103+
### Handles and Process Management
104+
Each registration returns a **struct handle** — a lightweight descriptor to control the process.
105+
106+
- **For Infinite Cycles (Loop)**
107+
- `LoopHandle` — handle with a `.Dispose()` method for manual stop.
108+
- `TokenLoopHandle` — for processes with a `CancellationToken`.
109+
Both variants support registering a cancellation callback via `.SetOnCanceled()`.*
110+
111+
- **For Conditional Cycles (While)**
112+
- `WhileHandle` — handle with a `.Dispose()` method for manual stop.
113+
- `TokenWhileHandle` — for processes with a `CancellationToken`.
114+
Support for subscribing to the completion of a process via `.SetOnCompleted()`.
115+
116+
117+
## Showcase
118+
119+
TimeScale controller example: activates the multiplier calculation only when the first modifier is added and completely shuts down when the list is empty.
120+
<details>
121+
<summary>Note</summary>
122+
The code is intentionally simplified to demonstrate lifecycle management via UniLoop.
123+
</details>
124+
125+
```csharp
126+
public class TimeScaleController
127+
{
128+
private readonly List<ITimeScaleModifier> _modifiers = new();
129+
private readonly float originalTimeScale;
130+
private LoopHandle _loopHandle;
131+
132+
public TimeScaleController() => originalTimeScale = Time.timeScale;
133+
134+
public void AddModifier(ITimeScaleModifier modifier)
135+
{
136+
_modifiers.Add(modifier);
137+
138+
// If this is the first modifier — start the update loop
139+
if (_modifiers.Count == 1)
140+
{
141+
_loopHandle = UniLoop.Schedule(UpdateTimeScale, PlayerLoopTiming.Update, PlayerLoopPhase.Early)
142+
.SetOnCanceled(() => Time.timeScale = originalTimeScale);
143+
}
144+
}
145+
146+
public void RemoveModifier(ITimeScaleModifier modifier)
147+
{
148+
if (_modifiers.Remove(modifier) && _modifiers.Count == 0)
149+
{
150+
// If no modifiers are left — completely stop the loop
151+
_loopHandle.Dispose();
152+
}
153+
}
154+
155+
private void UpdateTimeScale()
156+
{
157+
// Calculate average value based on dynamic data from modifiers
158+
var averageMultiplier = _modifiers.Average(q => q.Multiplier);
159+
Time.timeScale = originalTimeScale * averageMultiplier;
160+
}
161+
}
162+
```
163+
164+
165+
166+
## Advanced Optimization
167+
168+
### Slim Loop
169+
Lightweight version of the loop. Ideal for global systems running throughout the app's lifetime.
170+
- Fastest way to iterate in the PlayerLoop.
171+
- Limitation: If `Dispose()` is called during execution, the task will be removed in the next frame.
172+
- Registration via `UniLoop.Update.Loop.ScheduleSlim(action)` or `UniLoop.ScheduleSlim(...)`.
173+
174+
175+
### Zero-Alloc & Delegate Caching
176+
Using cached delegates and State (structs) eliminates closures and boxing during registration to achieve 0B GC Alloc.
177+
178+
```csharp
179+
public sealed class MyClass
180+
{
181+
private readonly struct PoolContext // Context as a readonly struct
182+
{
183+
public readonly IObjectPool<GameObject> Pool;
184+
public readonly GameObject Entity;
185+
public PoolContext(IObjectPool<GameObject> pool, GameObject entity) => (Pool, Entity) = (pool, entity);
186+
}
187+
188+
// Cache method references once during initialization
189+
private readonly Func<bool> _cachedPredicate;
190+
private readonly Action<PoolContext> _cachedOnCompleted;
191+
private IObjectPool<GameObject> _pool;
192+
193+
public MyClass()
194+
{
195+
_cachedPredicate = Predicate;
196+
_cachedOnCompleted = OnCompleted;
197+
}
198+
199+
public void Run()
200+
{
201+
var obj = _pool.Get();
202+
// Passing PoolContext through State — no allocations
203+
UniLoop.Update.While.Schedule(_cachedPredicate)
204+
.SetOnCompleted(_cachedOnCompleted, new PoolContext(_pool, obj));
205+
}
206+
207+
private void OnCompleted(PoolContext ctx) => ctx.Pool.Release(ctx.Entity);
208+
private bool Predicate() => /* logic */ true;
209+
}
210+
```
211+
### DeferredDenseArray
212+
Specialized collection at the core of UniLoop.
213+
- Dense Packing: All active tasks are stored in memory without "holes". Despite using reference types, this guarantees strict execution order and the fastest possible iteration.
214+
- Deferred Updates: Task additions and removals are buffered and applied only at safe moments. This ensures iteration stability without "collection modified" errors.
215+
216+
217+
218+
---
219+
## Performance
220+
221+
### Infinite Cycle (Loop)
222+
Comparison processing 10,000 active tasks. A single MonoBehaviour with a foreach loop over a HashSet is taken as the baseline (100%).
223+
224+
225+
| | Time (ms) | Speed (%) |
226+
|--------------------|-----------:|------------:|
227+
| **UniLoop (Slim)** | **2.70** | **157%** |
228+
| Update (Baseline) | 4.23 | 100% |
229+
| **UniLoop (Default)**| **4.36** | **97%** |
230+
| Coroutines | 10.44 | 40% |
231+
| UniTask (Yield) | 23.54 | 18% |
232+
233+
<details>
234+
<summary>Test Details</summary>
235+
236+
- `Update`: A MonoBehaviour storing `Action` delegates in a `HashSet` (for easy removal). Addition and removal of new Actions are also deferred.
237+
- `Coroutine`: A separate coroutine was started for each process with `yield return null`.
238+
- `UniTask`: An infinite loop using `UniTask.Yield`.
239+
</details>
240+
241+
### Conditional Cycle (While)
242+
Each frame, 100 new processes were started (lifetime ~1 sec).
243+
244+
#### Registration (Start)
245+
246+
247+
| | Creation (ms) | Speed (%) | Alloc (KB) |
248+
|-------------------|--------------:|-------------:|-------------:|
249+
| Update (Baseline)| 0.31 | 100% | 28.1 |
250+
| **UniLoop (Default)** | **0.64** | **47%** | **28.1** |
251+
| Coroutine | 0.67 | 45% | 36.7 |
252+
| **UniLoop (NoAlloc)**| **0.88** | **35%** | **0** |
253+
| UniTask (Yield) | 0.94 | 32% | 35.9 |
254+
| UniTaskWaitUntil | 1.10 | 28% | 37.5 |
255+
256+
#### Execution (Update)
257+
258+
259+
| | Update (ms) | Speed (%) | Alloc (KB) |
260+
|-------------------|--------------:|-------------:|-------------:|
261+
| Update (Baseline)| 2.02 | 100% | 0 |
262+
| **UniLoop (Default)** | 2.05 | 99% | 0 |
263+
| **UniLoop (NoAlloc)** | 2.05 | 99% | 0 |
264+
| WaitUntil | 2.12 | 95% | 0 |
265+
| Coroutine | 3.18 | 63% | 0 |
266+
| UniTask (Yield) | 6.49 | 31% | 0 |
267+
268+
<details>
269+
<summary>Test Details & NoAlloc Explanation</summary>
270+
Each process received a temporary object and two methods: a predicate and a completion callback to return the object to the pool.
271+
272+
`UniLoop` (NoAlloc) supports **Generic State Passing**, which eliminates **closures** and **boxing**. Combined with cached method references, this ensures **zero allocations** in the hot execution loop.
273+
</details>
274+
275+
---
276+
277+
### Honest Conclusion
278+
279+
Strictly looking at the numbers:
280+
- Execution Speed: `UniLoop` is on par with a raw `Update` loop and significantly faster than Coroutines or `UniTask` for mass updates.
281+
- Registration Cost: Starting a task requires more CPU than a simple `HashSet.Add`, primarily due to internal pooling logic.
282+
283+
**What you get in return:**
284+
- **Guaranteed execution order** for all tasks.
285+
- **Modern management** via `IDisposable` or `CancellationToken`.
286+
- **Total elimination of heap allocations.**
287+
288+
## Installation
289+
290+
### Unity Package Manager
291+
1. Open the **Package Manager** (`Window` -> `Package Manager`).
292+
2. Click the **"+"** button in the top-left corner.
293+
3. Select **"Add package from git URL..."**.
294+
4. Enter the following URL:
295+
296+
`https://github.com/CatCodeGames/UniLoop.git?path=Assets/PlayerLoop`
297+
298+
## License
299+
This project is licensed under the **MIT License**

0 commit comments

Comments
 (0)