This is part four of a series of tutorials on AMD FreeSync™ Premium Pro Technology (FreeSync HDR hereafter!)

  • Part 1 covered color spaces to get you used to common terminology and some of the problems that FreeSync HDR solves.
  • Part 2 covered tone mapping.
  • Part 3 covered gamut mapping.

 

Now that we have gone over FreeSync HDR, what it provides, and all the post processing passes that need to be modified to take advantage of FreeSync HDR, we can look at how to enable FreeSync HDR with all next gen graphics APIs.

We will be using a sample to demonstrate how to achieve this. You can find the sample used in this tutorial on GitHub:

Enabling FreeSync HDR on the Monitor

To enable FreeSync HDR on your monitor, you need to do the following:

  • Update the monitor’s firmware to the latest version
  • Go to AMD Radeon Settings -> Display -> AMD FreeSync and ensure it is enabled – there should be a white circle in the FreeSync box
  • Lastly, you need to enable FreeSync HDR in your monitor’s settings. This is usually accessed via the monitor’s menu button with the display settings option, and enabling FreeSync Premium (or HDR).

Once the above is completed, you should have FreeSync HDR correctly enabled on your monitor.

FreeSync HDR and DirectX® 12

We will demonstrate how to integrate FreeSync HDR into a game engine by following the FreeSync HDR DX12 code sample we released, which can be found here on GitHub. The FreeSync HDR API in DX12 is exposed via AGS (AMD GPU Services library) which can be found on here on GitHub.

Firstly, the app must initialize AGS as seen in Device.cpp :

// Create AGS context
AGSReturnCode result = agsInit(&m_agsContext, NULL, &m_agsGPUInfo);

m_agsContext  stores the global data required by AGS, while m_agsGPUInfo is a pointer to an array of meta data for each connected GPU on the computer. Each instance in the m_agsGPUInfo array stores a pointer to meta data for all connected monitors to that GPU. This means that the app needs to choose the GPU it wants to display with, find the monitor we are rendering to, and check if it supports FreeSync HDR.

This can be seen in Freesync2.cpp :

bool fs2EnumerateDisplayModes(std::vector *pModes)
{
    // ...

    const int numDevices = m_pGPUInfo->numDevices;
    const AGSDeviceInfo* devices = m_pGPUInfo->devices;
    int numDisplays = devices[0].numDisplays;

    // Find which display the app window is rendering to and store it in bestDisplayIndex
    // and check for FS2 support
    if (devices[0].displays[bestDisplayIndex].displayFlags & AGSDisplayFlags::AGS_DISPLAYFLAG_FREESYNC_2)
    {
        pModes->push_back(DISPLAYMODE_FS2_Gamma22);
        pModes->push_back(DISPLAYMODE_FS2_SCRGB);
    }

    // ...
}

In the sample above, we assume that the first GPU is the primary GPU, but a real game should have logic to allow the player to choose which GPU they want to render with. The data stored in the displays array is the queried monitor data which we talked about in the previous blog posts. It contains the monitor’s native color space primaries, min/max luminance, etc. You can see the struct’s full contents in amd_ags.h .

We find the display our app is presenting to, and then check if it has the FreeSync HDR display flag set.

After confirming the monitor supports FreeSync HDR, the swap chain is created using standard DX12 code. It is important to initialize its color format to the required FreeSync HDR color format that we want to support. This would be DXGI_FORMAT_R10G10B10A2_UNORM for DisplayNative or Gamma22 and DXGI_FORMAT_R16G16B16A16_FLOAT for scRGB. The app also needs to setup its window to get exclusive full screen mode via the Windows® API. This is achieved by creating a window with borderless full screen flags and making its size and resolution match that of the monitor.

Next, we need to populate an AGSDisplaySettings struct that we send to the driver to configure the FreeSync HDR mode we want to render as seen in Freesync2.cpp :

s_AGSDisplayInfo = m_pGPUInfo->devices[0].displays[s_displayIndex];

