Project 2

Scanline

Feeding the post-process pass from a rasterisation pipeline, then growing it into a glTF PBR viewer with tone mapping, HDR output and a physical sky

Course
CS 599, Ray Traced Real Time Rendering
Submitted
11 October 2026
Status
Complete

Application methods enabled (uncommented)

#Call / changeFileStatus
1createRenderTarget()vkapp_scanline.cppdone
2createPostDescriptor()vkapp_postProcess.cppdone
3createPostPipeline(): layout switched to the post descriptor setvkapp_postProcess.cppdone
4renderTarget sampler in post.frag, and the vkCmdBindDescriptorSets in postProcess()post.frag, vkapp_postProcess.cppdone
5createModel()vkapp_loadModel.cppdone
6createMatrixBuffer()vkapp_scanline.cppdone
7createObjDescriptionBuffer()vkapp_scanline.cppdone
8createScDescriptorSet()vkapp_scanline.cppdone
9createScPipeline()vkapp_scanline.cppdone
10renderFrame(): updateCameraBuffer() and rasterize()vkapp_runtime.cppdone
11ImGui: GUI defined, drawGUI() populatedvkapp.h, vkapp_runtime.cppdone

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

ParameterValue
Extentm_windowSize
FormatVK_FORMAT_R32G32B32A32_SFLOAT
UsageTRANSFER_DST, SAMPLED, TRANSFER_SRC, STORAGE, COLOR_ATTACHMENT
MemoryVK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
LayoutVK_IMAGE_LAYOUT_GENERAL
SamplerinitTextureSampler

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:

ListPDFLocal run
Vertices1,741,3371,741,337
Indices1,741,9111,741,911
Triangles580,637580,637
Materials4444
Material indices (one per triangle)580,637580,637
Textures1616
[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 four BufferWraps per object)
  • m_objDesc (their device addresses, read by the shaders through GL_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):

BindingTypeStagesHolds
0uniform buffervertex, fragment, raygenMatrixUniforms
1storage buffervertex, fragment, raygenObjDesc[]
2combined image sampler × nbTxtfragment, raygenevery texture of the scene
3uniform bufferfragment, raygen, missSkyUniforms (added, see The sky)
4combined image samplerfragment, raygen, misssky 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

StateValue
Vertex inputone binding, stride sizeof(Vertex) = 48 bytes: pos (offset 0), nrm (12), texCoord (24), tangent (32)
Topologytriangle list
Cull modeVK_CULL_MODE_NONE, every surface is drawn two-sided
Depthtest and write on, VK_COMPARE_OP_LESS_OR_EQUAL
Blendingoff
Renderingdynamic rendering: colour R32G32B32A32_SFLOAT, depth D32_SFLOAT
Push constantPushConstantRaster, 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: vkCreatePipelineLayout and vkCreateGraphicsPipelines, through VK_CHECK
  • Destroy registered: vkDestroyPipelineLayout + vkDestroyPipeline
  • Resize: both destroyed and recreated in recreateSizedVulkanResources; the viewport and scissor are baked in

Runtime

  • renderFrame(): updateCameraBuffer(), then rasterize(), then postProcess(). The raytrace() / denoise() branch stays commented out.
  • rasterize(): transitions the render target to COLOR_ATTACHMENT_OPTIMAL and the depth image to DEPTH_ATTACHMENT_OPTIMAL, begins dynamic rendering with both cleared, and issues one vkCmdDrawIndexed per instance with its push constants. The render target goes back to GENERAL for 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: vkQueueSubmit goes through VK_CHECK. vkQueuePresentKHR is checked by hand: VK_ERROR_OUT_OF_DATE_KHR and VK_SUBOPTIMAL_KHR are expected around a resize or an HDR toggle, so they pass through and the next acquireFrame rebuilds the swapchain; any other result fails.

Result

Click to zoom
| The framework's default living room through the scanline pipeline |
Click to zoom
| Application load and navigation with WASD and the mouse |

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. Material structure was extended with those four fields.
  • Images embedded in the asset’s own buffers, as in a .glb or living-room-2’s 17 PNGs inside its .bin, are decoded from memory.
  • alphaMode BLEND primitives 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_SRGB and the hardware linearises on sample. Normal and metallic-roughness maps stay UNORM: 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
  • VkSamplerCreateInfo is brace-initialised, so maxLod was 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.

