Bringing HLSL Ray Tracing to Vulkan

This post was revised March 2020 to reflect newly added support in DXC for targeting the SPV_KHR_ray_tracing multi-vendor extension.

Vulkan logo

DirectX Ray Tracing (DXR) allows you to render graphics using ray tracing instead of the traditional method of rasterization. This API was created by NVIDIA and Microsoft back in 2018.

A few months later, NVIDIA unveiled its Turing GPU architecture with native support in hardware to accelerate ray tracing workloads. Ever since, the ray-tracing ecosystem has been steadily evolving. Multiple AAA game titles using DXR have been announced and released, as well as industry standard visualization tools. 

Along with DXR, NVIDIA shipped the NVIDIA VKRay Vulkan vendor extension with the same level of ray tracing functionality exposed. Several Vulkan titles use NVIDIA VKRay, including Quake2 RTX, JX3 (MMO), and Wolfenstein: Youngblood.

Porting DirectX Content to Vulkan

The Vulkan API from The Khronos Group, being cross-platform, can reach a wide audience across a diverse range of platforms and devices. Many developers port content from DirectX to Vulkan to take advantage of this broader market reach. However, porting a title requires porting both the API calls (to Vulkan) and the shaders (to SPIR-V). 

While most ISVs can port 3D API calls with some reasonable effort, rewriting HLSL shaders in another shading language is a significant undertaking. Shader source code may evolve over many years. In some cases, shaders are also generated on the fly. Consequently, a cross-platform compiler that translates HLSL shader source into SPIR-V for execution by Vulkan is very attractive to developers. 

One such tool developed by Google is the SPIR-V backend to Microsoft’s open source DirectXCompiler (DXC). Over the past couple of years, this compiler has become the common, production-ready solution for bringing HLSL content to Vulkan. Khronos recently discussed more background on using HLSL in Vulkan in a recent post, HLSL as a first class Vulkan Shading Language

Now, bringing together the use of HLSL and ray tracing in Vulkan, NVIDIA has added ray tracing support to DXC’s SPIR-V backend by targeting the SPV_NV_ray_tracing extension under the NVIDIA VKRay extension. We have also upstreamed support for the multi-vendor extension, SPV_KHR_ray_tracing. 

NVIDIA VKRay example

Here’s how to use HLSL shaders in an existing app, created in the Vulkan Ray Tracing Tutorial written by NVIDIA engineers Martin-Karl Lefrançois and Pascal Gautron. 

The following code shows  the HLSL closest hit shader that calculates shadows with a single point light from the sample app:

#include "raycommon.hlsl"
#include "wavefront.hlsl"

struct MyAttrib
{
        float3 attribs;
};

struct Payload
{
   bool isShadowed;
};

[[vk::binding(0,0)]] RaytracingAccelerationStructure topLevelAS;

[[vk::binding(2,1)]] StructuredBuffer<sceneDesc> scnDesc;

[[vk::binding(5,1)]] StructuredBuffer<Vertex> vertices[];

[[vk::binding(6,1)]] StructuredBuffer<uint> indices[];


[[vk::binding(1,1)]] StructuredBuffer<WaveFrontMaterial> materials[];

[[vk::binding(3,1)]] Texture2D textures[];
[[vk::binding(3,1)]] SamplerState samplers[];

[[vk::binding(4,1)]] StructuredBuffer<int> matIndex[];

struct Constants
{
        float4 clearColor;
        float3 lightPosition;
        float lightIntensity;
        int lightType;
};

[[vk::push_constant]] ConstantBuffer<Constants> pushC;

