For contributors and agents. High-level architecture, source map, and the material system — concepts plus pointers into
src/andshaders/, not a restatement of the code. For runtime usage see the User Guide; for the scene data-flow deep dive see Rendering Architecture.
This project is developer-oriented: the RTX path tracer is the primary high-quality reference for ray tracing glTF PBR materials, while the rasterizer is a practical fallback for fast interaction and iteration. Both renderers share Vulkan resources and Slang shaders.
- Architecture
- Scene Graph
- Source Code Structure
- Material System
- Agentic Workflow Bridge
- Common Development Workflows
- Testing
- Contributing
For the internal data-flow walkthrough (Model -> RenderNodes -> GPU/TLAS), including how ray tracing acceleration structures are built, see Rendering Architecture.
For how glTF 2.1 external assets (complex scenes) are merged, flagged read-only, and re-externalized/flattened on save, see External Assets.
For glTF 2.1 support as a whole (status, PROVISIONAL markers, the tinygltf fork, test fixtures), see glTF 2.1.
nvapp::Application provides the Vulkan application lifecycle. You attach nvapp::IAppElement instances, and each is called for different states:
| Callback | Purpose |
|---|---|
onAttach() |
Create Vulkan resources, compile shaders, set up descriptor sets |
onDetach() |
Clean up all GPU resources |
onRender(cmd) |
Record rendering commands (dispatch to path tracer or rasterizer, then tone map) |
onResize() |
Recreate G-Buffer and notify renderers |
onUIRender() |
Render ImGui UI and handle input |
onUIMenu() |
Create menu bar (File, View, Tools, Debug) |
onFileDrop() |
Load dropped .gltf/.glb/.obj (or a .scene.json descriptor) as scenes, .hdr as environment maps |
In main() we attach:
GltfRenderer— the main element (scene loading, rendering, UI)ElementProfiler— GPU execution timingElementLogger— log output in a UI windowElementGpuMonitor— NVML-based GPU status monitoringElementDbgPrintf— shaderprintfdebug output (only when built withUSE_DBG_PRINTF)
On the first frame after a reset, the path tracer writes NDC depth (outDepth) so picking and the
overlays drawn after it (grid, gizmo) can depth-test against the path-traced image. It writes the
G-buffer depth image directly as a storage image when the depth format supports it.
nvvk::RenderTarget only grants STORAGE usage in that case, and Vulkan does not require it for any
depth format; D32_SFLOAT lacks it on AMD. There PathTracer binds an R32_SFLOAT staging image
instead and copies it into the depth image through a buffer (PathTracer::cmdCopyStagedDepth),
since core Vulkan has no direct copy between color and depth images.
The glTF scene is loaded via TinyGLTF and converted to a GPU-friendly representation shared by both renderers.
---
config:
layout: elk
theme: neutral
---
flowchart TB
subgraph RenderNodes["RenderNodes"]
rn0["rn0"]
rn1["rn1"]
rn2["rn2"]
rn3["rn3"]
rn4["rn4"]
end
subgraph s1["RenderPrimitives"]
n1["rp0"]
n2["rp1"]
end
subgraph s2["RenderMaterials"]
n3["m1"]
n4["m2"]
n5["m3"]
end
sceneRoot["sceneRoot"] --> node0["node0"]
node0 --> node1["node1"] & node2["node2"] & node3["node3"]
node3 --> mesh1["mesh1"]
mesh0["mesh0"] --> prim0["prim0"] & prim1["prim1"]
mesh1 --> prim2["prim1'"]
node1 --> mesh0
node2 --> mesh0
prim0 --> rn0 & rn2 & n1 & n3
prim1 --> rn1 & rn3 & n2 & n4
prim2 --> rn4 & n2 & n5
style node0 fill:#BBDEFB
style node1 fill:#BBDEFB
style node2 fill:#BBDEFB
style node3 fill:#BBDEFB
style prim0 fill:#C8E6C9
style prim1 fill:#C8E6C9
style prim2 fill:#C8E6C9
style RenderNodes fill:#BBDEFB
style s1 fill:#C8E6C9
style s2 fill:#E1BEE7
RenderNodes are the flattened instances with world matrices and material references. RenderPrimitives are the unique geometry (vertex/index buffers). Shared primitives create one BLAS for multiple render nodes.
RN0 == node0 -> node1 -> prim0 RN1 == node0 -> node1 -> prim1 RN2 == node0 -> node2 -> prim0 RN3 == node0 -> node2 -> prim1 RN4 == node0 -> node3 -> prim1'
Primary files grouped by subsystem (not an exhaustive listing — treat src/ and
shaders/ as the source of truth for the complete set):
src/
├── main.cpp # Entry point, Vulkan context, CLI parsing
├── renderer.cpp/hpp # Main renderer orchestrator (GltfRenderer)
├── renderer_base.hpp # BaseRenderer: abstract base for PathTracer / Rasterizer
├── renderer_pathtracer.cpp/hpp # Monte Carlo path tracer (Vulkan ray tracing + ray query)
├── sample_schedule.hpp # Samples per frame: ending on stops (cap, --outputSamples), the Auto SPP controller
├── renderer_rasterizer.cpp/hpp # Forward PBR rasterizer
├── renderer_silhouette.cpp/hpp # Selection highlight (compute shader)
├── env_image_loader.cpp/hpp # Decodes .hdr / .exr lat-longs to float RGBA for HdrIbl
├── env_baker.cpp/hpp # Bakes an analytic sky into the lat-long image + importance buffer
│ # that HdrIbl / HdrEnvDome consume (preview vs. commit)
├── gltf_environment_sky.cpp/hpp# OMI_environment_sky parse/write/strip, raw-value passthrough
├── mcp_timing.cpp/hpp # Optional MCP endpoint (--mcp): recompile shaders, time a
│ # GPU pass over a window (docs/mcp.md)
├── hover_picker.cpp/hpp # Async G-buffer readback for KHR_interactivity hover detection
├── camera_controls.cpp/hpp # Viewport bindings table (drags, wheel, clicks, keys -> action), the manipulator
│ # that dispatches from it (Shift pivots on the ray-picked point), and
│ # the per-frame input handling; Help's Camera tab reads the same table
├── settings_registry.hpp # One declaration per setting -> CLI + benchmark sequences + ImGui.ini;
│ # also captures each declaration-time default (Reset All to Default)
├── resources.hpp # Shared Vulkan resources and settings (`nvvk::FrameUploader` staging)
│
├── gltf_scene.cpp/hpp # Core scene loading and management
├── gltf_scene_vk.cpp/hpp # GPU buffer/texture upload (SceneVk)
├── gltf_scene_omm.cpp/hpp # Opacity micromaps (SceneOmm), EXT_mesh_opacity_micromap → VK_KHR_opacity_micromap
├── gltf_scene_rtx.cpp/hpp # BLAS/TLAS acceleration structures (SceneRtx)
├── gltf_scene_gpu.cpp/hpp # SceneGpu — coordinates SceneVk / AnimationVk / SceneRtx / TransformComputeVk
├── gltf_scene_editor.cpp/hpp # Scene editing (add/delete/duplicate nodes)
├── gltf_scene_animation.cpp/hpp# AnimationSystem: playback/interpolation (CPU), via Scene::animation()
├── gltf_scene_animation_vk.cpp/hpp # GPU compute skinning / morph target animation
├── gltf_scene_transform_vk.cpp/hpp # GPU compute world-matrix propagation
├── gltf_scene_merger.cpp/hpp # Merge two glTF scenes; append a file's resources (external assets)
├── gltf_scene_external_assets.cpp # glTF 2.1 external assets: file cache, placement, instance sets (ExternalAssetPlacer)
├── gltf_scene_loading.hpp # Shared file loading helpers (aliases, image rebase, meshopt)
├── gltf_scene_validator.cpp/hpp# Scene validation
├── gltf_version.cpp/hpp # glTF asset versions: what loads, what a save declares, 2.1 feature detection
├── gltf_compact_*.cpp/hpp # Orphan resource removal / buffer compaction
├── gltf_create_tangent.cpp/hpp # Tangent space generation (UV gradient + MikkTSpace)
├── gltf_material_cache.cpp/hpp # CPU-side material cache
├── gltf_image_loader.cpp/hpp # Image decoding (DDS, KTX, STB, WebP)
├── gltf_animation_pointer.* # KHR_animation_pointer support
├── ies_profile.cpp/hpp # EXT_lights_ies: IESNA LM-63 photometric profile parser
│
├── ui_inspector.cpp/hpp # Property inspector (per selection type: node/primitive/material/
│ # mesh/camera/light/texture/image/sampler/animation)
├── ui_scene_browser.cpp/hpp # Scene Browser window: Scene Graph tree + the Elements tab host
├── ui_scene_browser_elements.cpp # Elements tab: builds the ElementTypeDesc registry and the one
│ # generic list renderer (icon tabs, columns, toolbar, sort, filter)
├── ui_element_registry.hpp # ElementTypeDesc / ElementColumn / ElementAddVariant (data-driven list)
├── ui_gltf_labels.cpp/hpp # Shared image-name + sampler wrap/filter labels (list + inspector)
├── ui_renderer.cpp # Viewport UI, menus and mouse interaction
├── ui_environment.cpp/hpp # UiEnvironment: the Environment panel. Environment type, HDR file,
│ # the authored skies, the atmosphere folds, Sun & Time of Day.
│ # Takes what it needs through setters, like UiInspector
├── sky_sun.cpp/hpp # SkySun: who the sun is (renderer state, or a scene light marked
│ # as the sky's) and every path that aims it
├── sun_position.cpp/hpp # NOAA solar position: (place, UTC instant) -> bearing + altitude.
│ # Pure, self-contained, nothing else links against it
├── ui_dock_layout.cpp/hpp # Default docking arrangement, shared by start-up (main.cpp's
│ # dockSetup) and Windows > Reset UI Layout
├── ui_presentation_mode.cpp/hpp # Presentation mode (F11): hides panels + menu bar, borderless
│ # full screen on the window's monitor; saves/restores both
├── ui_commands.cpp/hpp # Command registry: every menu/toolbar/keyboard command and its
│ # key binding, and the shortcut routing (see Add a command)
├── ui_help.cpp/hpp # Help menu: Keyboard & Mouse reference (live camera table,
│ # searchable shortcuts generated from the registry) and About
├── ui_xmp.cpp/hpp # KHR_xmp_json_ld metadata display
├── ui_animation.cpp/hpp # Animation playback state and viewport animation widget
├── ui_mouse_state.hpp # Mouse state tracking for viewport
├── ui_busy_window.hpp # Loading/busy indicator overlay
│
├── dlss.cpp/hpp # DLSS adapter (Ray Reconstruction + Super Resolution)
├── dlss_wrapper.cpp/hpp # NGX / DLSS SDK wrapper (project-independent)
├── optix_denoiser.cpp/hpp # OptiX AI Denoiser integration
├── oidn_denoiser.cpp/hpp # Open Image Denoise on Vulkan (vk_oidn) integration
├── denoiser_schedule.hpp # Auto-denoise interval shared by OptiX and OIDN, DLSS hand-off weight
├── denoiser_handoff.cpp/hpp # DLSS hand-off: blend from DLSS-RR's image to OptiX / OIDN's
├── vk_cuda.cpp/hpp # Vulkan-CUDA interop (shared memory/semaphores)
│
├── gizmo_transform_vk.cpp/hpp # 3D translate/rotate/scale gizmo
├── gizmo_grid_vk.cpp/hpp # Infinite procedural grid
├── gizmo_visuals_vk.cpp/hpp # Helper overlay manager
│
├── undo_redo.cpp/hpp # Full undo/redo system for scene editing
├── tinygltf_utils.cpp/hpp # TinyGLTF material + node extension extraction (visibility/selectability/hoverability)
├── tinygltf_converter.cpp/hpp # OBJ to glTF converter
├── tiny_stb_implementation.cpp # STB library implementation (single compilation unit)
├── pipeline_cache_util.cpp/hpp # Vulkan pipeline cache persistence
├── scene_selection.cpp/hpp # Selection state and events
├── scene_descriptor.cpp/hpp # Scene descriptor set layout / bindings
├── scene_feature_detection.cpp/hpp # Detect which extensions a scene uses
├── scene_shader_macros.cpp/hpp # Emit -DGLTF_USE_* macros from detected features
├── timeline_pipeline.cpp/hpp # Timeline-semaphore submission helper
├── benchmarking.cpp/hpp # Headless timing / scripted GPU benchmarks
├── gltf_camera_utils.hpp # Camera utility functions
├── gpu_memory_tracker.hpp # GPU memory category tracking
├── scoped_banner.hpp # Scoped UI banner helper
└── utils.hpp # Miscellaneous utilities
shaders/
├── gltf_pathtrace.slang # Path tracer (compute + ray generation)
├── gltf_raster.slang # PBR rasterizer (vertex + fragment)
├── silhouette.comp.slang # Selection edge detection
├── skinning.comp.slang # GPU skinning
├── morph.comp.slang # GPU morph-target blending
├── world_matrix_propagate.comp.slang # Per-level world-matrix propagation
├── snapshot_prev_transforms.comp.slang # Snapshot previous transforms (motion vectors)
├── update_render_instances.comp.slang # Refresh render-instance data on GPU
├── animation_io.h.slang # Skinning/morph shader I/O
├── world_matrix_io.h.slang # World-matrix compute shader I/O
├── shaderio.h # Host/device shared structures
├── pathtrace_functions.h.slang # Path-tracer hit/shading helpers (getShadowTransmission, etc.)
├── common.h.slang # Shared shader utilities
├── get_hit.h.slang # Hit state interpolation
├── raytracer_interface.h.slang # RayQuery vs traditional RT abstraction
├── dlss_util.h # DLSS motion vector / guide buffer utilities
├── gizmo_grid.slang # Infinite grid rendering
├── gizmo_grid_shaderio.h.slang # Grid shader I/O
├── gizmo_visuals.slang # Transform gizmo overlays
├── gizmo_visuals_shaderio.h.slang # Gizmo shader I/O
├── optix_image_to_buffer.slang # OptiX denoiser buffer conversion
├── denoiser_handoff.slang # DLSS hand-off blend of two images
├── gltf_light_ies.h.slang # EXT_lights_ies: photometric-profile falloff lookup
├── sky_background.h.slang # Background (sun-bearing) field for authored skies; shared by both paths
├── sky_background.slang # Rasterizer background pass that draws it
├── sky_omi_shaderio.h.slang # SkyType + the per-type OMI_environment_sky parameter blocks
├── sky_gradient.h.slang # OMI `gradient` evaluator (bands + sun), shared by bake and background
├── env_bake.slang # Bakes any sky into the lat-long image + importance buffer; one
│ # switch, with the physical case reading SkyBruneton's LUTs
│
│ # Local material fork (see "Material System" below)
├── gltf_material_config.h # MAT_EXT_* compile-time feature flags
├── gltf_eval_config.h # GLTF_USE_* runtime behavior gates
├── gltf_scene_io.h.slang # GltfShadeMaterial / GltfLight / GltfScene shaderio structs
├── gltf_vertex_access.h.slang # Vertex buffer accessors
└── gltf_material_eval.h.slang # evaluateMaterial(): GltfShadeMaterial -> PbrMaterial
The glTF material struct (shaderio::GltfShadeMaterial) and its evaluateMaterial() function
are forked locally into shaders/gltf_*.h.slang rather than using the upstream
nvpro_core2/nvshaders/ versions. The fork exists for two reasons:
- Forkability: the struct layout can be tailored to this project without affecting other
samples that share
nvpro_core2. - Extension gating: every
KHR_materials_*extension (plusKHR_texture_transform) is controlled by a compile-timeMAT_EXT_*flag inshaders/gltf_material_config.h. When a flag is0, that extension's fields drop out of the relevant struct (GltfShadeMaterialforKHR_materials_*,GltfTextureInfoforKHR_texture_transform) and its code paths drop out ofevaluateMaterial()/getTexture()/getShadowTransmission(). Defaults are all1, reproducing the upstream behavior byte-for-byte. - Behavior gating (runtime-safe):
GLTF_USE_*inshaders/gltf_eval_config.hgates material eval, path-tracer state (PathTracerState, volume segment, shadow transmission), and raster extension paths without changing buffer layout. Optimal mode passes each gate as-DGLTF_USE_X=0or1fromSceneFeatureSet(never-DMAT_EXT_X=0at runtime).MAT_EXT_*remains layout authority on host and device only.
gltf_scene_io.h.slangandgltf_vertex_access.h.slangintentionally reuse the upstream include guards (DH_SCN_DESC_H,VERTEX_ACCESSORS_H) so that transitive includes fromnvpro_core2headers (e.g.nvshaders/light_contrib.h.slang) become no-ops. The local version must be included first in every translation unit.gltf_material_eval.h.slanguses its own guard (GLTF_MATERIAL_EVAL_H) because no upstream code transitively includes it.PbrMaterialand the BSDF interface (nvshaders/bsdf_functions.h.slang) stay innvpro_core2. The localevaluateMaterial()initializesPbrMaterialwithdefaultPbrMaterial()first, then overrides per extension — gating an extension off safely leaves the corresponding BSDF fields at their neutral defaults.- Struct members carry their defaults inline, so
GltfShadeMaterial m = {};works; no paralleldefault*()helpers to keep in sync (seeshaders/gltf_scene_io.h.slang). - Host-side layout is guarded by
static_asserts insrc/gltf_material_cache.cpp(8-byte alignment/stride plus anchor offsets), so flipping anyMAT_EXT_*flag that breaks the layout fails the build immediately. - Retroreflection is a worked example of the pattern:
MAT_EXT_RETROREFLECTIONgatesKHR_materials_retroreflectionon both host and device, with the evaluation living ingltf_material_eval.h.slang. See the KHR_materials_retroreflection specification.
Container formats can store fewer channels than the shader expects, in two different ways, and the renderer resolves both on the host so the shader stays simple.
- Authored swizzle. A KTX2 file may carry a
KTXswizzlekey (e.g. a BC5 metallic-roughness map that declares1rg1).nv_ktxparses it intoKTXImage::swizzle;gltf_image_loader.cppconverts it to aVkComponentMapping, andSceneVk::createImage()applies it to theVkImageView— so the swizzle costs nothing at sample time and both render paths see the remapped channels. The same field is used to expand single-channelstb_imageresults to grayscale. - Missing channels. A two-channel format (BC5,
R8G8, EAC_R11G11) has no third channel to swizzle from; Vulkan samples blue as a synthetic0. That is fine for data that only occupies two channels, but it breaks tangent-space normal maps, whose Z is implied rather than stored — decoding the synthetic0to-1flips the normal into the surface and the surface renders black.SceneVk::buildTextureFormatFlags()infers the component count from the decodedVkFormatand marks the exactly two-channel ones inGltfTextureInfo::flags;decodeTangentSpaceNormal()ingltf_material_eval.h.slangrebuilds Z from the unit-length assumption. Three-channel maps keep their authored Z, and single-channel formats are excluded — their green is synthetic too, so there is no second axis to reconstruct from.
Because the flag depends on the decoded image format, texture creation runs before
uploadMaterials() in SceneVk::create() and recreatePreservingTextures() — the material cache
bakes the per-texture sampler slot and format flags (TextureSlotTable) into GltfTextureInfo.
KHR_materials_scatter is the most involved extension in the fork because it changes either the
surface BSDF or the volume transport, depending on the material. Both branches live in the scatter
block of gltf_material_eval.h.slang; the mode is
selected by PbrMaterial::thickness (i.e. by KHR_materials_volume), matching the extension spec.
- Thin-walled (no volume, or thickness 0). The spec turns the transmissive part of the
dielectric base into a blend of the specular BTDF and a Lambertian BSDF that splits its energy
between the two hemispheres by the scatter anisotropy. There is no dedicated scatter lobe in
nvpro_core2's BSDF, so the block re-expresses that blend with the lobes that already exist. It movestransmission * scatterStrengthout of the specular BTDF into the diffuse bucket, then splits that bucket withdiffuseTransmissionFactor. Note what the factor is: the forward share of the whole diffuse bucket, not the anisotropy's forward fraction — the bucket also holds the untouched diffuse base, so the fraction has to be normalised by the bucket's own weight. Conflating the two is what makes the mapping wrong fortransmissionFactor < 1, and it is only by coincidence correct attransmissionFactor = 1. Done that way the lobe weights match the spec exactly for any transmission, strength and anisotropy. The spec also gives the residual specular BTDF and the diffuse reflection lobe different colors, andPbrMaterial::baseColoralone can only carry one — hencePbrMaterial::transmissionTintinnvpro_core2, a multiplier on the BTDF lobe that isfloat3(1)for every other material. - Volumetric (thickness > 0). The surface BSDF is untouched; the block only derives
PbrMaterial::scatterCoefficient(σs) by splitting theKHR_materials_volumeextinction with the Kulla-Conty single-scatter albedo. The transport that consumes it ishandleVolumeScatter()inpathtrace_functions.h.slang.
Some things about the transport are worth knowing before touching it:
- glTF media are homogeneous, so the free-flight distance is sampled analytically per spectral
channel and combined with the balance heuristic — not with delta tracking against a majorant.
A single majorant makes the low-extinction channels collide (and therefore random-walk) far too
often; because those extra collisions are lossless, their effective per-collision albedo drifts
towards 1 and the medium's perceived color shifts away from
multiscatterColor. VolumeMediumstores absorption, not extinction, alongside the scattering coefficient. A medium authored near white has σa as a tiny difference of two large numbers — recovering it by subtraction under limited precision quantizes the albedo straight to 1.- In-volume Russian roulette has its own probability cap (
RR_PCONT_CAP_VOLUME). A dense, high-albedo medium keeps the throughput near 1, so the surface cap would cull fully energetic paths at every scatter event and the medium would render far too dark. The cap must stay below 1 all the same: without roulette such a path never terminates. - Surfaces with a diffuse transmission lobe are lit from both hemispheres, so
sampleLights()takes a flag that disables the punctual-light hemisphere rejection insingleLightContribution(). Without it, a light behind a backlit leaf or sheet contributes nothing. For the same reasonSceneRtxdisables back-face culling on them (getInstanceFlag()): a diffusely transmitted ray reaches the far wall of a closed object from behind. - Straight-through shadow transmission (
getShadowTransmission()) only passes the specular part of the transmission, so in thin-walled mode it is scaled by1 - scatterStrength. It samples the transmission and scatter-strength textures at the hit, so a shadow ray agrees withevaluateMaterial()texel by texel. - The thickness texture is a rasterizer-only stand-in for the path length through the medium:
evaluateMaterial()applies it underRASTER_PIPELINEonly. In the path tracer the ray measures that length, andthicknessFactoralone selects thin-walled vs. volume. It is also what makes a surface a medium boundary: the path and its shadow rays count themselves inside only across surfaces with a thickness. - Light reaching the camera through a medium is attenuated wherever it comes from. The medium in
front of a hit (
processVolumeSegment()) is resolved before the surface's emission is added, and a shadow ray knows the medium it starts in (the path's, from a surface or a scatter point) and attenuates every segment it travels inside one, including the last, up to a light inside the medium.tests/render/volume_transmittance.pychecks both against analytic transmittance. - A path knows it is in a medium only by having crossed into one, so a camera path starts in the
medium the camera is in: once per accumulation of a scene with a
KHR_materials_volumematerial,PathTracer::probeCameraMedium()runs the path tracer's own pipeline for a single thread (probeCameraMedium()ingltf_pathtrace.slang), which follows the center camera ray to the nearest medium boundary and, if it meets it from behind, records that medium forpathTrace()to start in. Without it, a camera inside a fog sees no fog in front of it and fog behind the far wall.
The rasterizer cannot trace inside a medium, so evaluateMaterial() compiles only the thin-walled
branch for RASTER_PIPELINE — the approximation the scatter spec explicitly permits for renderers
without volumetric transport.
- Define a new
MAT_EXT_<NAME>flag ingltf_material_config.h, default1. - In
gltf_scene_io.h.slang, add the new struct fields (+ any texture slots) inside#if MAT_EXT_<NAME>with inline default values. - Add
GLTF_USE_<NAME>ingltf_eval_config.h(defaultMAT_EXT_<NAME>) and the matching#errorguard. - In
gltf_material_eval.h.slang, add the evaluation block inside#if GLTF_USE_<NAME>, overriding the relevantPbrMaterialfields. Respect the existing block order (volume block runs before IOR, etc.). - In
src/gltf_material_cache.cpp, add the matching CPU-side population block under#if MAT_EXT_<NAME>— layout only; never toggleMAT_EXT_*at runtime. - In
src/scene_feature_detection.cppandsrc/scene_shader_macros.cpp, detect scene usage and emit-DGLTF_USE_<NAME>=0|1in optimal mode. - If the path tracer or raster reads the new field outside
evaluateMaterial()(e.g.getShadowTransmission()inpathtrace_functions.h.slang), gate with#if GLTF_USE_<NAME>. - Update documentation in
README.md(extension support list) anduser-guide.md.
Some material extensions don't influence the BSDF at all — they feed denoiser / post-process
inputs instead. EXT_DLSS_NR is the reference implementation: it writes a 4-channel per-material
mask into a dedicated DLSS-NR guide-buffer slot rather than touching evaluateMaterial(). The
checklist differs from the PBR pattern above:
MAT_EXT_*flag + struct field ingltf_material_config.h/gltf_scene_io.h.slang(same as PBR, but nogltf_eval_config.h/gltf_material_eval.h.slangentry needed).- Add a
GBufferslot toOutputImageenum (shaders/shaderio.h) and the matchingVkFormattokRrInnerFormats[](src/dlss.cpp). - Write the slot in
processPixel(shaders/gltf_pathtrace.slang) inside the first-hit block, guarded byUSE_DLSS_SHADER && MAT_EXT_<NAME>. Propagate throughGuideScratch→GuideOutput→packSampleResult(shaders/pathtrace_functions.h.slang) if the value needs to survive path tracing. - Register the new slot in
kRrSlots[](src/renderer_pathtracer.cpp) so it is wired into theoutImagesbindless array. - Wire the new NGX input in
Dlss::evaluateNr()or the appropriate evaluate function (src/dlss.cpp), reading directly fromm_innerGBuffer. - tinygltf helpers (
src/tinygltf_utils.{hpp,cpp}), material cache loading (src/gltf_material_cache.cpp), extension registration (src/gltf_scene.cpp), and inspector UI (src/ui_inspector.{hpp,cpp}) follow the same pattern as PBR extensions — seeEXT_DLSS_NRfor the complete example. - Update
docs/denoising.md(for guide-buffer / denoiser integration) anduser-guide.md.
EXT_lights_ies provides photometrically accurate angular light distribution using IESNA LM-63
profile files. Per spec it defines a self-sufficient point light and does not compose with
KHR_lights_punctual. If a node carries both extensions, EXT_lights_ies wins and the KHR
attachment on that specific node is dropped at load with a warning (see
Scene::handleLightTraversal in src/gltf_scene.cpp); the shared KHR
light entity in model.lights[] is preserved, so other nodes referencing it are unaffected.
src/ies_profile.hpp parses the .ies file into a normalized,
azimuthally-averaged 1D candela curve (see its "Simplification" note on why genuinely asymmetric
fixtures lose their azimuthal variation); GltfScene.iesProfiles carries the flattened per-profile
tables to the GPU, and GltfLight.iesProfile indexes into it (see shaders/gltf_scene_io.h.slang).
Both render paths evaluate the profile identically via evalIesFactor() in
shaders/gltf_light_ies.h.slang.
The Inspector exposes multiplier and color as editable property-editor rows on any node
carrying EXT_lights_ies (UiInspector::renderNodeProperties -> renderIesEditor). Edits are
undoable via EditNodeIesCommand. The profile index itself is read-only (it refers to the loaded
.ies file array). Nodes that additionally carry KHR_lights_punctual show an inline warning in
the IES section explaining that the KHR attachment is ignored on that node. See
user-guide.md § IES Photometric Profiles for profile comparison renders and the
inspector UI.
Settings::envSystem (shaderio::EnvSystem) selects the environment source. The shaders do not
branch on it: every mode but eNone reaches them as one lat-long image plus its alias table
(Resources::hdrIbl) and, for the rasterizer, the prefiltered cubes derived from it
(Resources::hdrDome). That is the whole point of the design — sampling, MIS and raster IBL stay
free of per-sky-type code, and adding a sky type adds no shader branches.
An HDR file arrives there by being loaded. The analytic types — the physical sky and the
OMI_environment_sky types — arrive there by being baked: src/env_baker.*
dispatches shaders/env_bake.slang — one switch over every sky type
— into a lat-long image and an importance buffer, then hands the pair to
nvvk::HdrIbl::updateFromGpuImage and nvshaders::HdrEnvDome::updateEnvironment. The bake's
bindings and push constant are ours (shaders/env_bake_io.h.slang);
what nvpro_core2 fixes is the lat-long mapping and texel weighting (latlongToDir,
texelSolidAngle in nvshaders/functions.h.slang), next to the functions that read the image back.
What a sky is stays in this repository, next to the glTF loader that reads it.
The bake size is fixed (kEnvBakeSize in src/env_baker.hpp), and the alias table is not
built at it. The bake sums its importance per cell of a coarser sampling grid (kEnvSamplingGrid,
same header) — a shared-memory reduction inside each workgroup, so it costs next to nothing — and
updateFromGpuImage builds the table over the cells, so its cost is the grid's, not the image's. A sample picks a cell and then a
direction uniformly in solid angle within it, and the PDF written into the image's alpha is the
cell's, so sampling and MIS agree. That suits a baked sky, whose lighting field is smooth because its
sun is a light of its own; an HDR file keeps one entry per texel, since a photographed sun is a few
texels wide. Shaders therefore sample with the grid (SceneFrameInfo::envSamplingGridWidth/Height),
never the image size.
EnvBaker splits that into a cheap preview() (colour only, while something is moving) and a full
commit(). Which one runs is decided by GltfRenderer::requestEnvironmentPreview(): every edit
source reports through it, and the commit is paid on the first frame that asks for no preview. That
settle test, rather than an ImGui "is a control held" question, is what keeps a sun driven by the
gizmo or by an animation from committing once per frame. GltfRenderer::updateHdrImages() remains the single place that writes the
texturesCube[] / texturesHdr[HDR_IMAGE_INDEX] descriptors.
The image, its alias table and the cubes are all stored in the environment's own frame. One
orientation, SceneFrameInfo::envRotation — a unit quaternion, OMI_environment_sky's rotation
verbatim — takes that frame to world. Every lookup brings a world direction into it with
quatRotateInverse (nvshaders/functions.h.slang), and nvpro_core2's raster IBL (evalKhronosIBL)
and HdrEnvDome::draw take the same quaternion. Rotating the environment therefore never re-bakes;
only the physical sky does, when the sun moves relative to it.
The environment is two fields, and which one a ray reads is the part that has to be right:
- Lighting field — the baked lat-long image, deliberately sun-free. The sun is a directional
KHR_lights_punctualthat next-event estimation samples directly; baking it into the image too would light every rough surface with roughly two suns. - Background field — evaluated analytically per ray, sun included. Per-ray evaluation is also
what keeps a half-degree sun disk sharp, which no practical bake resolution could. The authored
sky types evaluate it in
shaders/sky_background.h.slang. The physical sky's background is evaluated per ray in the path tracer too (evalPhysicalSkyBackgroundinshaders/gltf_pathtrace.slang), through the same function the bake uses (shaders/sky_bruneton_radiance.h.slang), plus the half-degree disk (evalSunDiskBackgroundin the same header, fed by a radiance the bake publishes). What the camera sees is then independent of the bake resolution — the horizon, where sky meets ground in a hard edge, is what a lat-long would otherwise smear. The rasterizer still draws the baked image for this sky and adds the disk, so its horizon keeps the bake's resolution.
A camera ray, and an escaped ray whose last bounce was delta/specular, read the background field;
any other escaped ray reads the lighting field plus the sun disk (evalSkySunDisk),
MIS-weighted against the sun's NEE sample. The disk is needed there because a glTF roughness of 0
is clamped to a tiny but finite lobe, not a delta: NEE's cone samples almost never land in it, so
without the disk smooth glass or a mirror shows no sun. The header states the rule at its
definition because getting it wrong is silent and off by roughly 2x. Both render paths include that same
header — the path tracer from gltf_pathtrace.slang, the rasterizer from its background compute
pass shaders/sky_background.slang — so the two can never drift.
OMI's panorama.equirectangular is an index into the glTF's textures[], not a path. An .hdr
or .exr is not a standard glTF image mime type, and putting one into images[]/buffers[] to
satisfy that would bloat every scene that has a sky — so this renderer stores a URI in a sibling
NV_environment_sky_panorama block on the same sky entry:
"skies": [{
"type": "panorama",
"extensions": { "NV_environment_sky_panorama": { "uri": "../env/studio.exr" } }
}]The interop gap is deliberate and worth stating: a reader that understands only OMI sees
type: "panorama" with no source and falls back to its own default. That is a visible gap rather
than a silent wrong answer, which is the better failure. An authored equirectangular index is
never invented but always preserved, since index 0 is a valid texture — inventing one would load
the wrong image rather than fall back.
The URI resolves against the glTF that named it, like every other external asset
(external_assets.md), not against the resources directories — two scenes
naming the same file must not load each other's environment. On save it is written relative to the
file being written when the image lives under it, and absolute when it does not, so a scene and
its environment move together without a chain of .. that breaks at the first move.
Decoding is src/env_image_loader.{cpp,hpp}, which is also the single place that decides which
formats can be an environment. It dispatches on content rather than file extension, and covers
both .hdr and .exr — see that header for why EXR cannot live inside nvvk.
OMI_environment_sky is scene data, not a viewer preference.
src/gltf_environment_sky.* parses the extension into a
SkyDescriptor and writes it back on save, keeping the raw tinygltf::Value so sky types and keys
the renderer does not model survive a round trip untouched. GltfRenderer applies a parsed sky at
the top of the frame after the settings-restore hooks have run, so a scene that carries a sky wins
over the persisted .ini.
The optional external generation bridge lives in src/agentic_bridge.*. It defines a filesystem-based manifest and request/response envelope for tools that can generate HDR environments, texture references, or image-to-image enhancements.
Initialize the bridge with:
vk_gltf_renderer --agenticBridgeInit --agenticBridgeRoot path/to/agentic_bridgeThis creates manifest.json, requests/, responses/, and assets/ without requiring ComfyUI or any other generator to be installed. The same bridge can be initialized and driven from the in-app Agentic window (F7, in the Windows menu). The C++ is compiled only when the USE_AGENTIC CMake option is ON (the default). See Agentic Workflow Foundation for the staged plan and protocol shape.
- Register it once:
m_settings.add(...)(SettingsRegistry) for a renderer setting, orparameterRegistry.add(...)inmain.cppfor an app-level flag. - Wire it to renderer or app settings.
- Nothing to list by hand: it appears in
--help. Describe it in the user guide only where users need to know how to use it.
Commands live in one place: GltfRenderer::registerCommands() (ui_renderer.cpp) adds each to the
ui::CommandRegistry (ui_commands.hpp), with its label, icon, menu group, what it does, and
optionally when it is enabled or checked. Its key binding sits beside it in the same declaration,
held apart from the command so bindings can later become user-settable. Everything else is a view
of the registry: a menu draws it with menuItem(id) (icon, label and shortcut text come along),
the toolbar draws window and view toggles from it, and Help > Keyboard & Mouse lists every bound
command, grouped by menu. A new shortcut therefore needs no change outside registerCommands().
Keys go through Dear ImGui's shortcut routing (ImGui::Shortcut()), polled once per frame per
binding scope: global bindings by the menu bar, viewport bindings from inside the viewport window
(live while the viewport is hovered — no click needed — or focused), and F11 from
updatePresentationMode(), since the menu bar is hidden in presentation mode. That function also
polls the presentation-only bindings (Esc leaves the mode) while the mode is on, so they never claim a
key the rest of the time. Global bindings take
the lowest routing priority, so a widget using the keyboard keeps it: while a text field is edited,
letters, Delete, Home and Esc go to the field, and its own Ctrl+Z / Ctrl+Y win over Undo / Redo.
Two kinds of binding are listed but never polled. A command without a run is handled by other
code: the log window's key belongs to nvapp::ElementLogger. A binding with a text is matched by
the code that handles it: '?' as a typed character by ui_help.cpp, and "Drop file", the modifiers
held while a file is dropped, by onFileDrop(), which matches them against File > Import/Merge Scene
and File > Reference Scene instead of naming the keys itself.
The viewport's mouse, wheel and movement keys are not commands: they are held down, dragged or
clicked rather than pressed. They have a table of their own, kBindings in camera_controls.cpp;
each row names an input, the modifiers it needs and those it tolerates, the navigation modes and
projection it applies to, the action, and the label and tooltip Help shows. For an input, the first
matching row wins. AnchorCameraManipulator dispatches drags and the wheel from it instead of using
nvutils::CameraManipulator's own mapping, camera_controls::update() replaces
nvapp::ElementCamera::updateCamera() for the viewport, mouseClickedInViewport() takes the click
that selects and the double-click that sets the point of interest from it, and Help > Keyboard &
Mouse draws its Camera tab (mouse grid, clicks and keys) from it. Rebinding them is a change to that
table only.
A command that opens a dialog or rebuilds the scene only raises a request (m_menuRequests);
renderMenu() carries it out once the menus are closed.
- Add state in shared settings/resources.
- Bind and consume it in
renderer_pathtracer.cpporrenderer_rasterizer.cpp. - Expose UI controls in the corresponding UI module.
- Persist with the settings handler when needed.
- Parse and serialize extension fields in
tinygltf_utils.*. - If the extension affects the material model (any
KHR_materials_*), follow the checklist in Material System → Adding a new KHR_materials_* extension. - Otherwise, integrate runtime behavior in scene/shader code as needed.
- If the extension stores any index (into accessors/bufferViews/textures or a root-level array
it defines itself), add its remapping to
gltf_scene_merger.cppin the same change — otherwise it is silently dropped or cross-linked when scenes are merged. See External Assets → index remapping is exhaustive. - Update extension documentation in README.md and usage notes in user-guide.md.
Use RENDERING_ARCHITECTURE.md as the source of truth for:
- parse/rebuild flow
- render-node dirty propagation
- GPU buffer synchronization
- TLAS/BLAS update invariants
Tests are disabled by default. Enable with BUILD_TESTING=ON:
cmake -B build -DBUILD_TESTING=ON
cmake --build build --target vk_gltf_renderer_tests
ctest --test-dir build -C Release --output-on-failureSee tests/README.md for details.
This project uses the Developer Certificate of Origin (DCO). By contributing, you certify that you have the right to submit the work under the project's open source license.
Please open issues for bug reports and feature requests. Pull requests should target the main branch and include a clear description of the change.
All text files are stored in the repository with LF. This is enforced by .gitattributes
(* text=auto), with binary asset extensions pinned explicitly so they are never
EOL-rewritten.
* text=auto normalizes to LF on commit but sets no working-tree eol attribute, so what
you get on checkout is governed by your own Git configuration rather than by your platform:
| Setting | Working tree |
|---|---|
core.autocrlf=true (Git for Windows default) |
CRLF |
core.autocrlf=input |
LF |
core.autocrlf=false |
follows core.eol, default native — CRLF on Windows, LF elsewhere |
core.eol=lf |
LF, on any platform |
You do not need to change whichever you use: every combination round-trips cleanly, so editing a file produces a diff of only the lines you actually changed.
The one-time renormalization that established this is listed in .git-blame-ignore-revs,
so git blame skips it (GitHub and GitLab apply that file automatically).
