Skip to content

14 — Blob Seed Pattern

Store large immutable assets on the shared heap under caller-authored BlobIds so many entities can reference the same data by a content-pipeline-stable ID.

Source: com.trecs.core/Samples~/Tutorials/14_BlobSeedPattern/

What it does

A 6×6 grid of cubes cycles through one of two colour palettes. Each palette is a class ColorPalette { List<Color> Colors } — managed data that can't live in a component — stored once on the shared heap. Each cube holds a 12-byte SharedPtr<ColorPalette> pointing at its palette. The two palettes are seeded under stable, hand-authored BlobIds, so the same identifier always refers to the same blob regardless of init-time call order.

Why stable BlobIds?

Every SharedPtr.Alloc / NativeSharedPtr.Alloc requires a caller-supplied BlobId. Shared blobs are addressed by stable identifier so independent call sites resolve to the same allocation, and so the same identity survives snapshots, recordings, and re-load. Hand-author the IDs as named constants:

public static class PaletteIds
{
    public static readonly BlobId Warm = new(1001);
    public static readonly BlobId Cool = new(1002);
}

For the "allocate and go" pattern where IDs are auto-minted, see Sample 10 — Dynamic Collections. This sample is the content-pipeline variant.

The seeder pattern

A long-lived seeder allocates each blob once at startup under its stable ID and holds the resulting BlobPtr<T> as a member field. Without that anchor, the cache could evict the blob between init and the first entity spawn.

public class PaletteSeeder
{
    readonly BlobCache _blobCache;
    BlobPtr<ColorPalette> _warm;
    BlobPtr<ColorPalette> _cool;

    public PaletteSeeder(BlobCache blobCache) => _blobCache = blobCache;

    public void Initialize()
    {
        // BlobPtr.Alloc(cache, stableId, blob) seeds the blob under a caller-chosen
        // BlobId and returns a pinning handle. Later SharedPtr.Acquire(heap, stableId)
        // calls find the seeded blob and hand out ECS-refcounted handles to it.
        _warm = BlobPtr.Alloc(_blobCache, PaletteIds.Warm, BuildWarm());
        _cool = BlobPtr.Alloc(_blobCache, PaletteIds.Cool, BuildCool());
    }

    public void Dispose()
    {
        _warm.Dispose(_blobCache);
        _cool.Dispose(_blobCache);
    }
}

Entity-side lookup

Entity spawners call SharedPtr.Acquire<T>(world, stableId) — the lookup-only path. It finds the existing blob by ID, bumps its refcount, and returns a fresh handle:

world
    .AddEntity<SampleTags.Swatch>()
    .Set(new Position(pos))
    .Set(new PaletteRef
    {
        Value = SharedPtr.Acquire<ColorPalette>(world, paletteId),
        CycleSpeed = 0.3f,
    });

The component is plain and unmanaged — SharedPtr<T> is a 12-byte value type:

public partial struct PaletteRef : IEntityComponent
{
    public SharedPtr<ColorPalette> Value;
    public float CycleSpeed;
}

Reading the blob from a system

Systems dereference the handle through WorldAccessor, like any SharedPtr<T>:

public partial class PaletteCycleSystem : ISystem
{
    [ForEachEntity(typeof(SampleTags.Swatch))]
    void Execute(in PaletteRef palette, ref ColorComponent color)
    {
        var table = palette.Value.Get(World);
        // ... sample palette over time, write to ColorComponent
    }
}

Because the blob lives once in the shared heap, all 36 entities pointing at the same palette see identical data — and a mutable variant would update every entity in one write.

Cleanup discipline

Pointers on components must be disposed when the entity is removed — the framework does not auto-dispose, because it can't know whether you copied the handle elsewhere. This sample registers an OnRemoved observer to dispose each entity's SharedPtr<ColorPalette> handle when the entity is removed:

world
    .Events.EntitiesWithTags<SampleTags.Swatch>()
    .OnRemoved(OnSwatchRemoved)
    .AddTo(_subscriptions);

[ForEachEntity]
void OnSwatchRemoved(in PaletteRef palette)
{
    palette.Value.Dispose(_accessor);
}

See also Pointers — cleanup is manual, Sample 10 — Dynamic Collections, and Shared Heap Data for the sharing patterns this sample illustrates.

When to reach for this

  • Large, immutable assets that many entities share (colour palettes, lookup tables, mesh metadata, spline definitions, AI behaviour trees).
  • Content pipelines where the blob's identity must survive across runs, recordings, or snapshots — auto-minted IDs would drift.
  • Data too big or too managed (lists, dictionaries) to copy into each entity's component.

For per-entity managed data that isn't shared, use UniquePtr<T> instead (Sample 10).

Concepts introduced

  • Stable BlobId — caller-authored identifiers that keep the same identity across runs, independent of init-time ordering
  • Seeder pattern — a long-lived object allocates shared blobs at startup via BlobPtr.Alloc(cache, id, value) and anchors their lifetime
  • BlobPtr.Alloc(cache, id, value) vs SharedPtr.Acquire(world, id) — seeding (creates the blob and returns a pinning handle) vs lookup (acquires an ECS-refcounted handle to an already-seeded blob)
  • Cleanup ownership — pointers on components must be disposed explicitly; an OnRemoved observer is the canonical place