Project 3

Ray Casting

One ray per pixel through a Vulkan ray tracing pipeline, shading every hit with the same code as the scanline path, plus an any-hit alpha cutout

Course
CS 599, Ray Traced Real Time Rendering
Submitted
24 October 2026
Status
Under review

Application methods enabled (uncommented)

#Call / changeFileStatus
1createRtBuffers() / destroyRtBuffers()vkapp_raytracing.cppdone
2initRayTracing()vkapp_raytracing.cppdone
3createRtAccelerationStructure()wrap_acceleration.cppdone
4createRtDescriptorSet()vkapp_raytracing.cppdone
5createRtPipeline()vkapp_raytracing.cppdone
6createRtShaderBindingTable()vkapp_raytracing.cppdone
7PushConstantRay: three temporary light values, filled in raytrace()shared_structs.h, vkapp_raytracing.cppdone
8raytrace.rchit, raytrace.rmiss, raytrace.rgenshaders/done
9renderFrame(): raytrace() / rasterize() on useRaytracer, with a GUI checkboxvkapp_runtime.cppdone

Each pixel casts a single ray through the ray tracing pipeline into the same scene the scanline pipeline draws, and the result is lit with the same lighting calculation.

The PDF’s EvalBrdf is mat.diffuse/pi. Project 2 had already replaced that with a Cook-Torrance GGX BRDF, normal mapping and a physical sky, so “the exact same picture as the scanline algorithm” here means the ray tracer reuses all of it: see Matching the scanline path, and Texture filtering for the one place where the two still differ.

The additions past the assignment are an any-hit shader for alpha cutouts, the sky in the ray tracer, and shared debug views, under Beyond the framework.


Ray tracing initialization

createRtBuffers

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

The ray generation shader writes it as a storage image; raytrace() then copies it into m_renderTarget for the post pass. The format matches the render target, so the copy is a plain vkCmdCopyImage.

  • Destroy registered: destroyRtBuffers(), which destroys m_rtColCurrBuffer (the other five buffers arrive in later projects)
  • Resize: destroyRtBuffers() / createRtBuffers() in recreateSizedVulkanResources

initRayTracing

Queries VkPhysicalDeviceRayTracingPipelinePropertiesKHR for the SBT handle size and alignments, and calls m_rtBuilder.setup(...). It is called once, at startup: none of this depends on the window or the scene.

@@ *HERE* Destroy the temporary m_rtBuilder with m_rtBuilder.destroy() … or at program shutdown.

The builder is not temporary. It owns the BLAS and TLAS, and m_rtDesc binding 0 points at the TLAS it holds, so destroying it in initRayTracing frees the acceleration structures the descriptor set still references. That is also before createRtAccelerationStructure has run, so there is nothing built yet to destroy. m_rtBuilder.destroy() is called at shutdown in destroyAllVulkanResources, and in switchScene before the new scene’s structures are built.

createRtAccelerationStructure

  • BLAS: one per object, from objectToVkGeometryKHR. Positions read straight from the vertex buffer (R32G32B32_SFLOAT, stride sizeof(Vertex) = 48 bytes) with 32-bit indices. Built with PREFER_FAST_TRACE, and not compacted, since ALLOW_COMPACTION is not requested.
  • TLAS: one instance per ObjInst, with instanceCustomIndex = objIndex, so gl_InstanceCustomIndexEXT indexes ObjDesc[] directly, and TRIANGLE_FACING_CULL_DISABLE, since every surface is two-sided.
  • Geometry flags: no longer VK_GEOMETRY_OPAQUE_BIT_KHR, see Alpha cutout.
[17:24:58.970] info  Acceleration structures built: 1 BLAS, TLAS of 1 instances

The framework narrates these steps with printf; those calls now go through LOG_TRACE, so they are silent by default and compile out in Release.

  • Destroy registered: m_rtBuilder.destroy()
  • Resize: not needed, since the geometry does not depend on the window
  • Scene switch: destroyed and rebuilt

createRtDescriptorSet

