Application methods enabled (uncommented)
| # | Call / change | File | Status |
|---|---|---|---|
| 1 | createRenderTarget() | vkapp_scanline.cpp | done |
| 2 | createPostDescriptor() | vkapp_postProcess.cpp | done |
| 3 | createPostPipeline(): layout switched to the post descriptor set | vkapp_postProcess.cpp | done |
| 4 | renderTarget sampler in post.frag, and the vkCmdBindDescriptorSets in postProcess() | post.frag, vkapp_postProcess.cpp | done |
| 5 | createModel() | vkapp_loadModel.cpp | done |
| 6 | createMatrixBuffer() | vkapp_scanline.cpp | done |
| 7 | createObjDescriptionBuffer() | vkapp_scanline.cpp | done |
| 8 | createScDescriptorSet() | vkapp_scanline.cpp | done |
| 9 | createScPipeline() | vkapp_scanline.cpp | done |
| 10 | renderFrame(): updateCameraBuffer() and rasterize() | vkapp_runtime.cpp | done |
| 11 | ImGui: GUI defined, drawGUI() populated | vkapp.h, vkapp_runtime.cpp | done |
The assignment as specified is the living room scene drawn through the scanline pipeline into
m_renderTarget, and displayed by the post pass.
Everything in Beyond the framework is work done on top of it: a glTF loader, a metallic-roughness BRDF, selectable tone mapping, an scRGB HDR mode, a TOML scene table and a physical sky.
Post process: displaying an image
In project 1 the post pass manufactured its own image. Now it samples m_renderTarget,
which the scanline pipeline (and later the ray tracer) writes into.
createRenderTarget
| Parameter | Value |
|---|---|
| Extent | m_windowSize |
| Format | VK_FORMAT_R32G32B32A32_SFLOAT |
| Usage | TRANSFER_DST, SAMPLED, TRANSFER_SRC, STORAGE, COLOR_ATTACHMENT |
| Memory | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT |
| Layout | VK_IMAGE_LAYOUT_GENERAL |
| Sampler | initTextureSampler |
A 32-bit float target holds linear, unbounded radiance.
- Destroy registered:
m_renderTarget.destroy(m_device) - Resize: destroyed and recreated in
recreateSizedVulkanResources, since its extent is the window extent
createPostDescriptor
The render target as a combined image sampler for the fragment stage, via a single binding:
m_postDesc.setBindings(m_device, {
{0, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1, VK_SHADER_STAGE_FRAGMENT_BIT}
});
m_postDesc.write(m_device, 0, m_renderTarget.Descriptor());
- Destroy registered:
m_postDesc.destroy(m_device) - Resize: recreated with the render target, because it holds that image’s view
createPostPipeline
Project 1: The best we can do for now … Projects 2 and beyond: What we eventually need.
The project 1 layout is commented out and the second one enabled:
// @@ Project 1: The best we can do for now:
//createInfo.setLayoutCount = 0;
//createInfo.pSetLayouts = nullptr;
// @@ Projects 2 and beyond: What we eventually need:
createInfo.setLayoutCount = 1;
createInfo.pSetLayouts = &m_postDesc.descSetLayout;
post.frag declares layout(set=0, binding=0) uniform sampler2D renderTarget and postProcess()
binds m_postDesc.descSet before the fullscreen-triangle draw.
The layout’s creation is checked with VK_CHECK.
Tone mapping extension
The default fragColor = pow(texture(renderTarget, uv), vec4(1.0/2.2)) tone mapper
was replaced with a selectable Tone mapping operator via GUI and an HDR toggle.
Scene loading
createModel
The assignment PDF lists the counts its own run produced for the living room. A local run matches them:
| List | Local run | |
|---|---|---|
| Vertices | 1,741,337 | 1,741,337 |
| Indices | 1,741,911 | 1,741,911 |
| Triangles | 580,637 | 580,637 |
| Materials | 44 | 44 |
| Material indices (one per triangle) | 580,637 | 580,637 |
| Textures | 16 | 16 |
[17:51:46.782] info Assimp mNumMeshes: 128
[17:51:46.782] info Assimp mNumMaterials: 44
[17:51:47.349] info vertices: 1741337
[17:51:47.349] info indices: 1741911 (580637)
[17:51:47.350] info materials: 44
[17:51:47.350] info matIndx: 580637
[17:51:47.350] info textures: 16
[17:51:47.511] info Scene totals: 1 objects, 1 instances, 16 textures
The model data is then stored in the three structures the PDF describes:
m_objInst(one instance, identity transform)m_objData(the fourBufferWraps per object)m_objDesc(their device addresses, read by the shaders throughGL_EXT_buffer_reference2).
Destroy registered: a single wrapper method destroyModel(), which encapsulates all the
model-related teardown, object data and textures.
createMatrixBuffer / updateCameraBuffer
m_matrixBuff is a device-local uniform buffer holding MatrixUniforms (viewProj,
priorViewProj, viewInverse, projInverse). It is filled every frame inside the frame’s own
command buffer, with vkCmdUpdateBuffer between two buffer barriers. The first keeps the write
from racing reads by the previous frame; the second makes the write visible to the vertex and
ray-tracing stages.
- Destroy registered:
m_matrixBuff.destroy(m_device)
createObjDescriptionBuffer
m_objDesc uploaded as a storage buffer: one ObjDesc per object, carrying txtOffset and the
four buffer device addresses.
- Destroy registered:
m_objDescriptionBuff.destroy(m_device)
createScDescriptorSet
Shared by the scanline pipeline (set 0) and, later, the ray tracer (set 1):
| Binding | Type | Stages | Holds |
|---|---|---|---|
| 0 | uniform buffer | vertex, fragment, raygen | MatrixUniforms |
| 1 | storage buffer | vertex, fragment, raygen | ObjDesc[] |
| 2 | combined image sampler × nbTxt | fragment, raygen | every texture of the scene |
| 3 | uniform buffer | fragment, raygen, miss | SkyUniforms (added, see The sky) |
| 4 | combined image sampler | fragment, raygen, miss | sky environment cube (added) |
The texture count is baked into binding 2’s descriptorCount, so switching to a scene with a
different texture count rebuilds the set, the pipeline layout and the pipelines.
- Destroy registered:
m_scDesc.destroy(m_device)
createScPipeline
| State | Value |
|---|---|
| Vertex input | one binding, stride sizeof(Vertex) = 48 bytes: pos (offset 0), nrm (12), texCoord (24), tangent (32) |
| Topology | triangle list |
| Cull mode | VK_CULL_MODE_NONE, every surface is drawn two-sided |
| Depth | test and write on, VK_COMPARE_OP_LESS_OR_EQUAL |
| Blending | off |
| Rendering | dynamic rendering: colour R32G32B32A32_SFLOAT, depth D32_SFLOAT |
| Push constant | PushConstantRaster, vertex + fragment: light position / intensity / ambient, model matrix, object index, debug view |
tangent was appended to Vertex for normal mapping, after the original three attributes so that
none of their offsets moved.
- Results checked:
vkCreatePipelineLayoutandvkCreateGraphicsPipelines, throughVK_CHECK - Destroy registered:
vkDestroyPipelineLayout+vkDestroyPipeline - Resize: both destroyed and recreated in
recreateSizedVulkanResources; the viewport and scissor are baked in
Runtime
renderFrame():updateCameraBuffer(), thenrasterize(), thenpostProcess(). Theraytrace()/denoise()branch stays commented out.rasterize(): transitions the render target toCOLOR_ATTACHMENT_OPTIMALand the depth image toDEPTH_ATTACHMENT_OPTIMAL, begins dynamic rendering with both cleared, and issues onevkCmdDrawIndexedper instance with its push constants. The render target goes back toGENERALfor the post pass to sample.- Camera:
W/A/S/D,Space/C, left-drag to look, scroll for speed, as provided by the framework. - Submit and present:
vkQueueSubmitgoes throughVK_CHECK.vkQueuePresentKHRis checked by hand:VK_ERROR_OUT_OF_DATE_KHRandVK_SUBOPTIMAL_KHRare expected around a resize or an HDR toggle, so they pass through and the nextacquireFramerebuilds the swapchain; any other result fails.
Result
Beyond the framework
glTF 2.0 loader
The framework reads OBJ through assimp. A second reader, scene/gltf_loader.cpp, uses
fastgltf and fills the same ModelData, so everything downstream (buffer upload, texture
array, txtOffset) is shared. readModel picks a reader from the file extension.
- Node transforms are baked into model-space positions; normals use the inverse transpose.
- Materials carry glTF’s metallic-roughness:
metallic,roughness, a normal map and a metallic-roughness map, alongside the base colour.Materialstructure was extended with those four fields. - Images embedded in the asset’s own buffers, as in a
.glbor living-room-2’s 17 PNGs inside its.bin, are decoded from memory. alphaMode BLENDprimitives are skipped with a warning naming the materials (see the decal notes below).
Two classes of issue surfaced while integrating the glTF loader, both needing modifications and special handling that apply only to glTF.
Texture colour space and orientation
-
Double gamma: every image was loaded as
R8G8B8A8_UNORM, so the shader sampled sRGB-encoded bytes as linear and the post pass then brightened it a second time.Colour maps now load as
R8G8B8A8_SRGBand the hardware linearises on sample. Normal and metallic-roughness maps stayUNORM: they are data, and an sRGB curve would bend them. -
UV flip:
stbi_set_flip_vertically_on_load(true)was applied to everything. OBJ puts UV (0,0) at the bottom left, glTF at the top left, so glTF textures came out upside down. The flip is now chosen per loader.
The mip chain that was never sampled
Every texture shimmered under camera motion and settled when it stopped. generateMipmap builds
a full mip chain, but it was never sampled:
- the image view was created with
levelCount = 1, hiding every level above 0 VkSamplerCreateInfois brace-initialised, somaxLodwas 0, clamping the LOD to the top level and also defeating the requested anisotropy.
The image view now exposes all levels and maxLod = VK_LOD_CLAMP_NONE, so minified 4K textures
stopped reading single texels.
Runtime scene loading and configurations from TOML
Scene selection was a #if chain over #defines in vkapp.h, so every scene and its
configuration lived in code, which makes extension a bit cumbersome. The configuration
parameters (scene, camera and so on) now live in .toml files, which the application parses and
loads at startup.
config/scenes.toml: model files, opening camera, light, and per-scene fixups.config/presets.toml: camera configuration per scene, used to store interesting locations in the scene (used in future projects)
A Scene dropdown in the GUI switches between them at runtime.
| Scene | Source | Notes |
|---|---|---|
| Living room (OBJ) | framework | the assignment scene, through assimp |
| Sponza | Intel, 3 glTF files | 10,801,802 triangles, 82 textures (4K) |
| San Miguel | glTF conversion | |
| Living room 2 | glTF _extended | 601,209 triangles, 17 embedded textures |
| Classroom | glTF _extended | 3 textures |
Runtime scene loading needed new internal states in the framework: the frame is blocked with a loading message overlay, then a load of the model is dispatched.
Parallel texture decoding
One of the main choke points of a runtime model load is texture loading and decoding. In OpenGL you had to run through hoops to load textures in parallel, with complex state management. Vulkan avoids that altogether, since the loads and decodes can be issued in parallel.
Decoding now runs on worker threads in batches sized by a memory budget (a 4K RGBA image decodes to
67 MB, and 83 of them at once would be 5.3 GB). Upload stays serial and in order, since the texture
order is what textureId indexes.
| Texture work | Startup | |
|---|---|---|
| Serial | 60.4 s | 71.4 s |
| Parallel, 512 MB budget | 13.3 s | 23.1 s |
| Parallel, 2048 MB budget | 8.7 s | 18.3 s |
The budget sets the parallelism: at 512 MB only seven decodes fit in flight and 25 of 32 cores were idle. This is open to further fine tuning if required.
Shading
scanline.frag was extended from Lambert over diffuse into a common PBR shader whose BRDF
lives in brdf.glsl, so ray casting and path tracing in the upcoming projects can reuse it:
- Cook-Torrance GGX: GGX distribution, Schlick Fresnel, height-correlated Smith visibility; roughness squared into alpha and clamped at 0.045, to keep the results stable.
- Normal mapping: tangent frame from glTF’s
TANGENT(assimp’sCalcTangentSpacefor OBJ), re-orthogonalised against the shading normal, with fallbacks for missing frames, degenerate frames and textures that are not actually normal maps. - Emission: added rather than lit.
- Alpha cutout at 0.5: San Miguel’s foliage is authored as a diffuse texture with its alpha channel set, so alpha rejection is set at 0.5 in the shader.
- Ambient feeds F0 as well as the diffuse lobe: a metal has no diffuse lobe, so lighting only the diffuse term rendered every metal black.
Tone mapping
The render target is linear and unbounded; the swapchain is 8 bits of display-referred colour. Four operators are selectable from the GUI, plus a linear exposure applied before the curve:
| Operator | Character | Middle grey (0.18 in) |
|---|---|---|
| Reinhard | W·x/(x+W), softest, no toe | 0.153 |
| ACES (Narkowicz fit, ×0.6 input) | filmic toe and strong shoulder | 0.140 |
| PBR Neutral (Khronos) | identity below 0.76, shoulder only above | 0.140 |
| GT (Uchimura) | toe, linear mid-section, shoulder that moves only with the white point | 0.179 |
scRGB HDR output
The surface offers R16G16B16A16_SFLOAT with EXTENDED_SRGB_LINEAR_EXT (listed in project 1’s
format table). A runtime toggle rebuilds the swapchain in that format, with two calibration
sliders, paper white and peak nits for further fine tuning, since querying them from Vulkan was inconsistent.
Tone mapping stays on in HDR; its white point becomes peak / paperWhite.
HDR10/PQ is not implemented, and could be added later.
- The toggle reuses
recreateSizedVulkanResources. It is consumed at the top ofdrawFrame, not in the GUI callback, which runs after the swapchain image has been acquired. - ImGui has no notion of colour space. Its sRGB vertex colours read as linear in an scRGB buffer,
about five times too bright in the midtones, so
ImDrawDatais converted in place each frame. Window alpha also needed compensating: alpha blending is not invariant under a transfer function.
A physical sky
An HDRI provides natural directional lighting for a scene, but it has a couple of shortcomings: the sun’s azimuth can be moved and its elevation cannot, and one HDRI serves only one time of day, so sunrise, noon, sunset and night each need their own.
So the framework has been extended with a physical sky, based on Hillaire 2020:
Rayleigh and Mie scattering and ozone over a spherical planet.
It is enabled per scene with sky = true in scenes.toml.
The work splits into two parts (lighting/vkapp_sky.cpp):
When a setting changes, the background thread (computeSkyBake) computes:
- Atmosphere tables (transmittance, multiple scattering), only when haze or ground albedo changes. They describe the air, not the sun, so a new time of day reuses them.
- Sun direction from time, latitude, season and heading; the moon is placed opposite it.
- The cube: 128x128 RGBA16F, ray-marched per texel, box-filtered mips, then projected to order-2 SH for diffuse irradiance.
- Sun and moon colours: their light above the atmosphere times its transmittance. A low sun loses its blue to the longer path, which is what turns it orange at sunset.
- Ground illuminance, for auto exposure.
When a bake completes, the main thread (uploadSkyBake) copies the uniforms and uploads the cube.
The previous sky stays on screen until then.
Every frame (updateSkyBuffer) computes only the cheap parts:
- auto exposure,
(3 / E_ground)^adaptation; - the star fade, by sun elevation;
- the uniform-buffer update.
The shaders perform only lookups.
scanline.fraglights each pixel with the sun and moon throughEvalBrdf, SH diffuse, and cube specular at a mip picked by roughness (Karis’s analytic environment BRDF).sky.glsldraws the background, the sun and moon discs, and the stars.
The raster path has no occlusion, so covered areas are lit as if open to the sky; the ray tracer will reuse the same sky but will be able to provide occlusion. Also, at night the exposure factor can pass ×100, which pushes warm lamps toward white unless tone mapping is moved to luminance.

