Asset Manager¶
Single authority over all CPU-side cooked asset data at runtime. Owns every mesh, material,
texture, and node hierarchy ingested during a session, and owns the GPUMeshMaterials mirror
that drives the MatSB storage buffer read by the G-buffer fragment shader every frame.
Status: Current runtime ownership reference. The manager has 1,024 MiB in the configured
CPU profile and a 256 MiB TLSF container slab; its CPU assets are session-lifetime data. GPU
geometry residency/eviction is a separate RenderResourceManager responsibility.
See also: Engine Architecture · Rendering Domain · Memory Management
Table of Contents¶
- Position in the Pipeline
- Memory Layout
- Data Structures
- Ingest Pipeline
- Deduplication
- IngestMesh
- IngestTexture
- IngestMaterial
- GPU Material Binding — End to End
- Lookup API
- Thread Safety
- Initialization Order Contract
- Known Gaps
Position in the Pipeline¶
flowchart LR
disk[(Disk\n.zemesh / .zematerial\n.png textures)]
importers["Importers\nGltfImporter\nAssimpImporter"]
codec["AssetCodec\nSerialize* / Deserialize*"]
AM["AssetManager\nIngestMesh\nIngestTexture\nIngestMaterial"]
RRM["RenderResourceManager\nSubmitTextureFile\nUpdateBuffer"]
GPU[("GPU\nTextureArray\nMatSB · VertexSB")]
renderer["GraphicRenderer\nDrawScene"]
disk -->|cook-time| importers
disk -->|load-time| codec
importers --> codec
codec --> AM
AM -->|TextureHandle| RRM
RRM -->|async GPU upload| GPU
AM -->|GPUMeshMaterials| renderer
renderer -->|UpdateBuffer every frame| GPU
AssetManager does not stream assets in or out. Everything ingested lives for the lifetime
of the session in a fixed arena. A future StreamingManager will sit above it and manage
residency.
Memory Layout¶
The manager receives the 1,024 MiB AssetManager arena configured by MemoryBudgetConfig.
The meshes, node hierarchies, materials, texture-handle map, and material-slot map use a 256 MiB
TLSF slab so their variable-size growth can reclaim old blocks. GPUMeshMaterials, textures,
and some maps remain arena-backed. This is explicit engine-owned allocation, not a claim that
third-party import/decode code never uses another allocator.
graph TD
root["Configured CPU profile limit · 8 GiB"]
asset["AssetManager arena · 1,024 MiB\n256 MiB TLSF container slab"]
meshes["Meshes\nArray of AssetMesh · cap 5000"]
hier["NodeHierarchies\nArray of AssetNodeHierarchy · cap 5000"]
mats["Materials\nArray of AssetMaterial · cap 5000"]
tex["Textures\nArray of AssetTexture · cap 5000"]
gpu["GPUMeshMaterials\nArray of MeshMaterial · cap 5000\nmirrors Materials 1:1"]
uuid_map["UUIDToTextureHandle\nHash map: uuid → TextureHandle"]
hier_map["MeshToHierarchySlot\nHash map: MeshUUID → slot_index"]
registry["AssetRegistry\nuuid → SlotHandle + AssetState"]
root --> asset
asset --> meshes
asset --> hier
asset --> mats
asset --> tex
asset --> gpu
asset --> uuid_map
asset --> hier_map
asset --> registry
Data Structures¶
Flat arrays and slot indexing¶
Access to any asset is always by slot index extracted from an AssetHandle. The UUID is
first resolved through AssetRegistry to an AssetHandle; the handle encodes the type and
slot in a single uint32_t:
31 28 27 0
┌──────────┬─────────────────────────────────┐
│ type(4) │ slot index (28) │
└──────────┴─────────────────────────────────┘
CreateHandle(slot, type) and ReadAssetHandleIndex(handle) are the only encode/decode
points — no caller should bit-shift manually.
MeshMaterial — the GPU struct¶
MeshMaterial is uploaded verbatim to MatSB (set 0, binding 5) every frame via
RRM::UpdateBuffer. It mirrors AssetMaterial but replaces UUID texture references with
bindless TextureArray indices.
struct MeshMaterial { // std140 · 96 bytes
gpuvec4 AmbientColor;
gpuvec4 EmissiveColor;
gpuvec4 AlbedoColor;
gpuvec4 SpecularColor;
gpuvec4 RoughnessColor;
gpuvec4 Factors;
uint64_t EmissiveMap; // TextureArray index OR 0xFFFFFFFF (INVALID_MAP_HANDLE)
uint64_t AlbedoMap;
uint64_t SpecularMap;
uint64_t NormalMap;
uint64_t OpacityMap;
uint64_t _padding;
};
Fragment shader gate: if (material.AlbedoMap < INVALID_MAP_HANDLE) — valid textures have
small indices; unset slots hold 0xFFFFFFFF and are skipped, falling back to the material
color fields.
Ingest Pipeline¶
Deduplication¶
Every Ingest* method calls IsRegistered(uuid) first. If the UUID is already in
AssetRegistry, the call is a no-op — the asset is already resident.
IngestMesh¶
flowchart TD
A["IngestMesh(mesh, hierarchy)"]
B{"IsRegistered\nmesh.MeshUUID?"}
C["return — already loaded"]
D["Copy mesh vertices + indices + submeshes\ninto Arena · Meshes[slot]"]
E["RegisterAsset MESH"]
F["Copy hierarchy transforms + names\ninto Arena · NodeHierarchies[hier_slot]"]
G["RegisterAsset MESH_HIERARCHY"]
H["MeshToHierarchySlot[MeshUUID] = hier_slot\nO(1) lookup — no linear scan"]
I["Registry.SetState → Loaded\n(fires hot-reload callbacks)"]
A --> B
B -->|yes| C
B -->|no| D --> E --> F --> G --> H --> I
IngestTexture¶
IngestTexture(uuid, path) is the single canonical texture upload point. Both
IngestTextures (batch) and IngestMaterial (per-slot fallback) call it.
flowchart TD
A["IngestTexture(uuid, path)"]
B{"IsRegistered\nuuid?"}
C["return existing handle\nfrom UUIDToTextureHandle"]
D["Textures.push_use()\nreserve slot"]
E{"path empty?"}
F["snprintf WorkingSpacePath + path\n→ abs_path\nRRM.SubmitTextureFile(abs_path)"]
G{"Handle\nvalid?"}
H["ASSERT FallbackHandle.Valid()\nlog 'not found'\nHandle = FallbackHandle"]
I["ASSERT FallbackHandle.Valid()\nlog 'no path — extraction failed'\nHandle = FallbackHandle"]
J["RegisterAsset TEXTURE\nUUIDToTextureHandle[uuid] = Handle\nreturn Handle"]
A --> B
B -->|yes| C
B -->|no| D --> E
E -->|no| F --> G
G -->|valid| J
G -->|invalid| H --> J
E -->|yes| I --> J
The ASSERT FallbackHandle.Valid() enforces the startup invariant: InitFallbackTexture()
must run before any ingest. A crash here means the initialization sequence is broken — not
a recoverable error.
IngestMaterial¶
flowchart TD
A["IngestMaterial(mat)"]
B{"IsRegistered\nmat.MaterialUUID?"}
C["return — already loaded"]
D["Materials.push(mat)\nGPUMeshMaterials.push_use()\nRegisterAsset MATERIAL"]
E["Copy color fields\n(Albedo, Emissive, Roughness, Specular, Ambient, Factors)"]
F["tex_handle(AlbedoTexUUID, AlbedoTexPath)"]
G{"UUID in\nUUIDToTextureHandle?"}
H["return Handle.Index"]
I{"AlbedoTexPath\nempty?"}
J["IngestTexture(uuid, path)\nreturn new Handle.Index\n— triggers GPU upload now"]
K["return INVALID_MAP_HANDLE\n(shader skips this slot)"]
L["gpu_mat.AlbedoMap = result\n↩ repeat for Emissive · Normal · Opacity · Specular"]
A --> B
B -->|yes| C
B -->|no| D --> E --> F --> G
G -->|yes| H --> L
G -->|no| I
I -->|no| J --> L
I -->|yes| K --> L
The path-fallback branch (IngestTexture called from inside IngestMaterial) is what makes
scene reload and dragged-.zmesh work without a prior IngestTextures call: the material
carries its own texture paths from the .zematerial JSON and can trigger GPU upload on demand.
GPU Material Binding — End to End¶
sequenceDiagram
participant BG as Importer (background thread)
participant AM as AssetManager
participant RRM as RenderResourceManager
participant GPU as GPU
participant RT as GraphicRenderer (main thread)
participant FS as g_buffer.frag
BG->>AM: IngestTextures([tex_0 .. tex_N])
AM->>RRM: SubmitTextureFile(abs_path) per texture
RRM-->>AM: TextureHandle { Index = K }
AM->>AM: UUIDToTextureHandle[uuid] = { Index = K }
BG->>AM: IngestMaterial(mat)
AM->>AM: tex_handle(AlbedoTexUUID) → K
AM->>AM: GPUMeshMaterials[slot].AlbedoMap = K
BG->>AM: IngestMesh(mesh, hier)
AM->>AM: Meshes[slot] = mesh
Note over RT: Next frame — InstancesDirty = true
RT->>AM: GetMeshAsset(MeshUUID) → &Meshes[slot]
RT->>AM: GetAsset for AssetMaterial (sub.MaterialUUID) → &Materials[mat_slot]
RT->>RT: alloc.MaterialId = mat_slot
RT->>RRM: UpdateBuffer(MaterialBuffer, GPUMeshMaterials)
RRM->>GPU: vmaMemcpy → MatSB[mat_slot].AlbedoMap = K
FS->>GPU: mat = FetchMaterial(MaterialIdx)
FS->>GPU: texture(TextureArray[mat.AlbedoMap], uv)
Note over FS: only if mat.AlbedoMap < INVALID_MAP_HANDLE
Lookup API¶
GetAsset<T>(key) resolves a UUID or handle to a pointer into the flat arrays.
Two key types are supported for each of the four asset types (8 specializations total):
graph LR
uuid["uuid key"]
handle["AssetHandle key"]
reg["AssetRegistry\nFindByUUID(uuid)"]
sh["rec→SlotHandle"]
idx["ReadAssetHandleIndex(h)"]
arr["flat array\ne.g. Meshes[index]"]
ptr["T* (or nullptr)"]
uuid --> reg --> sh --> handle
handle --> idx --> arr --> ptr
If the UUID is not registered or the slot index is out of range, all paths return nullptr.
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 |
No standalone synchronization | Caller must not race registry mutation. |
GetAsset<T>(uuid/handle) |
No standalone synchronization | Safe after ingestion is quiescent or under the caller's synchronization. |
GetMeshNodeHierarchy |
No standalone synchronization | O(1) lookup; does not acquire IngestMutex. |
TryGetMeshBounds |
Yes | Acquires IngestMutex and copies the bounds. |
InitFallbackTexture |
Main thread only | Called once during engine init |
IngestMutex is std::recursive_mutex — IngestMaterial can call IngestTexture while
holding the lock without deadlocking.
Initialization Order Contract¶
sequenceDiagram
participant EP as EntryPoint
participant MM as MemoryManager
participant Log as Logger
participant Eng as Engine::Initialize
participant AM as AssetManager
participant RRM as RenderResourceManager
EP->>MM: Initialize(ZGiga(8ULL), Editor())
Note over MM: ZGiga uses uint64_t — no overflow
EP->>Log: Logger::Initialize(LoggingArena)
EP->>Eng: app→Initialize(&manager)
Eng->>AM: AssetManager::Initialize(arena, device, ws_path)
Note over AM: FallbackTextureHandle is invalid here
Eng->>RRM: RenderResourceManager::Initialize()
Eng->>AM: AssetManager::InitFallbackTexture()
Note over AM: FallbackTextureHandle = RRM.GetOrCreateFallbackTexture()
Note over AM: Safe to call Ingest* from this point
Any IngestTexture call that reaches the fallback path before InitFallbackTexture fires:
ZENGINE_VALIDATE_ASSERT(FallbackTextureHandle.Valid(),
"FallbackTextureHandle not initialized — InitFallbackTexture must be called before ingesting assets")
Known Gaps¶
| Gap | Tracking |
|---|---|
| CPU-side cooked asset eviction — manager data remains session-lifetime | Future streaming/asset residency policy |
| GPU geometry residency/eviction policy | Implemented separately in RenderResourceManager; do not infer CPU AssetManager eviction from it. |
| Raster and environment-map import paths remain duplicated | #750 |
| Manual reimport from the content browser needs current ZUI-panel integration | #602 |
| Importer and ZUI regression-test scaffolding is absent | #735 |
| Initial scanner results do not yet enqueue an import batch | VFSScanner currently registers assets; watcher modifications enqueue immediate reimport. |