Model loading#
How a model file becomes drawable geometry in the Vulkan engine: the two
loaders, the async parse/upload split that keeps the frame loop responsive, and
the multi-mesh flow that turns one file into per-mesh draws. The chronological
change log is docs/source/cpp-renderer-improvements.md.
Two loaders, one shape#
ObjLoader (scene/ObjLoader.{ixx,cpp}) and GltfLoader
(scene/GltfLoader.{ixx,cpp}) load .obj and .gltf/.glb into the SAME
CPU-side arrays, so everything downstream is loader-agnostic. Both expose:
parseCpu(path)— reads and decodes the file into flat arrays (vertices,indices, per-trianglematerialIndex,materials, plus a per-meshmeshRangestable, see below). Touches no Vulkan, so it can run on any thread.uploadParsed()— builds the Vulkan-sideModel(buffers, textures, meshes) from the lastparseCpuresult. Must run on the thread that owns the device.adoptParsed(other&&)— moves another loader’s parse results in, so a device-owning loader uploads what a device-free worker produced without copying the (tens-of-MB) arrays.
loadModel(path), the synchronous path, is parseCpu then uploadParsed
back-to-back on the calling (device-owning) thread.
Which nodes a glTF load walks#
GltfLoader::parseCpu walks a single scene, not every node in the document:
the default scene (data->scene) if the document names one, else the first
entry of data->scenes. Only nodes reachable from that scene’s roots are
loaded. A document with no scenes array at all (cgltf_validate permits
this) falls back to every node, with a warning, since that is the only geometry
available. This mirrors the WebGPU Rust loader’s
default_scene().or_else(|| scenes().next()) rule
(crates/webgpu_renderer/src/asset/gltf_loader.rs); keep the two in sync.
The async parse/upload split#
The parse is device-free and dominates load time (2802 ms of a 2818 ms load on
the bundled 27 MB dinosaurs.obj), so it runs off the render thread:
AsyncModelParse(scene/AsyncModelParse.ixx) runsparseCpuon a worker thread against a fresh device-free loader (ObjLoader{}/GltfLoader{}), chosen by file extension viaisGltfModelPath(scene/ModelFileKind.ixx), the same predicateScene::loadModelroutes through.When the worker finishes,
Scene::pollModelLoad(scene/Scene.cpp, thependingModelParse.parsedGltf()branch) hands the worker’s loader to a device-owning uploader viaadoptParsedand callsuploadParsed, the only ~15 ms the frame loop pays. The glTF and OBJ arms are symmetric.
So adoptParsed must move every field uploadParsed reads, meshRanges
included; miss one and the async path silently loads different geometry than
the synchronous one.
flowchart LR
subgraph worker["Worker thread (no device)"]
P["parseCpu(path)\nflat arrays + meshRanges"]
end
subgraph main["Render thread (owns device)"]
Poll["Scene::pollModelLoad\nwhen worker isFinished()"]
A["uploader.adoptParsed(move)\ntakes the arrays, no copy"]
U["uploader.uploadParsed()\nbuffers, textures, one Mesh per range"]
Add["Scene::add_model\none ObjectDescription per mesh"]
end
Start["AsyncModelParse.start(path)"] --> P
P -->|arrays moved| Poll --> A --> U --> Add
One file, many meshes#
A Model holds std::vector<Mesh> (scene/Model.ixx). One file becomes a
multi-mesh model through a per-mesh MeshRange, a slice of the flat arrays:
struct MeshRange {
vertexBase, vertexCount, indexStart, indexCount, triStart, triCount, doubleSided
};
doubleSided carries glTF material.doubleSided per range, so uploadParsed
hands it to add_new_mesh and the raster pass can disable back-face culling
for that mesh alone; it is always false for OBJ, which has no such concept.
The type lives in its own module, kataglyphis.vulkan.mesh_range
(scene/MeshRange.ixx), export imported by both loaders. It exports
MeshRange, the MeshSlice return type and sliceMeshRange, the one shared
implementation of “sub-vertices copied, sub-indices re-based by -vertexBase,
per-range material subset” that both loaders’ uploadParsed call.
parseCpu records one range per sub-object and keeps the flat arrays intact
(the ObjParseUnit/GltfParseUnit tests still assert on the flat getters);
uploadParsed slices each range via sliceMeshRange into its own
add_new_mesh. A sub-object is:
glTF — one primitive. Primitives already parse into disjoint contiguous vertex ranges, so the range is recorded directly.
OBJ — one shape (
o/ggroup). OBJ shapes share one attribute pool and vertices are deduplicated, soloadVerticesresets the dedup map per shape to keep each shape’s vertices in one contiguous block the slice can address. The cost is a duplicate of any vertex shared across distinct shapes (rare between separate objects, and pixel-identical either way).
A single-primitive glTF or single-shape OBJ yields exactly one range spanning everything, behaviour-identical to the pre-split single-mesh path.
What downstream gets for free#
Object identity is per mesh, not per model, so splitting a file into meshes feeds the systems that were already mesh-aware:
Object descriptions —
Scene::add_modelflattens oneObjectDescriptionper mesh;objectIndexis the flat mesh index (sum of prior models’ mesh counts + local index), pushed per draw in the forward/deferred/shadow record loops.Culling — the record loops iterate meshes with per-mesh AABBs, so a multi-mesh model culls per mesh instead of all-or-nothing.
Acceleration structures — one BLAS per model with one geometry per mesh; RT/PT kernels fetch the per-mesh material with
instanceCustomIndex (= the model's first-mesh flat index) + gl_GeometryIndexEXT.
Material fields and where they come from#
ObjMaterial (Src/shared/scene/ObjMaterial.hpp) is the single struct both
loaders fill and every shader reads. fromGltfMaterial (GltfLoader.cpp) is
the glTF mapping; ObjLoader::loadTexturesAndMaterials is the .mtl mapping.
A — means that format has no source for the field, so the struct keeps its
default (documented on the member in ObjMaterial.hpp). “Read by” names the
shading paths at a glance; the exact per-path expression, or “not read” and
why, is in
shader-sharing.md’s “Which C++ shading path reads which ObjMaterial
field”.
Member |
glTF source |
|
Read by |
|---|---|---|---|
|
|
|
|
|
|
|
all four shading paths, via |
|
— (pinned to |
|
|
|
|
|
|
|
dedup’d slot into |
dedup’d slot into |
all five entry points, via |
|
|
— (OBJ has no sampler concept; every OBJ texture keeps the repeat/linear/linear default) |
|
|
|
|
|
|
|
— |
|
|
|
— |
|
|
|
— |
|
|
|
— |
|
|
dedup’d slot into |
dedup’d slot into |
all four shading paths, via |
|
dedup’d slot into |
dedup’d slot into |
all four shading paths, via |
|
|
the winning normal-directive’s own |
|
|
dedup’d slot into |
— |
|
|
|
— |
the normal sample in all four shading paths, via |
|
|
— |
the metallic-roughness sample in all four shading paths, via |
|
|
— |
the emissive sample in all four shading paths, via |
|
|
— ( |
all four shading paths return the base colour unshaded when set: |
|
— (glTF carries opacity in |
dedup’d slot into |
|
Textures, samplers and the 128-slot budget#
Every texture from every loaded model lands in one flat, fixed-size global
array bound at TEXTURES_BINDING/SAMPLER_BINDING, sized by
common/host_device_shared_vars.hpp’s MAX_TEXTURE_COUNT constant:
const int MAX_TEXTURE_COUNT = 128;. Both loaders economise against that
budget:
glTF dedup by image, sampler and colour space —
GltfLoader::parseCpu’simageSlotmap keys on the triple(const cgltf_image *, const cgltf_sampler *, bool srgb), not the material or the texture slot (base-colour vs. emissive vs. normal). One decode+upload per triple, however many materials or texture slots reference it: a material whose base-colour and emissive views name the same PNG shares one slot (textureID == emissiveTextureID). Two textures that share an image but name different samplers, or need different colour spaces, still get distinct slots; collapsing them would make the second texture’s wrap/filter settings unobservable, or upload one of the two through the wrongVkFormat. A base-colour/normal pair sharing one image therefore gets two slots, one uploaded sRGB (Texture.cpp’suploadRgbapickseR8G8B8A8Srgb), the other UNORM (eR8G8B8A8Unorm): glTF normal maps are linear tangent-space data, not gamma-encoded colour.OBJ dedup by resolved path —
ObjLoader::loadTexturesAndMaterials’spathSlotmap keys on the pathresolveObjTexturePathreturns, not the rawmap_Kdstring, so two materials naming the same file through different.mtlspellings (tex.pngvs./tex.png) share one slot.A failed texture still occupies its slot — when
Texture::createFromFilefails for a non-empty name,ObjLoader::uploadParsed’saddTextureOrDefaultcall substitutes the default texture instead of skipping the slot, becausetextureIDis a dense counter over non-empty names and skipping would shift every latertextureIDdown by one.Sampler dedup by mip level and glTF sampler description —
Model::addSampler(scene/Model.cpp) keys on aSamplerKey{ mipLevel, GltfSamplerDesc }viafindSampler(vulkan_base/SamplerBuilder.cpp): N textures share onevk::Sampleronly when both their mip count and their wrap/filter settings match.Flattening into the global array —
assignTextureOffsets(scene/ObjectDescription.ixx) stamps each mesh’stexture_offsetwith its model’s running offset into the flattened array, advancing by that model’s texture count once its meshes are done;planFlattenedTextureSlots(same module) is the mirror image that builds the array, in model order, capped atMAX_TEXTURE_COUNTand padded with slot 0 past the cap. All five entry points (rasterizer,deferred,shadow_map,path_tracing,raytrace.rchit) compute and clamptextureIdthe same way,clamp(int(obj.texture_offset) + material.textureID, 0, MAX_TEXTURE_COUNT - 1), so a model that pushes the total past budget samples a wrong (but in-bounds) slot instead of reading out of the descriptor array.
Both loaders also emit per-vertex colours (glTF COLOR_0 in
GltfLoader::processPrimitive’s attribute switch; OBJ attrib.colors in
ObjLoader::loadVertices) and fill missing normals from the shared
scene/Vertex.{ixx,cpp} module, through different functions:
GltfLoader::processPrimitive calls computeFlatNormals when a primitive has
no NORMAL accessor, recomputing every corner; ObjLoader::loadVertices calls
the sibling fillMissingFlatNormals, which fills only corners left at a zero
normal (a file with no vn at all behaves like computeFlatNormals; one with
vn on only some faces keeps those).
map_Kd texture path resolution#
Both loaders resolve an OBJ material’s map_Kd the same way:
Normalise \ to / in the map_Kd value; resolve the result relative to
the directory containing the .mtl; if that file does not exist, retry under
a textures/ subdirectory of the same directory; if that misses too, warn
and fall back to the default texture.
The
\-to-/normalisation deliberately deviates from “as written in the.mtl”: a Windows-authored relative path (textures\wood.png) must still resolve on a platform where\is just another filename character.Relative to the
.mtlfirst is what the OBJ/MTL format specifies. Thetextures/retry exists because every textured OBJ inResources/Models(VikingRoom, and the AssetANT scenescrytek-sponza,PillumandSulo/WolfStahl) keeps its textures in atextures/sibling directory and names them by bare filename, not by the format’s convention.A path that resolves to neither candidate degrades silently to the untextured default (Vulkan) or a missing image (glTF) instead of failing the whole load, so both sides log a warning naming both candidates: the only signal a wrong or missing texture leaves.
An empty base directory (a bare filename with no directory component) is treated as
.on both sides, so the candidates stay relative instead of resolving against the filesystem root.
Where:
C++ (
scene/ObjLoader.cpp,resolveObjTexturePath) - builds both candidate paths and records the first thatstd::filesystem::exists.Rust (
crates/webgpu_renderer/src/asset/obj_to_gltf.rs,convert_file) - same two candidates, applied when copying the texture next to the converted glTF. The glTFuriit emits is always the bare filename as written in the.mtl, whichever candidate matched, so the converted document stays self-contained next to its.bin.
Invariants worth preserving#
parseCpuleaves the flatgetVertices()/getIndices()/getMaterialIndices()/getMaterials()exactly as before the split: the CPU parse tests key on them, and the slice reads from them.Every
MeshRangemust tile the flat arrays contiguously (no gap/overlap), and every index in a range must stay inside that range’s own vertex block, or the-vertexBasere-base insliceMeshRangecorrupts the mesh. TheMultiShape…/MultiPrimitive…RecordsPerPrimitiveMeshRangesparse tests assert this at parse level, andTest/commit/VulkanEngine/meshRangeSliceSuite.cppunit-testssliceMeshRangedirectly.Each mesh currently shares the full
materialsarray (itsmaterialIndexholds the original indices). Trimming to a per-mesh material subset is an optional optimisation tracked in the backlog; the slice loop is already shared insliceMeshRange, so that trim edits one place for both loaders.