BindingTypeStagesHolds
0acceleration structureraygen, closest hitthe TLAS
1storage imageraygenm_rtColCurrBuffer

The pipeline uses it as set 0, with project 2’s m_scDesc as set 1. That set already had the ray generation stage on its bindings, so the ray tracer reads the same matrices, ObjDesc[], textures and sky as the raster path.

@@ Resize m_rtDesc; This might not be necessary. Please experiment and tell me the result.

It is necessary. The layout does not depend on the window, but binding 1 holds the image view of m_rtColCurrBuffer, and that image is recreated on a resize, so leaving the set alone would point the shader at a destroyed view. Rewriting binding 1 would be enough on its own; destroying and recreating the whole set does the same thing and matches how m_postDesc is handled, which holds the render target’s view.

  • Destroy registered: m_rtDesc.destroy(m_device)
  • Resize: destroyed and recreated
  • Scene switch: recreated, for the new TLAS

createRtPipeline

StageShaderGroup
0raytrace.rgen0 (general)
1raytrace.rmiss1 (general)
2raytrace.rchit2, triangles hit group, closest hit
3raytrace.rahit (added)2, same hit group, any hit
  • Layout: set 0 m_rtDesc, set 1 m_scDesc, and one push constant range of sizeof(PushConstantRay) for raygen, closest hit and miss
  • Recursion depth: 10, as the framework sets it, though ray casting only needs 1
  • Results checked: vkCreatePipelineLayout and vkCreateRayTracingPipelinesKHR, through VK_CHECK
  • Destroy registered: vkDestroyPipelineLayout + vkDestroyPipeline
  • Resize: destroyed and recreated with m_rtDesc, since its layout references that set’s layout

createRtShaderBindingTable

One raygen, one miss and one hit handle, laid out in a device-local buffer. On the RTX 4090:

RegionStrideSize
raygen6464
miss3264
hit3264
callable00

Handle alignment is 32 and the group base alignment is 64, so each region starts on a 64-byte boundary. The raygen region’s size must equal its stride, hence 64 for a single 32-byte handle.

The SBT holds handles copied out of a specific pipeline, so it is only valid for the pipeline it was built from. Any time the pipeline is recreated, on a resize or a scene switch, the SBT is rebuilt after it.

  • Results checked: vkGetRayTracingShaderGroupHandlesKHR, through VK_CHECK
  • Destroy registered: m_shaderBindingTableBuff.destroy(m_device)
  • Resize and scene switch: destroyed and rebuilt after createRtPipeline()

The push constant

struct PushConstantRay
{
    ALIGNAS(16) vec3 scLightPos;     //  0
    ALIGNAS(16) vec3 scLightInt;     // 16
    ALIGNAS(16) vec3 scLightAmb;     // 32
    ALIGNAS(4) bool clear;           // 44
    ALIGNAS(4) float exposure;       // 48
    ALIGNAS(4) int debugView;        // 52  (added)
    ALIGNAS(4) int alignmentTest;    // 56
};

The PDF declares the lights as vec4; the @@ marker in shared_structs.h declares them as vec3, which is what is used. A vec3 is 12 bytes on a 16-byte boundary in both languages, so clear packs into the last 4 bytes of scLightAmb’s slot at offset 44 on both sides.

raytrace() fills the three lights from the scanline values each frame, and the ray generation shader checks alignmentTest == 1234 before anything else, writing solid red if it fails. It does not fire, and the validation layer reports no VUID-…-layout-03427.


Shaders

raytrace.rchit

Fills the payload from Vulkan’s built-ins:

payload.hit            = true;
payload.instanceIndex  = gl_InstanceCustomIndexEXT;
payload.primitiveIndex = gl_PrimitiveID;
payload.bc             = vec3(1 - bc.x - bc.y, bc.x, bc.y);
payload.hitPos         = gl_WorldRayOriginEXT + gl_WorldRayDirectionEXT * gl_HitTEXT;

The two hit attributes are the weights of vertices 1 and 2, so vertex 0 gets 1 - u - v.