[shader("closesthit")]
void main(inout hitPayload prd, in MyAttrib attr)
{
  // Object of this instance
  uint objId = scnDesc[InstanceIndex()].objId;

  // Indices of the triangle
  int3 ind = int3(indices[objId][3 * PrimitiveIndex() + 0],
                    indices[objId][3 * PrimitiveIndex() + 1],
                    indices[objId][3 * PrimitiveIndex() + 2]);
  // Vertex of the triangle
  Vertex v0 = vertices[objId][ind.x];
  Vertex v1 = vertices[objId][ind.y];
  Vertex v2 = vertices[objId][ind.z];

  const float3 barycentrics = float3(1.0 - attr.attribs.x - 
  attr.attribs.y, attr.attribs.x, attr.attribs.y);

  // Computing the normal at hit position
  float3 normal = v0.nrm * barycentrics.x + v1.nrm * barycentrics.y + 
  v2.nrm * barycentrics.z;
  // Transforming the normal to world space
  normal = normalize((mul(scnDesc[InstanceIndex()].transfoIT 
           ,float4(normal, 0.0))).xyz);


  // Computing the coordinates of the hit position
  float3 worldPos = v0.pos * barycentrics.x + v1.pos * barycentrics.y 
                    + v2.pos * barycentrics.z;
  // Transforming the position to world space
  worldPos = (mul(scnDesc[InstanceIndex()].transfo, float4(worldPos, 
              1.0))).xyz;

  // Vector toward the light
  float3  L;
  float lightIntensity = pushC.lightIntensity;
  float lightDistance  = 100000.0;

  // Point light
  if(pushC.lightType == 0)
  {
    float3 lDir      = pushC.lightPosition - worldPos;
    lightDistance  = length(lDir);
    lightIntensity = pushC.lightIntensity / (lightDistance * 
                     lightDistance);
    L              = normalize(lDir);
  }
  else  // Directional light
  {
    L = normalize(pushC.lightPosition - float3(0,0,0));
  }

  // Material of the object
  int               matIdx = matIndex[objId][PrimitiveIndex()];
  WaveFrontMaterial mat    = materials[objId][matIdx];


  // Diffuse
  float3 diffuse = computeDiffuse(mat, L, normal);
  if(mat.textureId >= 0)
  {
    uint txtId = mat.textureId + scnDesc[InstanceIndex()].txtOffset;
    float2 texCoord =
        v0.texCoord * barycentrics.x + v1.texCoord * barycentrics.y + 
                  v2.texCoord * barycentrics.z;
    diffuse *= textures[txtId].SampleLevel(samplers[txtId], texCoord,
            0).xyz;
  }

  float3  specular    = float3(0,0,0);
  float attenuation = 1;

  // Tracing shadow ray only if the light is visible from the surface
  if(dot(normal, L) > 0)
  {
    float tMin   = 0.001;
    float tMax   = lightDistance;
    float3  origin = WorldRayOrigin() + WorldRayDirection() * 
        RayTCurrent();
    float3  rayDir = L;
    uint  flags =
        RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH | 
        RAY_FLAG_FORCE_OPAQUE |
        RAY_FLAG_SKIP_CLOSEST_HIT_SHADER;

    RayDesc desc;
    desc.Origin = origin;
    desc.Direction = rayDir;
    desc.TMin = tMin;
    desc.TMax = tMax;

    Payload shadowPayload;
    shadowPayload.isShadowed = true;
    TraceRay(topLevelAS,
             flags,
             0xFF,
             0,
             0,
             1,
             desc,
             shadowPayload
    );

    if(shadowPayload.isShadowed)
    {
      attenuation = 0.9;
    }
    else
    {
      // Specular
      specular = computeSpecular(mat, WorldRayDirection(), L, normal);
    }
  }

  prd.hitValue = float3(lightIntensity * attenuation * (diffuse + 
  specular));
}

Translating to SPIR-V

Here are several interesting portions of the translation:

  • Resource binding
  • Entry points
  • Entry point arguments
  • Translation to intrinsics
  • ShaderBufferRecord (also known as user SBT data)

Resource binding 

At the top of the shader, there is a declaration of a new basic type in HLSL for ray tracing:

[[vk::binding(0,0)]] RaytracingAccelerationStructure topLevelAS;

DirectX uses global root signatures as a mechanism for resource binding. For Vulkan, [[vk::binding]] is a special annotation used to set the binding point and descriptor set location for resources. This annotation is translated to SPIR-V Binding and DescriptorSet decorations respectively, which are ignored when generating DXIL.

You can also continue using register(xX, spaceY) semantics which would be mapped to the Binding and DescriptorSet decorations. For information about a full list of annotations and mappings, see HLSL to SPIR-V Feature Mapping Manual.

RaytracingAccelerationStructure maps directly to SPIR-V opcode OpTypeAccelerationStructureNV/OpTypeAcccelerationStructureKHR.

Entry points

Shader entry points look like the following code example: 

[shader("closesthit")]
void main(inout hitPayload prd, in MyAttrib attr)

DXR HLSL shaders do not use a specific profile for compilation and are compiled as shader libraries (lib_6_* profiles). This allows hundreds of entry points for different ray tracing stages to be present in a single file. To specify specific stages, use the following the annotation:  

[shader(“<stage>”)] 

Use it where <stage> can be any of the following values:

raygeneration, intersection, closesthit, anyhit, miss

These shader libraries are translated to SPIR-V with multiple entry points in a single blob. For the above entry point, the SPIR-V code looks like the following:

OpEntryPoint ClosestHitNV %main "main" %gl_InstanceID %gl_PrimitiveID %5 %6 %7

Entry point arguments

void main(inout hitPayload prd, in MyAttrib attr)

DXR HLSL mandates specific rules for the number and type of arguments for each entry point of a ray tracing stage. For example, in closest-hit shaders, both arguments must be a user-defined structure type. The first represents the payload, while the second represents the hit attributes. A complete set of rules is outlined in the DXR specification

