The resource system was the first part of Trident I wrote. I did not even have a window yet; asset identity was the bit I wanted nailed down before the rest of the engine started leaning on it.

At prototype scale, loading an asset can be a dictionary from path string to object. Trident grew out of that fast. The editor works with source documents. The game loads cooked binary blobs. Some assets create D3D12 resources, so part of loading and eviction has to happen on the render side. Hot reload has to replace live objects without making every component rebind itself. Parent assets need to keep their children alive, otherwise a material instance can outlive the textures it points at.

This post follows that problem through the current Trident resource system. It is still a work-in-progress. Resource systems always seem to be, annoyingly.

#Asset identity

I avoid passing raw file paths around the engine. Trident has two URI wrappers instead.

ContentUri belongs to tools and editor code. It points at source content:

AssetUri belongs to runtime. It points at cooked output:

The file extension disappears during cooking. Fragments become sub-assets, usually through a URI fragment. A material can emit a default material instance as #instance; a generated font can emit its atlas as #atlas; planet processors emit cubemap fragments like #albedo and #normalHeight.

That split keeps authoring detail out of the game. A level editor can care about game:/Levels/Test.level; the runtime only needs the cooked assets:/Game/Levels/Test file under Data/.

ContentUri to AssetUri cook flow
Source assets use ContentUri, cooked runtime assets use AssetUri. Fragments let one source asset produce several runtime assets.

#Cooked files

I will write about the cooking pipeline properly another time. The part that matters here is that the runtime loader never reads raw source assets.

Cooked files are binary payloads with a fixed object header in front. The first three bytes are still the asset type, MAT for material, MSH for mesh, T2D for 2D texture, LVL for level, SHD for shader, and so on. After that comes build metadata: a TCI1 marker, the processor cache signature hash, and the cooked fragment hash.

That header is intentionally boring. It catches a bad type request early, gives the cache enough information to reason about stale output, and means the cooked file does not need its original extension. assets:/Game/Ships/Fighter resolves to a file, the registry validates the header, then hands the payload after the header to the registered IAssetLoadHandler for the requested runtime type.

#Runtime loading

Take a material instance used by a mesh. At runtime the mesh wants an AssetRef<MaterialInstance>. The material instance loader may need to load its parent Material, plus any textures referenced by the instance. Those assets need to stay alive for as long as the material instance stays alive.

The public call most systems use is still small:

Task<Result<AssetRef<TAsset>>> GetAsset<TAsset>(AssetUri assetId, bool loadIfNotLoaded = true)

ResourceRegistry handles the rest. It owns:

If the material instance is already loaded, the registry returns a new AssetRef<MaterialInstance> pointing at the existing sink. If another caller is already loading the same URI, the new caller joins that task. If nothing exists yet, the registry creates an AssetLoadRequest<MaterialInstance> and queues it.

I have not split this into a ResourceManager, ResourceCache, and ResourceScheduler. The registry is all three. That is not especially pure, but the invariant is easier to keep in one place: one URI, one active load, one loaded sink, one dependency graph.

#Dependencies

Dependencies are where resource systems tend to rot quietly.

If a material instance loader calls the registry directly for a texture, the texture might load, but nothing says the material instance owns it. Once the temporary texture handle gets disposed, the material instance can be left pointing at an asset that is queued for eviction. The same problem shows up with meshes owning default material instances, levels owning scripts and skyboxes, and anything else that loads children as part of its own load.

Trident forces loaders through the load context instead. A loader asks for child assets with GetDependency, not GetAsset. It still kicks off the same load request, but it also records a dependency edge from the parent request to the child asset.

If the child succeeds, the returned ref is staged against the parent request. When the parent completes, those staged refs are attached to the parent handle through a ConditionalWeakTable. If the child fails, the failure propagates to every request depending on it.

There are Roslyn analyser rules backing this up. Direct ResourceRegistry injection into loaders is banned, and loader calls to GetAsset, GetAssets, or AssetFuture.Load are banned. Loaders have to go through the context-aware dependency path. I do not want this rule living in a comment and my memory.

#Asset refs

AssetRef<T> is the owning handle for a loaded asset. It is disposable, cloneable, and ref-counted. Internally it points at an AssetRefCountedSink<T>, which owns the current asset object and a version counter.

That sink is the trick that makes hot reload tolerable. Reloading an asset does not invalidate every existing handle. The registry reuses the same sink, swaps the payload, and increments Version. Existing AssetRef<T> values still point at the same sink, but Asset returns the replacement object.

AssetRef hot reload sink
AssetRef points at a stable sink. Hot reload replaces the payload and increments the sink version while existing handles stay valid.

The handle is closer to a ticket for a mutable slot than a raw pointer to an object. Systems that cache derived state can watch Version; systems that dereference the asset each frame get the new value without a rebinding pass.

