How to do ray tracing in a fragment shader with VK_KHR_ray_query

Post 66. VK_KHR_ray_tracing_pipeline is a whole new dispatch model — shader binding tables, vkCmdTraceRaysKHR, ray-gen/miss/hit shaders. For shadows, ambient occlusion or picking that’s overkill. VK_KHR_ray_query puts the BVH traversal inside any existing shader: build an acceleration structure, bind it like a texture, and walk it with a few GLSL calls — from a fragment shader.

example.sh
$ ./app --headless out.ppm
device: AMD Radeon RX 7800 XT (RADV NAVI32)
wrote out.ppm

A checkerboard ground plane with a perfectly hard triangle shadow — every pixel cast a hardware-accelerated ray toward the light.

Example: VulkanExamples/p66_rayquery

Three enabling features

Ray query needs a three-struct feature chain — buffer device address (AS buffers are referenced by GPU address, not descriptor), accelerationStructure, and rayQuery itself:

main.cpp
vk::PhysicalDeviceBufferDeviceAddressFeatures bda{};
bda.setBufferDeviceAddress(true);
vk::PhysicalDeviceAccelerationStructureFeaturesKHR asf{};
asf.setAccelerationStructure(true).setPNext(&bda);
vk::PhysicalDeviceRayQueryFeaturesKHR rqf{};
rqf.setRayQuery(true).setPNext(&asf);
createDevice({VK_KHR_ACCELERATION_STRUCTURE_EXTENSION_NAME,
              VK_KHR_RAY_QUERY_EXTENSION_NAME,
              VK_KHR_DEFERRED_HOST_OPERATIONS_EXTENSION_NAME}, &rqf);

VK_KHR_deferred_host_operations is a dependency of the AS extension — no deferred ops are used, it just has to be listed.

BLAS + TLAS

A bottom-level AS holds geometry; a top-level AS holds instances pointing at BLASes. The dance for the triangle BLAS:

  1. Vertex buffer with eAccelerationStructureBuildInputReadOnlyKHR | eShaderDeviceAddress — GPU-addressed.
  2. vkGetAccelerationStructureBuildSizesKHR → AS storage + scratch sizes.
  3. vkCreateAccelerationStructureKHR on a storage buffer, vkCmdBuildAccelerationStructuresKHR to build.
  4. One VkAccelerationStructureInstanceKHR (identity transform, accelerationStructureReference = vkGetAccelerationStructureDeviceAddressKHR(blas)) → TLAS built the same way over an instances buffer.

Two traps I hit on RADV:

  • maxVertex is the highest vertex index, not the count. 3 vertices → maxVertex = 2. Setting 3 makes the driver read a garbage fourth vertex — the shadow becomes non-deterministic noise.
  • Bind the TLAS, not the BLAS. Binding the BLAS directly is nominally legal, but on RADV it produced randomly flickering hits. The instanced TLAS path renders bit-identical frames.

The shader side

GLSL needs #extension GL_EXT_ray_query and a SPIR-V 1.4+ target (shaderc_target_env_vulkan, shaderc_env_version_vulkan_1_3 in shaderc). Then a shadow ray is five calls:

s.frag
layout(set = 0, binding = 0) uniform accelerationStructureEXT scene;
rayQueryEXT q;
rayQueryInitializeEXT(q, scene, gl_RayFlagsTerminateOnFirstHitEXT,
                      0xFF, origin + dir * 0.02, 0.0, dir, tmax - 0.04);
while (rayQueryProceedEXT(q)) {}
bool shadow = rayQueryGetIntersectionTypeEXT(q, true)
            != gl_RayQueryCommittedIntersectionNoneEXT;

TerminateOnFirstHit is the shadow-ray optimization — traversal stops at the first opaque hit instead of finding the closest one. The 0.02 origin offset and tmax - 0.04 are the classic ray-tracing epsilon: push the ray off the surface and stop just short of the light so it doesn’t hit itself.

The AS binds through a normal descriptor with type eAccelerationStructureKHR — the write needs a VkWriteDescriptorSetAccelerationStructureKHR chained into pNext instead of pImageInfo/pBufferInfo.

When ray query wins

  • Shadows / AO / picking in a rasterizer — no SBT, no new pipeline type, one descriptor.
  • Works in any stage — fragment, compute, even vertex.
  • Costs: you manage BLAS/TLAS lifetime and updates yourself, and every ray pays BVH traversal in-shader (fine for one shadow ray; full path tracing still wants the RT pipeline).

Check out similar posts by category: C++ Vulkan