raytrace.rmiss

payload.hit = false;

raytrace.rgen

One ray per pixel, from the eye through the pixel centre unprojected with viewInverse and projInverse, traced from t = 0.001 to 10000. Then:

  • Miss: black, or the sky background when the scene has the sky (see The sky in the ray tracer).
  • Emissive hit: mat.emission * pcRay.exposure.
  • Any other hit: GetHitObjectData reconstructs the surface from the hit triangle, then C += scLightInt * max(dot(N, L), 0) * EvalBrdf(N, L, V, mat) plus the same constant ambient as the raster path.

The loop that runs once is kept as the framework wrote it, so project 4 can turn it into the Monte-Carlo loop.

pcRay.exposure is 2.0, set by the framework in initRayTracing, and scales emission in the ray tracer only. The raster path adds emission unscaled, so light-emitting surfaces are twice as bright when ray traced. It is left as the framework set it, since project 4 is where emission starts lighting the scene.

GetHitObjectData

Given the payload’s instance and primitive, it looks up the object’s four device addresses in ObjDesc[], fetches the triangle’s three vertices and its material, and weights the vertices by the barycentrics:

  • Normal: interpolated, normalized, then flipped to face the viewer (two-sided shading)
  • Normal map: the tangent interpolated, handedness from vertex 0, then perturbed by the normal-map texel
  • Base colour: material factor × texel
  • Metallic-roughness: scaled by the texture’s G and B channels

Each of these calls the same code the raster path uses.


Matching the scanline path

The ray tracer has to produce the same image as the scanline path. Project 2’s fragment shader had grown well past diffuse/pi, so instead of reimplementing it in the ray generation shader, everything that decides how a surface looks moved into includes that both shaders use:

IncludeHolds
brdf.glslEvalBrdf: Cook-Torrance GGX, the @@ Raycasting signature
surface.glsl (new)FaceForwardNormal, ApplyNormalMap, ApplyMetalRough, IsCutout
sky.glslSkyDirect (sun and moon through EvalBrdf), SkyAmbient (SH diffuse + cube specular), the background
debug_view.glsl (new)every debug view, from a DebugSurface both paths fill

scanline.frag shrank to gathering its inputs and calling these. Only the inputs differ between the two paths:

ScanlineRay tracer
Surface attributesinterpolated by the rasterizerbarycentric weights of the hit triangle’s vertices
Face normal, for two-sided shadingcross(dFdx(worldPos), dFdy(worldPos))cross(v1 − v0, v2 − v0)
Oriented towardthe eye vectoragainst the ray direction
Front facinggl_FrontFacingdot(faceN, rayDir) < 0
Texture levelhardware mip selection from derivativesmip 0
Alpha cutoutdiscardignoreIntersectionEXT in any-hit

Front facing on both sides is counter-clockwise as seen from the eye.


Runtime

  • renderFrame(): updateCameraBuffer(), then raytrace() or rasterize() on useRaytracer, then postProcess(). denoise() stays commented out until project 6.
  • Mode switch: a Raytrace mode checkbox at the top of the GUI, and the framework’s R key.
  • raytrace(): fills m_pcRay, binds the pipeline and both descriptor sets, pushes the constants, calls vkCmdTraceRaysKHR over the window, then CmdCopyImages m_rtColCurrBuffer into m_renderTarget. From there the post pass, tone mapping and auto exposure are unchanged.

Result

Click to zoom
| Application launch and teardown with raycasting changes implemented |
Click to zoom
| Living Room, toggling between the scanline and ray traced paths |

Texture filtering in the ray tracer

With both paths on the same shading code, Sponza still looked different between modes: textures were sharper when ray traced, and the stone and pillars showed a fine grain in the lighting that the raster image did not have.

Cause

Ray tracing stages have no screen-space derivatives. The rasterizer shades pixels in 2×2 quads and picks a mip level from how fast the UVs change across the quad; a ray has no neighbours, so every texture is read with textureLod(…, 0). Where a pixel covers several texels, the ray tracer takes one of them instead of the filtered average.