When the last AssetRef<T> is disposed, the sink queues the asset for eviction. Eviction is processed on Process_RenderThread(), which keeps destruction on the side of the engine that understands render-resource lifetime. Some GPU APIs are fussy about which thread creates and destroys device objects. I would rather pay for explicit handoff than debug a driver-shaped ghost later.

#Load flow

The load path is a small state machine:

  1. A caller asks ResourceRegistry.GetAsset<T>(assetId).
  2. The registry checks the loaded dictionary.
  3. If the asset is not loaded, it checks the in-flight dictionary.
  4. If no compatible request exists, it creates an AssetLoadRequest<T>.
  5. The resource thread resolves the AssetUri into a file under Data/.
  6. A file I/O worker reads the bytes.
  7. The resource thread validates the file header.
  8. The registered IAssetLoadHandler<T> deserialises the payload.
  9. The loader switches threads if it needs device-affine work.
  10. Completion creates or updates the loaded sink and resolves the task.
Resource load pipeline across threads
ResourceRegistry owns the load state machine. File reads, resource-thread work, and render-resource work move through explicit queues.

There is one resource thread, two file I/O threads, and a render-resource thread when the renderer is not thread-sensitive. Loaders can use SwitchToRenderThread() and SwitchToResourceThread() on the load context. The call site says when ownership changes, rather than hiding it behind a helper that happens to poke D3D12 from the wrong place.

Texture loading is the usual example. The CPU-side payload can be deserialised on the resource thread, but the actual GPU texture has to be created on the render side. Shader, material, mesh, and skybox loaders follow the same pattern in different shapes.

#Futures

AssetFuture<T> is the other common handle. It stores an AssetUri, but does not load it immediately. When code calls Load(ResourceRegistry), the future asks the registry for an AssetRef<T> and keeps that ref alive until Unload or Dispose.

In practice I use three shapes:

The editor maps these into typed content pickers instead of asking users to type URI strings. EcsDomService converts runtime asset fields into TypedContentUri<T> for document editing, and LevelProcessor converts those typed content URIs back into cooked AssetUri values.

This is the part of the design I still like most. Component code can say AssetRef<MeshInstance> or AssetFuture<Material> and the editor, cooker, level loader, and runtime registry all get enough information from the type.

#Hot reload

Editor reload is built on the same primitives as normal loading.

When a document is saved, AssetReloadMonitor cooks the changed asset, then schedules a reload on the game frame provider. DocumentAssetReloader<T> calls ResourceRegistry.ForceReloadIfAlreadyLoaded<T>. Shader editing has a more direct path: ShaderReloaderService watches .hlsl and .hlsli changes, debounces them, cooks, and reloads affected shader assets.

Because loaded assets sit behind stable sinks, existing handles survive reload. The asset object changes, the sink version increments, and dependent systems can either read the new value directly or rebuild cached state when the version changes.

#Transient assets

The transient:/ scheme exists mostly for tools and previews.

An editor can generate a temporary material instance, cook it through the same pipeline, and load it as assets:/Transient/.... MutableTransientAssetService<T> coalesces rapid edits so dragging a slider does not create a long backlog of stale preview cooks.

That means preview assets exercise the real cooker and real runtime loaders. There is less special-case editor code, and fewer differences between what the material editor shows and what the game will load later.

#Sharp edges

ResourceRegistry does a lot. Cache, load scheduling, dependency graph, thread handoff, reload, eviction. Keeping those invariants together has helped, but the file is dense and changes need care. I can feel the cost of that decision now.

AssetRef<T> has a finalizer, but deterministic disposal is still the intended path. Leaked refs eventually release, not promptly. That is fine as a last resort, but relying on the GC to run a finalizer before a GPU lifetime issue appears is a bad plan.

Dependency lifetime attachment is keyed by the specific returned handle object. Clones share the sink, but the ConditionalWeakTable entry belongs to the original handle. That has worked so far, but it is one of those details that needs tests because it is easy to reason about incorrectly.

The cook pipeline has no central runtime manifest. Runtime loads files directly from Data/ by URI. That keeps things simple, but discovery, packaging, and validation are still more tool-side than runtime-side.

The resource threads are custom and deliberately narrow. That is good for ownership, but it means loader authors need to stay inside the model. The analysers help, but the model still needs better documentation and tests around cancellation, shutdown, and reload races.

#Type signatures

The part that has held up is making resource ownership visible in type signatures.

If a component has AssetRef<MeshInstance>, it owns a live mesh instance ref. If a render task has AssetFuture<Material>, it has a typed URI that can be loaded when the task is ready. If a processor emits an AssetUri<TextureCube>, it is describing a cooked dependency, not smuggling a path string through the system.

There is still a lot to improve, especially around packaging, tests, and some editor hard-coding. But most systems no longer need to know how a texture gets from game:/Textures/Foo.png to a GPU resource. They ask for the thing they need, keep the handle for as long as they need it, and let the registry deal with the ugly middle.