SceneSourceNotes
Living room (OBJ)frameworkthe assignment scene, through assimp
SponzaIntel, 3 glTF files10,801,802 triangles, 82 textures (4K)
San MiguelglTF conversion
Living room 2glTF _extended601,209 triangles, 17 embedded textures
ClassroomglTF _extended3 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 workStartup
Serial60.4 s71.4 s
Parallel, 512 MB budget13.3 s23.1 s
Parallel, 2048 MB budget8.7 s18.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.

Click to zoom
| Switching different scenes at runtime |

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’s CalcTangentSpace for 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:

OperatorCharacterMiddle grey (0.18 in)
ReinhardW·x/(x+W), softest, no toe0.153
ACES (Narkowicz fit, ×0.6 input)filmic toe and strong shoulder0.140
PBR Neutral (Khronos)identity below 0.76, shoulder only above0.140
GT (Uchimura)toe, linear mid-section, shoulder that moves only with the white point0.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 of drawFrame, 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 ImDrawData is 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:

  1. 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.
  2. Sun direction from time, latitude, season and heading; the moon is placed opposite it.
  3. The cube: 128x128 RGBA16F, ray-marched per texel, box-filtered mips, then projected to order-2 SH for diffuse irradiance.
  4. 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.
  5. 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.frag lights each pixel with the sun and moon through EvalBrdf, SH diffuse, and cube specular at a mip picked by roughness (Karis’s analytic environment BRDF).
  • sky.glsl draws 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.

| Toggle in zoom  ·  N — Day / Night |
Click to zoom
DAY
N — DAY / NIGHT
| Sponza under the physical sky, press N for night |
Click to zoom
| Sponza under the physical sky, day to night transition |
Click to zoom
| San Miguel under the physical sky, day to night transition |

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, then destroySwapchain()
  • Recreated at the bottom: createSwapchain(), then (on a format change only) ImGui’s pipeline, then createDepthResource(), 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

@@ requestFileAnswered in
Destroy / resize m_renderTargetvkapp_scanline.cppcreateRenderTarget
Destroy / resize m_postDescvkapp_postProcess.cppcreatePostDescriptor
Projects 2 and beyond: pipeline layout with m_postDescvkapp_postProcess.cppcreatePostPipeline
Uncomment vkCmdBindDescriptorSets in postProcessvkapp_postProcess.cppcreatePostPipeline
Destroy m_matrixBuffvkapp_scanline.cppcreateMatrixBuffer
Destroy m_objDescriptionBuffvkapp_scanline.cppcreateObjDescriptionBuffer
Destroy m_scDescvkapp_scanline.cppcreateScDescriptorSet
Verify vkCreatePipelineLayout / vkCreateGraphicsPipelinesvkapp_scanline.cppcreateScPipeline
Destroy / resize m_scPipelineLayout and m_scPipelinevkapp_scanline.cppcreateScPipeline
Destroy all the model structuresvkapp_loadModel.cppcreateModel
Good place for ImGui variablesvkapp.hGUI
Put GUI elements on the screenvkapp_runtime.cppGUI
Screen capture of the final result2-scanline.pdfResult
No validation-layer messages2-scanline.pdfValidation-layer verification
Verify vkCreatePipelineLayout in createPostPipelinevkapp_postProcess.cppcreatePostPipeline
Verify vkAllocateMemory / vkBindBufferMemory, vkCreateImage / vkCreateImageView, vkCreateSamplerwrap_buffer.cppVK_CHECK at each call
Verify vkQueueSubmit / vkQueuePresentKHRvkapp_runtime.cppRuntime

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.

ResourceDestroyedResizedRebuilt on scene switch
m_renderTargetyesyes—
m_postDescyesyes—
m_postPipelineLayout / m_postPipelineyesyes—
m_matrixBuffyes——
m_objDescriptionBuffyes—yes
m_scDescyes—yes
m_scPipelineLayout / m_scPipelineyesyesyes
model buffers and textures (destroyModel)yes—yes
m_skyBuff, m_envCubeyes——
m_skyPipelineLayout / m_skyPipelineyesyesyes
m_imguiDescPool (+ backend)yespipeline only, on a format change—