On albedo that reads as sharpness, and as shimmer once the camera moves. Normal maps are worse: lighting is non-linear in the normal, so one unfiltered normal per pixel gives a speckle of N·L and highlight values that filtering would have averaged away. That is why the normal maps show up as grain in the lighting and not only as a crisper texture.

Isolating it with the debug views

Comparing albedo between modes cannot separate the two effects, because albedo is aliased too. The normal views can:

Debug viewRaster vs ray tracer
Geometric normalidentical: interpolation, barycentrics and the two-sided flip agree
Normal (after the normal map)same colours, plus a fine grain in the ray tracer
N·Lsame as Normal

The geometric normal does not change between modes, so the surface reconstruction is not at fault. The normal-mapped one changes only by grain, as if a noise filter were laid over the same image. The difference comes only from sampling the normal map at mip 0.

Click to zoom
| Normal and N·L debug views across modes: grain only where the normal map is sampled |

Why the default scene does not show it

The framework’s living room loads through the OBJ path, which reads only the base colour texture: no normal maps. The difference only appears on the glTF scenes added in project 2, such as Sponza, whose materials carry normal maps.

What fixes it

Neither is implemented here, by choice:

  • Accumulation (project 4): jittering the ray within the pixel over frames averages many texels per pixel, which filters the textures implicitly. Path tracing needs this anyway.
  • Ray cones (Akenine-Möller et al., Ray Tracing Gems, ch. 20): estimate a texture LOD from the ray’s spread and the distance travelled, giving filtered lookups at one sample per pixel.

Accumulation makes ray cones unnecessary while the camera is still. They are worth revisiting for the denoiser in project 6, which works from few samples per pixel and wants clean first-hit albedo and normals.


Beyond the framework

Alpha cutout with an any-hit shader

The raster path cuts out foliage with discard where the base colour alpha is under 0.5. San Miguel’s trees and plants depend on it. The framework’s ray tracer had no equivalent and could not have one as shipped, since two things marked everything opaque:

  • objectToVkGeometryKHR set VK_GEOMETRY_OPAQUE_BIT_KHR on the geometry
  • traceRayEXT passed gl_RayFlagsOpaqueEXT

Either one alone skips any-hit shaders entirely, so both were removed. With no shader to run yet, a ray still hit the rectangle the leaves are drawn on and the whole quad came out solid.

raytrace.rahit runs for every candidate hit and decides whether it counts:

Material mat = materials.m[matIndices.i[gl_PrimitiveID]];
if (mat.textureId < 0) return;   // untextured: always opaque

// UV from the barycentrics, as GetHitObjectData does
...
float alpha = textureLod(textureSamplers[nonuniformEXT(txtId)], uv, 0.0).a;
if (IsCutout(alpha))
    ignoreIntersectionEXT;

IsCutout lives in surface.glsl and scanline.frag uses it too, so both paths cut at the same threshold. The shader sits in the same hit group as the closest-hit shader, so the SBT layout is unchanged. m_scDesc bindings 1 (ObjDesc[]) and 2 (textures) add the any-hit stage, since it reads both.

Trade-offs:

  • Cost: every triangle is now non-opaque, so any-hit runs on every candidate hit, including stone and floors that will never cut out. The textured-only early-out keeps it cheap, but the proper fix is to carry glTF’s alphaMode on the material and keep MASK primitives in their own non-opaque geometry. Per object isn’t enough: each of Sponza’s and San Miguel’s few objects mixes cutout and opaque materials.
  • Mip 0 again: raster cuts out on filtered alpha and the ray tracer on the full-resolution texel, so distant foliage is slightly crisper and more aliased when ray traced, from the same cause.
  • Shadow rays (project 4b): they will need the same treatment, or leaves cast solid shadows.
Click to zoom
| San Miguel's foliage cut out by the any-hit shader |

The sky in the ray tracer