Cleanup and resize
destroyAllVulkanResources
Destroyed in reverse of creation order with ImGui first:
ImGui_ImplVulkan_Shutdown();
vkDestroyDescriptorPool(m_device, m_imguiDescPool, nullptr);
vkDestroyPipelineLayout(m_device, m_scPipelineLayout, nullptr);
vkDestroyPipeline(m_device, m_scPipeline, nullptr);
vkDestroyPipelineLayout(m_device, m_skyPipelineLayout, nullptr);
vkDestroyPipeline(m_device, m_skyPipeline, nullptr);
m_scDesc.destroy(m_device);
m_objDescriptionBuff.destroy(m_device);
destroySkyResources();
m_matrixBuff.destroy(m_device);
destroyModel();
m_postDesc.destroy(m_device);
vkDestroyPipelineLayout(m_device, m_postPipelineLayout, nullptr);
vkDestroyPipeline(m_device, m_postPipeline, nullptr);
m_renderTarget.destroy(m_device);
m_depthImage.destroy(m_device);
vkDestroyCommandPool(m_device, m_cmdPool, nullptr);
destroySwapchain();
vkDestroySurfaceKHR(m_instance, m_surface, nullptr);
vkDestroyDevice(m_device, nullptr);
vkDestroyInstance(m_instance, nullptr);
recreateSizedVulkanResources
- Destroyed at the top: scanline pipeline and layout, sky pipeline and layout, post pipeline and
layout,
m_postDesc,m_renderTarget,m_depthImage, thendestroySwapchain() - Recreated at the bottom:
createSwapchain(), then (on a format change only) ImGui’s pipeline, thencreateDepthResource(),createRenderTarget(),createPostDescriptor(),createPostPipeline(),createScPipeline(),createSkyPipeline()
m_postDesc is on the list even though its layout does not depend on the window: it holds the
render target’s image view, which is new after a resize.
Validation-layer verification
VK_LAYER_KHRONOS_validation is requested unconditionally and appears in the layer list below. The
living room run produced no validation-layer messages, and so did other scenes such as Sponza,
scene switches (Classroom → Sponza → Classroom) and an SDR → HDR → SDR round trip.
Console output of the living room run, at info level (the trace level used in project 1 floods
once the per-frame checks are running):
[17:51:32.934] info Vulkan SDK version 1.4.357
[17:51:32.936] info ---- GLFW required extensions [count : 2] ------------------------------
[17:51:32.936] info VK_KHR_surface
[17:51:32.936] info VK_KHR_win32_surface
[17:51:32.953] info ---- InstanceLayers Info [count: 24] -----------------------------------
...
[17:51:32.954] info VK_LAYER_KHRONOS_validation
...
[17:51:33.065] info ---- Phyiscal Devices Info [count : 2] --------------------------------
[17:51:33.066] info Checking GPU : NVIDIA GeForce RTX 4090
[17:51:33.066] info Found discrete GPU
[17:51:33.066] info All required extensions found
[17:51:33.067] info Checking GPU : AMD Radeon(TM) Graphics
[17:51:33.067] info Rejecting GPU since its non discrete : [deviceType : VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU]
...
[17:51:33.271] info Swapchain: VK_FORMAT_B8G8R8A8_UNORM / VK_COLOR_SPACE_SRGB_NONLINEAR_KHR
[17:51:33.548] info UI font: ...\MonoLisaTextUpright.ttf numeric font: ...\MonoLisaCodeUpright.ttf
[17:51:33.554] info Loaded 5 scenes from config/scenes.toml
[17:51:33.555] info Loaded 6 camera presets from config/presets.toml
[17:51:33.555] info ---- Scene: Living room (OBJ) ------------------------------------------
[17:51:33.556] info ReadAssimpFile File: models/living_room/living_room.obj
[17:51:33.562] info Assimp 6.0 Reading models/living_room/living_room.obj
...
[17:51:46.782] info Assimp mNumMeshes: 128
[17:51:46.782] info Assimp mNumMaterials: 44
[17:51:46.782] info Assimp mNumTextures: 0
[17:51:47.349] info vertices: 1741337
[17:51:47.349] info indices: 1741911 (580637)
[17:51:47.350] info materials: 44
[17:51:47.350] info matIndx: 580637
[17:51:47.350] info textures: 16
[17:51:47.377] info decoding 16 textures, up to 32 threads and 2048 MB in flight
[17:51:47.468] info texture 1/16 (upload 4 ms, sRGB): models/living_room/textures/apple.jpg
...
[17:51:47.503] info texture 16/16 (upload 1 ms, sRGB): models/living_room/textures/photo4.jpg
[17:51:47.503] info 16 textures decoded and uploaded in 132 ms
[17:51:47.511] info Scene totals: 1 objects, 1 instances, 16 textures
[17:51:48.089] info ---- Draw loop ---------------------------------------------------------
GUI
GUI is defined in vkapp.h, and the ImGui variables sit at the @@ marked for them. The panel
is pinned to the right edge, with the theme ported from the vazhi.thodari viewer, and has:
- Stats: frame time, scene, objects, textures, triangles, output mode
- Scene: model dropdown, camera preset buttons
- Debug view, Tone map, Preserve hue, Exposure, and HDR output with its two sliders
- Sky: time of day (with animation), heading, latitude, season, sun, moon, haze, ground albedo, stars, auto exposure
- Light: azimuth, elevation, distance, intensity, ambient, orbit (when the sky is off). Orbiting the light is the quickest check that normal mapping is live: flat shading stays put, normal-mapped surfaces crawl with highlights.
Appendix A: where each @@ comment is addressed
@@ request | File | Answered in |
|---|---|---|
Destroy / resize m_renderTarget | vkapp_scanline.cpp | createRenderTarget |
Destroy / resize m_postDesc | vkapp_postProcess.cpp | createPostDescriptor |
Projects 2 and beyond: pipeline layout with m_postDesc | vkapp_postProcess.cpp | createPostPipeline |
Uncomment vkCmdBindDescriptorSets in postProcess | vkapp_postProcess.cpp | createPostPipeline |
Destroy m_matrixBuff | vkapp_scanline.cpp | createMatrixBuffer |
Destroy m_objDescriptionBuff | vkapp_scanline.cpp | createObjDescriptionBuffer |
Destroy m_scDesc | vkapp_scanline.cpp | createScDescriptorSet |
Verify vkCreatePipelineLayout / vkCreateGraphicsPipelines | vkapp_scanline.cpp | createScPipeline |
Destroy / resize m_scPipelineLayout and m_scPipeline | vkapp_scanline.cpp | createScPipeline |
| Destroy all the model structures | vkapp_loadModel.cpp | createModel |
| Good place for ImGui variables | vkapp.h | GUI |
| Put GUI elements on the screen | vkapp_runtime.cpp | GUI |
| Screen capture of the final result | 2-scanline.pdf | Result |
| No validation-layer messages | 2-scanline.pdf | Validation-layer verification |
Verify vkCreatePipelineLayout in createPostPipeline | vkapp_postProcess.cpp | createPostPipeline |
Verify vkAllocateMemory / vkBindBufferMemory, vkCreateImage / vkCreateImageView, vkCreateSampler | wrap_buffer.cpp | VK_CHECK at each call |
Verify vkQueueSubmit / vkQueuePresentKHR | vkapp_runtime.cpp | Runtime |
Appendix B: destroy / resize registry
Every resource this project creates, whether it is destroyed, and whether it is rebuilt on a resize or a scene switch.
| Resource | Destroyed | Resized | Rebuilt on scene switch |
|---|---|---|---|
m_renderTarget | yes | yes | — |
m_postDesc | yes | yes | — |
m_postPipelineLayout / m_postPipeline | yes | yes | — |
m_matrixBuff | yes | — | — |
m_objDescriptionBuff | yes | — | yes |
m_scDesc | yes | — | yes |
m_scPipelineLayout / m_scPipeline | yes | yes | yes |
model buffers and textures (destroyModel) | yes | — | yes |
m_skyBuff, m_envCube | yes | — | — |
m_skyPipelineLayout / m_skyPipeline | yes | yes | yes |
m_imguiDescPool (+ backend) | yes | pipeline only, on a format change | — |