SPIR-V does not allow shader entry points to have arguments. During translation, these variables are added to the global scope with storage classes IncomingRayPayloadNV/IncomingRayPayloadKHR and HitAttributeNV/HitAttributeKHR, respectively. The translation also takes care of proper in-out copy semantics.

Translation of intrinsics

There is a one-to-one mapping for system value intrinsics like InstanceIndex() to SPIR-V built-ins. For more information about the full list of the mappings, see HLSL to SPIR-V Feature Mapping Manual. Matrix intrinsics ObjectToWorld3x4() and WorldToObject3x4() in HLSL don’t have a direct mapping to SPIR-V built-ins. For these, use the original non-transposed SPIR-V built-ins and transpose the result during the translation.

The TraceRay() intrinsic in HLSL uses a specific predeclared structure type, RayDesc. This type is populated with geometric information for the ray, such as origin, direction, and parametric min and max. The OpTraceNV/OpTraceRayKHR action needs each of these parameters as separate arguments. The following code example unpacks the RayDesc structure during translation as follows.

OpTraceNV %245 %uint_13 %uint_255 %uint_0 %uint_0 %uint_1 %244 %float_0_00100000005 %192 %191 %uint_0

OpTraceRayKHR %245 %uint_13 %uint_255 %uint_0 %uint_0 %uint_1 %244 %float_0_00100000005 %192 %191 %uint_0

TraceRay() is templated intrinsic, with the last argument being the payload. There are no templates in SPIR-V.  OpTraceNV/OpTraceRayKHR works around this limitation by providing a location number of the RayPayloadNV/RayPayloadKHR decorated variable. This allows different calls to use different payloads and thereby mimics the template functionality. During translation, RayPayloadNV/RayPayloadKHR decorated variables with unique location numbers are generated while doing copy-in and copy-out of data to preserve the semantics of the TraceRay() call.

ShaderBufferRecord (also known as user SBT data)

NVIDIA’s VKRay extension for ray tracing allows read-only access to user data in the shader binding table (SBT) in ray tracing shaders using shader record buffer blocks. For more information, see the Vulkan 1,2 specification. The SBT data is not directly accessible in HLSL shaders.

To expose this feature, add the [[vk::shader_record_nv]]/[[vk::shader_record_ext]] annotation to ConstantBuffer/cbuffers declarations:

struct S { float t; }
[[vk::shader_record_nv]]
ConstantBuffer<S> cbuf;

DXR introduced local root signatures for binding resources for each shader present in the SBT. Instead of emulating local root signature at SPIR-V level and enforcing some contract on the app, we provide access to the user data section inside the SBT. This, along with support for VK_EXT_descriptor_indexing and its corresponding SPIR-V capability RuntimeDescriptorArrayEXT, can achieve the same effect as local root signature while retaining flexibility. Here’s a code example:

[[vk::binding(0,0)] Texture2D<float4> gMaterials[];
struct Payload { float4 Color; };
struct Attribs { float2 value; };
struct MaterialData { uint matDataIdx; };
[[vk::shader_record_nv]]
ConstantBuffer<MaterialData> cbuf;
void main(inout Payload prd, in Attribs bary)
{
    Texture2D tex = gMaterials[NonUniformResourceIndex(matDataIdx)]
    prd.Color += tex[bary.value];
}

From our experience, this mechanism lines up fairly well with the way most DXR applications use the SBT. It’s also simpler to deal with from the application side, compared to other potential approaches for emulating a local root signature.

Generating SPIR-V using the Microsoft DXC compiler

The earlier HLSL code can be converted to SPIR-V targeting the KHR extension by running the following command:

dxc.exe -T lib_6_4 raytrace.rchit.hlsl -spirv -Fo raytrace.rchit.spv -fvk-use-scalar-layout

To target the NV extension, run the following command:

dxc.exe -T lib_6_4 raytrace.rchit.hlsl -spirv -Fo raytrace.rchit.spv -fvk-use-scalar-layout -fspv-extension="SPV_NV_ray_tracing"

The options used are as follows: 

  • -T lib_6_4: Use the standard profile for compiling ray tracing shaders. 
  • -spirv: Generate output in SPIR-V.
  • -Fo <filename>: Generate an output file from <filename>.

That’s pretty much it! You can plug in the generated SPIR-V blob in the sources and see that it runs as expected, as shown in Figure 2. If you compare the SPIR-V generated from HLSL or corresponding GLSL, it looks very similar.


Figure 2: Vulkan Ray Tracing tutorial using HLSL shaders. 

Conclusion

The NVIDIA VKRay extension, with the DXC compiler and SPIR-V backend, provides the same level of ray tracing functionality in Vulkan through HLSL as is currently available in DXR. You can now develop ray-tracing applications using DXR or NVIDIA VKRay with minimized shader re-writing to deploy to either the DirectX or Vulkan APIs.

We encourage you to take advantage of this new flexibility and expand your user base by bringing ray tracing titles to Vulkan.

References

No Comments