switch (displayMode)
{
    case DISPLAYMODE_FS2_Gamma22:
    {
         agsDisplaySettings.mode = AGSDisplaySettings::Mode::Mode_Freesync2_Gamma22;
         agsDisplaySettings.flags = disableLocalDimming;
         break;
    }

    case DISPLAYMODE_FS2_SCRGB:
    {
         agsDisplaySettings.mode = AGSDisplaySettings::Mode::Mode_Freesync2_scRGB;
         agsDisplaySettings.flags = disableLocalDimming;
         break;
    }

    // ...
}

agsDisplaySettings.chromaticityRedX = s_AGSDisplayInfo.chromaticityRedX;
agsDisplaySettings.chromaticityRedY = s_AGSDisplayInfo.chromaticityRedY;
agsDisplaySettings.chromaticityGreenX = s_AGSDisplayInfo.chromaticityGreenX;
agsDisplaySettings.chromaticityGreenY = s_AGSDisplayInfo.chromaticityGreenY;
agsDisplaySettings.chromaticityBlueX = s_AGSDisplayInfo.chromaticityBlueX;
agsDisplaySettings.chromaticityBlueY = s_AGSDisplayInfo.chromaticityBlueY;
agsDisplaySettings.chromaticityWhitePointX = s_AGSDisplayInfo.chromaticityWhitePointX;
agsDisplaySettings.chromaticityWhitePointY = s_AGSDisplayInfo.chromaticityWhitePointY;
agsDisplaySettings.minLuminance = s_AGSDisplayInfo.minLuminance;

if (disableLocalDimming)
    s_AGSDisplayInfo.maxLuminance = s_AGSDisplayInfo.avgLuminance;

agsDisplaySettings.maxLuminance = s_AGSDisplayInfo.maxLuminance;

Lastly, we call the function to set the display mode:

AGSReturnCode rc = agsSetDisplayMode(s_pAGSContext, 0, s_displayIndex, &agsDisplaySettings);

assert(rc == AGS_SUCCESS);

In the above code, we populate an AGSDisplaySettings struct and send it off to the driver via agsSetDisplayMode . You will notice that most values are copied from m_AGSDisplayInfo (the monitor’s meta data) but there are a few values that we need to calculate. Depending on whether our game enables local dimming, the values of maxLuminance , and flags need to be set differently.

When providing the monitor’s max luminance to AGS and the game’s tone mapper, we need to provide the value of m_AGSDisplayInfo.maxLuminance if local dimming is enabled, otherwise we provide the value of m_AGSDisplayInfo.avgLuminance . As explained in the post about tone mapping, it’s up to the game if it wants to enable or disable local dimming. FreeSync HDR provides the option to do so, and the correct max luminance value for both the cases. The game should also provide the chromaticity values of the display to the gamut mapper so that the mapper knows what the target gamut is.

After setting the display settings, our game is configured to render in FreeSync HDR mode for DX12! In a real game, the player usually has options to change which monitor the game is being rendered to, or whether it is in FreeSync HDR mode. When such changes occur, the swap chain will need to be recreated and the display settings would need to be set again. The game will also need to refeed the monitor’s meta data, but initializing AGS and getting the GPU meta data only happens once during the game’s initialization.

The final thing left to do is provide the tone mapper with monitor luminance data, provide the gamut mapper with the monitor’s chromaticity values, and encode the final frame buffer in the correct FreeSync HDR format. The tone and gamut mapper are game specific, so we cannot go over how to integrate them as it is different with every engine. However, we will go over how to encode colors in the final frame buffer in the HLSL Code for FreeSync HDR Display Formats section.

FreeSync HDR and Vulkan

We will demonstrate how to integrate FreeSync HDR into a game engine by following the FreeSync HDR Vulkan code sample we released, which can be found on GitHub here. The FreeSync HDR API in Vulkan is exposed via extensions that are queried from within Vulkan.

After we setup Vulkan and are beginning to create our instance, we must query to check if the following instance extension in file ExtFreeSync2.cpp is supported:

std::vector required_extension_names = {
    VK_KHR_GET_SURFACE_CAPABILITIES_2_EXTENSION_NAME
};

This extension adds instance level support for querying HDR metadata from monitors.

Afterwards, we create our instance using standard Vulkan code with the requested extension, and we query its respective functions.

