Vulkan RT Pipeline


Pipeline Overview

  • Unlike rasterization (vertex → fragment), RT pipeline has 5 shader stages
  • Ray generation → [intersection → any-hit] → closest-hit / miss
  • Hardware traverses the TLAS/BLAS, calling shaders at each stage
  • Shader Binding Table (SBT) maps ray types to shader programs

Shader Stages

  • Ray Generation (.rgen)
    • Entry point — one invocation per pixel (or per ray)
    • Calls traceRayEXT() to launch rays
    • Reads result from payload, writes to output image
  • Intersection (.rint)
    • Custom intersection test for non-triangle geometry (AABBs, spheres, curves)
    • Not needed for triangle geometry — hardware handles it
    • Calls reportIntersectionEXT(t, hitKind) to report a hit
  • Any-Hit (.rahit)
    • Called for every potential hit (not just closest)
    • Used for: alpha testing, transparency, counting hits
    • Can call ignoreIntersectionEXT() to reject a hit
    • Can call terminateRayEXT() to stop traversal
  • Closest-Hit (.rchit)
    • Called once for the closest confirmed hit
    • Main shading happens here: BRDF evaluation, shadow rays, etc.
    • Access to gl_HitTEXT, gl_PrimitiveID, gl_InstanceCustomIndexEXT
  • Miss (.rmiss)
    • Called when ray hits nothing
    • Returns sky/environment color
    • Can have multiple miss shaders (one per ray type)

Shader Binding Table (SBT)

  • Maps ray types to shader programs
  • Layout: [RayGen | Miss shaders | Hit groups | Callable shaders]
  • Each entry is a shader handle (32 bytes) + optional inline data
  • Alignment requirements
    • shaderGroupHandleSize — typically 32 bytes
    • shaderGroupBaseAlignment — typically 64 bytes
    • shaderGroupHandleAlignment — typically 32 bytes
    • Each region must be aligned to shaderGroupBaseAlignment
  • SBT regions
    • Ray generation: exactly 1 shader
    • Miss: one per ray type (e.g., primary rays, shadow rays)
    • Hit groups: one per (geometry type × ray type) combination
    • Each hit group = {closest-hit, any-hit, intersection} shaders
  • Indexing formula
    • hitGroupIndex = instanceSBTOffset + geometryIndex * sbtStride + rayContributionToHitGroupIndex
    • instanceSBTOffset = VkAccelerationStructureInstanceKHR.instanceShaderBindingTableRecordOffset
  • Building the SBT
    // Get shader handles
    std::vector<uint8_t> handles(groupCount * handleSize);
    vkGetRayTracingShaderGroupHandlesKHR(device, pipeline, 0, groupCount, handles.size(), handles.data());
     
    // Upload to GPU buffer with correct alignment
    // Each entry: [handle (32 bytes)] [inline data (optional)]
    // Padded to shaderGroupBaseAlignment (64 bytes)

Ray Generation Shader

#version 460
#extension GL_EXT_ray_tracing : require
 
layout(set=0, binding=0) uniform accelerationStructureEXT tlas;
layout(set=0, binding=1, rgba32f) uniform image2D outputImage;
layout(set=0, binding=2) uniform CameraData { mat4 viewInv; mat4 projInv; } cam;
 
layout(location=0) rayPayloadEXT vec3 payload;
 
void main() {
    vec2 pixel = vec2(gl_LaunchIDEXT.xy) + vec2(0.5);  // pixel center
    vec2 uv    = pixel / vec2(gl_LaunchSizeEXT.xy) * 2.0 - 1.0;
    
    vec4 origin    = cam.viewInv * vec4(0, 0, 0, 1);
    vec4 target    = cam.projInv * vec4(uv.x, uv.y, 1, 1);
    vec4 direction = cam.viewInv * vec4(normalize(target.xyz / target.w), 0);
    
    traceRayEXT(
        tlas,
        gl_RayFlagsOpaqueEXT,  // ray flags
        0xFF,                   // cull mask
        0,                      // sbtRecordOffset (hit group index)
        1,                      // sbtRecordStride
        0,                      // miss index
        origin.xyz, 0.001,      // origin, tMin
        direction.xyz, 1e4,     // direction, tMax
        0                       // payload location
    );
    
    imageStore(outputImage, ivec2(gl_LaunchIDEXT.xy), vec4(payload, 1.0));
}

Closest-Hit Shader

#version 460
#extension GL_EXT_ray_tracing : require
#extension GL_EXT_nonuniform_qualifier : require
#extension GL_EXT_buffer_reference : require
#extension GL_EXT_scalar_block_layout : require
 
