Application methods enabled (uncommented)
| # | Call / change | File | Status |
|---|---|---|---|
| 1 | createRtBuffers() / destroyRtBuffers() | vkapp_raytracing.cpp | done |
| 2 | initRayTracing() | vkapp_raytracing.cpp | done |
| 3 | createRtAccelerationStructure() | wrap_acceleration.cpp | done |
| 4 | createRtDescriptorSet() | vkapp_raytracing.cpp | done |
| 5 | createRtPipeline() | vkapp_raytracing.cpp | done |
| 6 | createRtShaderBindingTable() | vkapp_raytracing.cpp | done |
| 7 | PushConstantRay: three temporary light values, filled in raytrace() | shared_structs.h, vkapp_raytracing.cpp | done |
| 8 | raytrace.rchit, raytrace.rmiss, raytrace.rgen | shaders/ | done |
| 9 | renderFrame(): raytrace() / rasterize() on useRaytracer, with a GUI checkbox | vkapp_runtime.cpp | done |
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
| Parameter | Value |
|---|---|
| Image | m_rtColCurrBuffer |
| 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 |
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 destroysm_rtColCurrBuffer(the other five buffers arrive in later projects) - Resize:
destroyRtBuffers()/createRtBuffers()inrecreateSizedVulkanResources
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, stridesizeof(Vertex)= 48 bytes) with 32-bit indices. Built withPREFER_FAST_TRACE, and not compacted, sinceALLOW_COMPACTIONis not requested. - TLAS: one instance per
ObjInst, withinstanceCustomIndex = objIndex, sogl_InstanceCustomIndexEXTindexesObjDesc[]directly, andTRIANGLE_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
| Binding | Type | Stages | Holds |
|---|---|---|---|
| 0 | acceleration structure | raygen, closest hit | the TLAS |
| 1 | storage image | raygen | m_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
| Stage | Shader | Group |
|---|---|---|
| 0 | raytrace.rgen | 0 (general) |
| 1 | raytrace.rmiss | 1 (general) |
| 2 | raytrace.rchit | 2, triangles hit group, closest hit |
| 3 | raytrace.rahit (added) | 2, same hit group, any hit |
- Layout: set 0
m_rtDesc, set 1m_scDesc, and one push constant range ofsizeof(PushConstantRay)for raygen, closest hit and miss - Recursion depth: 10, as the framework sets it, though ray casting only needs 1
- Results checked:
vkCreatePipelineLayoutandvkCreateRayTracingPipelinesKHR, throughVK_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:
| Region | Stride | Size |
|---|---|---|
| raygen | 64 | 64 |
| miss | 32 | 64 |
| hit | 32 | 64 |
| callable | 0 | 0 |
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, throughVK_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:
GetHitObjectDatareconstructs the surface from the hit triangle, thenC += 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:
| Include | Holds |
|---|---|
brdf.glsl | EvalBrdf: Cook-Torrance GGX, the @@ Raycasting signature |
surface.glsl (new) | FaceForwardNormal, ApplyNormalMap, ApplyMetalRough, IsCutout |
sky.glsl | SkyDirect (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:
| Scanline | Ray tracer | |
|---|---|---|
| Surface attributes | interpolated by the rasterizer | barycentric weights of the hit triangle’s vertices |
| Face normal, for two-sided shading | cross(dFdx(worldPos), dFdy(worldPos)) | cross(v1 − v0, v2 − v0) |
| Oriented toward | the eye vector | against the ray direction |
| Front facing | gl_FrontFacing | dot(faceN, rayDir) < 0 |
| Texture level | hardware mip selection from derivatives | mip 0 |
| Alpha cutout | discard | ignoreIntersectionEXT in any-hit |
Front facing on both sides is counter-clockwise as seen from the eye.
Runtime
renderFrame():updateCameraBuffer(), thenraytrace()orrasterize()onuseRaytracer, thenpostProcess().denoise()stays commented out until project 6.- Mode switch: a Raytrace mode checkbox at the top of the GUI, and the framework’s
Rkey. raytrace(): fillsm_pcRay, binds the pipeline and both descriptor sets, pushes the constants, callsvkCmdTraceRaysKHRover the window, thenCmdCopyImagesm_rtColCurrBufferintom_renderTarget. From there the post pass, tone mapping and auto exposure are unchanged.
Result
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 view | Raster vs ray tracer |
|---|---|
| Geometric normal | identical: interpolation, barycentrics and the two-sided flip agree |
| Normal (after the normal map) | same colours, plus a fine grain in the ray tracer |
| N·L | same 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.
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:
objectToVkGeometryKHRsetVK_GEOMETRY_OPAQUE_BIT_KHRon the geometrytraceRayEXTpassedgl_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
alphaModeon the material and keepMASKprimitives 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.
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_rtDescand 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
@@ request | File | Answered in |
|---|---|---|
Destroy / resize the RT buffers; complete destroyRtBuffers() | vkapp_raytracing.cpp | createRtBuffers |
*HERE* destroy m_rtBuilder | vkapp_raytracing.cpp | initRayTracing |
Destroy the acceleration structures with m_rtBuilder.destroy() | wrap_acceleration.cpp | createRtAccelerationStructure |
Destroy m_rtDesc; resize it and report | vkapp_raytracing.cpp | createRtDescriptorSet |
Destroy / resize m_rtPipelineLayout and m_rtPipeline | vkapp_raytracing.cpp | createRtPipeline |
Destroy m_shaderBindingTableBuff | vkapp_raytracing.cpp | createRtShaderBindingTable |
Verify vkGetRayTracingShaderGroupHandlesKHR | vkapp_raytracing.cpp | createRtShaderBindingTable |
| Raycasting: declare 3 temporary light values | shared_structs.h | The push constant |
| Raycasting: provide 3 temporary light values | vkapp_raytracing.cpp | The push constant |
| Raycasting: fill the payload | raytrace.rchit | raytrace.rchit |
Raycasting: payload.hit = false | raytrace.rmiss | raytrace.rmiss |
Raycasting: write EvalBrdf | raytrace.rgen | Matching the scanline path |
| Raycasting: break on a miss | raytrace.rgen | raytrace.rgen |
| Raycasting: accumulate a light’s emission | raytrace.rgen | raytrace.rgen |
| Raycasting: lighting calculation | raytrace.rgen | raytrace.rgen |
| Raycasting: write C to the output pixel | raytrace.rgen | raytrace.rgen |
Call raytrace() from renderFrame | 3-ray-casting.pdf | Runtime |
Appendix B: destroy / resize registry
Project 3’s resources, added to project 2’s registry.
| Resource | Destroyed | Resized | Rebuilt on scene switch |
|---|---|---|---|
m_rtColCurrBuffer | yes | yes | — |
m_rtBuilder (BLAS + TLAS) | yes | — | yes |
m_rtDesc | yes | yes | yes |
m_rtPipelineLayout / m_rtPipeline | yes | yes | yes |
m_shaderBindingTableBuff | yes | yes | yes |