We then create a surface and device as seen in Device.cpp with the following extensions referenced from  ExtFreeSync2.cpp :

std::vector required_extension_names = {
    VK_EXT_HDR_METADATA_EXTENSION_NAME,
    VK_AMD_DISPLAY_NATIVE_HDR_EXTENSION_NAME,
    VK_EXT_FULL_SCREEN_EXCLUSIVE_EXTENSION_NAME
};

These FreeSync HDR extensions are as follows:

  • VK_EXT_HDR_METADATA_EXTENSION_NAME – allows us to query and set HDR display formats for our swap chain.
  • VK_AMD_DISPLAY_NATIVE_HDR_EXTENSION_NAME – allows us to query and set FreeSync HDR display modes for our surface.
  • VK_EXT_FULL_SCREEN_EXCLUSIVE_EXTENSION_NAME – allows our app to display in full screen exclusive which is required for FreeSync HDR.

It is important to note that some of these extensions are new, and you must use the newest AMD drivers/Vulkan SDK to have access to them.

Once we verify that our device has the above extensions, we populate all our FreeSync HDR structs that are required to enable FreeSync HDR as seen in FreeSync2.cpp :

// VkSurfaceFullScreenExclusiveWin32InfoEXT
s_SurfaceFullScreenExclusiveWin32InfoEXT.sType = VK_STRUCTURE_TYPE_SURFACE_FULL_SCREEN_EXCLUSIVE_WIN32_INFO_EXT;
s_SurfaceFullScreenExclusiveWin32InfoEXT.pNext = nullptr;
s_SurfaceFullScreenExclusiveWin32InfoEXT.hmonitor = MonitorFromWindow(hWnd, MONITOR_DEFAULTTOPRIMARY);

// VkSurfaceFullScreenExclusiveInfoEXT
s_SurfaceFullScreenExclusiveInfoEXT.sType = VK_STRUCTURE_TYPE_SURFACE_FULL_SCREEN_EXCLUSIVE_INFO_EXT;
s_SurfaceFullScreenExclusiveInfoEXT.pNext = &s_SurfaceFullScreenExclusiveWin32InfoEXT;
s_SurfaceFullScreenExclusiveInfoEXT.fullScreenExclusive = VK_FULL_SCREEN_EXCLUSIVE_APPLICATION_CONTROLLED_EXT;

// VkPhysicalDeviceSurfaceInfo2KHR
s_PhysicalDeviceSurfaceInfo2KHR.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SURFACE_INFO_2_KHR;
s_PhysicalDeviceSurfaceInfo2KHR.pNext = &s_SurfaceFullScreenExclusiveInfoEXT;
s_PhysicalDeviceSurfaceInfo2KHR.surface = surface;

// VkHdrMetadataEXT
s_HdrMetadataEXT.sType = VK_STRUCTURE_TYPE_HDR_METADATA_EXT;
s_HdrMetadataEXT.pNext = nullptr;

// VkDisplayNativeHdrSurfaceCapabilitiesAMD
s_DisplayNativeHdrSurfaceCapabilitiesAMD.sType = VK_STRUCTURE_TYPE_DISPLAY_NATIVE_HDR_SURFACE_CAPABILITIES_AMD;
s_DisplayNativeHdrSurfaceCapabilitiesAMD.pNext = &s_HdrMetadataEXT;

// VkSurfaceCapabilities2KHR
s_SurfaceCapabilities2KHR.sType = VK_STRUCTURE_TYPE_SURFACE_CAPABILITIES_2_KHR;
s_SurfaceCapabilities2KHR.pNext = &s_DisplayNativeHdrSurfaceCapabilitiesAMD;

VkResult res = g_vkGetPhysicalDeviceSurfaceCapabilities2KHR(s_physicalDevice, 
                                                            &s_PhysicalDeviceSurfaceInfo2KHR, 
                                                            &s_SurfaceCapabilities2KHR);
assert(res == VK_SUCCESS);
// VkSwapchainDisplayNativeHdrCreateInfoAMD
s_SwapchainDisplayNativeHdrCreateInfoAMD.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_DISPLAY_NATIVE_HDR_CREATE_INFO_AMD;
s_SwapchainDisplayNativeHdrCreateInfoAMD.pNext = &s_SurfaceFullScreenExclusiveInfoEXT;
s_SwapchainDisplayNativeHdrCreateInfoAMD.localDimmingEnable = 
                                                    s_DisplayNativeHdrSurfaceCapabilitiesAMD.localDimmingSupport;

