For contributors and agents. The scene data-flow model (glTF model → RenderNodes → GPU SSBO / BLAS / TLAS) explained conceptually, with pointers to the owning code. Exact struct fields, thresholds, and function signatures live in the cited source files, not here.
Purpose: Document the complete data flow from glTF model through the Vulkan ray tracing and rasterization pipeline -- from scene graph to BLAS/TLAS acceleration structures and GPU render nodes.
Critical for: Understanding how scene editing (duplicate/delete) affects the ray tracing and rasterization paths.
┌─────────────────────────────────────────────────────────────────────────┐
│ CPU: tinygltf::Model (Scene Hierarchy) │
│ • nodes[i] - Scene graph nodes │
│ • meshes[j] - Mesh definitions │
│ • materials[k] - Material definitions │
└─────────────────────────────────────────────────────────────────────────┘
│
│ parseScene()
│ (traverses hierarchy, flattens to instances)
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ CPU: Flat Render Arrays (Derived, Regenerated) │
│ • RenderNode[] - One per primitive instance (N:1 with leaf nodes/primitives) │
│ • RenderPrimitive[] - Deduplicated geometry / BLAS │
└─────────────────────────────────────────────────────────────────────────────────┘
│
┌────────────┴────────────┐
│ │
uploadRenderNodes/syncFromScene cmdCreateBuildTopLevelAS()
│ │
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ GPU: SSBO (Shader Access) │ │ Ray Tracing: TLAS │
│ GltfRenderNode nodes[N] │ │ VkASInstance instances[N] │
│ • objectToWorld │ │ • transform matrix │
│ • materialID │ │ • BLAS reference │
│ • renderPrimID │ │ • instanceCustomIndex │
└──────────────────────────────┘ └──────────────────────────────┘
Key Insight: RenderNodes are derived structures, rebuilt from Model whenever hierarchy changes.
There is a direct correlation between BLAS and primitive index:
- BLAS (Bottom-Level Acceleration Structures) are built from
RenderPrimitive[]in order:m_blasAccel[renderPrimID]. - TLAS (Top-Level AS) instances reference BLAS via
object.renderPrimID:blasAddress = m_blasAccel[object.renderPrimID].address.
When are BLAS actually built? Only when the Vulkan scene is (re)created:
- Once at load:
GltfRenderer::createVulkanScene()→buildAccelerationStructures()→createBottomLevelAccelerationStructure()+cmdBuildBottomLevelAccelerationStructure(). - Again only on full geometry rebuild:
GltfRenderer::rebuildVulkanSceneInternal()→buildAccelerationStructures()(e.g. whenprimitivesChangedis set in dirty flags).
BLAS are not rebuilt every frame or on every parseScene(). Hierarchy-only edits (reparent, duplicate, delete) that do not change the set of meshes/primitives only update CPU render nodes and TLAS (transforms/visibility); the existing BLAS array is reused. So for a given geometry state, BLAS are built once.
If the order of primitives (and thus renderPrimID) ever changed without rebuilding the BLAS, the TLAS would reference the wrong BLAS. Therefore:
- RenderPrimitive list must be built in deterministic order (by mesh index, then primitive index), not in traversal order. See
parseScene(): the unique-primitive list is filled by iteratingm_model.meshesand their primitives before any scene-graph traversal. - BLAS are built from that ordered list;
renderPrimIDis stable across hierarchy edits (reparent, duplicate, delete) as long as the mesh set is unchanged. - Do not clear and repopulate
m_renderPrimitives/m_uniquePrimitiveIndexin a way that depends on traversal order, or BLAS and TLAS will go out of sync.
When a scene uses EXT_mesh_opacity_micromap and the device exposes VK_KHR_opacity_micromap
(and VK_KHR_device_address_commands, which it depends on), SceneOmm (owned by SceneVk, see
gltf_scene_omm.*) builds one opacity micromap per root micromaps[] entry and uploads the
per-primitive micromap index buffers alongside the geometry. Under the KHR extension a micromap is
an acceleration structure of type VK_ACCELERATION_STRUCTURE_TYPE_OPACITY_MICROMAP_KHR: it is
created with vkCreateAccelerationStructure2KHR and built with vkCmdBuildAccelerationStructuresKHR.
Results are keyed by renderPrimID so they follow the same BLAS ordering contract above.
SceneRtx::createBottomLevelAccelerationStructure() then attaches a
VkAccelerationStructureTrianglesOpacityMicromapKHR to each primitive's triangle geometry
(triangles.pNext), and the path-tracer RT pipeline is created with the OMM flag
(VK_PIPELINE_CREATE_RAY_TRACING_OPACITY_MICROMAP_BIT_KHR).
Ray queries opt in separately: every entry point that runs a ray query against a BLAS carrying a
micromap must declare the SPIR-V OpacityMicromapIdKHR execution mode. Its capability is only
legal on a device with the extension, so it cannot live in the embedded SPIR-V. Instead
gltf_pathtrace.slang emits it (inline SPIR-V on computeMain and rgenMain) behind the
USE_OMM macro, which PathTracer sets only when SceneOmm::hasAny() reports a micromap; a scene
that gains or loses micromaps triggers a recompile like any other variant switch.
This only accelerates alpha-tested traversal — microtriangles flagged "unknown" still run the
existing any-hit alpha logic — so the image is identical to the non-OMM path. When the extension
is unsupported (or --useOpacityMicromap 0), the whole subsystem is skipped and rendering falls
back to the regular alpha path.
The eOpacityMicromap entry of enum Visualization (see shaders/shaderio.h) adds a debug view
that colors the primary surface by how OMM traversal resolved it, using the fact that alpha
evaluation only happens for "unknown" micro-triangles: OMM-opaque micro-triangles are committed by
the hardware without any alpha work (green); "unknown" ones invoke the any-hit shader, which sets
payload.ommUnknown (yellow); transparent ones are culled so those rays miss the mesh and show the
environment behind. No baked micromap buffers are read by the shaders. Both techniques consult the
micromap: the RT pipeline through the pipeline flag above, the inline RayQuery technique through
USE_OMM.
This debug view (and its payload.ommUnknown field) is compiled out of the normal render path
behind the USE_VISUALIZE macro — the host sets it (see PathTracer::compileShader) only when the
selected Visualization mode is not eRendered, so shipping renders carry no extra payload field
or visualization branches. Because it is a compile gate, switching between normal rendering and any
visualization mode triggers a shader recompile (cached per VariantKey, so repeat switches are
fast); switching among visualization modes does not, since they share USE_VISUALIZE == 1 and
select the mode at runtime via frameInfo->visualization.
Location: gltf_scene.cpp
Purpose: Traverse scene hierarchy and flatten into renderable instances.
parseScene():
1. Snapshot current render-node state (worldMatrix, materialID, renderPrimID, visible)
2. clearParsedData() -- wipe render nodes, primitives, lights
3. createMissingTangentsForModel() -- ensure tangent attributes exist (stabilizes primitive keys)
4. primMap = buildPrimitiveKeyMap() -- register unique primitives (by Scene::primitiveKey) in deterministic order
(by mesh index, then primitive index — not traversal order)
5. For each root node in current scene:
traverseSceneGraph(depth-first):
- collect lights via handleLightTraversal()
- collect render nodes via handleRenderNode(nodeID, worldMatrix, primMap)
6. updateRenderNodesFull() -- apply animations, skinning, morph, visibility
7. Diff against snapshot → set dirty flags:
renderNodesVk / renderNodesRtx (indices that changed)
allRenderNodesDirty (count changed or ≥ kFullUpdateRatio dirty)
materials, primitivesChanged, lights
handleRenderNode(nodeID, worldMatrix, primMap):
if node has no mesh → skip, continue traversal
(a placing node with an instance set is reached through the traversal's anyNode callback)
createRenderNodesForNode(nodeID, worldMatrix, visible=true, primMap):
if node has an instance set (glTF 2.1 external asset, docs/external_assets.md):
one RenderNode per (entry, primitive of the entry's mesh), worldMatrix = world × entry's local
else for each primitive in mesh:
renderPrimID = render primitive of (mesh, primitive) -- deduplicated, from buildPrimitiveKeyMap
build RenderNode { worldMatrix, materialID, renderPrimID, refNodeID, skinID, visible }
if node has EXT_mesh_gpu_instancing:
one RenderNode per instance, worldMatrix = world × instance
else:
register single RenderNode in m_renderNodeRegistry
A node drawn more than once (instancing, instance set) keeps one local matrix per render node,
in registry order, in Scene::getRenderNodeLocalMatrices(): every world-matrix update (CPU serial,
CPU parallel, GPU update_render_instances) computes world(node) × local.
tinygltf::Model: RenderNodes[] (flat, contiguous):
Scene.nodes = [0]
Node[0] "Car" (No RenderNode - empty transform)
├─ children = [1, 2, 3]
│
├─ Node[1] "Body" → RenderNode[0]: {worldMat, matID=5, primID=10, refNode=1}
│ mesh = 5 (1 primitive)
│
├─ Node[2] "Wheels" (No RenderNode - empty transform)
│ ├─ children = [4, 5]
│ │
│ ├─ Node[4] "WheelFL" → RenderNode[1]: {worldMat, matID=7, primID=12, refNode=4}
│ │ mesh = 7 (1 primitive)
│ │
│ └─ Node[5] "WheelFR" → RenderNode[2]: {worldMat, matID=7, primID=12, refNode=5}
│ mesh = 7 (1 primitive) ↑ SAME primID (instanced!)
│
└─ Node[3] "Engine" → RenderNode[3]: {worldMat, matID=9, primID=15, refNode=3}
→ RenderNode[4]: {worldMat, matID=9, primID=16, refNode=3}
mesh = 8 (2 primitives) ↑ SAME node, multiple RenderNodes!
Observations:
- Empty nodes (Car, Wheels) → No RenderNodes (just hierarchy)
- Multi-primitive mesh (Engine) → Multiple RenderNodes (one per primitive)
- Instancing (WheelFL, WheelFR) → Different RenderNodes, same
primID - Hierarchy depth irrelevant → Flat RenderNodes array
Function: void SceneVk::uploadRenderNodes(staging, scene, dirtyIndices) or syncFromScene(staging, scene)
Location: gltf_scene_vk.cpp
Purpose: Upload RenderNode data to GPU SSBO for shader access.
Each render node uploads to the GPU as a GltfRenderNode (object↔world transforms plus
materialID and renderPrimID). The authoritative layout lives in
shaders/gltf_scene_io.h.slang and is consumed by gltf_raster.slang /
gltf_pathtrace.slang — read the header for the exact fields.
uploadRenderNodes(staging, scene, dirtyIndices):
renderNodes = scene.getRenderNodes()
ensureRenderNodeBuffer(renderNodes.size) -- recreate GPU buffer if size changed
if buffer was recreated OR dirtyIndices is empty:
Full upload: convert all RenderNodes → GltfRenderNode[], stage entire buffer
else:
Surgical upload: for each index in dirtyIndices,
convert RenderNode[index] → GltfRenderNode, stage at byte offset
Rasterization (vertex shader):
instance = renderNodes[pushConst.renderNodeID] -- one draw per render node; index via push constant
worldMatrix = instance.objectToWorld
materialID = instance.materialID
Ray Tracing (closest hit / any hit):
instance = renderNodes[InstanceIndex()] -- Slang: TLAS instance index
worldMatrix = instance.objectToWorld
materialID = instance.materialID
The path tracer treats emissive triangle meshes as area lights it samples directly (next-event
estimation + MIS in sampleLights / gltf_pathtrace.slang), instead of only finding them by chance
when a bounce ray happens to hit one. Punctual lights and the environment are sampled the same way.
Path tracer only — the rasterizer is unaffected.
How it works, step by step
Host — build the emitter list (SceneVk::uploadEmissiveTriangles):
- Walk every render node (an instance of a primitive) and look at its material.
- Keep it only if it is a constant emitter: emissive (
emissiveFactor × emissiveStrength) is non-black and it has no emissive texture. (Textured emitters stay on the BSDF-only path.) - For each triangle of that primitive, store a reference — render node, primitive, triangle index — plus the radiance (not baked vertices, so the shader reads live geometry/transforms).
- Compute the triangle's world-space area (verts × node transform) and give it a selection
weight = area × luminance (see the defensive tweak below). Build a Vose alias table from the
weights (
aliasProb/aliasIndexper triangle) for O(1) GPU selection, plus the totals (numEmissiveTriangles,emissiveTotalWeight,emissiveMeanLum). - Upload the flat list + those scalars to the scene descriptor.
Device — sample a light at each surface hit (sampleLights):
- See what light sources exist: environment, punctual lights, emissive triangles.
- Choose a category: first "a light" vs "the environment", then within lights "a punctual light" vs "an emissive triangle" (the probabilities balance the categories).
- For an emissive triangle: pick one in O(1) via the alias table (bright/large ones win more) → read its live world vertices → sample a random point on it → form direction/distance and the sample's pdf → evaluate the surface BSDF toward it → cast a shadow ray; if unblocked, add the light, MIS-weighted against BSDF sampling.
- Separately, when a normal bounce ray hits an emitter directly (
gltf_pathtrace.slang, the emissive add), its glow is added but MIS-weighted, so the "sampled via NEE" and "hit by chance" paths combine without double-counting. The weights on the two sides agree only because the NEE pdf includes the probability of picking the category in step 7, which is what the hit side recomputes. Punctual lights are the exception: no bounce ray can hit one, so their samples take no MIS weight at all. All but the sky's sun, whose disk a bounce ray can see when it escapes: its cone sample is MIS-weighted against the BSDF like an emitter (skySunNeePdf/computeSkySunHitMisWeightinshaders/pathtrace_functions.h.slang). Without that, a lobe narrower than the disk — smooth glass, a polished mirror — would never show the sun.
Details / why it's shaped this way
The list is derived data, regenerated wholesale (never patched). It stores references, but each
emitter's selection weight is its world-space area × luminance, so it also depends on the
emitter's transform. syncFromScene rebuilds it when materials, the emitter set, or an emitter's
transform change; a DirtyFlags::emissiveDirty latch is consumed independently of the sync mask so
the GPU-transform path (which syncs only materials + lights) rebuilds it too. The shader samples the
emitter at its live GPU transform, and the pdf cancels the world area (below), so the host table
and the live geometry must agree on that area — a stale table biases the estimate, it does not merely
add noise. Because the GPU-transform path leaves the render-node CPU mirror lazily stale,
Scene::updateLocalMatricesAndLights refreshes emitters' world matrices on edits (as it does light
world matrices) and flags a rebuild when one moved.
Selection is power-weighted (an O(1) Vose alias table, not a CDF binary search), with a
defensive floor so a dim emitter next to a bright one is never starved: each emitter's luminance
is pulled toward the scene's area-weighted mean, lum' = (1−f)·lum + f·meanLum
(EMISSIVE_DEFENSIVE_FRACTION, kept in sync between host and shader). The alias table samples the
same distribution P(i) = weightᵢ / emissiveTotalWeight, so the pdf is unchanged.
The world area cancels in the solid-angle pdf (pdf = lum' · dist² / (emissiveTotalWeight · cos)),
so the BSDF-hit MIS weight is recoverable from local hit geometry (the emitter's radiance +
emissiveTotalWeight + emissiveMeanLum) with no per-triangle lookup; and because meanLum = W/A
the total weight W is unchanged. The weight uses world area, so the list is rebuilt whenever an
emitter's transform changes: interactive edits via Scene::updateLocalMatricesAndLights, and
animated/rigid moves via the world-matrix update (updateWorldMatrices* latch emissiveDirty when a
moving render node is an emitter). Morph/skin-deformed emitters are the one remaining case that falls
back to last-rebuilt-pose sampling. The scene-descriptor scalars refresh whenever the count
or the weights change, so radiance-only edits (e.g. dragging emissive strength) take effect
immediately.
Location: gltf_scene_rtx.cpp
Purpose: Build Top-Level Acceleration Structure (TLAS) for ray tracing.
cmdCreateBuildTopLevelAccelerationStructure(cmd, staging, scene):
drawObjects = scene.getRenderNodes()
for each RenderNode in drawObjects:
blasAddress = m_blasAccel[renderNode.renderPrimID].address
if not renderNode.visible:
blasAddress = 0 -- hide from ray traversal
create VkAccelerationStructureInstanceKHR:
transform = renderNode.worldMatrix -- 3x4 row-major
instanceCustomIndex = renderNode.renderPrimID -- read in shader via InstanceID()
accelerationStructureReference = blasAddress -- which BLAS to use
mask = 0x01
flags = getInstanceFlag(material) -- cull mode from material
append to m_tlasInstances[]
upload m_tlasInstances[] to GPU via staging
build TLAS acceleration structure on device
TLAS (Top-Level Acceleration Structure)
├─ Instance[0] → BLAS[primID=10] @ transform[worldMatrix]
├─ Instance[1] → BLAS[primID=12] @ transform[worldMatrix] ← Instanced (same BLAS)
├─ Instance[2] → BLAS[primID=12] @ transform[worldMatrix] ← Instanced (same BLAS)
├─ Instance[3] → BLAS[primID=15] @ transform[worldMatrix]
└─ Instance[4] → BLAS[primID=16] @ transform[worldMatrix]
Each BLAS (Bottom-Level AS):
BLAS[primID] = acceleration structure for RenderPrimitive[primID]
• Built from vertex/index buffers
• Shared across multiple instances (e.g., wheels)
When a ray hits TLAS instance[i] (Slang RT / ray query builtins):
InstanceIndex() = i -- index into renderNodes SSBO
InstanceID() = renderPrimID -- index into renderPrimitives / BLAS (instanceCustomIndex)
Shader reads:
renderNodes[InstanceIndex()] → transform, materialID
renderPrimitives[InstanceID()] → vertex/index buffer info
struct RenderNode (defined in src/gltf_scene.hpp) is one flattened primitive instance:
a world matrix computed during parseScene() traversal plus back-references
(materialID → model materials, renderPrimID → m_renderPrimitives, refNodeID →
model node, skinID) and a visible flag. See the header for the exact fields.
Relationships:
- N:1 with Node - Multiple RenderNodes per node (if mesh has multiple primitives)
- N:1 with Primitive - Multiple RenderNodes share same primitive (instancing)
- N:1 with Material - Many RenderNodes use same material
struct RenderPrimitive (in src/gltf_scene.hpp) is a unique, deduplicated piece of
geometry: a pointer back to the tinygltf::Primitive, vertex/index counts, and the
owning meshID.
Purpose: Deduplicated geometry - if two nodes use same mesh, they share RenderPrimitives.
Identity: Scene::primitiveKey decides which mesh primitives share one: by default their
attribute and index accessor indices. In a scene with glTF 2.1 external assets it is the data the
accessors read (buffer, byte offset, stride, count, format), so files that each declare accessors
over the same bytes of a shared .bin share one RenderPrimitive and one BLAS
(external_assets.md §6). Skinned and morphed primitives keep the index key:
skinning and morphing write the RenderPrimitive's vertex buffers. Several mesh primitives may map to
one RenderPrimitive, so meshID is only the first mesh that uses it; the per-mesh table
(Scene::getMeshRenderPrimitive) answers which RenderPrimitive draws a given mesh primitive.
Deformed primitives are per pose. Skinning and morphing write the vertices for one pose: a
skin, and morph weights (Scene::getMorphWeights: the node's weights, else the mesh's; a
weights animation channel writes the node's). Scene::assignDeformedRenderPrimitives gives each
pose of a primitive its own RenderPrimitive (the same tinygltf::Primitive, own vertex buffers and
BLAS, nodeID set), so a copy is made only when one primitive is drawn in several poses:
- static drawers share the per-mesh RenderPrimitive: nodes that don't skin it, or only morph it with the default weights of the mesh it was made from, and instance sets;
- nodes of another mesh reading the same accessors, with other default weights, share a pose per mesh;
- read-only copies of one placed node (
Scene::kPlacedSourceKey) share one pose: placements animate in lockstep, so a crowd of one character is skinned and ray traced once; - any other node that skins the primitive, or morphs it with weights of its own, has its own pose (two skins on one mesh, as in RecursiveSkeletons).
Copies are appended after the per-mesh RenderPrimitives in node order. AnimationSystem makes one
skin task per skinned RenderPrimitive and one morph task per RenderPrimitive with morph targets,
weighted or not: a target without a weight blends with 0 until an animation gives it one.
Indexing: renderNode.renderPrimID → m_renderPrimitives[renderPrimID]
// RenderNodeRegistry: flat vector + bidirectional maps
// - getRenderNodes() → vector<RenderNode> (flat array for GPU upload)
// - getRenderNodesForNode(nodeID) → nodeID → list of RenderNode indices (unordered_map internally)
// - getNodeAndPrim(renderNodeID) → (nodeID, primIndex)
// Example:
// getRenderNodesForNode(3) = {5, 6} // Node 3 has RenderNodes 5 and 6
// → Node 3 has a mesh with 2 primitives
// → RenderNode[5] and RenderNode[6] both have refNodeID = 31. scene.load("file.gltf")
→ load tinygltf::Model from disk
→ parseScene()
→ build m_renderPrimitives[] (deduplicated, deterministic order)
→ build m_renderNodes[] (flat instances from hierarchy traversal)
2. sceneGpu.create(cmd, scene) (drives SceneVk internally)
→ upload vertex/index buffers for all primitives
→ uploadRenderNodes() → create GPU SSBO, upload all GltfRenderNode[]
3. buildAccelerationStructures()
→ createBottomLevelAccelerationStructure() → prepare BLAS build data
→ cmdBuildBottomLevelAccelerationStructure() → GPU BLAS build (budgeted)
→ cmdCreateBuildTopLevelAccelerationStructure() → GPU TLAS build
User edits a node transform (gizmo, inspector):
scene.editor().setNodeTRS(nodeIdx, translation, rotation, scale)
→ modifies m_model.nodes[nodeIdx] TRS values
→ calls markNodeDirty(nodeIdx) → adds to m_dirtyFlags.nodes
On next frame, updateSceneChanges(cmd):
1. updateSceneChanges_NodeTransforms():
scene.updateNodeWorldMatrices()
→ recomputes world matrices for dirty nodes and descendants
→ updates RenderNode.worldMatrix in the registry
(markNodeDirty() only queues node indices; updateNodeWorldMatrices()
marks the affected render-node indices as it walks the hierarchy)
2. sceneVk.syncFromScene(staging, scene)
→ reads dirty flags → uploads ONLY changed RenderNodes (surgical)
3. sceneRtx.syncTopLevelAS(cmd, staging, scene)
→ updates TLAS instances with new transforms (rebuild or update)
Note: Animation updates follow a similar but separate path, GltfRenderer::reconcileAnimationGpuState
(not via updateSceneChanges). The same functions are called
(updateNodeWorldMatrices, syncFromScene, syncTopLevelAS)
but within the animation frame section, which also handles morph/skin GPU uploads.
Optimization: Only changed RenderNodes uploaded, not entire buffer.
GPU transform path. The steps above are the CPU path. When SceneGpu::shouldUseGpuTransform
allows it (the "GPU Compute Transformation" toggle plus canUseGpuTransformPath in
gltf_scene_transform_vk.cpp), both flows instead upload only the dirty local matrices and let
TransformComputeVk::dispatchTransformUpdate propagate world matrices level by level, write the
render-node and TLAS instance transforms, and refit the TLAS, all on the GPU. The CPU world-matrix
mirror is then stale for the moved nodes (Scene::addGpuStaleNodes) until a later CPU frame
reconciles them.
The GPU path is only taken when the change can move geometry: Scene::dirtyNodesReachGeometry
says whether any dirty node's subtree holds a mesh (or render nodes are already dirty). A node with
no mesh below it -- a light, such as the sky's sun on every frame of a Time of Day drag, a camera,
an empty group -- takes the CPU path, which uploads the light and leaves the TLAS alone. That frame
flags just those nodes' GPU locals as stale (markLocalsStale) rather than forcing a full re-upload
(markGpuStale).
User duplicates a node:
scene.editor().duplicateNode(nodeIdx)
→ duplicateNodeRecursive() deep-copies nodes + subtree in m_model
→ links new subtree into parent's children (or scene roots)
→ calls parseScene() internally:
snapshot → clearParsedData → buildPrimitiveKeyMap → traverse → diff
→ dirty flags: allRenderNodesDirty = true (count changed)
On next frame, updateSceneChanges(cmd):
1. sceneVk.syncFromScene(staging, scene)
→ buffer size mismatch detected → recreate buffer → full upload
2. sceneRtx.syncTopLevelAS(cmd, staging, scene)
→ instance count changed → full TLAS rebuild
Key: Hierarchy changes trigger full rebuild (not surgical update).
Note: deleteNode and duplicateNode are methods on SceneEditor, accessed via scene.editor().
SceneEditor::deleteNode(nodeIndex):
1. deleteNodeRecursive(nodeIndex):
for each child (deepest first):
removeNodeFromParent / removeNodeFromSceneRoots
erase from m_model.nodes[]
remapIndicesAfterNodeDeletion() -- fix all node/animation/skin references
2. parseScene() -- full rebuild, sets dirty flags
On next frame, updateSceneChanges(cmd):
sceneVk.syncFromScene(...) -- resize + upload render nodes
sceneRtx.syncTopLevelAS(...) -- rebuild TLAS
Result: Deleted node's RenderNodes disappear automatically (not in traversal anymore).
SceneEditor::duplicateNode(originalIndex):
1. newIdx = duplicateNodeRecursive(originalIndex, originalParent)
deep-copies node + all descendants in m_model.nodes[]
2. Link new subtree under same parent (or as scene root)
3. parseScene() -- full rebuild, sets dirty flags
return newIdx
On next frame, updateSceneChanges(cmd):
sceneVk.syncFromScene(...) -- resize + upload render nodes
sceneRtx.syncTopLevelAS(...) -- rebuild TLAS
Result: Duplicated node's RenderNodes appear automatically (in traversal now).
The millisecond figures below are rough order-of-magnitude estimates for orientation, not
measured benchmarks — profile with the built-in tools (see docs/benchmarking.md) for real numbers.
- CPU: O(N) where N = number of dirty nodes (typically 1-10)
- GPU Upload: Only changed RenderNodes (typically < 1KB)
- Cost: ~0.1 ms
- CPU: O(N) where N = total nodes in scene (parseScene traversal)
- GPU Upload: All RenderNodes (typically 1-100 KB)
- TLAS Rebuild: O(N) instances
- Cost: ~1-5 ms (depends on scene complexity)
- Simple: 10-50 nodes → 20-100 RenderNodes
- Complex: 100-500 nodes → 200-1000 RenderNodes
- Very Complex: 1000+ nodes → 2000+ RenderNodes
Implication: Full rebuilds are cheap enough to do on every hierarchy edit (< 5ms).
m_renderNodeRegistry.getRenderNodes() -- contiguous vector<RenderNode>
m_renderPrimitives[] -- contiguous vector<RenderPrimitive>
Both are directly uploadable to GPU via staging (single memcpy-style transfer).
┌────────────────────────────────────────┐
│ m_bRenderNode (SSBO) │
│ ┌────────────────────────────────────┐ │
│ │ GltfRenderNode[0] │ │ ← Instance 0
│ │ GltfRenderNode[1] │ │ ← Instance 1
│ │ GltfRenderNode[2] │ │ ← Instance 2
│ │ ... │ │
│ │ GltfRenderNode[N-1] │ │ ← Instance N-1
│ └────────────────────────────────────┘ │
└────────────────────────────────────────┘
↑ Shader indexing: raster → nodes[pushConst.renderNodeID];
ray tracing → nodes[InstanceIndex()]
Access Pattern: Direct indexing (O(1)) in shaders.
Never modify render nodes in the registry directly. Always modify the tinygltf Model, then call the appropriate rebuild:
- Transform change:
setNodeTRS()+updateNodeWorldMatrices() - Hierarchy change: editor operation (which calls
parseScene()internally)
RenderNodes[i] corresponds to TLAS Instance[i].
When a ray hits instance i, InstanceIndex() = i, and the shader reads renderNodes[i] for transform and material.
m_renderPrimitives[p] corresponds to m_blasAccel[p].
The BLAS array is stable across hierarchy-only edits (reparent, duplicate, delete of
existing meshes). It is rebuilt when the deduplicated primitive set changes
(primitivesChanged), and individual BLAS may be updated in place for animated
geometry (skinning / morph via SceneRtx::updateBottomLevelAS()). The TLAS is rebuilt
or updated when instances move, appear, or disappear.
Animated scenes build their BLAS with ALLOW_UPDATE and skip compaction; only static scenes
compact (see GltfRenderer::buildAccelerationStructures()). Vulkan permits compacting an
updatable BLAS and refitting it afterward, but this app's build+compact+per-frame-update path
triggers a GPU device-lost, so compaction is skipped for animated scenes as a workaround.
| Operation | Model | RenderNodes | GPU SSBO | TLAS |
|---|---|---|---|---|
| Load scene | ✅ New | ✅ Full rebuild | ✅ Create & upload | ✅ Create & build |
| Merge scene | ✅ New | ✅ Full rebuild | ✅ Full GPU recreation | ✅ Full rebuild |
| Transform change | ✅ Modified | ✅ Partial update | ✅ Surgical upload | ✅ Update instances |
| Add empty node | ✅ Modified | No change (no parseScene(); empty node has no mesh) |
No GPU work | No GPU work |
| Delete node | ✅ Modified | ✅ Full rebuild | ✅ Resize & full upload | ✅ Rebuild |
| Duplicate node | ✅ Modified | ✅ Full rebuild | ✅ Resize & upload new | ✅ Rebuild |
| Reparent | ✅ Modified | ✅ Transforms only | ✅ Surgical upload (transforms) | ✅ Update instances |
| Split mesh | ✅ Modified | ✅ Full rebuild | ✅ Surgical upload (changed indices) | ✅ Update instances |
| Merge mesh | ✅ Modified | ✅ Full rebuild | ✅ Surgical upload (changed indices) | ✅ Update instances |
| Material change | ✅ Modified | ✅ Partial update | ✅ Surgical upload | Usually none; an edit that changes a material's RTX instance flags (opacity, culling) updates the instances that use it (SceneRtx::updateInstanceFlagsCache()) |
Key: parseScene() always does a full CPU rebuild, but its internal diff sets precise dirty flags
so the GPU sync is surgical -- only changed render node indices, new materials, etc. are uploaded.
parseScene() snapshots the full render node state (worldMatrix, materialID, renderPrimID, visible) before
clearing, then compares after rebuild. This sets precise dirty flags:
renderNodesVk/renderNodesRtx: indices where any field differs (surgical upload)allRenderNodesDirty: set when count changes or the dirty fraction reacheskFullUpdateRatio(gltf_scene.hpp) — full upload, avoids hash-set overheadmaterials: new material indicesprimitivesChanged: primitive count changed (BLAS rebuild needed)lights: all lights dirty if light count changed
The renderer has one unified sync path (syncFromScene + syncTopLevelAS) that processes these flags.
Buffer resize is handled automatically by size-mismatch detection in uploadRenderNodes and rebuildTopLevelAS.
Image/texture edits are consumed at frame top (before any panel records an ImGui::Image), not through
syncFromScene, and come in three forms:
texturesChanged— a structural change (replace-in-place, reload, remove/insert at an arbitrary index, or a texture's sampler reference).GltfRenderer::applyPendingTextureRebuild()does a fullrebuildVulkanSceneFull(): it stalls the queue, frees and recreates every scene image (re-reading them from disk) and rebuilds the acceleration structures.texturesTailChanged— a tail-only change: image(s)/texture(s) were appended to, or removed from, the end of the model with every lower index untouched (importing, and undoing/redoing an imported texture — seeSceneEditor::importImageAsTexture).GltfRenderer::applyPendingTextureTailSync()callsSceneVk::syncTextureTail(), which loads/creates only the new tail images (or deferred-frees removed ones) and appends their views; the renderer then writes only the new bindless descriptor slots (writeTextureDescriptorRange). No queue stall, no re-read of existing images, no AS work. A first import into a scene that has no images/textures falls back totexturesChanged, because the empty scene carries 1×1 dummy defaults on the GPU that do not match the model sizes.samplers— an in-place property edit of an existingmodel.samplers[i](wrap/filter, from the Inspector). Images, texture views and sampler slot assignments are untouched, soGltfRenderer::applyPendingSamplerUpdate()only recreates that oneVkSampler(SceneVk::updateSampler()) and rewrites its singleeSamplersdescriptor slot. No queue stall, no image touch, no AS work.
texturesChanged and texturesTailChanged gate the per-frame material sync in updateSceneChanges()
until the frame-top reconcile has run, so the material buffer never references a texture index the
descriptor array does not yet contain. samplers needs no such gate — a sampler's slot index never
changes, only its VkSampler properties — but it is likewise preserved across updateSceneChanges()'s
end-of-frame clearDirtyFlags() so it survives until applyPendingSamplerUpdate() consumes it.
Merging or referencing a scene reuses the same tail idea from the (threaded) rebuild path rather than the
frame-top flags: because SceneMerger only appends (existing image/texture/material indices never move),
rebuildVulkanSceneInternal(RebuildMode::eMergeAppend) rebuilds geometry, render nodes and materials in
full (cheap, no disk) but calls SceneVk::syncTextureTail() to keep the resident textures and load only
the new tail images — so a merge no longer re-reads every image. A merge into a scene with no textures
still uses eFull (the empty-scene dummy defaults do not match the model, same reason as the import
fallback above).
When the m_validateGpuSync flag is enabled (default: true), after every updateSceneChanges(), validateGpuSync() compares:
- Shadow copy of last-uploaded render nodes (materialID, renderPrimID) against current CPU state
- TLAS
instanceCustomIndexagainst CPUrenderPrimID - Material buffer size against CPU material count
Any mismatch is logged as a warning with a descriptive error message.
parseScene()-gltf_scene.cpphandleRenderNode()-gltf_scene.cppcreateRenderNodesForNode()-gltf_scene.cppbuildPrimitiveKeyMap()-gltf_scene.cppupdateNodeWorldMatrices()-gltf_scene.cppupdateRenderNodeDirtyFromNodes()-gltf_scene.cpp(helper; not in the default transform/animation path — dirty flags are maintained incrementally)markNodeDirty()-gltf_scene.cpp
duplicateNode()/duplicateNodeRecursive()-gltf_scene_editor.cppdeleteNode()/deleteNodeRecursive()/deleteNodeSingle()-gltf_scene_editor.cppremapIndicesAfterNodeDeletion()-gltf_scene_editor.cpp
syncFromScene()-gltf_scene_vk.cppuploadRenderNodes()-gltf_scene_vk.cppuploadPrimitives()-gltf_scene_vk.cppuploadMaterials()-gltf_scene_vk.cpp(uploads theshaderio::GltfShadeMaterialarray produced bynvvkgltf::MaterialCacheingltf_material_cache.cpp; the material struct is locally forked, see developer.md → Material System)createVertexBuffers()-gltf_scene_vk.cpp
cmdCreateBuildTopLevelAccelerationStructure()-gltf_scene_rtx.cppcreateBottomLevelAccelerationStructure()-gltf_scene_rtx.cppsyncTopLevelAS()/rebuildTopLevelAS()-gltf_scene_rtx.cpp
updateSceneChanges()-renderer.cpp(unified sync path, processes all dirty flags)
After parseScene(), log each render node's refNodeID, materialID, and renderPrimID.
The count should match the expected number of mesh-primitive instances in the scene.
After cmdCreateBuildTopLevelAccelerationStructure(), check that m_tlasInstances.size() equals scene.getRenderNodes().size().
The built-in validateGpuSync() does this automatically when enabled.
- RenderNodes count wrong → parseScene() not called after structural edit
- Invisible objects → Check
renderNode.visibleflag - Wrong transforms → Check if
updateNodeWorldMatrices()called - Missing instances → Check if node was added to scene.nodes[] (not just created)
- Wrong materials after structural edit → Check
validateGpuSync()output in debug build
Data is ONE-WAY:
Model (authoritative) → parseScene() → RenderNodes (derived) → GPU
Never modify RenderNodes directly - always modify Model and rebuild.
Hierarchy changes are cheap - parseScene() + GPU upload ~1-5ms.
This architecture makes editing simple:
- Modify vectors in
m_modeldirectly - Call
parseScene()to regenerate RenderNodes - Upload to GPU via
syncFromScene()oruploadRenderNodes() - Rebuild TLAS via
syncTopLevelAS()orcmdCreateBuildTopLevelAccelerationStructure()
No complex synchronization needed - derived data is always regenerated from source of truth.
Option 1: Split parseScene() into targeted rebuilds
rebuildPrimitivesAndRenderNodes()for split/merge meshrebuildRenderNodesAndLights()already exists and is used for adding lights; node delete/duplicate still go through a fullparseScene()- Only rebuild what the operation requires
- Medium complexity
Option 2: Incremental RenderNode updates
- Add/remove RenderNodes without full rebuild
- Remap TLAS instances manually
- Very complex, high bug risk
Current Approach (Full CPU Rebuild + Surgical GPU Sync):
- ✅ Simple and correct
- ✅ CPU rebuild < 5ms for typical scenes (~10-20ms for 1M nodes)
- ✅ GPU sync is surgical (only changed indices uploaded)
- ✅ Debug validation catches any drift
- ✅ One unified sync path in the renderer
Recommendation: Keep full CPU rebuild + surgical GPU sync. Split parseScene() later if profiling shows CPU cost matters at 1M+ node scale.