Project 2 put the sky’s uniforms and environment cube in m_scDesc (bindings 3 and 4) with the ray generation and miss stages already set, so the ray tracer could reuse it. It now does:

  • Lighting: with the sky on, a hit is lit by SkyDirect + SkyAmbient, the same calls the raster path makes, instead of the point light.
  • Background: a camera ray that misses returns SkyBackground, with the sun and moon discs and the stars. The disc anti-aliasing needs a pixel’s angular size, which comes from the neighbouring pixel’s ray direction, since there are no derivatives.

Before this, a Sponza ray traced image was point-lit while auto exposure still scaled it for the sky, so the two modes didn’t match in brightness. Now both are lit by the sky that the exposure is computed from, and they agree.

This is still raster-style sky lighting, with no visibility, so interiors are lit as if roofless. Shadow rays toward the sun and importance sampling the environment come with path tracing.

Debug views in both paths

Project 2’s debug views were a switch inside scanline.frag. They now live in debug_view.glsl, which takes a DebugSurface (albedo, normal, geometric normal, view vector, N·L, UV, mip, alpha, facing) that each path fills from its own inputs. PushConstantRay gained debugView, so all nine views work in both modes, and toggling R compares the two paths on one property at a time. That is how the texture filtering difference was isolated.


Cleanup and resize

destroyAllVulkanResources

The ray tracing objects are destroyed first, before the descriptor set and buffers they reference:

ImGui_ImplVulkan_Shutdown();
vkDestroyDescriptorPool(m_device, m_imguiDescPool, nullptr);

m_shaderBindingTableBuff.destroy(m_device);
vkDestroyPipelineLayout(m_device, m_rtPipelineLayout, nullptr);
vkDestroyPipeline(m_device, m_rtPipeline, nullptr);
m_rtDesc.destroy(m_device);
m_rtBuilder.destroy();
destroyRtBuffers();
// ... project 2's objects, unchanged ...

recreateSizedVulkanResources

  • Destroyed: the SBT, RT pipeline and layout, m_rtDesc and the RT buffers, ahead of project 2’s list
  • Recreated, after project 2’s list: createRtBuffers(), createRtDescriptorSet(), createRtPipeline(), createRtShaderBindingTable()

The acceleration structures and the builder are not touched: they do not depend on the window.

switchScene

The reverse of resize. The window is unchanged, so the RT buffers stay: the next frame overwrites them. Everything tied to the geometry is rebuilt: the SBT, pipeline and m_rtDesc are destroyed along with m_rtBuilder, then createRtAccelerationStructure(), createRtDescriptorSet(), createRtPipeline() and createRtShaderBindingTable() run after the scanline objects.


Validation-layer verification

VK_LAYER_KHRONOS_validation stays on unconditionally. The living room and Sponza ran with no validation-layer messages through startup, ray tracing and shutdown.

Console output of the living room run at trace level, filtered to the ray tracing setup. The four shader modules are raygen, miss, closest hit and any hit; the two allocations after the handle query are the SBT’s staging and device buffers:

[17:34:10.475] info  ---- Scene: Living room (OBJ) ------------------------------------------
[17:34:23.594] trace     Call VkApp::objectToVkGeometryKHR to return a BlasInput entry.
[17:34:23.594] trace   Call buildBlas to build vector<AccelWrap> m_blas
[17:34:23.595] trace     Call cmdCreateBlas
[17:34:23.637] trace   Call buildTlas with a list of BLAS instances
[17:34:23.642] trace     Call cmdCreateTlas
[17:34:23.646] info  Acceleration structures built: 1 BLAS, TLAS of 1 instances
[17:34:23.649] trace vkCheck successful  vkCreateShaderModule(m_device, &createInfo, nullptr, &shaderModule)
[17:34:23.650] trace vkCheck successful  vkCreateShaderModule(m_device, &createInfo, nullptr, &shaderModule)
[17:34:23.650] trace vkCheck successful  vkCreateShaderModule(m_device, &createInfo, nullptr, &shaderModule)
[17:34:23.651] trace vkCheck successful  vkCreateShaderModule(m_device, &createInfo, nullptr, &shaderModule)
[17:34:23.651] trace vkCheck successful  vkCreatePipelineLayout(m_device, &pipelineLayoutCreateInfo, nullptr, &m_rtPipelineLayout)
[17:34:23.665] trace vkCheck successful  vkCreateRayTracingPipelinesKHR(m_device, {}, {}, 1, &rayPipelineInfo, nullptr, &m_rtPipeline)
[17:34:23.665] trace Shader binding table:
[17:34:23.665] trace   alignments:
[17:34:23.665] trace     handleAlignment: 32
[17:34:23.665] trace     baseAlignment:   64
[17:34:23.665] trace   counts:
[17:34:23.666] trace     miss:   1
[17:34:23.666] trace     hit:    1
[17:34:23.666] trace     handle: 3 = 1+missCount+hitCount
[17:34:23.666] trace   regions stride:size:
[17:34:23.666] trace     rgen 64:64
[17:34:23.666] trace     miss 32:64
[17:34:23.666] trace     hit  32:64
[17:34:23.666] trace     call  0: 0
[17:34:23.666] trace vkCheck successful  vkGetRayTracingShaderGroupHandlesKHR(m_device, m_rtPipeline, 0, handleCount, dataSize, handles.data())
[17:34:23.666] trace vkCheck successful  vkAllocateMemory(m_device, &allocInfo, nullptr, &wrap.memory)
[17:34:23.666] trace vkCheck successful  vkBindBufferMemory(m_device, wrap.buffer, wrap.memory, 0)
[17:34:23.667] trace vkCheck successful  vkAllocateMemory(m_device, &allocInfo, nullptr, &wrap.memory)
[17:34:23.667] trace vkCheck successful  vkBindBufferMemory(m_device, wrap.buffer, wrap.memory, 0)
[17:34:23.668] info  ---- Draw loop ---------------------------------------------------------
...
[17:34:28.364] info  ---- Cleanup -----------------------------------------------------------

Appendix A: where each @@ comment is addressed

@@ requestFileAnswered in
Destroy / resize the RT buffers; complete destroyRtBuffers()vkapp_raytracing.cppcreateRtBuffers
*HERE* destroy m_rtBuildervkapp_raytracing.cppinitRayTracing
Destroy the acceleration structures with m_rtBuilder.destroy()wrap_acceleration.cppcreateRtAccelerationStructure
Destroy m_rtDesc; resize it and reportvkapp_raytracing.cppcreateRtDescriptorSet
Destroy / resize m_rtPipelineLayout and m_rtPipelinevkapp_raytracing.cppcreateRtPipeline
Destroy m_shaderBindingTableBuffvkapp_raytracing.cppcreateRtShaderBindingTable
Verify vkGetRayTracingShaderGroupHandlesKHRvkapp_raytracing.cppcreateRtShaderBindingTable
Raycasting: declare 3 temporary light valuesshared_structs.hThe push constant
Raycasting: provide 3 temporary light valuesvkapp_raytracing.cppThe push constant
Raycasting: fill the payloadraytrace.rchitraytrace.rchit
Raycasting: payload.hit = falseraytrace.rmissraytrace.rmiss
Raycasting: write EvalBrdfraytrace.rgenMatching the scanline path
Raycasting: break on a missraytrace.rgenraytrace.rgen
Raycasting: accumulate a light’s emissionraytrace.rgenraytrace.rgen
Raycasting: lighting calculationraytrace.rgenraytrace.rgen
Raycasting: write C to the output pixelraytrace.rgenraytrace.rgen
Call raytrace() from renderFrame3-ray-casting.pdfRuntime

Appendix B: destroy / resize registry

Project 3’s resources, added to project 2’s registry.

ResourceDestroyedResizedRebuilt on scene switch
m_rtColCurrBufferyesyes—
m_rtBuilder (BLAS + TLAS)yes—yes
m_rtDescyesyesyes
m_rtPipelineLayout / m_rtPipelineyesyesyes
m_shaderBindingTableBuffyesyesyes