Asset Manager¶
Status: Implemented; retained as a completed implementation record.
Location: ZEngine/ZEngine/Managers/AssetManager.h/.cpp
Depends on: import-pipeline.md, render-resource-manager.md, vfs-ticket6-asset-registry.md
Related issues: #603, #635
Maintenance boundary:
reference/asset-pipeline.mdis the current ownership and thread-safety reference. This ticket retains the original migration rationale, diagrams, and API sketches; where one conflicts with the source or that current reference, it is historical rather than an API contract. In particular, CPU asset lifetime is session-long while GPU geometry residency and eviction are now implemented separately inRenderResourceManager.
Overview¶
AssetManager is the single authority over all CPU-side asset data at runtime. It owns every
AssetMesh, AssetMaterial, AssetTexture, and AssetNodeHierarchy that has been cooked
by the import pipeline. It also owns the GPU-side material data mirror (GPUMeshMaterials)
that is uploaded to the MatSB storage buffer every frame.
It is not a CPU asset streaming system and does not evict its ingested CPU assets. They live
for the lifetime of the engine session in a fixed arena. GPU geometry residency, eviction, and
reload are active RenderResourceManager responsibilities, not evidence of CPU asset eviction.
Position in the Pipeline¶
flowchart LR
disk[(Disk\n.zemesh / .zematerial\n.png textures)]
importers["Importers\nGltfImporter\nAssimpImporter"]
codec["AssetCodec\nSerialize*\nDeserialize*"]
AM["AssetManager\nIngestMesh\nIngestTexture\nIngestMaterial"]
RRM["RenderResourceManager\nSubmitTextureFile\nUpdateBuffer"]
GPU[("GPU\nTextureArray\nMatSB\nVertexSB")]
renderer["GraphicRenderer\nDrawScene"]
disk -->|"cook-time"| importers
disk -->|"load-time"| codec
importers --> codec
codec --> AM
AM -->|"TextureHandle"| RRM
RRM -->|"GPU upload"| GPU
AM -->|"GPUMeshMaterials[]"| renderer
renderer -->|"UpdateBuffer"| GPU
Memory Layout¶
Correction: this section described an earlier single-arena design that was superseded by the
Phase 2 TLSF work. There is no intermediate 512 MB sub-arena — AssetManager::Arena is set
directly to the full ArenaAllocator* passed into Initialize() (the engine's budgeted asset
arena). Sitting alongside it is a separate 256 MB TLSFSlab (ContainerSlab,
CONTAINER_SLAB_BYTES = 256 * 1024 * 1024) that specifically backs the containers expected to
grow/realloc repeatedly (Meshes, NodeHierarchies, Materials, UUIDToTextureHandle,
UUIDToMaterialSlot) so their reallocation can extend in-place inside the slab instead of
accumulating dead blocks in the parent arena. GPUMeshMaterials, Textures,
MeshToHierarchySlot, and AssetRegistry are allocated directly from Arena, not the slab.
graph TD
ParentArena["AssetManager::Arena (engine-budgeted, no intermediate carve)"]
ContainerSlab["AssetManager::ContainerSlab (TLSFSlab, 256 MB)"]
Meshes["Meshes: Array of AssetMesh\nmax ~5000 entries — in slab"]
Hierarchies["NodeHierarchies: Array of AssetNodeHierarchy\nmax ~5000 entries — in slab"]
Materials["Materials: Array of AssetMaterial\nmax ~5000 entries — in slab"]
UUIDTexMap["UUIDToTextureHandle\nHash map: uuid → TextureHandle — in slab"]
UUIDMatMap["UUIDToMaterialSlot\nHash map: uuid → slot — in slab"]
Textures["Textures: Array of AssetTexture\nmax ~5000 entries — direct from Arena"]
GPU["GPUMeshMaterials: Array of MeshMaterial\nmirrors Materials 1:1 — direct from Arena"]
HierMap["MeshToHierarchySlot\nHash map: MeshUUID → slot — direct from Arena"]
Registry["AssetRegistry\n(uuid → SlotHandle + state) — direct from Arena"]
ParentArena --> ContainerSlab
ContainerSlab --> Meshes
ContainerSlab --> Hierarchies
ContainerSlab --> Materials
ContainerSlab --> UUIDTexMap
ContainerSlab --> UUIDMatMap
ParentArena --> Textures
ParentArena --> GPU
ParentArena --> HierMap
ParentArena --> Registry
Data Structures¶
CPU-side flat arrays¶
All arrays are pre-allocated at Initialize() with capacity 5000. Access is by slot index,
not by UUID — UUIDs are resolved through AssetRegistry to a SlotHandle, then the slot
index is extracted from the handle.
| Array | Element type | Index key |
|---|---|---|
Meshes |
AssetMesh |
ReadAssetHandleIndex(rec->SlotHandle) |
NodeHierarchies |
AssetNodeHierarchy |
MeshToHierarchySlot[MeshUUID] |
Materials |
AssetMaterial |
ReadAssetHandleIndex(rec->SlotHandle) or UUIDToMaterialSlot[MaterialUUID] |
Textures |
AssetTexture |
ReadAssetHandleIndex(rec->SlotHandle) |
GPUMeshMaterials |
MeshMaterial |
same slot as Materials |
Correction — missing field: UUIDToMaterialSlot (HashMap<uuid, uint32_t>) is a dedicated
per-type dedup map for materials, analogous to UUIDToTextureHandle for textures and
MeshToHierarchySlot for hierarchies. It was omitted from the original table above.
MeshMaterial (GPU struct)¶
MeshMaterial is uploaded verbatim to MatSB (set 0, binding 5) every frame. It mirrors
AssetMaterial but replaces texture UUID references with bindless array indices.
struct MeshMaterial { // std140 layout, 96 bytes
gpuvec4 AmbientColor;
gpuvec4 EmissiveColor;
gpuvec4 AlbedoColor;
gpuvec4 SpecularColor;
gpuvec4 RoughnessColor;
gpuvec4 Factors;
uint64_t EmissiveMap; // bindless TextureArray index or INVALID_MAP_HANDLE (0xFFFFFFFF)
uint64_t AlbedoMap;
uint64_t SpecularMap;
uint64_t NormalMap;
uint64_t OpacityMap;
uint64_t _padding;
};
The fragment shader gate: if (material.AlbedoMap < INVALID_MAP_HANDLE) — valid textures
have small indices; unset slots hold 0xFFFFFFFF and are skipped.
AssetHandle encoding¶
31 28 27 0
┌──────────┬─────────────────────────────────┐
│ type(4) │ slot index (28) │
└──────────┴─────────────────────────────────┘
CreateHandle(slot, type) and ReadAssetHandleIndex(handle) encode/decode this.
Ingest Pipeline¶
Deduplication¶
Correction: Ingest* methods deliberately do not call IsRegistered(uuid) for dedup.
VFSScanner pre-registers asset UUIDs in AssetRegistry before the asset is actually ingested
(uploaded into the flat arrays), so IsRegistered/GetAsset would give a false positive — the
UUID looks registered even though no Mesh/Texture/Material slot exists for it yet. Instead,
each Ingest* method checks its own dedicated map before inserting: IngestMesh checks
MeshToHierarchySlot[MeshUUID], IngestTexture checks UUIDToTextureHandle[uuid], and
IngestMaterial checks UUIDToMaterialSlot[MaterialUUID]. If the UUID is already present in the
relevant map, the ingest is a no-op and the existing slot/handle is returned; otherwise the asset
is uploaded and the map is updated.
Also undocumented previously: ReloadFromDisk(Core::Memory::ArenaAllocator* scratch) (re-imports
from disk using a scratch arena), GetOrCreateUUID(ctx, asset_path, importer_name) (resolves or
mints a UUID for a path via the importer), and bounding-sphere computation performed inside
IngestMesh.
IngestMesh¶
flowchart TD
A["IngestMesh(mesh, hierarchy)"]
B{IsRegistered\nmesh.MeshUUID?}
C[return — already loaded]
D["Copy mesh data into Arena\nMeshes[slot] = mesh\nRegisterAsset MESH"]
E["Copy hierarchy data into Arena\nNodeHierarchies[hier_slot] = hierarchy\nRegisterAsset MESH_HIERARCHY"]
F["MeshToHierarchySlot[MeshUUID] = hier_slot\n→ O(1) lookup replaces linear scan"]
G["Registry.SetState → Loaded"]
A --> B
B -->|yes| C
B -->|no| D
D --> E
E --> F
F --> G
IngestTexture (single canonical upload)¶
flowchart TD
A["IngestTexture(uuid, path)"]
B{IsRegistered\nuuid?}
C["return existing handle\nfrom UUIDToTextureHandle"]
D["Textures.push_use() — allocate slot"]
E{path empty?}
F["Build absolute path:\nWorkingSpacePath + '/' + path\nRRM.SubmitTextureFile → TextureHandle"]
G{Handle valid?}
H["ASSERT FallbackHandle.Valid()\nLog 'not found'\nnew_tex.Handle = FallbackHandle"]
I["ASSERT FallbackHandle.Valid()\nLog 'no path — extraction failed'\nnew_tex.Handle = FallbackHandle"]
J["RegisterAsset TEXTURE\nUUIDToTextureHandle[uuid] = Handle\nreturn Handle"]
A --> B
B -->|yes| C
B -->|no| D
D --> E
E -->|no| F
F --> G
G -->|valid| J
G -->|invalid| H
H --> J
E -->|yes| I
I --> J
The assert on
FallbackHandle.Valid()enforces the initialization contract:AssetManager::InitFallbackTexture()must be called before any ingest can use the fallback. A crash here means the engine startup sequence is broken, not a recoverable error.
IngestTextures (batch)¶
Iterates the array and calls IngestTexture per element. The IngestMutex is acquired once
per element (inside IngestTexture) since the mutex is std::recursive_mutex.
IngestMaterial¶
flowchart TD
A["IngestMaterial(mat)"]
B{IsRegistered\nmat.MaterialUUID?}
C[return — already loaded]
D["Materials.push(mat)\nRegisterAsset MATERIAL\nGPUMeshMaterials.push_use()"]
E["Copy color fields to gpu_mat\n(AlbedoColor, EmissiveColor, ...)"]
F["tex_handle(AlbedoTexUUID, AlbedoTexPath)"]
G{UUID in\nUUIDToTextureHandle?}
H["return Handle.Index"]
I{path empty?}
J["IngestTexture(uuid, path)\nreturn new Handle.Index"]
K["return INVALID_MAP_HANDLE"]
L["gpu_mat.AlbedoMap = result\n(repeat for Emissive, Normal, Opacity, Specular)"]
A --> B
B -->|yes| C
B -->|no| D
D --> E
E --> F
F --> G
G -->|yes| H --> L
G -->|no| I
I -->|no| J --> L
I -->|yes| K --> L
The tex_handle fallback path — calling IngestTexture from inside IngestMaterial — is
what makes scene reload and dragged-.zmesh work: the material knows its own texture paths
and can trigger GPU upload without a prior IngestTextures call.
GPU Material Binding — End to End¶
sequenceDiagram
participant Import as GltfImporter (background thread)
participant AM as AssetManager
participant RRM as RenderResourceManager
participant GPU as GPU (TextureArray + MatSB)
participant Render as GraphicRenderer (main thread)
participant Shader as g_buffer.frag
Import->>AM: IngestTextures([tex_0..tex_N])
AM->>RRM: SubmitTextureFile(abs_path) per texture
RRM-->>AM: TextureHandle{Index=K}
AM->>AM: UUIDToTextureHandle[uuid] = {Index=K}
Import->>AM: IngestMaterial(mat)
AM->>AM: tex_handle(AlbedoTexUUID) → K
AM->>AM: GPUMeshMaterials[slot].AlbedoMap = K
Import->>AM: IngestMesh(mesh, hier)
AM->>AM: Meshes[slot] = mesh
Note over Render: Next frame — InstancesDirty
Render->>AM: GetMeshAsset(MeshUUID)
AM-->>Render: &Meshes[slot]
Render->>AM: GetAsset for AssetMaterial (sub.MaterialUUID)
AM-->>Render: &Materials[mat_slot]
Render->>Render: alloc.MaterialId = mat_slot
Render->>RRM: UpdateBuffer(MaterialBuffer, GPUMeshMaterials)
RRM->>GPU: vmaMemcpy → MatSB[mat_slot].AlbedoMap = K
Shader->>GPU: FetchMaterial(MaterialIdx) → mat
Shader->>GPU: texture(TextureArray[mat.AlbedoMap], uv) if AlbedoMap < INVALID
Lookup API¶
GetAsset<T>(key) is a template with two key types:
graph LR
GetAssetUUID["GetAsset for type T (uuid)"]
GetAssetHandle["GetAsset for type T (AssetHandle)"]
Registry["AssetRegistry\nFindByUUID(uuid)"]
SlotHandle["rec->SlotHandle"]
Index["ReadAssetHandleIndex(h)"]
Array["Flat array\ne.g. Meshes[index]"]
GetAssetUUID --> Registry
Registry --> SlotHandle
SlotHandle --> GetAssetHandle
GetAssetHandle --> Index
Index --> Array
Specializations exist for AssetMesh, AssetMaterial, AssetTexture, AssetNodeHierarchy
with both uuid and AssetHandle key types — 8 specializations total.
Thread Safety¶
| Method | Thread-safe | Notes |
|---|---|---|
IngestMesh |
Yes | Acquires IngestMutex (recursive) |
IngestTexture |
Yes | Acquires IngestMutex |
IngestTextures |
Yes | Calls IngestTexture per element |
IngestMaterial |
Yes | Acquires IngestMutex; may call IngestTexture (recursive lock) |
IsRegistered |
Yes | Read-only registry lookup |
GetAsset<T>(uuid) |
Yes | Read-only after ingest completes |
GetMeshAsset |
Yes | Read-only |
GetMeshNodeHierarchy |
Yes | O(1) via MeshToHierarchySlot |
InitFallbackTexture |
Main thread only | Called once during engine init |
IngestMutex is std::recursive_mutex — IngestMaterial can safely call IngestTexture
without deadlock.
Initialization Order Contract¶
sequenceDiagram
participant EP as EntryPoint
participant MM as MemoryManager
participant Log as Logger
participant Eng as Engine
participant AM as AssetManager
participant RRM as RenderResourceManager
EP->>MM: Initialize(8 GB, Editor())
EP->>Log: Logger::Initialize(LoggingArena)
EP->>Eng: Engine::Initialize()
Eng->>AM: AssetManager::Initialize(AssetArena, device, ws_path)
Note over AM: FallbackTextureHandle is NOT set yet
Eng->>RRM: RenderResourceManager::Initialize()
Eng->>AM: AssetManager::InitFallbackTexture()
Note over AM: FallbackTextureHandle = RRM.GetOrCreateFallbackTexture()
Note over AM: ASSERT fires in IngestTexture if called before this point
Any IngestTexture call before InitFallbackTexture will hit:
ZENGINE_VALIDATE_ASSERT(s_Instance->FallbackTextureHandle.Valid(),
"FallbackTextureHandle not initialized — InitFallbackTexture must be called before ingesting assets")
Known Gaps and Future Work¶
See issue #635 for the full memory budget redesign tracking. Key items relevant to AssetManager:
- No eviction — all ingested assets live until shutdown. A
StreamingManager(2 GB budget, tracked in #635) will sit above AssetManager and manage which assets are resident. - AssetManager arena sizing —
s_Instance->Arenais the full engine-budgeted arena passed intoInitialize()(no intermediate carve; see the corrected "Memory Layout" section above), plus a 256 MBContainerSlab(TLSFSlab) for the containers that grow/realloc repeatedly. The remaining budget headroom is used forImportCoordinatorandRenderResourceManagerobjects placed viaZPushStructCtor. - Duplicate importer instances —
AssetImporterUIComponentallocates its ownGltfImporter(64 MB) andAssimpImporter(350 MB) in addition to the engine's importers. Consolidation tracked in #635.