Tags and Layers as Assets
Tags and layers are small features with a surprisingly large reach. They decide which objects collide, which objects a camera sees, and which objects gameplay code can find. Their data ends up in scenes, prefabs, editor controls, save files, and sometimes network messages.
I had already implemented a conventional version in Turian: physics layers, rendering layers, tags, and the filtering and query APIs around them. Fixed tables make this straightforward. A layer becomes a small index, a mask becomes a few bits, and the runtime can answer its questions cheaply.
Then, while reviewing the implementation, I started wondering how those tables would behave as the engine grew. Physics and rendering are only two consumers. Audio, networking, save systems, and gameplay modules could all benefit from similar classifications. A module distributed separately also needs identities that survive being installed in a project whose tables are already populated.
Turian already has a DataAsset system for shared, editable game data. That suggested a question: could tags and layers themselves be assets, while keeping the runtime representation small enough for thousands of nodes?
This article describes the architecture I settled on and the tradeoffs behind it. The durable-state foundation is implemented and tested; the complete integration into nodes, consumers, and editor workflows is still in progress.
What the design needs to preserve
There are three requirements I want to hold at the same time:
- Stable identity. Renaming a layer, changing its presentation, or moving an asset should preserve references to it.
- Composition. A project should be able to combine declarations from its own code, designer-authored assets, and independently developed modules.
- A small runtime cost. Culling and physics read memberships frequently. Those reads should use compact data, and adding a module should not allocate a large table on every node.
The asset system gives me a place for identity and presentation. The runtime still needs a representation chosen for its access patterns.
Authoring groups, values, and tags
The authoring model has four main pieces:
| Asset | What it owns |
|---|---|
LayerValueAsset |
One value's GUID, display name, color, and description |
LayerGroupAsset |
An ordered manifest of value references and an explicit default |
TagAsset |
A tag's GUID and presentation metadata |
NodeLayerSettings |
The project's ordered groups, available tags, and physics/rendering group bindings |
A group describes one classification space. For example, a project might define a physics group with Default, Water, and Actors, a rendering group with Default, World, and Interface, and an audio group with its own values.
A node selects one value in each group. A consumer's mask selects a subset of that group's values. Tags are independent memberships: a node can carry several tags without choosing a single value from a group.
Names are presentation metadata. They can change, and independent assets can share a display name. References identify assets by GUID. Each group owns its value manifest, so modules can edit their own groups without putting every declaration into one large shared table.
The physics and rendering bindings are also explicit references. A designer can rename either group without changing which group the consumer uses.
Three representations for three jobs
The central design choice is to give authoring, session setup, and runtime queries their own representations:
| Stage | Representation | Job |
|---|---|---|
| Authoring and persistence | Asset GUIDs and group/value GUID pairs | Preserve identity and editing intent |
| Session setup | An immutable GUID-to-slot/index layout | Validate the manifest and bind identities |
| Runtime | Byte memberships, ushort tag IDs, and bit masks | Answer frequent queries cheaply |
flowchart LR
subgraph A["Authoring and persistence"]
direction TB
A1["Assets and typed code keys"]
A2["GUID identity and presentation"]
end
subgraph B["Session setup"]
direction TB
B1["LayerInterningService"]
B2["Validate, intern, fingerprint"]
end
subgraph C["Runtime queries"]
direction TB
C1["NodeLayers — 24 B per node"]
C2["ushort tag IDs and 256-bit masks"]
end
A -- "bind at session start" --> B
B -- "compact immutable layout" --> C
At session setup, LayerInterningService validates and snapshots the resolved settings. Groups receive integer slots. Each group's default receives value index zero, followed by its remaining values in manifest order. Tags receive compact IDs in ascending GUID order.
Once those tables exist, a runtime membership read needs only a slot and a byte. The display name, color, description, and asset object remain shared authoring data.
This follows the same stable-identity principle that motivated Open Asset Package: references should survive changes to how data is named or organized. Here, the extra step is translating those identities into a compact layout for an active session.
Code registration without string keys
Asset authoring also needs to work with modules written in C#. The registration API accepts types with explicit TypeId attributes:
using MASS4.Attributes;
using Turian.Engine.Core;
[TypeId("ea3c1636-272b-453b-8a73-2187d01581a5")]
public sealed record Physics : ILayerGroupKey;
[TypeId("89414f36-2137-4d82-b7ea-cced74c4864e")]
public sealed record Water : ILayerValueKey<Physics>;
[TypeId("812a8d5a-ad5e-4f26-ae8a-6d5e6d120d91")]
public sealed record Player : ITagKey;
var settings = new LayerRegistrationBuilder()
.Group<Physics>(group => group.Default().Value<Water>())
.Tag<Player>()
.Build();
settings.PhysicsGroup = settings.Groups[0];
The GUIDs in this example identify the keys. Keeping the same TypeId preserves identity through a type or namespace rename. No marker instances are created. Water declares its group through ILayerValueKey<Physics>, allowing the compiler to reject registering it under another group.
The current marker API uses classes and record classes. readonly record struct keys remain a known limitation because the shared TypeId attribute targets classes.
A designer asset can use the same asset GUID as a code key's TypeId. That intentionally enriches the code identity: a matching value or tag asset supplies presentation data, and a matching group asset supplies its complete manifest and default. The TypeId of the DataAsset class itself has a separate job: identifying the serialized payload type.
16 inline slots, sparse overflow
An early draft proposed a hard limit of 16 groups. The attraction was obvious: one byte per group, 16 bytes of membership data. The problem appeared as soon as I considered modules. A system using only a few values would still consume one of those 16 project-wide slots.
The implemented membership cache keeps the first 16 groups inline and stores additional nondefault assignments in a sorted sparse array.
On a 64-bit runtime, NodeLayers occupies 24 bytes: 16 inline membership bytes and an eight-byte array reference. That reference is null when there are no overflow assignments. Registering more groups alone adds no per-node allocation. An unassigned overflow group resolves to its default index zero.
Each overflow entry occupies eight bytes, including padding, plus the array's header and allocation overhead. Overflow reads use binary search. Edits copy the small array, preserving independent value copies of the struct; unchanged assignments allocate nothing.
This makes group order a performance decision. Physics and rendering belong near the start because their memberships are read across many nodes every frame. Layout construction warns if either explicitly bound consumer falls outside the first 16 slots. If profiling identifies a hot overflow group, its consumer should move memberships into a scene-owned column indexed by node handle.
The 24-byte figure describes the membership cache. Tags, masks, durable GUID state, and the rest of a node have their own storage costs. Separating identity from execution does not make persistent state free; it lets us keep presentation metadata shared and choose runtime storage deliberately.
A byte selects a value; a mask selects a subset
Each group supports 256 values. A node's byte is an index into those values. A consumer mask has one bit for each value, held in four inline ulong words.
That distinction matters. Eight independent flags have 256 possible combinations. The membership byte instead selects one of 256 values, while the 256-bit consumer mask can describe any subset of them. Multiple memberships within the same group would need a different node representation and API.
Tags use the full 65,536-ID ushort space. Zero is a valid ID. Lookup success is returned separately, so no ID has to be reserved as a missing-value sentinel. A module declaring 8,192 tags leaves 57,344 IDs for other modules and project content.
These remain explicit capacity choices. Invalid manifests are rejected before a usable layout is constructed. The group count has no fixed 16-slot project limit, although its shared tables and any assigned overflow entries still consume memory.
Runtime indices have a lifetime
Suppose a group's nondefault values are Water and Actors. Inserting another value before them can shift their byte indices in the next session. Their GUIDs remain unchanged.
That gives us a strict persistence rule: runtime indices are session-scoped and never durable. Scenes, prefabs, gameplay saves, and authored masks store identities. They resolve those identities again when loaded into a new layout.
NodeLayerState stores explicit group/value GUID pairs and tag GUIDs. LayerMaskState stores a group GUID and selected value GUIDs. Binding produces NodeLayers, compact tag IDs, and CompiledLayerMask. JSON serialization of the runtime membership and mask caches is rejected, with guidance to save the GUID state objects instead.
The gameplay-save round trip is already tested, including inserting a value between saving and loading. Automatic Node scene/prefab persistence and cloning still need to connect to this foundation.
Defaults preserve intent
There is a subtle difference between following a group's default and explicitly choosing the value that happens to be its default today.
Imagine that the default is X. One node is unassigned; another explicitly selects X. Later, the designer changes the default to Y. The unassigned node should become Y. The explicitly assigned node should remain X.
Both nodes initially resolve to runtime index zero, so that byte cannot preserve the distinction. The durable state must retain the explicit GUID pair. A separate follow-default or reset action clears the reference. This behavior is covered by a regression test and must remain visible in the Inspector workflow.
Deleted content has a similar editing concern. A node referencing a missing value falls back to its known group's default, while retaining the orphan GUID. A mask retains a missing value's GUID but contributes no bit for it. Missing tags retain their identities without contributing runtime membership. Each missing identity warns once per snapshot. If the content returns, those references can resolve again.
Determinism needs explicit inputs
The settings asset's ordered lists and explicit builder calls are the registration entry points. Group discovery never depends on filesystem traversal, asset-database enumeration, or assembly discovery order. Module startup must likewise use an explicit registration sequence.
Explicit registration is also a NativeAOT constraint, not just a determinism preference. Turian ships as ahead-of-time-compiled binaries, and the usual alternative — scanning loaded assemblies for marker attributes at startup — is exactly the pattern that breaks under trimming: types disappear from the binary unless linker annotations pin them in, and those annotations multiply with every module. The builder API moves discovery to compile time. What the runtime sees is the explicit registration sequence, and the TypeId GUIDs are ordinary string literals the compiler preserves without help.
The session layout also carries a versioned SHA-256 fingerprint. It covers ordered group, value, and tag identities with their runtime slots and indices, plus the physics and rendering bindings. Presentation metadata does not affect it. The byte protocol is documented and tested against a fixture generated independently from the C# implementation.
That fingerprint allows a peer or an index-based replay to establish whether its compact data has the same meaning. A mismatch refuses compatibility. Ordinary GUID-based saves can bind to a changed layout; compact-index exchanges require agreement on the layout and content.
Editor changes must respect the same boundary. The planned workflow shows the active and proposed fingerprints, identifies connected peers that need revalidation, and offers restart/rebind or defer. Deferring keeps the existing session snapshot and leaves a visible pending-change banner. A live session must never reinterpret existing bytes through replacement tables.
CPU capacity does not dictate GPU width
The renderer currently uses 32-bit layer data in its lighting path. I initially treated 256-bit masks as requiring one coordinated CPU, persistence, uniform, shader, and SPIR-V conversion.
A closer look clarified that the GPU needs the rendering group's mask. Other groups can have their own widths without changing render uniforms.
CompiledLayerMask.TryGetLegacyBits() permits the current packing when its group declares at most 32 values. It rejects wider groups even if one particular selection happens to use only low bits: the renderer still needs to interpret every valid membership in that group.
This lets CPU and persistence integration proceed while the rendering group fits the existing GPU path. Wider rendering groups require an explicit rejection at renderer binding until the uniform and shader work lands. Silent truncation would make filtering depend on which side of the CPU/GPU boundary performed the check.
What gameplay code should keep
Changing tag storage must preserve the query behavior programmers already use. Queries select active nodes, respect ancestor activity, and exclude destroyed nodes. A single-result query returns null on a miss; an all-results query returns an empty list.
Within a scene, results follow node registration order. Scene-manager queries visit loaded scenes in load order and then persistent nodes. Tag changes and activation changes preserve a registration's position, and duplicate tag memberships do not duplicate results.
The planned public query surface accepts GUIDs and typed tag keys, resolving to ushort IDs inside the session registry. The storage change must preserve the existing hooks for tag edits, reparenting, destruction, and scene unloading. Play Mode must create independent registrations for its scene copy.
Where the implementation stands
The authoring assets, typed registration, identity enrichment, immutable layouts, compact membership cache, GUID state objects, orphan handling, masks, fingerprint guard, and validation tests are implemented.
The remaining work connects those pieces to session bootstrap, Node serialization and cloning, tag registry storage and public queries, physics/rendering consumers, editor controls, templates, and peer/replay loading. The wider GPU path is needed when the rendering group exceeds 32 values.
The tradeoffs are concrete: setup performs validation and identity resolution; overflow reads are more expensive than inline reads; overflow edits allocate; compact IDs have capacity limits; and a layout change needs an explicit boundary.
What I like about this architecture is how clearly it assigns responsibility. Assets preserve identity and designer intent. Session setup validates and binds that data. Runtime consumers use compact memberships and masks. That gives me a way to expand tags and layers across more systems while keeping the frequently accessed data small—and to make compatibility failures explicit when layouts change.
#architecture#engine#csharp#gamedev