External Assets (glTF 2.1 complex scenes)
July 21, 2026 · View on GitHub
For contributors and agents. Authoritative design of glTF 2.1 external-asset load / merge / edit / save. Concept-level; the symbols cited in
src/are the source of truth for the details.
How vk_gltf_renderer loads, renders, edits, and saves glTF 2.1 external assets — the
feature that lets one glTF file reference and instantiate other glTF files. This document is the
authoritative description of the mechanism; read it before touching the code paths below.
TL;DR — A referencing (
externalAsset) node's target file is loaded and merged into the single runtimetinygltf::Model, attached as children of that node. Merged-in nodes are flagged read-only by a marker written into each node'sextras. On save, that marker is used to either strip the merged content back out (re-externalize, the default) or strip only the references and keep the geometry (flatten).
1. The glTF 2.1 data model
Three additions to core glTF, parsed/serialized by our fork of tinygltf
(nvpro_core2/third_party/tinygltf/tiny_gltf.h, branch mkl/externalAssets):
| JSON | tinygltf | Meaning |
|---|---|---|
top-level files[] | Model::files (tinygltf::File) | A file reference: external uri or a bufferView, plus a required mimeType, plus optional aliases. |
top-level externalAssets[] | Model::externalAssets (tinygltf::ExternalAsset) | An external glTF asset; its file indexes files[]. |
node.externalAsset | Node::externalAsset (int, -1 = none) | A node instantiating externalAssets[externalAsset] at its transform. |
A file with node.externalAsset set is a complex scene. The node carrying it is the
instance node (editable); everything loaded from the referenced file is merged content
(read-only).
Note: tinygltf here is the v2 header. v2 is in maintenance mode (sunset after mid-2026), but the whole renderer is built on v2, so external-asset support lives in v2. The runtime-only read-only marker is deliberately not a
Nodestruct field (see §4) so the shared header is not polluted for a single application.
2. End-to-end flow
LOAD (Scene::load, gltf_scene.cpp)
loadGltfFile(top.gltf) ────────────────► m_model (has files/externalAssets, node.externalAsset)
resolveExternalAssets() ◄── the merge step (§3)
for each unique file:
load child.gltf
mergeIntoNode(child, firstRefNode) → append child, attach roots as children
instanceSubtree(...) for extra ref nodes → share geometry (§3)
recordReferencedAsset(...) → record provenance + stamp read-only marker into each merged node's extras (§4)
parseScene() → build RenderNodes / RenderPrimitives (dedup → shared BLAS) (§6)
EDIT (SceneEditor + UI)
every mutator checks isNodeReadOnly() and refuses to touch merged content (§5)
SAVE (Scene::save(filename, selfContained), gltf_scene.cpp)
work on a COPY of m_model (live scene untouched):
selfContained == false → removeExternalAssetContent() (re-externalize, default) (§7)
selfContained == true → flattenExternalAssets() (bake, portable) (§7)
The runtime m_model always holds the fully-merged scene; the two save transforms run on a
throwaway copy.
3. Merging references (resolveExternalAssets + SceneMerger)
Scene::resolveExternalAssets() (gltf_scene.cpp) runs inside load(), after loadGltfFile
and before parseScene. Algorithm:
- Group referencing nodes by the
files[]index they resolve to (viaexternalAssets[]). This is captured up front, before any merge appends nodes. - For each unique file: resolve its
urirelative to the parent asset, load it withloadGltfFile, and validate. - Merge once under the first referencing node with
SceneMerger::mergeIntoNode. - Instance the merged subtree under each additional referencing node with
SceneMerger::instanceSubtree. - Tag every merged node read-only via
recordReferencedAsset(which stamps the marker throughsetExternalAssetMarker) (§4).
The instance node's own mesh/camera are cleared (spec: externalAsset takes precedence).
SceneMerger (gltf_scene_merger.{hpp,cpp})
mergeIntoNode(base, imported, targetNode)— appends all ofimported's resources (buffers, accessors, meshes, materials, skins, animations, nodes, …) intobasewith every index rebased by anIndexRemappingoffset table, then attaches the imported default-scene root nodes as children oftargetNode(no synthetic wrapper node — this matches the spec: "append the externalAsset root node(s) to the array of children for the node"). Returns aMergeResult { firstNode, lastNode, roots }.instanceSubtree(base, source, targetNode)— creates another instance of an already-merged subtree sharing geometry: it copies the node hierarchy (and duplicates skins and animation channels so instances animate independently) but leavesmesh/material/accessorindices pointing at the shared, already-appended resources. Because the copies reference the same accessors,parseScenededuplicates them to the sameRenderPrimitiveand hence the same BLAS — instancing, not duplication.
Why merge into one model? The renderer is hard-bound to a single
tinygltf::Model: all GPU arrays (materials/textures/vertex/index buffers) are flat and indexed into that one model, and ray tracing already instances a shared BLAS via TLAS transforms. Keeping external assets as separate coexisting models would require per-model index offsets threaded through the CPU build, theshaderiostructs, the shaders, and the editor — a much larger change for no runtime win.
Index remapping is exhaustive — extend it when you add an extension. Because everything is appended into one model, every index the imported model stores must be rebased by the matching
IndexRemappingfield (seecomputeOffsets/ theremapAndAppend*helpers ingltf_scene_merger.cpp). This includes indices buried inside extensions, both in the core arrays they point at (accessors, bufferViews, textures, …) and in any per-model array the extension defines itself. When an extension owns a root-level array (e.g.KHR_lights_punctuallights[],EXT_mesh_opacity_micromapmicromaps[]), that array must be concatenated onto the base's, a newIndexRemappingoffset added for it, and the per-node / per-primitive references to it (and the bufferView/accessor indices it stores) remapped. A missed remap is silent: it either drops the extension (merged into an empty base, offset 0, so the un-copied root array simply never appears) or cross-links it to the wrong resource (non-empty base). If you add a glTF extension that stores any index, add its remapping here in the same change.
Nested references & cycle detection
A referenced file may itself reference other files. Before a child is merged, it is made
self-contained by Scene::flattenReferencedModel (gltf_scene.cpp), which recursively resolves the
child's own externalAssets (loading, alias-applying, and merging them in place) so only one level
of merging ever happens against the runtime m_model.
Cycle detection uses a DFS ancestry stack — the canonical paths of the files currently on the resolution chain:
resolveExternalAssetsseeds theancestrylist with the canonicalized top-level scene file, so a child that references back to the root is caught.- Before loading each child, its path is canonicalized (
weakly_canonical(absolute(path))) and checked againstancestry. A match is a back-edge → the reference is logged (reference cycle detected) and skipped. - The canonical key is pushed before recursing and popped after, so it is a true path stack. As a
result diamonds are not false positives (
A→B→D,A→C→Dis fine) — only genuine cycles (A↔B,A→B→C→A, self-reference, child→root) are flagged. - A hard depth backstop (
kMaxExternalAssetDepth = 16) stops runaway recursion even if the path check is defeated (see caveat in §9).
4. Read-only flagging (the extras marker)
Merged nodes must be recognisable as read-only for the lifetime of the runtime model, surviving node renumbering (delete) and copying (duplicate). The mechanism:
- Marker:
Scene::kExternalAssetContentKey="NV_external_asset_content", written into each merged node'sextras(gltf_scene.cpp,recordReferencedAsset/setExternalAssetMarker). Its value is the index intoScene::m_referencedAssets(provenance: source URI +file/externalAssetindices). - Query:
Scene::isNodeReadOnly(nodeIndex)returnsnode.extras.Has(marker);Scene::isMeshReadOnly(meshIndex)returns true if any read-only node uses that mesh. - The instance node itself is NOT marked — it stays editable (you can move/rotate/scale the whole reference). Only the merged descendants are marked.
Why extras and not a set or a struct field
- A
std::unordered_set<int>of node indices goes stale the momentdeleteNoderenumbers the node array — the previous implementation had exactly this bug. - A field on
tinygltf::Nodewould pollute the shared tinygltf header for one app. extraslives inside the node, so it is copied byduplicateNodeand travels throughdeleteNoderenumbering automatically — zero bookkeeping. It is standard glTF app-data.
⚠️ Invariant — never replace a node's
extraswholesale. Any code that doesnode.extras = tinygltf::Value(newObject)will wipe the marker, un-tagging merged content. Such a node then leaks into saves and duplicates on reload. Always merge keys into the existingextrasobject. (applyRenderCameraToNodeingltf_scene.cpphad this bug — it now preserves existing extras.)
5. Editing guards
The marker is enforced so referenced content cannot be edited, while the instance node stays free.
- Editor (
gltf_scene_editor.cpp):blockIfNodeReadOnly(nodeIndex, op)early-returns (with a log) from every mutator that could change merged content —setNodeTRS,renameNode,duplicateNode,deleteNode,setNodeParent,setNode*/clearNode*(mesh/camera/skin),duplicateMeshForNode,split/mergePrimitiveMaterial.setPrimitiveMaterialis mesh-keyed so it guards viaisMeshReadOnly. Undo commands and drag-drop route through these methods, so they are covered too. - UI:
ui_inspector.cppshows aICON_MS_LOCK Referenced asset (read-only)badge and wraps the transform / light / camera / material controls inImGui::BeginDisabled(readOnly).ui_scene_browser.cppskips read-only roots in the scene-transform apply loop (applySceneTransform, which edits nodes directly, bypassing the editor) and disables the node context-menu mutations (duplicate/delete/add-child/rename) for read-only nodes. The per-node viewport gizmo instead routes throughSceneEditor::setNodeTRS, which is guarded byblockIfNodeReadOnly.
6. Rendering / instancing
parseScene builds one RenderPrimitive per unique geometry (dedup key = attribute + index
accessor indices) and one RenderNode per (node, primitive) instance. Ray tracing builds one
BLAS per RenderPrimitive and one TLAS instance per RenderNode. Because instanceSubtree shares
accessors across references, N references to the same file yield one BLAS, N TLAS instances —
verify with the BLAS count when a file is referenced more than once.
7. Saving
Scene::save(filename, selfContained) (gltf_scene.cpp) transforms a copy of m_model when
m_referencedAssets is non-empty, then serializes the copy. Two forms:
Re-externalize — removeExternalAssetContent(model) (default, selfContained == false)
Writes the complex-scene form (small top file that references the external files):
- Remove every node with the marker, remapping all node-index references (children, scene roots, skin joints/skeleton, animation channel targets; empty animations erased).
- Run the resource-compaction cascade (
collectReferencedResources→ remap tables →extractUsedElements→updateAllReferences→::compactModel) to drop the now-orphaned meshes/materials/skins/animations/accessors/etc. - Drop any buffer no
bufferViewreferences (a geometry-less complex scene has no buffer; an emptybyteLength: 0buffer is invalid glTF and fails to load).
Result: instance nodes (keeping externalAsset) + the files/externalAssets tables, no inlined
geometry, no markers.
Flatten — flattenExternalAssets(model) (selfContained == true)
Produces a portable self-contained file (shareable without the referenced assets): keeps the
merged geometry inline and instead strips every reference — clears node.externalAsset, removes
the marker from extras, and clears the files/externalAssets tables. The former instance nodes
become ordinary transform nodes.
Both functions live in gltf_compact_scene.cpp (declared in gltf_compact_model.hpp) and run on a
copy, so the live runtime scene is never disturbed.
Image relocation (.gltf only)
When writing a text .gltf (not .glb), Scene::save relocates referenced image.uri files next
to the saved file. It resolves each URI through the load-time image search paths, then: keeps in
place any image that already resolves to the destination (a save-in-place, or a save-as into a
folder that already holds the referenced files, does not duplicate into images/); deduplicates
by canonical source path so a file shared by several entries is copied at most once; and otherwise
copies into images/ with a uniquified name and rewrites the URI. See the uriBySource loop in
Scene::save (gltf_scene.cpp) for the exact rules.
8. File / symbol map
| Concern | Location |
|---|---|
Parse/serialize files/externalAssets/node.externalAsset | tiny_gltf.h (nvpro_core2, branch mkl/externalAssets) |
| Resolve + merge on load | Scene::resolveExternalAssets, gltf_scene.cpp |
| Merge / instance primitives | SceneMerger::mergeIntoNode, SceneMerger::instanceSubtree, MergeResult, gltf_scene_merger.{hpp,cpp} |
| Read-only marker + queries | Scene::kExternalAssetContentKey, isNodeReadOnly, isMeshReadOnly, ReferencedAsset, gltf_scene.{hpp,cpp} |
| Editor guards | SceneEditor::blockIfNodeReadOnly + guarded mutators, gltf_scene_editor.cpp |
| UI (badge / disabled controls / gizmo skip) | ui_inspector.cpp, ui_scene_browser.cpp |
| Save (re-externalize / flatten) | Scene::save, removeExternalAssetContent, flattenExternalAssets, gltf_scene.cpp + gltf_compact_scene.cpp |
9. Known limitations / deferred work
- Embedded external assets (
file.bufferView/data:URI) are not yet resolved — only external fileuris. This also applies toFileAliastargets: an alias whosefileis stored in abufferView/data:URI is skipped (onlyuri-backed alias targets are honored). - Save to a different folder does not relocate/copy the referenced files or rewrite relative
file.uris (fine for save-in-place). - Cycle detection is path-string based. The ancestry check (§3) compares canonical paths with a
case-sensitive string compare, and
weakly_canonicaldoes not normalize a path to its on-disk casing. On case-insensitive filesystems (Windows) a cycle reached through differently-cased paths (a.gltf↔A.gltf) or via hardlinks can slip past the check. In that case thekMaxExternalAssetDepth = 16backstop still terminates recursion (with redundant merges and a warning) rather than hanging.
Implemented (glTF 2.1): nested external assets are resolved recursively with a cycle guard,
FileAlias inner-URI redirection is applied at load time, and file.uri is percent-decoded.