layout(location=0) rayPayloadInEXT vec3 payload;
layout(location=1) rayPayloadEXT   bool isShadowed;
hitAttributeEXT vec2 baryCoords;
 
layout(set=0, binding=0) uniform accelerationStructureEXT tlas;
 
// Per-instance data (indexed by gl_InstanceCustomIndexEXT)
layout(set=0, binding=3, scalar) buffer InstanceData {
    InstanceInfo instances[];
};
 
void main() {
    // Reconstruct hit point
    vec3 bary = vec3(1.0 - baryCoords.x - baryCoords.y, baryCoords.x, baryCoords.y);
    InstanceInfo inst = instances[gl_InstanceCustomIndexEXT];
    
    // Fetch vertex data via buffer device address
    VertexBuffer vb = VertexBuffer(inst.vertexAddress);
    uint i0 = IndexBuffer(inst.indexAddress).indices[gl_PrimitiveID * 3 + 0];
    uint i1 = IndexBuffer(inst.indexAddress).indices[gl_PrimitiveID * 3 + 1];
    uint i2 = IndexBuffer(inst.indexAddress).indices[gl_PrimitiveID * 3 + 2];
    
    vec3 N = normalize(bary.x * vb.vertices[i0].normal
                     + bary.y * vb.vertices[i1].normal
                     + bary.z * vb.vertices[i2].normal);
    N = normalize(mat3(gl_ObjectToWorldEXT) * N);  // transform to world space
    
    // Evaluate BRDF, trace shadow rays, etc.
    payload = shade(N, inst.material, gl_WorldRayDirectionEXT);
}

Miss Shader

layout(location=0) rayPayloadInEXT vec3 payload;
layout(set=0, binding=4) uniform sampler2D envMap;
 
void main() {
    vec3 dir = gl_WorldRayDirectionEXT;
    vec2 uv  = vec2(atan(dir.z, dir.x) / (2.0 * PI) + 0.5,
                    acos(dir.y) / PI);
    payload = texture(envMap, uv).rgb;
}

Shadow Ray Pattern

  • Use a separate payload location for shadow rays
// In closest-hit shader, for NEE:
layout(location=1) rayPayloadEXT bool isShadowed;
 
isShadowed = true;  // assume shadowed
traceRayEXT(
    tlas,
    gl_RayFlagsTerminateOnFirstHitEXT |
    gl_RayFlagsSkipClosestHitShaderEXT,
    0xFF, 1, 1, 1,  // different miss index for shadow miss shader
    hitPoint + N * 0.001, 0.001,
    lightDir, lightDist * 0.999,
    1  // payload location 1
);
if (!isShadowed) {
    // add direct lighting contribution
}
  • Shadow miss shader: layout(location=1) rayPayloadInEXT bool isShadowed; void main() { isShadowed = false; }

Pipeline Creation

// Shader stages
std::vector<VkPipelineShaderStageCreateInfo> stages = {
    { .stage = VK_SHADER_STAGE_RAYGEN_BIT_KHR,       .module = rgenModule },
    { .stage = VK_SHADER_STAGE_MISS_BIT_KHR,         .module = rmissModule },
    { .stage = VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR,  .module = rchitModule },
};
 
// Shader groups
std::vector<VkRayTracingShaderGroupCreateInfoKHR> groups = {
    { .type = VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR,
      .generalShader = 0 },  // rgen
    { .type = VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR,
      .generalShader = 1 },  // miss
    { .type = VK_RAY_TRACING_SHADER_GROUP_TYPE_TRIANGLES_HIT_GROUP_KHR,
      .closestHitShader = 2 },  // hit group
};
 
VkRayTracingPipelineCreateInfoKHR pipelineInfo{};
pipelineInfo.stageCount = stages.size();
pipelineInfo.pStages    = stages.data();
pipelineInfo.groupCount = groups.size();
pipelineInfo.pGroups    = groups.data();
pipelineInfo.maxPipelineRayRecursionDepth = 2;  // primary + shadow
vkCreateRayTracingPipelinesKHR(device, {}, {}, 1, &pipelineInfo, nullptr, &pipeline);

Ray Recursion Depth

  • maxPipelineRayRecursionDepth — max nested traceRayEXT calls
  • Depth 1: primary rays only (no shadow rays from hit shader)
  • Depth 2: primary + shadow rays (most common)
  • Higher depth: expensive — prefer iterative approach with payload
  • For path tracing: use iterative loop in rgen shader, not recursive hit shaders