A high-performance, double-buffered event system for Unity DOTS (Data-Oriented Technology Stack).
IceEvents provides a thread-safe, Burst-compatible way to send messages between ECS systems with automatic lifecycle management. It supports both single-threaded and parallel writing, making it ideal for everything from game logic triggers to high-frequency collision events.
- Multi-Producer Multi-Consumer: Fully supports multiple systems writing to and reading from the same event channel.
- Zero Sync Points: Fully asynchronous design. Writing events in jobs requires zero synchronization points on the main thread, ensuring maximum parallelism.
- Parallel Writing: Thread-safe, lock-free parallel writing from multiple jobs simultaneously.
- Double-Buffered Reliability: Events persist for up to two frames to ensure availability. Note: Consumers must run every frame (or tick) to avoid missing data.
- Cross-Loop Consistency: Safe to read
Updateevents inFixedUpdate(and vice-versa). Events are never lost or read twice across loop boundaries. - Burst Compatible: Fully optimized for Burst compilation.
- Zero GC: Allocation-free event writing during gameplay (amortized).
Warning
This project is still in early development and testing. Code reviews and testing are highly welcome!
This package is designed for Unity DOTS projects. Ensure you have the Entities package installed.
Create a struct implementing IEvent. It must be unmanaged to be Burst-compatible.
using IceEvents;
using Unity.Entities;
public struct DamageEvent : IEvent
{
public Entity Target;
public float Amount;
}Crucial Step: You must register your event type at the assembly level. This allows Unity's generic system creation to set up the necessary lifecycle systems automatically.
Add this to any .cs file in your assembly (e.g., EventRegistration.cs):
using Unity.Entities;
using IceEvents;
// Required for the event storage
[assembly: RegisterGenericComponentType(typeof(EventBuffer<DamageEvent>))]
// Required for lifecycle management (clearing buffers, swapping double buffers)
[assembly: RegisterGenericSystemType(typeof(EventLifecycleUpdateSystem<DamageEvent>))]
[assembly: RegisterGenericSystemType(typeof(EventLifecycleFixedSystem<DamageEvent>))]
// Required ONLY if you use Parallel Writing (Parallel or StreamParallel)
[assembly: RegisterGenericJobType(typeof(EventCommitJob<DamageEvent>))]IceEvents supports three modes of writing.
Use this within a standard IJob or on the Main Thread.
[BurstCompile]
public partial struct GameEventSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var buffer = SystemAPI.GetSingletonRW<EventBuffer<GameEvent>>();
// Pass to a job
new ProcessLogicJob
{
Writer = buffer.GetWriter()
}.Schedule();
}
}
[BurstCompile]
struct ProcessLogicJob : IJob
{
public EventWriter<GameEvent> Writer;
public void Execute()
{
Writer.Write(new GameEvent { Type = GameEventType.Ping });
}
}The ParallelEventWriter is the preferred way to write events from parallel jobs like IJobEntity. It is robust and easy to use.
[BurstCompile]
public partial struct CollisionSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var buffer = SystemAPI.GetSingletonRW<EventBuffer<CollisionEvent>>();
// Create a parallel writer handle
var writerHandle = buffer.ValueRW.GetParallelWriter(Allocator.TempJob);
// Schedule your job
new CollisionJob
{
Writer = writerHandle.Writer
}.ScheduleParallel();
// CRITICAL: Schedule the commit job to merge events back to the main buffer
writerHandle.ScheduleCommit(ref state);
}
}
[BurstCompile]
partial struct CollisionJob : IJobEntity
{
public ParallelEventWriter<CollisionEvent> Writer;
void Execute(in Collision collision, in Entity entity)
{
if (collision.HasContact)
{
Writer.Write(new CollisionEvent { Entity = entity });
}
}
}Use StreamParallelEventWriter only if you need explicit control over batching index for extreme performance optimization. For 99% of use cases, Queue Parallel is sufficient and easier to use.
Key requirement: You must manage BeginForEachIndex and EndForEachIndex manually.
// In your Job
public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, ...)
{
Writer.BeginForEachIndex(unfilteredChunkIndex);
// ... write events ...
Writer.EndForEachIndex();
}Readers can traverse events from the previous frame. They remember their progress, so you can read a few events, pause, and resume later (though typically you consume all at once).
Note on System Order: Readers fail if the EventBuffer singleton doesn't exist yet. Ensure your reader system updates after the lifecycle system.
[UpdateInGroup(typeof(SimulationSystemGroup))]
[CreateAfter(typeof(EventLifecycleUpdateSystem<DamageEvent>))] // Ensure buffer exists
public partial struct DamageMonitorSystem : ISystem
{
private EventReader<DamageEvent> _reader;
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<EventBuffer<DamageEvent>>();
var buffer = SystemAPI.GetSingletonBuffer<EventBuffer<DamageEvent>>();
_reader = buffer.GetUpdateReader(Allocator.Persistent);
}
public void OnUpdate(ref SystemState state)
{
var buffer = SystemAPI.GetSingleton<EventBuffer<DamageEvent>>();
// 1. Update reader state on Main Thread (safe, just reading pointers)
_reader.Update(buffer);
// 2. Pass to Job (Zero Sync Point, fully async)
new DamageReaderJob
{
Reader = _reader
}.Schedule();
}
public void OnDestroy(ref SystemState state)
{
_reader.Dispose();
}
}
[BurstCompile]
public struct DamageReaderJob : IJob
{
public EventReader<DamageEvent> Reader;
public void Execute()
{
// Safe to iterate in parallel with writers (reading previous frame)
foreach (var evt in Reader)
{
// Logic here...
}
}
}Caution
Main Thread Safety: If you absolutely must read on the Main Thread (e.g., for Debug.Log), you MUST call state.Dependency.Complete() before iterating the reader. Failing to do so will cause race conditions with jobs writing to the buffer.
You can choose between GetUpdateReader() (for Update loop events) and GetFixedReader() (for FixedUpdate / Physics loop events). Use the one matching your system's update rate.
- Prefer
ParallelEventWriter: It handles load balancing automatically and prevents index out-of-bounds errors common with manual streams. - JobDeps: When using parallel writers, always call
ScheduleCommit(ref state). This ensures that the temporary parallel buffers are merged into the main event list and thatstate.Dependencyis correctly chained. - Main Thread Safety: If you must write on the main thread, call
state.Dependency.Complete()before accessingSystemAPI.GetSingletonRW. - Registration: If you get "Unknown Type" errors, double-check your
[assembly: RegisterGeneric...]attributes.
- "The previously scheduled job writes to... BufferUpdate": You likely forgot to call
ScheduleCommitin a parallel writing system, or you have a race condition where a reader is running at the same time as a writer. - "IndexOutOfRangeException" in Stream Writer: Your
batchCountpassed toGetStreamParallelWriterdoes not match the actual number of tasks/chunks, or you are using the wrong index inBeginForEachIndex. Use Queue Writer to avoid this.
- Roslyn-based API Optimization: Simplify usage with source generators (基于 Roslyn 的 API 使用优化).
- Unique ParallelWriter: Support for strict uniqueness using
NativeHashSet(基于 NativeHashSet 的无重 ParallelWriter). - Performance Optimization: Further tuning for extreme high-frequency scenarios (更进一步的性能优化).