Dynamic Collections¶
Experimental
The Trecs dynamic-collection family is experimental — API and backing-storage details may change in future 0.x releases.
Why native collections can't sit directly in a component¶
Native collection types (NativeList<T>, NativeHashMap<K,V>, NativeQueue<T>, etc.) hold a direct pointer to a memory address. Trecs serializes components as raw memory and expects them to be self-contained — a bare NativeList field would write its pointer bytes, not the elements behind them, and snapshots/recordings would silently drop the contents.
public partial struct CCollisionPairBuffer : IEntityComponent
{
// Don't do this — the NativeList pointer won't survive serialization
public NativeList<CollisionPair> Value;
}
For collections with known bounds, FixedList<N> is usually simplest — it stores data inline in the component and needs no manual disposal.
For dynamically-sized data, Trecs provides its own collection types: TrecsList<T>, TrecsArray<T>, and TrecsDictionary<TKey, TValue>. Their backing storage lives on a dedicated heap that Trecs walks during snapshot / record / playback, so the contents round-trip automatically alongside the component bytes — no NativeUniquePtr wrapper or custom serializer needed.
Common patterns¶
All three collection types share these traits:
- Handle-sized structs. The struct on the component is a lightweight handle (4-8 bytes). Backing data lives on the world's native heap.
- Read/Write wrappers. Access goes through
Read(...)/Write(...)methods that return typed wrappers, so Unity's job-safety system can track conflicts. - Managed and native flavors. Main-thread wrappers (
TrecsListWrite<T>, etc.) auto-grow on overflow. Burst-safe wrappers (NativeTrecsListWrite<T>, etc.) do not auto-grow — pre-size withEnsureCapacityon the main thread before scheduling. - Manual disposal. Not auto-freed when an entity is removed. Dispose in an
OnRemovedobserver — see Pointers — cleanup is manual. Writeisref this. The call site needs a writable reference to the handle — same rule as the gotcha forNativeUniquePtr.
TrecsList<T>¶
A growable list of unmanaged values. 4-byte handle.
public partial struct CCollisionPairBuffer : IEntityComponent
{
public TrecsList<CollisionPair> Value;
}
Allocating¶
var list = TrecsList.Alloc<CollisionPair>(World, initialCapacity: 16);
World.AddEntity<MyTag>()
.Set(new CCollisionPairBuffer { Value = list });
Reading and writing¶
// Main thread
ref readonly var entry = ref list.Read(World)[0];
var write = list.Write(World);
write.Add(new CollisionPair(a, b));
write[0] = updatedEntry;
write.RemoveAtSwapBack(2);
write.Clear();
Inside a Burst job:
[ForEachEntity(typeof(MyTag))]
[WrapAsJob]
static void Execute(ref CCollisionPairBuffer buf, in NativeWorldAccessor world)
{
var write = buf.Value.Write(world);
write.Add(/* ... */);
}
Capacity¶
Main-thread Add auto-grows (doubling). In a Burst job, Add throws on overflow — pre-size with list.EnsureCapacity(World, minCapacity) on the main thread before scheduling.
TrecsArray<T>¶
A fixed-size array of unmanaged values. 8-byte handle (handle + inline length). The size is locked at allocation and never grows — use this when the element count is known up front but too large to inline via FixedArray<N>.
Allocating¶
var arr = TrecsArray.Alloc<float3>(World, length: 64);
World.AddEntity<MyTag>()
.Set(new CWaypoints { Value = arr });
Reading and writing¶
// Main thread
ref readonly var point = ref arr.Read(World)[0];
var write = arr.Write(World);
write[0] = new float3(1, 2, 3);
TrecsArrayRead<T> and TrecsArrayWrite<T> are Burst-safe — the same wrappers work on the main thread and in jobs. There is no Add or Remove; only indexed access.
TrecsDictionary<TKey, TValue>¶
A growable hash dictionary. 4-byte handle. Keys must implement IEquatable<TKey>.
public partial struct CInventory : IEntityComponent
{
public TrecsDictionary<int, int> Value; // item ID → count
}
Allocating¶
var dict = TrecsDictionary.Alloc<int, int>(World, initialCapacity: 8);
World.AddEntity<MyTag>()
.Set(new CInventory { Value = dict });
Reading and writing¶
// Main thread
var read = dict.Read(World);
if (read.TryGetValue(itemId, out int count)) { /* ... */ }
var write = dict.Write(World);
write.Add(itemId, 5);
write[itemId] = 10;
write.Remove(itemId);
write.Clear();
Other write operations: TryAdd, Set (update existing, throws if missing), GetOrAdd (get-or-create returning ref TValue), GetValueByRef.
Inside a Burst job, NativeTrecsDictionaryWrite provides the same API but does not auto-grow — pre-size with dict.EnsureCapacity(World, minCapacity) before scheduling.
Add vs TryAdd vs Set vs indexer¶
var write = dict.Write(World);
write.Add(42, 100); // insert only; debug-assert on duplicate
bool added = write.TryAdd(42, 200); // insert only; silent no-op on duplicate
write.Set(42, 300); // update only; throws if key missing
write[99] = 500; // add-or-update (most permissive)
Index-based access¶
For advanced patterns — e.g., correlating a dictionary entry with data in a parallel array — use the dense-array index API:
var write = dict.Write(World);
if (write.TryGetIndex(key, out int idx))
{
ref var val = ref write.GetValueAtIndex(idx);
val.Score += 10;
}
Disposing¶
All three types must be manually disposed. Dispose in an OnRemoved observer:
[ForEachEntity]
void OnEntityRemoved(in CCollisionPairBuffer buf, in CInventory inv)
{
buf.Value.Dispose(World);
inv.Value.Dispose(World);
}
Forgetting to dispose leaks the backing storage; Trecs reports leaks at world shutdown in debug/editor builds.
When to reach for each option¶
| Use | When |
|---|---|
FixedList<N> / FixedArray<N> |
Known small upper bound. Inline, no disposal. |
TrecsArray<T> |
Fixed count known at allocation, too large to inline. |
TrecsList<T> |
Variable per-entity count. |
TrecsDictionary<TKey, TValue> |
Key-value lookups. |
UniquePtr<List<T>> |
Managed element types (classes, strings). Main-thread only. Needs a registered ISerializer<T> — see Serialization. |