In vkGetPhysicalDeviceSurfaceCapabilities2KHR , we need to set fullscreenExclusive to VK_FULL_SCREEN_EXCLUSIVE_APPLICATION_CONTROLLED_EXT .

In VkSurfaceFullScreenExclusiveWin32InfoEXT, we need to set hMonitor to the monitor we would like to display on. In the sample, we always choose the primary monitor but in a real game, it should be app driven.

We then call vkGetPhysicalDeviceSurfaceCapabilities2KHR which will populate s_SurfaceCapabilities2KHR , s_DisplayNativeHdrSurfaceCapabilitiesAMD and s_HdrMetadataEXT . s_HdrMetadataEXT contains the monitor’s native color space primaries, min/max luminance, etc – the queried monitor data which we talked about in the previous blog posts. VkDisplayNativeHdrSurfaceCapabilitiesAMD stores whether the monitor supports local dimming.

Finally, in VkSwapchainDisplayNativeHdrCreateInfoAMD , we need to set localDimmingEnable to true if our monitor supports local dimming, and false otherwise. This will be used later during swap chain creation.

Now that we have all our FreeSync HDR structs filled out, we need to check if our surface supports FreeSync HDR as is seen in FreeSync2.cpp :

bool fs2EnumerateDisplayModes(std::vector *pModes)
{
    //...

    // Get the list of formats
    uint32_t formatCount;
    VkResult res = g_vkGetPhysicalDeviceSurfaceFormats2KHR(s_physicalDevice, 
                                                           &s_PhysicalDeviceSurfaceInfo2KHR, 
                                                           &formatCount,  
                                                           NULL);
    assert(res == VK_SUCCESS);
    if (res != VK_SUCCESS)
        return false;

    std::vector surfFormats(formatCount);
    for (UINT i = 0; i < formatCount; ++i)
    {
        surfFormats[i].sType = VK_STRUCTURE_TYPE_SURFACE_FORMAT_2_KHR;
    }
    res = g_vkGetPhysicalDeviceSurfaceFormats2KHR(s_physicalDevice, 
                                                  &s_PhysicalDeviceSurfaceInfo2KHR, 
                                                  &formatCount, 
                                                  surfFormats.data());
    assert(res == VK_SUCCESS);
    if (res != VK_SUCCESS)
        return false;

    for (uint32_t i = 0; i < formatCount; i++)   
    {
        if (surfFormats[i].surfaceFormat.format == VK_FORMAT_A2R10G10B10_UNORM_PACK32 &&
            surfFormats[i].surfaceFormat.colorSpace == VK_COLOR_SPACE_DISPLAY_NATIVE_AMD)
        {
            pModes->push_back(DISPLAYMODE_FS2_Gamma22);
        }

        if (surfFormats[i].surfaceFormat.format == VK_FORMAT_R16G16B16A16_SFLOAT &&
            surfFormats[i].surfaceFormat.colorSpace == VK_COLOR_SPACE_DISPLAY_NATIVE_AMD)
        {
            pModes->push_back(DISPLAYMODE_FS2_SCRGB);
        }
    }

    //...
}

Once we have found the display format we were looking for, we create our window in exclusive full screen mode via the Windows® API. This is achieved by creating a window with borderless full screen flags and making its size and resolution match that of the monitor. Afterwards, we create our swap chain as seen in SwapChain.cpp using the below code:

// ...

VkSwapchainCreateInfoKHR swapchain_ci = {};

swapchain_ci.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
swapchain_ci.pNext = nullptr;

if (ExtFreeSync2AreAllExtensionsPresent())
{
    swapchain_ci.pNext = GetVkSwapchainDisplayNativeHdrCreateInfoAMD();
}

swapchain_ci.surface = surface;
swapchain_ci.imageFormat = m_surfaceFormat.format;
swapchain_ci.imageColorSpace = m_surfaceFormat.colorSpace;

