This tutorial shows how to create and archive pipeline states with the render state packager off-line tool on the example of a simple path tracer.
Diligent Engine allows compiling shaders from source code at run-time and using them to create pipeline states. While simple and convenient, this approach has downsides:
Pipeline state processing can be completely performed off-line using the Render state packager tool. The tool uses the Diligent Render State Notation
, a JSON-based render state description language. One archive produced by the packager can contain multiple pipeline states as well as shaders and pipeline resource signatures that can be loaded and immediately used at run-time without any overhead. Each object can also contain data for different backends (e.g. DX12 and Vulkan), thus one archive can be used on different platforms.
Archivig pipelines off-line provides a number of benefits:
In this tutorial, we will use the render state packager to create an archive that contains all pipeline states required to perform basic path tracing.
This tutorial implements a basic path tracing algorithm with the next event estimation (aka light source sampling). In this section we provide some details about the rendering process. Additional information about path tracing can be easily found on the internet. Ray Tracing in One Weekend and Rendering Introduction Course from TU Wien University could be good starting points. Path tracing shader source code also contains a lot of additional details.
The rendering process consists of the following three stages:
At the first stage, the scene is rendered into a G-buffer consisting of the following render targets:
RGBA8_UNORM
)RGBA8_UNORM
)R11G11B10_FLOAT
)R32_FLOAT
)At the second stage, a screen-size quad is rendered that for each pixel of the G-buffer reconstructs its world-space position, traces a light path through the scene and adds the contribution to the radiance accumulation buffer. Each frame, a set number of new paths are traced and their contributions are accumulated. If camera moves or light attributes change, the accumulation buffer is cleard and the process starts over.
Finally, at the third stage, the radiance in the light accumulation buffer is resolved by averaging all light path contributions.
For the sake of illustration, the scene in this tutorial is defined by a number of analytic shapes (boxes) and rays are traced through the scene by computing intersections with each box and finding the closest one for each ray. Real applications will likely use DXR/Vulkan ray tracing (see Tutorial 21 - Ray Tracing and Tutorial 22 - Hybrid Rendering).
A box is defined by its center, size, albedo, emissive power and type:
For the Type
field, two values are allowed: lambertian diffuse surface and light source.
A ray is defined by its origin and normalized direction:
A hit point contains information about the color at the intersection, the surface normal, the distance from the ray origin to the hit point and also the hit type (lambertian surface, diffuse light source or none):
An intersection of the ray with the box is computed by the IntersectAABB
function:
The function takes the ray information, box attributes and also the current hit point information. If the new hit point is closer than the current one defined by the Hit
, the struct is updated with the new color, normal, distance and hit type.
Casting a ray though the scene consists of intersecting the ray with each box:
The G-buffer is rendered by a full-screen render pass, where the pixel shader performs ray casting through the scene. It starts by computing the ray starting and end points using the inverse view-projection matrix:
Next, the shader casts a ray though the scene and writes the hit point properties to the G-buffer targets:
Finally, the shader computes the depth by transforming the hit point with the view-projection matrix:
Path tracing is the core part of the rendering process and is implemented by a pixel shader that is executed for each screen pixel. The shader starts from positions defined by the G-buffer and traces a given number of light paths through the scene, each path performing a given number of bounces. For each bounce, the shader traces a ray towards the light source and computes its contribution. It then selects a random direction at the shading point using the cosine-weighted hemispherical distribution, casts a ray in this direction and repeats the process at the new location.
The shader starts by reading the G-buffer and reconstructing the attributes of the primary camera ray:
Where PSIn.Pos.xy
are the pixel's screen coordinates.
The shader then prepares a two-dimensional hash seed that will be used for pseudo-random number generation:
This is quite important moment: the seed must be unique for each frame, each sample and every bounce to produce a good random sequence. Bad sequences will result in slower convergence or biased result. g_Constants.uFrameSeed1
and g_Constants.uFrameSeed2
are random seeds computed by the CPU for each frame.
The shader then traces a given number of light paths and accumulates their contributions. Each path starts with the primary camera ray:
In the loop, the shader first samples the light source:
The SampleLightSource
function samples a random point on the light source surface and evaluates terms required for the next event estimation. It starts by using two pseudo-random values in the [0, 1] range produced by the hash22
to select a random point on the light source and computing the direction vector to this point:
Next, the function casts a shadow ray towards the selected point and computes it visibility:
Finally, the function computes the probability density of the selected direction, which in our case is constant and is equal to 1 over the projected solid angle spanned by the light source:
After we get the light source sample properties, we update the path radiance as follows:
Where:
f3Throughput
is the path throughput, i.e. the maximum possible remaining contribution after all bounces so far. Initially it is float3(1, 1, 1)
, and is updated at each bounce.BRDF
is the bidirectional reflectance distribution function that determines how much of the incoming light is reflected into the view direction. In our case, it is a simple Lambertian BRDF (perfectly diffuse surface), and equals to Hit.Albedo / PI
.NdotL
aka cos(Theta) is the cosine of the angle between the surface normal and the direction to the light source.LightSample.f3Emittance
is the light source emittance.LightSample.fVisibility
is the light source sample point visibility.LightSample.Prob
is the probabity of selecting the direction.After adding the light contribution, we go to the next sample by selecting a random direction using the cosine-weighted hemispherical distribution:
Dir
is the direction and Prob
is its corresponding probability density. Cosine-weighted distribution assigns more random samples near the normal direction and fewer at the horizon, since they produce lesser contribution due to the 'N dot L' term.
We then update the throughput:
Finally, we trace a ray in the generated direction and update the hit properties:
The process then repeats for the next surface sample location.
After all bounces in the path are traced, the path contribution is combined with the total radiance:
After all samples are traced, the shader adds the total radiance to the accumulation buffer:
The fLastSampleCount
indicates how many samples have been accumulated so far. Zero value indicates that the shader is executed for the first time and in this case it overwrites the previous value.
Refer to the shader source code for additional details.
Radiance accumulation buffer resolve is done in another full-screen render pass and is pretty straightforward: it simply averages all the accumulated paths:
The three stages of the rendering process are implemented by three pipeline states. The pipelines are defined using the Diligent Render State Notation. They are created off-line using the render state packager and packed into a single archive. At run-time, the archive is loaded and pipelines are unpacked using the IDearchiver
object.
Diligent Render State Notation (DRSN) is a JSON-based render state description language. The DRSN file conists of the following sections:
Imports
sections defines other DRSN files whose objects should be imported into this one, pretty much the same way the #include
directive works.Defaults
section defines the default values for objects defined in the file.Shaders
section contains shader descriptions.RenderPasses
section defines render passes used by the pipelines.ResourceSignatures
section defines pipeline resource signatures.Pipelines
section contains pipeline states.All objects in DRSN files are referenced by names that must be unique for each object category. The names are used at run-time to unpack the states.
DRSN reflects the core structures of the engine in JSON format. For example, the G-buffer PSO is defined in DRSN as follows:
Refer to this page for more information about the Diligent Render State Notation.
This tutorial uses the following command line to create the archive:
The command line uses the following options:
-i
- The input DRSN file-r
- Render state notation files search directory-s
- Shader search directory-o
- Output archive file--dx11
, --dx12
, --opengl
, --vulkan
, --metal_macos
, --metal_ios
- device flags for which to generate the pipeline data. Note that devices not supported on a platform (e.g. --metal_macos
on Windows, or --dx11
on Linux) are ignored.--print_contents
- Print the archive contents to the logFor the full list of command line options, run the packager with -h
or --help
option.
If you use CMake, you can define a custom command to create the archive as a build step:
To unpack pipeline states from the archive, first create a dearciver object using the engine factory:
Then read the archive data from the file and load it into the dearchiver:
To unpack the pipeline state from the archive, populate an instance of PipelineStateUnpackInfo
struct with the pipeline type and name. Also, provide a pointer to the render device:
While most of the pipeline state parameters can be defined at build time, some can only be specified at run-time. For instance, the swap chain format may not be known at the archive packing time. The dearchiver gives an application a chance to modify some of the pipeline state create properties through a special callback. Properties that can be modified include render target and depth buffer formats, depth-stencil state, blend state, rasterizer state. Note that properties that define the resource layout can't be modified. Note also that modifying properties does not affect the loading speed as no shader recompilation or patching is necessary.
We define the callback that sets the render target formats using the MakeCallback
helper function:
Finally, we call the UnpackPipelineState
method to create the PSO from the archive:
After the pipeline states are unpacked from the archive, they can be used in a usual way. We need to create the shader resource binding objects and initialize the variables.
To render the scene, we run each of the three stages described above. First, populate the G-buffer:
Next, perform the path tracing using the full-screen render pass:
Finally, resolve radiance:
You can also change the following parameters: