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
- VK_NV_ray_tracing
- VK_KHR_ray_tracing
- GLSL_NV_ray_tracing
- GLSL_EXT_ray_tracing
- DXC documentation for NV_ray tracing, KHR_ray_tracing
- HLSL shaders for the tutorial
Related resources
- GTC session: Ray-Tracing Development using NVIDIA Nsight Graphics and NVIDIA Nsight Systems* (Spring 2023)
- GTC session: Connect with the Experts: Using NVIDIA Developer Tools to Optimize Ray Tracing (Spring 2023)
- SDK: Nsight Graphics
- SDK: OptiX
- SDK: Path Tracing SDK
- Webinar: Introducing NVIDIA Quadro RTX