Skip to content

Raytracing

Carsten Rudolph edited this page Feb 3, 2024 · 5 revisions

Hardware raytracing is an optional feature, that can be integrated by specifying the GraphicsDeviceFeatures::RayTracing build flag. The raytracing sample demonstrates how to use it. The following guide will lead you through the steps required to perform raytracing in more detail.

Acceleration Structures

Hardware raytracing behaves different to rasterization in may ways, not only due to the way it calculates light transport, but on a lower level in the way it interacts with the GPU. In standard rasterization, only one pipeline state and consequentially only one shader can be active at a time, whilst with ray tracing, the active shader is selected depending on the geometry that has been hit. This requires the geometry to be carefully prepared to work with raytracing pipelines. Geometry for raytracing workloads is expressed in terms of acceleration structures. Those structures are optimized representations of the scene, kept on GPU memory that allow for efficient ray traversal. There are two types of acceleration structures: bottom-level and top-level, also referred to in a short form as BLAS and TLAS. Bottom-level acceleration structures describe the geometry, i.e. they contain mesh vertices and indices. On the other hand, top-level acceleration structures are also referred to as geometry instances. They reference a BLAS and assign it properties for ray traversal. Multiple top-level acceleration structures can reference the same bottom-level acceleration structure.

In LiteFX, acceleration structures are built in a multi-stage process. This process begins with first creating sets of BLAS and TLAS structures and assigning them with their respective data, i.e., defining geometries and geometry instances. Afterwards, acceleration structures need to be built. Building an acceleration structure means to transform them into a hardware-specific optimal representation for ray traversal. The result of this process is written into a buffer on the GPU. There is one such buffer for each acceleration structure and before being able to build them, those buffers must be allocated. This is done by calling IAccelerationStructure::allocateBuffers() on them. The last step in this process is to actually perform the build. Building an acceleration structure is done directly on the GPU, so just like any other workload, it is an asynchronous process that is recorded onto a command buffer, in this case using ICommandBuffer::buildAccelerationStructure. This process takes an additional temporary buffer called scratch buffer, that can be released afterwards. If none is provided, the engine creates one for the duration of the building process. If multiple acceleration structures are built, it can be more efficient to pre-allocate a larger buffer and re-use it for all building commands. The required memory amount can be computed by querying the IAccelerationStructure::requiredScratchMemory on each acceleration structure to find and reserve the largest chunk.

The following diagram shows the general workflow for how to build acceleration structures in LiteFX:

---
title: Acceleration structure creation workflow
---
flowchart TD
  classDef result   fill:#A5FF7F
  classDef input    fill:#FFB27F
  classDef resource fill:#D1FFE9

  subgraph blasBuiltup["Bottom-level acceleration structure (BLAS)"]
    direction TB
    createBlas["IGraphicsFactory::createBottomLevelAccelerationStructure"] -.- blas{{"BLAS<br>Triangle meshes or axis-aligned bounding boxes"}}
    allocBlas["IBottomLevelAccelerationStructure::allocateBuffer"]

    createBlas --> allocBlas

    class blas resource
  end

  subgraph tlasBuiltup["Top-level acceleration structure (TLAS)"]
    direction TB

    createTlas["IGraphicsFactory::createTopLevelAccelerationStructure"] -.- tlas{{"TLAS<br>Instance data (id, hit group, ...)"}}
    allocTlas["ITopLevelAccelerationStructure::allocateBuffer"]

    createTlas --> allocTlas
    class tlas resource
  end

  blasBuiltup --> tlasBuiltup
  blas -..- tlas

  commandBuffer{{"ICommandBuffer"}}
  class commandBuffer input

  subgraph allocAndBuild["Allocation and build"]
    direction LR

    allocScratchBuffer["IGraphicsFactory::createBuffer(BufferType: Storage, ResourceHeap: Resource, ResourceUsage: AllowWrite)"] -.- scratchBuffer{{"Scratch buffer"}}
    buildBlas["ICommandBuffer::buildAccelerationStructure"] -.- blasBuffer{{"BLAS buffer"}}
    buildTlas["ICommandBuffer::buildAccelerationStructure"] -.- tlasBuffer{{"TLAS buffer"}}

    commandBuffer -..- buildBlas
    scratchBuffer -..- buildBlas
    blas -..- buildBlas

    commandBuffer -..- buildTlas
    scratchBuffer -..- buildTlas
    tlas -..- buildTlas

    allocScratchBuffer --> buildBlas
    buildBlas --> buildTlas

    class scratchBuffer resource
    class blasBuffer result
    class tlasBuffer result
  end

  tlasBuiltup --> allocAndBuild

Raytracing Pipeline

As mentioned above, the ray-tracing pipeline is vastly different from the rasterization pipeline, as it can store different shaders for different kinds of geometry and invoke them based on what geometry was hit.

Shader Module Types

There are different types of shaders within a ray tracing pipeline, each have a different role and differ in the situation when they are invoked.

Ray Generation

Hit Groups

A hit group refers to a group of shaders that is invoked, if a ray hits a geometry. It is defined by a unique index, that is passed to individual geometries when building the BLAS. Hit group indices must be continuous and unique, i.e., there must be no gaps between indices and no index must exist twice. A hit group must contain either an Intersection module, if the geometry is an bounding box or an Any Hit/Closest Hit module, if the geometry is a triangle mesh. In the later case, both module types are allowed to be combined, but at least one of them needs to exist in a hit group.

Miss Shaders

Callable Shaders

Resource Binding

There are two ways to bind resources to ray-tracing shaders:

  • Global resources are defined through the pipeline layout of the ray-tracing pipeline and are available for all shaders.
  • Local resources are defined for each shader individually. There are some restrictions to them, as detailed below.

Global Resource Bindings

Local Resource Bindings

Local resources only support buffers and constants. Textures cannot be bound to local shader resources. Instead, a constant can be used as a (non-uniform) resource index into a bindless texture array. From a shader they are accessed through an annotated ConstantBuffer like so:

struct BindingTableLayout
{
    StructuredBuffer<float3> VertexColors; // Buffer Reference
    uint MaterialId; // Constant
};

[[vk::shader_record_ext]]
ConstantBuffer<BindingTableLayout> ShaderBindingTable : register(b0, space1);

Or equally in GLSL:

layout(buffer_reference, buffer_reference_align=8, scalar) buffer VertexColors {
    vec3 colors[];
};

layout(shaderRecordEXT, std430) buffer ShaderBindingTable {
    VertexColors colors; // Buffer reference
    uint materialId; // Constant
};

Keep in mind that each shader only allows one shader record structure.