Skip to content

Commit

Permalink
FidelityFX SSSR release v1.1
Browse files Browse the repository at this point in the history
  • Loading branch information
rys committed Aug 26, 2020
1 parent b9c98e6 commit cc1bb86
Show file tree
Hide file tree
Showing 68 changed files with 11,245 additions and 1,206 deletions.
18 changes: 17 additions & 1 deletion .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,32 @@ build_dx12:
paths:
- sample/bin/

build_vk:
tags:
- windows
- amd64
stage: build
script:
- 'cmake -S sample -B sample/build/VK -G "Visual Studio 15 2017" -A x64 -DGFX_API=VK'
- 'cmake --build sample/build/VK --config Release'
artifacts:
paths:
- sample/bin/

package_sample:
tags:
- windows
- amd64
stage: deploy
dependencies:
- build_dx12
- build_vk
script:
- echo "Packaging build"
- echo cd .\sample\bin\ > %SampleName%_DX12.bat
- echo start %SampleName%_DX12.exe >> %SampleName%_DX12.bat
- echo cd .\sample\bin\ > %SampleName%_VK.bat
- echo start %SampleName%_VK.exe >> %SampleName%_VK.bat
artifacts:
name: "%SampleName%-%CI_COMMIT_TAG%-%CI_COMMIT_REF_NAME%-%CI_COMMIT_SHORT_SHA%"
paths:
Expand All @@ -38,4 +53,5 @@ package_sample:
- sample/bin/
- sample/media/
- docs/
- "%SampleName%_DX12.bat"
- "%SampleName%_DX12.bat"
- "%SampleName%_VK.bat"
Binary file removed docs/FFX_SSSR_API.pdf
Binary file not shown.
Binary file modified docs/FFX_SSSR_GUI.pdf
Binary file not shown.
Binary file modified docs/FFX_SSSR_Technology.pdf
Binary file not shown.
30 changes: 19 additions & 11 deletions ffx-sssr/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@ project(stochastic-screen-space-reflections)

find_package(PythonInterp 3.6 REQUIRED)

option(FFX_SSSR_NO_D3D12 "Stochastic Screen Space Reflections - Skip D3D12 backend" OFF)
option(FFX_SSSR_NO_VK "Stochastic Screen Space Reflections - Skip VK backend" OFF)
# ensure that only one option is enabled
if(FFX_SSSR_VK AND FFX_SSSR_D3D12)
message(FATAL_ERROR "FFX_SSSR_VK and FFX_SSSR_D3D12 are enabled. Please make sure to enable only one at a time.")
endif()

if(FFX_SSSR_VK)
find_package(Vulkan REQUIRED)
endif()

set_property(GLOBAL PROPERTY USE_FOLDERS ON)

Expand All @@ -25,8 +31,7 @@ file(GLOB FFX_SSSR_SOURCE_FILES
file(GLOB FFX_SSSR_SHADER_FILES
${CMAKE_CURRENT_SOURCE_DIR}/shaders/*.hlsl)

if(FFX_SSSR_NO_D3D12)
else()
if(FFX_SSSR_D3D12)
file(GLOB FFX_SSSR_HEADER_FILES_D3D12
${CMAKE_CURRENT_SOURCE_DIR}/inc/ffx_sssr_d3d12.h
${CMAKE_CURRENT_SOURCE_DIR}/src/d3d12/*.h)
Expand All @@ -36,8 +41,7 @@ else()
${CMAKE_CURRENT_SOURCE_DIR}/src/d3d12/*.cpp)
endif()

if(FFX_SSSR_NO_VK)
else()
if(FFX_SSSR_VK)
file(GLOB FFX_SSSR_HEADER_FILES_VK
${CMAKE_CURRENT_SOURCE_DIR}/inc/ffx_sssr_vk.h
${CMAKE_CURRENT_SOURCE_DIR}/src/vk/*.h)
Expand All @@ -54,7 +58,6 @@ foreach(shaderfile classify_tiles
intersect
prepare_indirect_args
resolve_eaw
resolve_eaw_stride
resolve_spatial
resolve_temporal)

Expand Down Expand Up @@ -87,12 +90,12 @@ target_include_directories(FFX_SSSR PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/external
target_include_directories(FFX_SSSR PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/externals/dxc)
target_include_directories(FFX_SSSR PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/externals/samplerCPP)

if(FFX_SSSR_NO_D3D12)
target_compile_definitions(FFX_SSSR PRIVATE FFX_SSSR_NO_D3D12)
if(FFX_SSSR_D3D12)
target_compile_definitions(FFX_SSSR PRIVATE FFX_SSSR_D3D12)
endif()

if(FFX_SSSR_NO_VK)
target_compile_definitions(FFX_SSSR PRIVATE FFX_SSSR_NO_VK)
if(FFX_SSSR_VK)
target_compile_definitions(FFX_SSSR PRIVATE FFX_SSSR_VK)
endif()

target_sources(FFX_SSSR PRIVATE
Expand Down Expand Up @@ -124,3 +127,8 @@ if(MSVC)
VS_TOOL_OVERRIDE
"None")
endif()


if(FFX_SSSR_VK)
target_link_libraries (FFX_SSSR Vulkan::Vulkan)
endif()
77 changes: 52 additions & 25 deletions ffx-sssr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@
The **FidelityFX SSSR** library provides the means to render stochastic screen space reflections for the use in real-time applications.
A full sample running the library can be found on the [FidelityFX SSSR GitHub page](https://github.com/GPUOpen-Effects/FidelityFX-SSSR.git).

The library supports D3D12 with SM 6.0 or higher.
The library supports D3D12 and Vulkan.

## Prerequisits

The library relies on [dxcompiler.dll](https://github.com/microsoft/DirectXShaderCompiler) to generate DXIL/SPIRV from HLSL at runtime.
Use the version built for SPIRV from the [DirectXShaderCompiler GitHub repository](https://github.com/microsoft/DirectXShaderCompiler) or the one that comes with the [Vulkan SDK 1.2.141.2 (or later)](https://www.lunarg.com/vulkan-sdk/) if you are planning to use the Vulkan version of **FidelityFX SSSR**.

## Device Creation

Vulkan version only: The library relies on [VK_EXT_subgroup_size_control](https://www.khronos.org/registry/vulkan/specs/1.2-extensions/man/html/VK_EXT_subgroup_size_control.html) for optimal performance on RDNA. Make sure the extension is enabled at device creation by adding `VK_EXT_SUBGROUP_SIZE_CONTROL_EXTENSION_NAME` to `ppEnabledExtensionNames` if it is available.
Also enable `subgroupSizeControl` in `VkPhysicalDeviceSubgroupSizeControlFeaturesEXT` and chain it into the `pNext` chain of `VkDeviceCreateInfo` if the extension name is available. It is fine to run **FidelityFX SSSR** if the extension is not supported.

## Context - Initialization and Shutdown

Expand All @@ -14,13 +24,25 @@ First the header files must be included. This is `ffx_sssr.h` for Graphics API i
#include "ffx_sssr_d3d12.h"
```

Then a context must be created. This usually is done only once per device.
or `ffx_sssr_vk.h` for Vulkan specific definitions:

```C++
#include "ffx_sssr.h"
#include "ffx_sssr_vk.h"
```

Then a context must be created. This usually is done only once per device. Depending on the preferred API, populate either `FfxSssrD3D12CreateContextInfo` or `FfxSssrVkCreateContextInfo`.

```C++
FfxSssrD3D12CreateContextInfo d3d12ContextInfo = {};
d3d12ContextInfo.pDevice = myDevice;
d3d12ContextInfo.pUploadCommandList = myCommandList;

FfxSssrVkCreateContextInfo vkContextInfo = {};
vkContextInfo.device = myDeviceHandle;
vkContextInfo.physicalDevice = myPhysicalDeviceHandle;
vkContextInfo.uploadCommandBuffer = myUploadCommandBufferHandle;

FfxSssrLoggingCallbacks loggingCallbacks = {};
loggingCallbacks.pUserData = myUserData;
loggingCallbacks.pfnLogging = myLoggingFunction;
Expand All @@ -32,10 +54,11 @@ contextInfo.frameCountBeforeMemoryReuse = myMaxFrameCountInFlight;
contextInfo.uploadBufferSize = 8 * 1024 * 1024;
contextInfo.pLoggingCallbacks = &loggingCallbacks;
contextInfo.pD3D12CreateContextInfo = &d3d12ContextInfo;
contextInfo.pVkCreateContextInfo = &vkContextInfo;
```

The library requires certain input textures from the application to create a reflection view.
Thus, the context requires user specified unpack functions (HLSL) to access the individual attributes. It is recommended to keep these snippets as small as possible to guarantee good performance.
Thus, the context requires user specified unpack functions (HLSL SM 6.0) to access the individual attributes. It is recommended to keep these snippets as small as possible to achieve good performance.
The function headers have to match in order for the shaders to compile. The `FFX_SSSR_*_TEXTURE_FORMAT` macros hold the definitions provided in the `p*TextureFormat` members of `FfxSssrCreateContextInfo`. The snippets provided below shall serve as a starting point:

```C++
Expand All @@ -61,7 +84,7 @@ if (status != FFX_SSSR_STATUS_OK) {
}
```

Finally, submit the command list provided to the `pUploadCommandList` member of `FfxSssrCreateContextInfoD3D12` to the queue of your choice to upload the internal resources to the GPU.
Finally, submit the command list provided to the `pUploadCommandList` member of `FfxSssrCreateContextInfoD3D12` to the queue of your choice to upload the internal resources to the GPU. The same is required on Vulkan for the `uploadCommandBuffer` member of `FfxSssrVkCreateContextInfo`.

Once there is no need to render reflections anymore the context should be destroyed to free internal resources:

Expand All @@ -76,7 +99,7 @@ if (status != FFX_SSSR_STATUS_OK) {

Reflection views represent the abstraction for the first bounce of indirect light from reflective surfaces as seen from a given camera.

`FfxSssrReflectionView` resources can be created as such:
`FfxSssrReflectionView` resources can be created as such. Depending on the API fill either `FfxSssrD3D12CreateReflectionViewInfo` or `FfxSssrVkCreateReflectionViewInfo`:

```C++
FfxSssrD3D12CreateReflectionViewInfo d3d12ReflectionViewInfo = {};
Expand All @@ -92,11 +115,26 @@ d3d12ReflectionViewInfo.sceneSRV;
d3d12ReflectionViewInfo.environmentMapSRV;
d3d12ReflectionViewInfo.pEnvironmentMapSamplerDesc;

FfxSssrVkCreateReflectionViewInfo vkReflectionViewInfo = {};
vkReflectionViewInfo.depthBufferHierarchySRV;
vkReflectionViewInfo.motionBufferSRV;
vkReflectionViewInfo.normalBufferSRV;
vkReflectionViewInfo.roughnessBufferSRV;
vkReflectionViewInfo.normalHistoryBufferSRV;
vkReflectionViewInfo.roughnessHistoryBufferSRV;
vkReflectionViewInfo.reflectionViewUAV;
vkReflectionViewInfo.sceneFormat;
vkReflectionViewInfo.sceneSRV;
vkReflectionViewInfo.environmentMapSRV;
vkReflectionViewInfo.environmentMapSampler;
vkReflectionViewInfo.uploadCommandBuffer;

FfxSssrCreateReflectionViewInfo reflectionViewInfo = {};
reflectionViewInfo.flags = FFX_SSSR_CREATE_REFLECTION_VIEW_FLAG_ENABLE_PERFORMANCE_COUNTERS | FFX_SSSR_CREATE_REFLECTION_VIEW_FLAG_PING_PONG_NORMAL_BUFFERS | FFX_SSSR_CREATE_REFLECTION_VIEW_FLAG_PING_PONG_ROUGHNESS_BUFFERS;
reflectionViewInfo.outputWidth = width;
reflectionViewInfo.outputHeight = height;
reflectionViewInfo.pD3D12CreateReflectionViewInfo = &d3d12ReflectionViewInfo;
reflectionViewInfo.pVkCreateReflectionViewInfo = &vkReflectionViewInfo;

FfxSssrReflectionView myReflectionView;
FfxSssrStatus status = ffxSssrCreateReflectionView(myContext, &reflectionViewInfo, &myReflectionView);
Expand All @@ -105,7 +143,7 @@ if (status != FFX_SSSR_STATUS_OK) {
}
```

All SRVs and UAVs must be allocated from a CPU accessible descriptor heap as they are copied into the descriptor tables of the library. `FFX_SSSR_CREATE_REFLECTION_VIEW_FLAG_ENABLE_PERFORMANCE_COUNTERS` can be used if the application intends to query for timings later. The `FFX_SSSR_CREATE_REFLECTION_VIEW_FLAG_PING_PONG_*` flags should be set if the normal or roughness surfaces are written in an alternating fashion. Don't set the flags if the surfaces are copied each frame.
On D3D12 all SRVs and UAVs must be allocated from a CPU accessible descriptor heap as they are copied into the descriptor tables of the library. `FFX_SSSR_CREATE_REFLECTION_VIEW_FLAG_ENABLE_PERFORMANCE_COUNTERS` can be used if the application intends to query for timings later. The `FFX_SSSR_CREATE_REFLECTION_VIEW_FLAG_PING_PONG_*` flags should be set if the normal or roughness surfaces are written in an alternating fashion. Don't set the flags if the surfaces are copied each frame.

The reflection view depends on the screen size. It is recommended to destroy the reflection view on resize and create a new one:

Expand All @@ -127,12 +165,15 @@ if (status != FFX_SSSR_STATUS_OK) {

## Reflection View - Resolve

Calling `ffxSssrEncodeResolveReflectionView` dispatches the actual shaders that perform the hierarchical tracing through the depth buffer and optionally also dispatches the denoising passes if the `FFX_SSSR_RESOLVE_REFLECTION_VIEW_FLAG_DENOISE` flag is set:
Calling `ffxSssrEncodeResolveReflectionView` dispatches the actual shaders that perform the hierarchical tracing through the depth buffer and optionally also dispatches the denoising passes if the `FFX_SSSR_RESOLVE_REFLECTION_VIEW_FLAG_DENOISE` flag is set. Depending on the API populate either `FfxSssrD3D12CommandEncodeInfo` or `FfxSssrVkCommandEncodeInfo`:

```C++
FfxSssrD3D12CommandEncodeInfo d3d12EncodeInfo = {};
d3d12EncodeInfo.pCommandList = myCommandList;

FfxSssrVkCommandEncodeInfo vkEncodeInfo = {};
vkEncodeInfo.commandBuffer = myCommandBufferHandle;

FfxSssrResolveReflectionViewInfo resolveInfo = {};
resolveInfo.flags = FFX_SSSR_RESOLVE_REFLECTION_VIEW_FLAG_DENOISE | FFX_SSSR_RESOLVE_REFLECTION_VIEW_FLAG_ENABLE_VARIANCE_GUIDED_TRACING;
resolveInfo.temporalStabilityScale = 0.99f;
Expand All @@ -141,9 +182,9 @@ resolveInfo.mostDetailedDepthHierarchyMipLevel = 1;
resolveInfo.depthBufferThickness = 0.015f;
resolveInfo.minTraversalOccupancy = 4;
resolveInfo.samplesPerQuad = FFX_SSSR_RAY_SAMPLES_PER_QUAD_1;
resolveInfo.eawPassCount = FFX_SSSR_EAW_PASS_COUNT_1;
resolveInfo.roughnessThreshold = 0.2f;
resolveInfo.pD3D12CommandEncodeInfo = &d3d12EncodeInfo;
resolveInfo.pVkCommandEncodeInfo = &vkEncodeInfo;
FfxSssrStatus status = ffxSssrEncodeResolveReflectionView(myContext, myReflectionView, &resolveInfo);
if (status != FFX_SSSR_STATUS_OK) {
// Error handling
Expand All @@ -156,8 +197,7 @@ if (status != FFX_SSSR_STATUS_OK) {
* `resolveInfo.mostDetailedDepthHierarchyMipLevel` limits the most detailed mipmap for depth buffer lookups when tracing non-mirror reflection rays.
* `resolveInfo.depthBufferThickness` configures the accepted hit distance behind the depth buffer in view space.
* `resolveInfo.minTraversalOccupancy` limits the number of threads in the depth traversal loop. If less than that number of threads remain present they exit the intersection loop early even if they did not find a depth buffer intersection yet. This only affects non-mirror reflection rays.
* `resolveInfo.samplesPerQuad` serves as a starting point how many rays are spawned in glossy regions. The only supported values are `FFX_SSSR_RAY_SAMPLES_PER_QUAD_1`, `FFX_SSSR_RAY_SAMPLES_PER_QUAD_2` and `FFX_SSSR_RAY_SAMPLES_PER_QUAD_4`. The use of `FFX_SSSR_RESOLVE_REFLECTION_VIEW_FLAG_ENABLE_VARIANCE_GUIDED_TRACING` dynamically bumps this up to `4` to enforce convergence on a per pixel basis.
* `resolveInfo.eawPassCount` configures the number of Edge-aware á-trous wavelet passes. The only supported values are `FFX_SSSR_EAW_PASS_COUNT_1` and `FFX_SSSR_EAW_PASS_COUNT_3`.
* `resolveInfo.samplesPerQuad` serves as a starting point how many rays are spawned in glossy regions. The only supported values are `FFX_SSSR_RAY_SAMPLES_PER_QUAD_1`, `FFX_SSSR_RAY_SAMPLES_PER_QUAD_2` and `FFX_SSSR_RAY_SAMPLES_PER_QUAD_4`. The use of `FFX_SSSR_RESOLVE_REFLECTION_VIEW_FLAG_ENABLE_VARIANCE_GUIDED_TRACING` dynamically bumps this up to a maximum of `4` to enforce convergence on a per pixel basis.
* `resolveInfo.roughnessThreshold` determines the roughness value below which reflection rays are spawned. Any roughness values higher are considered not reflective and the reflection view will contain `(0, 0, 0, 0)`.

When resolving a reflection view, the following operations take place:
Expand All @@ -167,15 +207,7 @@ When resolving a reflection view, the following operations take place:
- The resulting radiance information is denoised using spatio-temporal filtering.
- The shading values are written out to the output buffer supplied at creation time.

Note that the application is responsible for issuing the UAV barrier to synchronize the writes to the output buffer:

```
D3D12_RESOURCE_BARRIER resourceBarrier = {};
resourceBarrier.Type = D3D12_RESOURCE_BARRIER_TYPE_UAV;
resourceBarrier.UAV.pResource = myOutputBuffer;
myCommandList->ResourceBarrier(1, &resourceBarrier);
```
Note that the application is responsible for issuing the required barrier to synchronize the writes to the output buffer.

## Reflection View - Performance Profiling

Expand Down Expand Up @@ -219,12 +251,7 @@ if (status != FFX_SSSR_STATUS_OK) {
}
```

The retrieved times are expressed in numbers of GPU ticks and can be converted to seconds by querying the timestamp frequency of the queue used to execute the encoded command list:

```C++
uint64_t gpuTicksPerSecond;
myCommandQueue->GetTimestampFrequency(&gpuTicksPerSecond);
```
The retrieved times are expressed in GPU ticks and can be converted using the timestamp frequency of the queue used to execute the encoded command list on D3D12 (`GetTimestampFrequency`). On Vulkan the `timestampPeriod` member of `VkPhysicalDeviceLimits` can be used to convert the times from GPU ticks to nanoseconds.

## Frame management

Expand Down
18 changes: 7 additions & 11 deletions ffx-sssr/inc/ffx_sssr.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ THE SOFTWARE.

#define FFX_SSSR_MAKE_VERSION(a,b,c) (((a) << 22) | ((b) << 12) | (c))

#define FFX_SSSR_API_VERSION FFX_SSSR_MAKE_VERSION(1, 0, 0)
#define FFX_SSSR_API_VERSION FFX_SSSR_MAKE_VERSION(1, 1, 0)

#define FFX_SSSR_STATIC_LIBRARY

Expand Down Expand Up @@ -60,6 +60,9 @@ FFX_SSSR_DEFINE_HANDLE(FfxSssrReflectionView)
typedef struct FfxSssrD3D12CreateContextInfo FfxSssrD3D12CreateContextInfo;
typedef struct FfxSssrD3D12CreateReflectionViewInfo FfxSssrD3D12CreateReflectionViewInfo;
typedef struct FfxSssrD3D12CommandEncodeInfo FfxSssrD3D12CommandEncodeInfo;
typedef struct FfxSssrVkCreateContextInfo FfxSssrVkCreateContextInfo;
typedef struct FfxSssrVkCreateReflectionViewInfo FfxSssrVkCreateReflectionViewInfo;
typedef struct FfxSssrVkCommandEncodeInfo FfxSssrVkCommandEncodeInfo;

/**
The return codes for the API functions.
Expand All @@ -85,15 +88,6 @@ enum FfxSssrRaySamplesPerQuad
FFX_SSSR_RAY_SAMPLES_PER_QUAD_4
};

/**
The number of passes for Edge-aware �-trous wavelet filtering.
*/
enum FfxSssrEawPassCount
{
FFX_SSSR_EAW_PASS_COUNT_1,
FFX_SSSR_EAW_PASS_COUNT_3
};

/**
The available flags for creating a reflection view.
*/
Expand Down Expand Up @@ -154,6 +148,7 @@ typedef struct FfxSssrCreateContextInfo
union
{
const FfxSssrD3D12CreateContextInfo* pD3D12CreateContextInfo;
const FfxSssrVkCreateContextInfo* pVkCreateContextInfo;
};
} FfxSssrCreateContextInfo;

Expand All @@ -168,6 +163,7 @@ typedef struct FfxSssrCreateReflectionViewInfo
union
{
const FfxSssrD3D12CreateReflectionViewInfo* pD3D12CreateReflectionViewInfo;
const FfxSssrVkCreateReflectionViewInfo* pVkCreateReflectionViewInfo;
};
} FfxSssrCreateReflectionViewInfo;

Expand All @@ -183,11 +179,11 @@ typedef struct FfxSssrResolveReflectionViewInfo
uint32_t minTraversalOccupancy; ///< Minimum number of threads per wave to keep the intersection kernel running.
float depthBufferThickness; ///< Unit in view space. Any intersections further behind the depth buffer are rejected as invalid hits.
FfxSssrRaySamplesPerQuad samplesPerQuad; ///< Number of samples per 4 pixels in denoised regions. Mirror reflections are not affected by this.
FfxSssrEawPassCount eawPassCount; ///< Number of EAW passes.
float roughnessThreshold; ///< Shoot reflection rays for roughness values that are lower than this threshold.
union
{
const FfxSssrD3D12CommandEncodeInfo* pD3D12CommandEncodeInfo; ///< A pointer to the Direct3D12 command encoding parameters.
const FfxSssrVkCommandEncodeInfo* pVkCommandEncodeInfo; ///< A pointer to the Vulkan command encoding parameters.
};
} FfxSssrResolveReflectionViewInfo;

Expand Down
2 changes: 1 addition & 1 deletion ffx-sssr/inc/ffx_sssr_d3d12.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ typedef struct FfxSssrD3D12CreateReflectionViewInfo
D3D12_CPU_DESCRIPTOR_HANDLE normalHistoryBufferSRV; ///< Last frames normalBufferSRV. The descriptor handle must be allocated on a heap allowing CPU reads.
D3D12_CPU_DESCRIPTOR_HANDLE roughnessHistoryBufferSRV; ///< Last frames roughnessHistoryBufferSRV. The descriptor handle must be allocated on a heap allowing CPU reads.
D3D12_CPU_DESCRIPTOR_HANDLE environmentMapSRV; ///< Environment cube map serving as a fallback for ray misses. The descriptor handle must be allocated on a heap allowing CPU reads.
const D3D12_STATIC_SAMPLER_DESC * pEnvironmentMapSamplerDesc; ///< Description for the environment map sampler.
const D3D12_SAMPLER_DESC * pEnvironmentMapSamplerDesc; ///< Description for the environment map sampler.
D3D12_CPU_DESCRIPTOR_HANDLE reflectionViewUAV; ///< The fully resolved reflection view. Make sure to synchronize for UAV writes. The descriptor handle must be allocated on a heap allowing CPU reads.
} FfxSssrD3D12CreateReflectionViewInfo;

Expand Down
Loading

0 comments on commit cc1bb86

Please sign in to comment.