![]() |
vkmEngine 1.0.0
A C++ game engine · vkmengine.com
|
ResourceManager is the single owner of every asset the engine loads: meshes, textures, materials, fonts, skeletons, animation clips and sounds. Assets are referenced from components and the render view through type-safe generational handles, and the GPU-uploadable ones sync through a per-resource version counter.
It stores those types and no others (IS_ENGINE_ASSET), each in a slot made when the manager is. A slot made on first use for a type a gameplay module declared would carry the module's code, and outlive the module across a script reload, so storing one is a compile error.
Each handle wraps a StorageIndex (index + generation), so a stale handle is detectable - ask isAlive(handle) before reaching through one you did not just make. It is not safe to use: get, edit and remove assert on it, and VKM_ASSERT compiles to nothing in release, so a stale handle aborts a debug build and reads freed storage in a shipped one. The generation exists so you can tell, not so the manager can absorb the mistake.
A handle is a runtime identity: it names a slot in one session's ResourceManager, which is why serialization stores names instead. The authored counterpart is AssetRef<Asset> (resource/asset_ref.h) - a name plus the asset kind it names - which is how a behavior field points at an asset and how that asset ends up in the scene's assets block. See scripting.md.
Every asset inherits Resource. Its four identity fields are private, read through const accessors, and written only by ResourceManager - the one friend the class has. The manager keeps a per-type name index and guarantees names are unique and non-empty, so a name written behind its back would leave findByName looking for a string the asset no longer carries - which is why nothing else can write one.
One name is one asset, and add() holds that by replacing what stands under a name it is given rather than suffixing the newcomer into wall_512 (2). That is what makes adding by name repeatable, which is what a script reload needs - onStart runs again, so a behavior that builds an asset builds it a second time. The suffix is still there for an asset that arrives with no name, which is claiming no identity, and for rename(), where a collision is a slip rather than a request. A caller that wants a second asset asks for a free name and says so by passing one - the editor's "New Material" picks the name before it calls.
| Accessor | Type | Notes |
|---|---|---|
| name() | const std::string& | Stable identity for serialization and look-up. Assigned by add(asset, name), changed by rename() |
| version() | uint64_t | Started at 1 by add(), moved on by commit(), swapValue() and an add() that replaced this asset; backends compare to skip re-upload |
| uid() | uint64_t | Process-unique instance id stamped by add(). A handle names a slot; this names the asset in it, which is how an async completion knows the graph did not change under it |
| isHidden() | bool | When true, filtered from pickers / Asset Browser / scene save (previews, fallbacks). Set via addPrivate() |
| sourceJson() | nlohmann::json& | The asset's recipe (loader/generator descriptor). The editor cooker bakes it into the library + cooked cache; scenes reference the asset by name, not by this descriptor |
The source descriptor is held by unique_ptr against a forward-declared nlohmann::json so headers don't drag the JSON header in; that is why Resource's Rule of 5 is defined out of line in resource.cpp, where the full type is visible. hasSource() tests the slot, and the const sourceJson() asserts rather than allocating.
addPrivate() is what marks an asset hidden, and the editor is its only caller: the Asset Browser's preview sphere and neutral thumbnail material, the Material Editor's preview primitives. The cooker leaves them out of the manifest and AssetSerializer refuses to write a reference to one, so save files contain only user-relevant content.
Every kind the asset library holds is named by one enum, AssetType (resource/asset_type.h). Nothing on disk carries its numeric value: the manifest and the scene write the name, and a cooked file carries its own kind tag. It sits beside the assets rather than inside AssetLibrary because naming a kind is not the same job as owning the database, and code that needs only the tag should not have to include the manifest, its map and <filesystem> with it. FontAsset is the one kind with no value there - a font is baked at startup, not cooked into the library.
A mesh is skinned iff skin is non-empty - the asset already knows, so no component has to say so.
The skin rides in its own stream rather than inside Vertex because folding four indices and four weights in would cost every vertex of every mesh in the engine 25% more bandwidth, paid hardest by the shadow pass, which reads only aPos and replays the geometry per cascade tile and per cube face. A rock does not pay for skinning. Indices are 16-bit because the cooked format has no migration path and an 8-bit index would weld a 255-bone ceiling into it permanently; weights are quantised so the four bytes sum to exactly 255, which makes w / 255.0 sum to exactly 1.0 and spares every vertex stage a renormalise.
skinRadius is computed, not authored: computeAndSetSkinRadius(skeleton) sits beside computeAndSetBounds() and is owed by whoever fills skin, exactly as the bounds are owed by whoever fills vertices. There is one implementation because leaving the field at zero is not a smaller box but a wrong one - a posed character is bounded by the box of its posed bone origins inflated by this radius (see Visibility), and the culls keep exactly what the box says, so an under-sized box does not over-draw, it deletes the character.
skeleton is a name, not a handle: a compatibility tag rather than a dependency. The mesh uploads its skin stream either way and the pose it is drawn with comes from whatever rig is driving it, so the name is what lets the runtime report the failure that actually happens - a rig assigned to the wrong character
TextureAsset extends Resource plus the engine-level TextureParams (width, height, internalFormat / format / type, wrapS / wrapT, filterOverride, generateMipmaps, mipLevels). It owns the raw pixelData - every level mipLevels names, level 0 first; an imported texture is named by its file, so name() says where it came from. The backend converts the params to GL state at upload time. The runtime frees the pixels once the backend holds them (RenderSystem::releaseUploadedPixels, asked through RenderBackend::holdsPixels for the upload of the asset's current version), since a game never reads them again; the asset keeps its params. The editor keeps them, because its cook bakes from them. That an asset has no pixels is therefore not that it failed: the backend's missing-texture warning asks its own mirror whether the pixels ever arrived. What the texels mean is its TextureUsage - Color (albedo, emission: sRGB, filtered as light), Data (roughness, masks, packed maps: linear numbers) or Normal (a tangent-space direction). Nothing in an image file says which, so whoever imports a texture states it - a material slot knows what it samples - and the recipe records it. Once decoded, the usage and the colour space are internalFormat's and nothing else's: each usage is stored in formats no other takes (colour the sRGB ones, a normal RG8/BC5RG, data the rest), and isSrgb() and usage() read them back off it. An import still decoding cannot choose its format until the channel count is known, so its stub carries the four-channel format of the right usage, and the decode settles which of that usage's formats it is.
A normal map keeps its x and y alone, as RG8, and the PBR shader rebuilds z from them; storing z buys nothing and costs a block format that holds two channels better than any that holds three. The decode keeps a file's own channel count otherwise, except where no format would sample it the way the shader reads it: grey and alpha, and grey colour, are widened to four channels (decodeChannels, resource/texture_format.h). A grey data file stays one channel, and the backend swizzles a one-channel texture (R8, BC4R) to read its red in all three colour channels with an opaque alpha - so a grey roughness map bound where a shader reads roughness from green and metalness from blue gives it the grey, as the file decoded to colour would, rather than zero. A model's maps that sit beside it as files are loaded by loadTexture like any other file texture, so the import and the recipe that reloads it decode them by the one rule. Pixel rows are tightly packed, which is why the backend sets GL_UNPACK_ALIGNMENT to 1 when it starts.
filterOverride is the texture's own say over how it is sampled, and it is deliberately narrow: None (the default) or Nearest. Filtering is otherwise a machine-quality trade owned by RenderSettings::textureFiltering, but some content is wrong when its texels are blended at any quality level - pixel art, lookup tables, UI sprites - and only the texture knows that. A texture that states Nearest keeps it whatever the setting says; everything else follows the setting, so a filtering menu still reaches the whole scene. The cost question (bilinear against trilinear against a degree of anisotropy) has no per-asset answer and is not expressible here. resolveTextureFilter in the GL backend is the one place the two meet.
A file texture states it in its recipe, beside usage and generateMipmaps:
The key is absent from a texture that has no opinion, which is nearly all of them. Code that builds a TextureAsset directly sets params.filterOverride instead. The editor has no per-texture import panel, so those two are the authoring surface.
An import holds what the file decoded to: one level of texels. What is cooked is what the GPU samples, built once by the cooker (AssetCooker::bakeTexture, src/tools/cook/texture_bake.cpp) rather than at every load:
Three kinds of texture stay texels: one smaller than a block (under 4x4 - the 1x1 fallbacks) and one that states Nearest (pixel art and lookup tables are wrong when a block's endpoints round them, for the reason they asked not to be blended) still get their chain; one that is not 8-bit is passed through as it loaded, with the one level it came with.
The runtime then only uploads. A texture that carries levels or blocks is created empty and has each level specified as it came (glCompressedTexImage2D for blocks), with GL_TEXTURE_MAX_LEVEL pinned to the last; GL builds a chain only for a texture that asks for one and carries a single uncompressed level (buildsMipsAtUpload) - an import the editor drew before it was cooked, or a float texture. isMipmapped is the one answer to "does this texture sample through a chain", and the filter resolve reads it.
Two encoders do the blocks. BC4 and BC5 are the engine's own (src/tools/cook/bc4_encoder.h): each block tries both of the format's palettes, starts each from the block's extremes and refines the endpoints by least squares against the indices the texels chose, a few rounds - which takes about a quarter off the error of stopping at the extremes on noisy blocks, and stays in integers, so its bytes are the same on every build. BC7 is basis_universal's scalar bc7e, maintained upstream, the one file of that submodule compiled, at its basic level, a row of blocks per ThreadPool task. Measured on project_alpha's 2048x2048 art with 8 threads (RGB PSNR):
| Level | Colour map | Normal map |
|---|---|---|
| ultrafast | 0.2 s, 46.3 dB | 0.1 s, 30.8 dB |
| veryfast | 1.1 s, 47.0 dB | 1.5 s, 35.1 dB |
| basic | 1.5 s, 47.1 dB | 5.1 s, 36.0 dB |
| slow | 3.9 s, 47.1 dB | 4.7 s, 36.1 dB |
basic is where a normal map stops improving, and a normal map is where BC7 loses most; colour is past what an eye can tell at every level from veryfast. A full cook of a project pays this once - a texture re-bakes only when its recipe or its source moved. A normal map does not go through BC7 at all: it is stored as x and y and cooked to BC5, each level renormalised before it is stored, so the table's normal-map column is what BC7 would make of one. project_alpha's cooked textures shrink from 1450 MB (level 0 alone) to 670 MB with every chain.
Full PBR material. The scalar properties cover:
It carries texture handles for albedo, normal, metallic, roughness, a combined metallic-roughness slot, AO-metallic-roughness (glTF), AO, emission, height, clearcoat, transmission.
All optional PBR features are runtime toggles: one shared PBR ubershader branches on the individual material scalars and texture-present uniforms at draw time. There is no feature bitset and no per-variant compiled shaders; see Rendering.
MaterialType is Opaque = 0, Transparent = 1, Unlit = 2, or AlphaMask = 3. The backend partitions the visible draws by type (GLBackend::partitionDrawables): Opaque and Unlit share the opaque bucket and AlphaMask has its own, both writing depth; Transparent is drawn last, back to front, after one copy of the opaque + sky scene that a transmissive surface refracts.
A baked SDF glyph atlas: the atlas pixels, its dimension, the vertical metrics, and a per-glyph table. Self-contained on purpose - it owns its texels rather than a TextureHandle - which is what lets it survive a scene load: the font is engine-owned (baked once at startup, never written to a scene file), so ResourceManager::swap and clear leave the font slot where it is rather than trading it away with the rest of the graph. See In-game UI.
A rig, as a flat array rather than a tree:
Two decisions carry the rest of the skeletal path:
Bones are indices, not entities. A hundred entities per character would be walked by the hierarchy, listed in the hierarchy panel and written to the scene file, for data that is rebuilt every frame and has no authoring meaning. An index also maps straight onto a rigid body: a RagdollBone names its bone by index.
parent < index is a validated invariant, not a convention. The importer emits bones depth-first, and findSkeletonFault (skeleton_asset.h) states the rule once: the cooked reader and writer refuse a skeleton that breaks it, and SkeletalAnimationSystem leaves a rig built in code that breaks it unposed. findClipFault is the same for a clip. That is what makes composing a pose one forward loop with no recursion and no visited set, and what makes a cycle unrepresentable rather than something every walk has to defend against.
bindPose is stored rather than derived from inverseBind, because recovering it means inverting and re-localising, which is lossy the moment a bone carries scale. The three vectors are parallel and always the same length; the writer refuses a skeleton where they are not.
A baked clip: every bone's keys in six flat arrays, with a per-bone table of ranges into them.
AnimationTrack<T> is deliberately not reused here. Three tracks over a hundred bones is three hundred heap vector pairs and three hundred easing function pointers for one clip; six flat arrays are six allocations, bulk-writable to the cooked file and cache-linear over a bone sweep. Easing goes with it - keys arrive from a DCC tool already baked at its own sample rate, and there is no author to pick a curve per bone. The keyframe Animation component keeps AnimationTrack<T> (see Animation).
A clip is bound to its rig at cook time: bones is parallel to the named skeleton's bone array, so nothing resolves a bone name at runtime.
Markers are what the clip announces as it plays - a footstep, the frame a swing connects. They live on the clip and not on the Animator that plays it, because a footstep belongs to the walk: every character playing that walk gets the same footsteps without authoring them again, and retiming the walk moves them with it. Crossing one publishes an AnimationEvent on the EventBus; see Animation.
Nothing in glTF or FBX carries an animation event, so a marker is authored in the clip's recipe rather than imported, beside the path and the clip index:
The loader drops a marker with no name or a time outside the clip (it could never fire at the instant it names), sorts what is left by time, and writes it back into the clip's own source - which is what stops the next cook from regenerating the recipe without the markers it just read.
A sound, decoded in full at load: 16-bit interleaved PCM at the rate and channel layout the source file carried.
The one asset payload in the engine whose ownership is shared, and the reason is the mixer: a playing voice reads those samples from the audio thread while a scene load frees the asset from the main thread without asking. Sharing ownership with the voice turns that from a use-after-free into a sound that keeps playing for the one frame it takes AudioSystem to notice.
A clip is decoded at load, not streamed: a streamed clip would be the only asset that keeps a file open past its load, and a Resource is a value a scene load builds in a staging manager and swaps in whole. See Audio for the full argument and what it costs.
commit(handle) bumps the asset's own version counter. GLView keys on this per-asset version: it keeps a per-asset cached version and rebuilds GPU state only when the cached value diverges from the asset's version().
A version only tracks edits within one asset graph. A wholesale replacement (scene load, editor play-stop restore) is what epoch() is for: the incoming graph restarts at the same indices, generations and versions, so the backend compares the epoch and drops every mirror when it moves. swap() and clear() both bump it.
Every asset type gets its own SparseSet<T> (made with the manager) plus a SlotAllocator for generational keys and a name index for O(1) lookup:
Recipe imports live in src/tools/, outside the engine core; they wire the AssetFactory seam at startup so the engine-side AssetSerializer can import any recipe a cooked file does not serve. The procedural generators are engine code, in src/engine/resource/generate/, because the engine itself builds with them - a default scene, a fallback texture. The tools split by dependency weight:
The runtime registers no imports and reads cooked files only, so it links none of the importers - model, texture or sound; the editor registers the recipe imports and (re)cooks recipes into the cache.
| File | Provides |
|---|---|
| mesh_generators.cpp | generateTriangle/Plane/Cube/Sphere/Pyramid/Cone/Cylinder free functions |
| texture_generators.cpp | Solid color, white, black, normal, gray (1x1 fallback) |
| material_generators.cpp | Default PBR material with the fallback texture suite |
| light_generators.cpp | generateLight(LightType) - a light component with that type's defaults |
The generators are plain free functions. Each stamps the recipe it was made from, and the string dispatch back ("cube" -> generator) is createGeneratedMesh and createGeneratedTexture in the same two files, so one file writes a generator's keys and reads them; the recipe factories in src/tools/cook/recipe_registration.cpp hand those kinds to them.
An import is two steps. parseModel (import/model_source.h) reads the file into a SourceModel - the engine's own terms, whatever the format - and model_loaders.cpp builds every asset and the scene from that, once for every format. A process-wide cache holds the last eight parsed files, because one file yields several assets and the recipe factories ask for them one at a time.
| Format | Library | What the parse does to reach the engine's space |
|---|---|---|
| glTF, GLB | cgltf | Already right-handed, +Y up, metres. V is flipped, because glTF's runs down the image and the engine's up; an authored tangent's w already means the engine's. |
| FBX, OBJ | ufbx | Converted at load to right-handed, +Y up, metres, with the conversion folded into geometry, node transforms and keys (UFBX_SPACE_CONVERSION_MODIFY_GEOMETRY), so a centimetre rig arrives as a metre one with unit scale on every joint. Pivots stay in the node transforms (UFBX_PIVOT_HANDLING_RETAIN), so every export of one rig names and places its joints alike. OBJ is assumed to be in metres already. |
Every mesh is read as three corners per triangle, given tangents and then welded: corners equal in every byte, skin included, become one vertex, in the order each first appears. A mesh that authored tangents keeps them; every other one gets MikkTSpace's, computed per corner before the weld as its reference integration does - it is the frame bakers bake normal maps against, so it is the only one such a map shades correctly on. Its sign is the engine's w.
The asset names are deterministic so a re-import relinks: the file's project reference and the part - <ref>:mesh<i>, <ref>:mat<i>, <ref>:skeleton (one rig per file), <ref>:clip<i> and <ref>:emb:<image> - so assets/props/crate.glb:mesh0. The reference, not the stem, because two files with one stem in two folders are two models. The indices are the file's own order, which makes that order part of the format:
The rig is the union of every joint any of the file's meshes names, plus the nodes joining them down from their lowest common ancestor, emitted depth-first so parent < index holds by construction. A file holding two rigs - joints down more than one branch of a node that is not itself a joint - is refused rather than merged into one with an invented shared root and a single bone numbering that no clip in the file is bound to.
Clips resolve their channels to bone indices at import, against that same rig. A channel naming a node outside it - a camera, a prop, the mesh node an exporter animated - is dropped and counted. An FBX take is resampled by ufbx into keys a linear sampler reproduces, with pivots, pre-rotations and the unit conversion already in them; a channel that never moves keeps one key. A glTF STEP channel is held by a key just before the next; a CUBICSPLINE one is sampled at its keys, and its tangents are not read.
A vertex keeps its four strongest influences, renormalised among themselves before they are quantised, so a fifth is never dropped after the others were normalised against it. One that arrives with no influence at all is bound rigidly to the rig root and counted, rather than left at zero weight - sum(w * M) with every w zero collapses it onto the origin, which reads as a broken importer instead of as one bad vertex.
The import test suite holds the space each format arrives in: a glTF, an OBJ and a centimetre FBX written by the test, read back through parseModel.
| File | Provides |
|---|---|
| import/texture_loaders.cpp | Load via stb_image, channels decoded by the texture's usage |
| import/material_loaders.cpp | Folder loader: scans a folder for *Color*, *Normal*, etc. |
| import/model_source.cpp | parseModel: glTF/GLB through cgltf, FBX/OBJ through ufbx, into one SourceModel; MikkTSpace tangents and the weld; loadSourceModel, a process-wide cache of the last eight parsed files, each kept while the files it read are unchanged |
| import/model_loaders.cpp | Mesh, material, rig, clip and scene import from a SourceModel |
| import/audio_loaders.cpp | miniaudio-backed sound import: wav / mp3 / flac decoded to s16 |
| loader/environment_loaders.cpp | HDR equirectangular image loader (loadHDRImage) for IBL / skybox |
| loader/image_loaders.cpp | Plain RGBA decode for what draws outside the asset graph - the splash logo |
| loader/font_baker.cpp | bakeFontSDF: a TTF baked to an SDF atlas and kerning table |
See IO and serialization for the full flow. AssetSerializer::saveAssetsForScene emits only the assets actually referenced by the scene - Collider (a mesh part's mesh), Mesh (mesh + material), LOD (every level's mesh), Decal (its material), Animator (its rig and clip), AudioSource (its sound), UIImage (its texture) and every AssetRef field on a behavior, plus the textures those materials reference. emitDescriptor gates every reference a component holds as a handle, so a hidden or unnamed asset can never be written from one. A behavior's AssetRef fields are walked with them and go out through emitNamedRef: an authored name has no handle to inspect, so it is written exactly as authored, and a name the library does not hold is reported by loadAssetSection on load rather than dropped at save. A component that writes an asset name into the scene file has to be walked there, or the name has nothing to resolve against on load. On load, assets with the same name already in the manager are skipped (loads are idempotent), and new assets go through the AssetFactory dispatch by kind.