// ...

res = vkCreateSwapchainKHR(device, &swapchain_ci, NULL, &m_swapChain);
assert(res == VK_SUCCESS);

The main differences between regular swap chain creation and FreeSync HDR swap chain creation are:

  • We need to use a FreeSync HDR surface format when we set the swap chains surface format.
  • We need to provide a VkSwapchainDisplayNativeHdrCreateInfoAMD struct to our pNext variable with the queried values from vkGetPhysicalDeviceSurfaceCapabilities2KHR . This will give our swap chain the ability to acquire exclusive full screen on our chosen monitor, and it will also set whether we can enable/disable local dimming on this swap chain.
  • It is important to note that in order to successfully acquire exclusive full screen, the window’s width, height and the surface extents of the swap chain need to match the monitor’s resolution.

The FreeSync HDR display mode that our swap chain uses is decided by the surface format and color space we pass in. We set these when we check if our surface supports our desired FreeSync HDR display mode.

The last thing left to do is to acquire full screen exclusive mode on our monitor, and set our swap chain’s color space to the monitor’s native color space.

vkAcquireFullScreenExclusiveModeEXT(device, swapchain);

vkSetHdrMetadataEXT(device, swpachainCount, swapChain, &s_HdrMetadataEXT);

We first call vkAcquireFullScreenExclusiveModeEXT to give our app full screen exclusive control.

We then call vkSetHdrMetadataEXT with the queried s_HdrMetadataEXT struct which will enable the FreeSync HDR color space on our swap chain and complete our setup process in Vulkan.

In a real game, the player usually has options to change which monitor the game is being rendered to, or whether it is in FreeSync HDR mode. When such changes occur, the swap chain will need to be recreated and we will have to check for display format support again as well as re-querying the monitor’s meta data if we change which monitor we are rendering to. The instance extensions do not need to be re-queried, neither do the device extensions unless we are changing the GPU we are running on.

To change the local dimming setting, we can do so using the below function: 

vkSetLocalDimmingAMD(m_swapChain, localDimmingEnable);

After local dimming is changed, we need to re-query the HDR meta data and we need to set the HDR meta data again so that we have the correct max luminance values for our monitor.

The final thing left to do is provide the tone mapper with monitor luminance data, provide the gamut mapper with the monitors chromaticity values, and encode the final frame buffer in the correct FreeSync HDR format using the queried VkHdrMetadataEXT struct. The tone and gamut mapper are game specific, so we cannot go over how to integrate them as it is different with every engine. However, we will go over how to encode colors in the final frame buffer in the HLSL Code for FreeSync HDR Display Formats section below.

Shader Code for FreeSync HDR Display Formats

In the first post, we went over the different requirements for frame buffer color encoding for each FreeSync HDR display format. Here, we will be showing the shader code that is run per pixel right before the frame buffer is presented on the screen ( ColorConversionPS.hlsl ). The shader receives as input a color for each pixel in the frame buffer that is encoded in Rec709, and outputs a color encoded in the selected FreeSync HDR display format.

switch (u_displayMode)
{
    // ...
    case 1:
    {
        // FS2_Gamma22
        // Convert to display native color space ie the value queried from AGS
        color.xyz = mul(u_contentToMonitorRecMatrix, color).xyz;

        // Apply gamma
        color.xyz = pow(color.xyz, 1.0f / 2.2f);
        break;
    }

    case 2:
    {
        // FS2_scRGB
        // Scale to maxdisplayLuminanace / 80
        // In this case luminanace value queried from AGS
        color.xyz = (color.xyz * (u_displayMaxLuminancePerNits - u_displayMinLuminancePerNits)) 
                                + u_displayMinLuminancePerNits;
        break;
    }
}

We set up a switch statement where we encode each color depending on our FreeSync HDR display mode:

  • For FS2_Gamma22 , we multiply the color by a matrix which converts it from Rec709 to the displays native color space. We next apply the inverse gamma curve, and we then send out the pixels to the display. If our input color was in Rec2020, we would build a matrix to convert Rec2020 to display native instead of Rec709 to display native.
  • For FS2_scRGB , we already have the color encoded in Rec709, so we multiply each channel by the dynamic range in nits / 80. If the color was stored in Rec2020 for example, we would have to convert it to Rec709 in this step.

To compute the matrix which converts a color from Rec709 to the monitor’s native color space, we use the following code as seen in ColorConversion.cpp :

XMFLOAT3X3 CalculateDisplayXYZToRGBMatrix(float xw, float yw, float xr, float yr, 
                                                                   float xg, float yg, float xb, float yb)
{
    // ref: http://www.brucelindbloom.com/index.html?Math.html

    float Xw = xw / yw;
    float Yw = 1;
    float Zw = (1 - xw - yw) / yw;

    float Xr = xr / yr;
    float Yr = 1;
    float Zr = (1 - xr - yr) / yr;

    float Xg = xg / yg;
    float Yg = 1;
    float Zg = (1 - xg - yg) / yg;

    float Xb = xb / yb;
    float Yb = 1;
    float Zb = (1 - xb - yb) / yb;

    XMFLOAT3X3 XRGB = XMFLOAT3X3(Xr, Xg, Xb, Yr, Yg, Yb, Zr, Zg, Zb);
    XMFLOAT3X3 XRGBInverse = Inv3X3Mat(XRGB);

    XMFLOAT3 referenceWhite = XMFLOAT3(Xw, Yw, Zw);
    XMFLOAT3 SRGB = MUL3X3MatWithVec3(XRGBInverse, referenceWhite);

    XMFLOAT3X3 displayRGBToXYZMatrix = XMFLOAT3X3(SRGB.x * Xr, SRGB.y * Xg, SRGB.z * Xb, SRGB.x * Yr, 
                                                  SRGB.y * Yg, SRGB.z * Yb, SRGB.x * Zr, SRGB.y * Zg, SRGB.z * Zb);

    return Inv3X3Mat(displayRGBToXYZMatrix);
}

// ...

XMFLOAT3X3 rec709RGBToXYZ = XMFLOAT3X3(0.4124564f, 0.3575761f, 0.1804375f,
                                       0.2126729f, 0.7151522f, 0.0721750f,
                                       0.0193339f, 0.1191920f, 0.9503041f);

XMFLOAT3X3 XYZToDisplayNative = CalculateDisplayXYZToRGBMatrix(0.312f, 0.329f,
                                    monitorDisplayPrimaryRed.x, monitorDisplayPrimaryRed.y,
                                    monitorDisplayPrimaryGreen.x, monitorDisplayPrimaryGreen.y,
                                    monitorDisplayPrimaryBlue.x, monitorDisplayPrimaryBlue.y);

XMFLOAT3X3 rec709ToDisplayNative = Mul3X3MatWith3X3Mat(XYZToDisplayNative, rec709RGBToXYZ);

In the above code, we create a matrix which transforms a color from Rec709 to XYZ, and we create a second matrix which transforms a color from XYZ to DisplayNative.

Next, we multiply both matrices to combine the linear transformations into one matrix and we send it off to the GPU to be used in the shader.

We use a helper function which generates the XYZ to RGB matrix given the primaries of the RGB color space; these values are queried from the FreeSync HDR monitor. The math for the helper function is sourced from http://www.brucelindbloom.com/index.html?Math.html.

It is important to note that in the above shader code, we are not gamut mapping any colors, we are simply encoding them in the color space required by our FreeSync HDR display format. In a real game, if we apply a gamut mapper, our colors should already be encoded in the monitor’s native color space after gamut mapping, so we can simply feed the data across in FreeSync2_Gamma22 .

After following all the above steps, you should now have FreeSync HDR integrated inside your game with support for your desired FreeSync HDR display format!

Conclusion

This brings us to the end of the FreeSync HDR blog post series. We have gone over what FreeSync HDR is, what it solves, how to integrate it into various post processing passes, and a code walkthrough for each explicit API.

We hope that this series has helped you better understand what FreeSync HDR is, and how to use it in your applications.

Read more

You can find example usage of FreeSync HDR as part of FidelityFX™ Luminance Preserving Mapper (LPM):

Read our other detailed tutorials on AMD FreeSync Premium Pro HDR to gain an in-depth understanding: