Skip to content

Writing custom shaders

Chuck Walbourn edited this page Nov 23, 2022 · 28 revisions
Getting Started

This lesson covers the basics of writing your own HLSL shaders and using them with DirectX Tool Kit, in particular to customize SpriteBatch.

Setup

First create a new project using the instructions from the previous lessons: Using DeviceResources and Adding the DirectX Tool Kit which we will use for this lesson.

Creating custom shaders with HLSL

The general approach is to author your own shaders in HLSL and compile them. For this lesson, we'll focus on writing a custom pixel shader and rely on the built-in vertex shader for SpriteBatch, but the same basic principles apply to all HLSL shaders: vertex shaders, pixel shaders, geometry shaders, hull shaders, domain shaders, and even compute shaders.

Setting up our test scene

Save the file sunset.jpg to your new project's folder. Using to the top menu and select Project / Add Existing Item.... Select "sunset.jpg" and hit "OK".

In the Game.h file, add the following variable to the bottom of the Game class's private declarations (right after the m_graphicsMemory variable you already added as part of setup):

DirectX::SimpleMath::Matrix m_world;
DirectX::SimpleMath::Matrix m_view;
DirectX::SimpleMath::Matrix m_projection;

std::unique_ptr<DirectX::CommonStates> m_states;
std::unique_ptr<DirectX::GeometricPrimitive> m_shape;
std::unique_ptr<DirectX::BasicEffect> m_effect;
std::unique_ptr<DirectX::SpriteBatch> m_spriteBatch;

Microsoft::WRL::ComPtr<ID3D12Resource> m_background;

std::unique_ptr<DirectX::DescriptorHeap> m_resourceDescriptors;

enum Descriptors
{
    SceneTex,
    Background,
    BlurTex1,
    BlurTex2,
    Count
};

RECT m_fullscreenRect;

In Game.cpp, update the constructor:

Game::Game() noexcept(false) :
    m_fullscreenRect{}
{
    m_deviceResources = std::make_unique<DX::DeviceResources>();
    m_deviceResources->RegisterDeviceNotify(this);
}

In Game.cpp, add to the TODO of CreateDeviceDependentResources after where you have created m_graphicsMemory:

m_states = std::make_unique<CommonStates>(device);
m_shape = GeometricPrimitive::CreateTorus();

m_resourceDescriptors = std::make_unique<DescriptorHeap>(device,
    Descriptors::Count);

ResourceUploadBatch resourceUpload(device);

resourceUpload.Begin();

m_shape->LoadStaticBuffers(device, resourceUpload);

RenderTargetState sceneState(m_deviceResources->GetBackBufferFormat(),
    m_deviceResources->GetDepthBufferFormat());

{
    EffectPipelineStateDescription pd(
        &GeometricPrimitive::VertexType::InputLayout,
        CommonStates::Opaque,
        CommonStates::DepthDefault,
        CommonStates::CullCounterClockwise,
        sceneState);

    m_effect = std::make_unique<BasicEffect>(device, EffectFlags::Lighting, pd);
    m_effect->EnableDefaultLighting();
}

{
    SpriteBatchPipelineStateDescription pd(sceneState);
    m_spriteBatch = std::make_unique<SpriteBatch>(device, resourceUpload, pd);
}

DX::ThrowIfFailed(CreateWICTextureFromFile(device, resourceUpload,
    L"sunset.jpg",
    m_background.ReleaseAndGetAddressOf()));

CreateShaderResourceView(device, m_background.Get(),
    m_resourceDescriptors->GetCpuHandle(Descriptors::Background));

auto uploadResourcesFinished = resourceUpload.End(
    m_deviceResources->GetCommandQueue());

uploadResourcesFinished.wait();

m_view = Matrix::CreateLookAt(Vector3(0.f, 3.f, -3.f),
    Vector3::Zero, Vector3::UnitY);

In Game.cpp, add to the TODO of CreateWindowSizeDependentResources:

auto size = m_deviceResources->GetOutputSize();
m_fullscreenRect = size;

auto vp = m_deviceResources->GetScreenViewport();
m_spriteBatch->SetViewport(vp);

m_projection = Matrix::CreatePerspectiveFieldOfView(XM_PIDIV4,
    float(size.right) / float(size.bottom), 0.01f, 100.f);

In Game.cpp, add to the TODO of OnDeviceLost where you added m_graphicsMemory.reset():

m_states.reset();
m_shape.reset();
m_effect.reset();
m_spriteBatch.reset();
m_background.Reset();
m_renderDescriptors.reset();

In Game.cpp, add to the TODO of Update:

auto totalTime = static_cast<float>(timer.GetTotalSeconds());

m_world = Matrix::CreateRotationZ(totalTime / 2.f)
    * Matrix::CreateRotationY(totalTime)
    * Matrix::CreateRotationX(totalTime * 2.f);

In Game.cpp, add to the TODO of Render:

ID3D12DescriptorHeap* heaps[] = { m_resourceDescriptors->Heap(), m_states->Heap() };
commandList->SetDescriptorHeaps(static_cast<UINT>(std::size(heaps)), heaps);

m_spriteBatch->Begin(commandList);
m_spriteBatch->Draw(
    m_resourceDescriptors->GetGpuHandle(Descriptors::Background),
    GetTextureSize(m_background.Get()),
    m_fullscreenRect);
m_spriteBatch->End();

m_effect->SetMatrices(m_world, m_view, m_projection);
m_effect->Apply(commandList);
m_shape->Draw(commandList);

In Game.cpp, modify Clear to remove the call to ClearRenderTargetView since we are drawing a full-screen sprite first which sets every pixel--we still need to clear the depth/stencil buffer of course:

commandList->OMSetRenderTargets(1, &rtvDescriptor, FALSE, &dsvDescriptor);
commandList->ClearDepthStencilView(dsvDescriptor,
    D3D12_CLEAR_FLAG_DEPTH, 1.0f, 0, 0, nullptr);

Build and run to see our initial scene.

Screenshot of Torus

Click here for troubleshooting advice

If you get a runtime exception, then you may have the "sunset.jpg" in the wrong folder, have modified the "Working Directory" in the "Debugging" configuration settings, or otherwise changed the expected paths at runtime of the application. You should set a break-point on CreateWICTextureFromFile and step into the code to find the exact problem.

Compiling and loading shaders

Save the files Bloom.hlsli, BloomCombine.hlsl, BloomExtract.hlsl, GaussianBlur.hlsl, SpriteVertexShader.hlsl, and ReadData.h to your new project's folder. Using to the top menu and select Project / Add Existing Item.... Select "Bloom.hlsli" and hit "OK". Repeat for "BloomCombine.hlsl", "BloomExtract.hlsl", "GaussianBlur.hlsl", "SpriteVertexShader.hlsl", and "ReadData.h".

View Properties on each of the .hlsl files ("BloomCombine.hlsl", "BloomExtract.hlsl", and "GaussianBlur.hlsl") and for "All Configurations" and "All Platforms", set the "Shader Type" to "Pixel Shader (/ps)" and select "OK". For "SpriteVertexShader.hlsl", set it to "Vertex Shader (/vs)".

HLSL Compiler Settings

In pch.h add after the other #include statements:

#include "ReadData.h"

In the Game.h file, add the following variables to the bottom of the Game class's private declarations:

std::unique_ptr<DirectX::SpriteBatch> m_bloomExtract;
std::unique_ptr<DirectX::SpriteBatch> m_bloomCombine;
std::unique_ptr<DirectX::SpriteBatch> m_gaussianBlur;
Microsoft::WRL::ComPtr<ID3D12RootSignature> m_rootSig;

DirectX::GraphicsResource m_blurParamsWidth;
DirectX::GraphicsResource m_blurParamsHeight;
DirectX::GraphicsResource m_bloomParams;

At the top of the Game.cpp file after the using namespace statements, add the following:

namespace
{
    struct VS_BLOOM_PARAMETERS
    {
        float bloomThreshold;
        float blurAmount;
        float bloomIntensity;
        float baseIntensity;
        float bloomSaturation;
        float baseSaturation;
        uint8_t na[8];
    };

    static_assert(!(sizeof(VS_BLOOM_PARAMETERS) % 16),
        "VS_BLOOM_PARAMETERS needs to be 16 bytes aligned");

    struct VS_BLUR_PARAMETERS
    {
        static constexpr size_t SAMPLE_COUNT = 15;

        XMFLOAT4 sampleOffsets[SAMPLE_COUNT];
        XMFLOAT4 sampleWeights[SAMPLE_COUNT];

        void SetBlurEffectParameters(float dx, float dy,
            const VS_BLOOM_PARAMETERS& params)
        {
            sampleWeights[0].x = ComputeGaussian(0, params.blurAmount);
            sampleOffsets[0].x = sampleOffsets[0].y = 0.f;

            float totalWeights = sampleWeights[0].x;

            // Add pairs of additional sample taps, positioned
            // along a line in both directions from the center.
            for (size_t i = 0; i < SAMPLE_COUNT / 2; i++)
            {
                // Store weights for the positive and negative taps.
                float weight = ComputeGaussian( float(i + 1.f), params.blurAmount);

                sampleWeights[i * 2 + 1].x = weight;
                sampleWeights[i * 2 + 2].x = weight;

                totalWeights += weight * 2;

                // To get the maximum amount of blurring from a limited number of
                // pixel shader samples, we take advantage of the bilinear filtering
                // hardware inside the texture fetch unit. If we position our texture
                // coordinates exactly halfway between two texels, the filtering unit
                // will average them for us, giving two samples for the price of one.
                // This allows us to step in units of two texels per sample, rather
                // than just one at a time. The 1.5 offset kicks things off by
                // positioning us nicely in between two texels.
                float sampleOffset = float(i) * 2.f + 1.5f;

                Vector2 delta = Vector2(dx, dy) * sampleOffset;

                // Store texture coordinate offsets for the positive and negative taps.
                sampleOffsets[i * 2 + 1].x = delta.x;
                sampleOffsets[i * 2 + 1].y = delta.y;
                sampleOffsets[i * 2 + 2].x = -delta.x;
                sampleOffsets[i * 2 + 2].y = -delta.y;
            }

            for (size_t i = 0; i < SAMPLE_COUNT; i++)
            {
                sampleWeights[i].x /= totalWeights;
            }
        }

    private:
        float ComputeGaussian(float n, float theta)
        {
            return (float)((1.0 / sqrtf(2 * XM_PI * theta))
                * expf(-(n * n) / (2 * theta * theta)));
        }
    };

    static_assert(!(sizeof(VS_BLUR_PARAMETERS) % 16),
        "VS_BLUR_PARAMETERS needs to be 16 bytes aligned");

    enum BloomPresets
    {
        Default = 0,
        Soft,
        Desaturated,
        Saturated,
        Blurry,
        Subtle,
        None
    };

    BloomPresets g_Bloom = Default;

    static const VS_BLOOM_PARAMETERS g_BloomPresets[] =
    {
        //Thresh  Blur Bloom  Base  BloomSat BaseSat
        { 0.25f,  4,   1.25f, 1,    1,       1 }, // Default
        { 0,      3,   1,     1,    1,       1 }, // Soft
        { 0.5f,   8,   2,     1,    0,       1 }, // Desaturated
        { 0.25f,  4,   2,     1,    2,       0 }, // Saturated
        { 0,      2,   1,     0.1f, 1,       1 }, // Blurry
        { 0.5f,   2,   1,     1,    1,       1 }, // Subtle
        { 0.25f,  4,   1.25f, 1,    1,       1 }, // None
    };
}

In Game.cpp, add to the TODO of CreateDeviceDependentResources after resourceUpload.Begin and before the resourceUpload.End:

{
    RenderTargetState rtState(m_deviceResources->GetBackBufferFormat(),
        DXGI_FORMAT_UNKNOWN);

    SpriteBatchPipelineStateDescription pd(rtState);

    auto vsBlob = DX::ReadData(L"SpriteVertexShader.cso");

    DX::ThrowIfFailed(
        device->CreateRootSignature(0, vsBlob.data(), vsBlob.size(),
            IID_PPV_ARGS(m_rootSig.ReleaseAndGetAddressOf())));        

    pd.customRootSignature = m_rootSig.Get();
    pd.customVertexShader = { vsBlob.data(), vsBlob.size() };

    auto blob = DX::ReadData(L"BloomExtract.cso");
    pd.customPixelShader = { blob.data(), blob.size() };
    m_bloomExtract = std::make_unique<SpriteBatch>(device, resourceUpload, pd);

    blob = DX::ReadData(L"BloomCombine.cso");
    pd.customPixelShader = { blob.data(), blob.size() };
    m_bloomCombine = std::make_unique<SpriteBatch>(device, resourceUpload, pd);

    blob = DX::ReadData(L"GaussianBlur.cso");
    pd.customPixelShader = { blob.data(), blob.size() };
    m_gaussianBlur = std::make_unique<SpriteBatch>(device, resourceUpload, pd);
}
Click here for Xbox development note

Note that due to the use of the 'monolithic' DirectX 12.X Runtime, you will need to use IID_GRAPHICS_PPV_ARGS instead of IID_PPV_ARGS for Xbox One XDK / Microsoft GDKX development. See DirectXHelpers.

In Game.cpp, add to the TODO of CreateWindowSizeDependentResources:

VS_BLUR_PARAMETERS blurData = {};
blurData.SetBlurEffectParameters(1.f / (float(size.right) / 2), 0,
    g_BloomPresets[g_Bloom]);
m_blurParamsWidth = m_graphicsMemory->AllocateConstant(blurData);

blurData.SetBlurEffectParameters(0, 1.f / (float(size.bottom) / 2),
    g_BloomPresets[g_Bloom]);
m_blurParamsHeight = m_graphicsMemory->AllocateConstant(blurData);

m_bloomParams = m_graphicsMemory->AllocateConstant(g_BloomPresets[g_Bloom]);
m_bloomCombine->SetViewport(vp);

auto halfvp = vp;
halfvp.Height /= 2.;
halfvp.Width /= 2.;
m_bloomExtract->SetViewport(halfvp);
m_gaussianBlur->SetViewport(halfvp);

In Game.cpp, add to the TODO of OnDeviceLost before m_graphicsMemory.reset():

m_blurParamsWidth.Reset();
m_blurParamsHeight.Reset();
m_bloomParams.Reset();

Also add to the TODO:

m_bloomExtract.reset();
m_bloomCombine.reset();
m_gaussianBlur.reset();
m_rootSig.Reset();

Build and run. The scene is unchanged, but we've loaded our new shaders.

Click here for troubleshooting advice

If you get a runtime exception, then the shaders are not getting built as expected by Visual Studio. The DX::ReadData helper looks in the same directory as the EXE for the compiled shader files (since they are built for each configuration Debug, Release, etc.). See if the files "BloomExtract.cso", "BloomCombine.cso", "GaussianBlur.cso", and "SpriteVertexShader.cso" are present in the directory where the project's EXE is built. If one or more of them is missing, check the properties on each of the HLSL files as above, and double-check the general settings for those files as well.

HLSL Compiler Settings

Technical note

Our vertex shader does exactly what the standard SpriteBatch vertex shader does, and in theory you could just not set pd.customVertexShader. It's, however, challenging to make sure your custom shader has the same Shader Model and root signature definition as the library. In fact, our "BloomCombine.hlsl" can't use the standard root signature because it has two textures. Therefore, we just use our own custom rootsig and vertex shader to match for all three of our PSOs.

Also, because we are customizing SpriteBatch, we must have t0 in the first root signature parameter, and b0 in the second. Otherwise the code in the library will mismatch with our custom root signature. This limitation doesn't apply when writing our own effects such as in our next lesson.

See PSOs, Shaders, and Signatures for more information on root signatures.

Implementing a post processing effect

Save the files RenderTexture.h and RenderTexture.cpp to your new project's folder. Using to the top menu and select Project / Add Existing Item.... Select "RenderTexture.h" and hit "OK". Repeat for "RenderTexture.cpp".

Add to the Game.h file to the #include section:

#include "RenderTexture.h"

In the Game.h file, add the following variables to the bottom of the Game class's private declarations:

std::unique_ptr<DirectX::DescriptorHeap> m_renderDescriptors;

std::unique_ptr<DX::RenderTexture> m_offscreenTexture;
std::unique_ptr<DX::RenderTexture> m_renderTarget1;
std::unique_ptr<DX::RenderTexture> m_renderTarget2;

enum RTDescriptors
{
    OffscreenRT,
    Blur1RT,
    Blur2RT,
    RTCount
};

RECT m_bloomRect;

Then add the following method to the Game class's private declarations:

void PostProcess(_In_ ID3D12GraphicsCommandList* commandList);

In Game.cpp, update the constructor:

Game::Game() noexcept(false) :
    m_fullscreenRect{},
    m_bloomRect{}
{
    m_deviceResources = std::make_unique<DX::DeviceResources>();
    m_deviceResources->RegisterDeviceNotify(this);

    const auto format = m_deviceResources->GetBackBufferFormat();
    m_offscreenTexture = std::make_unique<DX::RenderTexture>(format);
    m_renderTarget1 = std::make_unique<DX::RenderTexture>(format);
    m_renderTarget2 = std::make_unique<DX::RenderTexture>(format);
}

In Game.cpp, add to the TODO of CreateDeviceDependentResources:

m_renderDescriptors = std::make_unique<DescriptorHeap>(device,
    D3D12_DESCRIPTOR_HEAP_TYPE_RTV,
    D3D12_DESCRIPTOR_HEAP_FLAG_NONE,
    RTDescriptors::RTCount);

m_offscreenTexture->SetDevice(device,
    m_resourceDescriptors->GetCpuHandle(SceneTex),
    m_renderDescriptors->GetCpuHandle(OffscreenRT));

m_renderTarget1->SetDevice(device,
    m_resourceDescriptors->GetCpuHandle(BlurTex1),
    m_renderDescriptors->GetCpuHandle(Blur1RT));

m_renderTarget2->SetDevice(device,
    m_resourceDescriptors->GetCpuHandle(BlurTex2),
    m_renderDescriptors->GetCpuHandle(Blur2RT));

In Game.cpp, add to the TODO of CreateWindowSizeDependentResources:

m_offscreenTexture->SetWindow(size);

// Half-size blurring render targets
m_bloomRect = { 0, 0, size.right / 2, size.bottom / 2 };

m_renderTarget1->SetWindow(m_bloomRect);
m_renderTarget2->SetWindow(m_bloomRect);

In Game.cpp, add to the TODO of OnDeviceLost:

m_offscreenTexture->ReleaseDevice();
m_renderTarget1->ReleaseDevice();
m_renderTarget2->ReleaseDevice();
m_resourceDescriptors.reset();

In Game.cpp, add to Render at the TODO before any other statements:

m_offscreenTexture->BeginScene(commandList);

In Game.cpp, add to Render just before the call to Present:

m_offscreenTexture->EndScene(commandList);

PostProcess(commandList);

In Game.cpp, modify Clear to use m_offscreenTexture instead of the swap chain render target view:

auto rtvDescriptor = m_renderDescriptors->GetCpuHandle(OffscreenRT);

In Game.cpp, add the new method PostProcess

void Game::PostProcess(
     ID3D12GraphicsCommandList* commandList)
{
    if (g_Bloom == None)
    {
        auto renderTarget = m_deviceResources->GetRenderTarget();
        auto offscreenTarget = m_offscreenTexture->GetResource();

        ScopedBarrier barriers(commandList,
            {
                CD3DX12_RESOURCE_BARRIER::Transition(renderTarget,
                    D3D12_RESOURCE_STATE_PRESENT,
                    D3D12_RESOURCE_STATE_COPY_DEST, 0),
                CD3DX12_RESOURCE_BARRIER::Transition(offscreenTarget,
                    D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE,
                    D3D12_RESOURCE_STATE_COPY_SOURCE, 0)
            });

        // Pass-through test
        commandList->CopyResource(renderTarget, offscreenTarget);
    }
    else
    {
        enum RootParameterIndex
        {
            TextureSRV,
            ConstantBuffer,
            Texture2SRV,
            MyConstantBuffer,
        };

        // scene -> RT1 (downsample)
        m_renderTarget1->BeginScene(commandList);

        auto rtvDescriptor = m_renderDescriptors->GetCpuHandle(Blur1RT);
        commandList->OMSetRenderTargets(1, &rtvDescriptor, FALSE, nullptr);

        auto vp = m_deviceResources->GetScreenViewport();

        Viewport halfvp(vp);
        halfvp.height /= 2.;
        halfvp.width /= 2.;
        commandList->RSSetViewports(1, halfvp.Get12());

        m_bloomExtract->Begin(commandList, SpriteSortMode_Immediate);
        commandList->SetGraphicsRootConstantBufferView(RootParameterIndex::MyConstantBuffer,
            m_bloomParams.GpuAddress());
        auto sceneTex = m_resourceDescriptors->GetGpuHandle(SceneTex);
        m_bloomExtract->Draw(sceneTex,
            GetTextureSize(m_offscreenTexture->GetResource()),
            m_bloomRect);
        m_bloomExtract->End();

        m_renderTarget1->EndScene(commandList);

        // RT1 -> RT2 (blur horizontal)
        m_renderTarget2->BeginScene(commandList);

        rtvDescriptor = m_renderDescriptors->GetCpuHandle(Blur2RT);
        commandList->OMSetRenderTargets(1, &rtvDescriptor, FALSE, nullptr);

        m_gaussianBlur->Begin(commandList, SpriteSortMode_Immediate);
        commandList->SetGraphicsRootConstantBufferView(RootParameterIndex::MyConstantBuffer,
            m_blurParamsWidth.GpuAddress());
        auto blur1Tex = m_resourceDescriptors->GetGpuHandle(BlurTex1);
        m_gaussianBlur->Draw(blur1Tex,
            GetTextureSize(m_renderTarget1->GetResource()),
            m_bloomRect);
        m_gaussianBlur->End();

        m_renderTarget2->EndScene(commandList);

        // RT2 -> RT1 (blur vertical)
        m_renderTarget1->BeginScene(commandList);

        rtvDescriptor = m_renderDescriptors->GetCpuHandle(Blur1RT);
        commandList->OMSetRenderTargets(1, &rtvDescriptor, FALSE, nullptr);

        m_gaussianBlur->Begin(commandList, SpriteSortMode_Immediate);
        commandList->SetGraphicsRootConstantBufferView(RootParameterIndex::MyConstantBuffer,
            m_blurParamsHeight.GpuAddress());
        auto blur2Tex = m_resourceDescriptors->GetGpuHandle(BlurTex2);
        m_gaussianBlur->Draw(blur2Tex,
            GetTextureSize(m_renderTarget2->GetResource()),
            m_bloomRect);
        m_gaussianBlur->End();

        m_renderTarget1->EndScene(commandList);

        // RT1 + scene
        TransitionResource(commandList, m_deviceResources->GetRenderTarget(),
            D3D12_RESOURCE_STATE_PRESENT,
            D3D12_RESOURCE_STATE_RENDER_TARGET);

        rtvDescriptor = m_deviceResources->GetRenderTargetView();
        commandList->OMSetRenderTargets(1, &rtvDescriptor, FALSE, nullptr);

        commandList->RSSetViewports(1, &vp);

        m_bloomCombine->Begin(commandList, SpriteSortMode_Immediate);
        commandList->SetGraphicsRootConstantBufferView(RootParameterIndex::MyConstantBuffer,
            m_bloomParams.GpuAddress());
        commandList->SetGraphicsRootDescriptorTable(RootParameterIndex::Texture2SRV, blur1Tex);
        m_bloomCombine->Draw(sceneTex,
            GetTextureSize(m_offscreenTexture->GetResource()),
            m_fullscreenRect);
        m_bloomCombine->End();

        TransitionResource(commandList, m_deviceResources->GetRenderTarget(),
            D3D12_RESOURCE_STATE_RENDER_TARGET,
            D3D12_RESOURCE_STATE_PRESENT);
    }
}

Build and run to see the bloom in action:

Screenshot of Torus

Change the value in Game.cpp for g_Bloom to "Saturated" instead of "Default":

BloomPresets g_Bloom = Saturated;

Build and run to see a different set of bloom settings in action:

Screenshot of Torus

Change the value in Game.cpp for g_Bloom to "None" to render our original scene without bloom.

Technical note

We are relying on the RenderTexture helper to insert the resource barriers for transitioning it's resource between D3D12_RESOURCE_STATE_RENDER_TARGET and D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE for simplicity. If running this sample project in PIX, you will get warnings because we don't combine all our resource barriers into a single call.

CMake

For this tutorial, we make use of the built-in Visual Studio HLSL build rules which handles building our shaders automatically. If you are using CMake instead, then you need to build the shaders using a custom target.

# Build HLSL shaders
add_custom_target(shaders)

set(HLSL_SHADER_FILES BloomCombine.hlsl BloomExtract.hlsl GaussianBlur.hlsl)

set_source_files_properties(${HLSL_SHADER_FILES} PROPERTIES ShaderType "ps")

set(HLSL_SHADER_FILES ${HLSL_SHADER_FILES} SpriteVertexShader.hlsl)
set_source_files_properties(SpriteVertexShader.hlsl PROPERTIES ShaderType "vs")

foreach(FILE ${HLSL_SHADER_FILES})
  get_filename_component(FILE_WE ${FILE} NAME_WE)
  list(APPEND CSO_SHADER_FILES ${CMAKE_BINARY_DIR}/${FILE_WE}.cso)
  get_source_file_property(shadertype ${FILE} ShaderType)
  add_custom_command(TARGET shaders
                     COMMAND dxc.exe /nologo /Emain /T${shadertype}_6_0 $<IF:$<CONFIG:DEBUG>,/Od,/O3> /Zi /Fo ${CMAKE_BINARY_DIR}/${FILE_WE}.cso /Fd ${CMAKE_BINARY_DIR}/${FILE_WE}.pdb ${FILE}
                     MAIN_DEPENDENCY ${FILE}
                     COMMENT "HLSL ${FILE}"
                     WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
                     VERBATIM)
endforeach(FILE)

add_dependencies(${PROJECT_NAME} shaders)

add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
                  COMMAND ${CMAKE_COMMAND} -E copy ${CSO_SHADER_FILES} $<TARGET_FILE_DIR:${PROJECT_NAME}>
                  COMMAND_EXPAND_LISTS)

Technical notes

First the original scene is rendered to a hidden render target m_offscreenTexture as normal. The only change here was for Clear to use m_offscreenTexture's render target view rather than DeviceResources backbuffer render target view.

Our first past of post-processing is to render the original scene texture as a 'full-screen quad' onto our first half-sized render target using the custom shader in "BloomExtract.hlsl" into m_renderTarget1.

float4 main(float4 color : COLOR0, float2 texCoord : TEXCOORD0) : SV_Target0
{
    float4 c = Texture.Sample(TextureSampler, texCoord);
    return saturate((c - BloomThreshold) / (1 - BloomThreshold));
}

Screenshot of post-processed torus

We take the result of the extract & down-size and then blur it horizontally using "GausianBlur.hlsl" from m_renderTarget1 to m_renderTarget2.

float4 main(float4 color : COLOR0, float2 texCoord : TEXCOORD0) : SV_Target0
{
    float4 c = 0;

    // Combine a number of weighted image filter taps.
    for (int i = 0; i < SAMPLE_COUNT; i++)
    {
        c += Texture.Sample(TextureSampler, texCoord + SampleOffsets[i]) * SampleWeights[i];
    }

    return c;
}

Screenshot of post-processed torus

We take that result in m_renderTarget2 and then blur it vertically using the same shader--we are using a Gaussian blur which is a separable filter which allows us to do the filter in two simple render passes one for each dimension--back into m_renderTarget1.

Screenshot of post-processed torus

And finally we take the result of both blur passes in m_renderTarget1 and combine it with our original scene texture m_offscreenTexture using the "BloomCombine.hlsl" shader to get our final image into the presentation swapchain.

// Helper for modifying the saturation of a color.
float4 AdjustSaturation(float4 color, float saturation)
{
    // The constants 0.3, 0.59, and 0.11 are chosen because the
    // human eye is more sensitive to green light, and less to blue.
    float grey = dot(color.rgb, float3(0.3, 0.59, 0.11));

    return lerp(grey, color, saturation);
}

float4 main(float4 color : COLOR0, float2 texCoord : TEXCOORD0) : SV_Target0
{
    float4 base = BaseTexture.Sample(TextureSampler, texCoord);
    float4 bloom = BloomTexture.Sample(TextureSampler, texCoord);

    // Adjust color saturation and intensity.
    bloom = AdjustSaturation(bloom, BloomSaturation) * BloomIntensity;
    base = AdjustSaturation(base, BaseSaturation) * BaseIntensity;

    // Darken down the base image in areas where there is a lot of bloom,
    // to prevent things looking excessively burned-out.
    base *= (1 - saturate(bloom));

    // Combine the two images.
    return base + bloom;
}

We use half-size-in-each-dimension render targets for the blur because it is a quarter the memory/bandwidth to render, and because we are blurring the image significantly there's no need for the 'full' resolution. Other kinds of post-process effects may require more fidelity in the temporary buffers.

More to explore

Next lesson: Authoring an Effect

Further reading

DirectX Tool Kit docs SpriteBatch

Shader Model 6

Using dxc.exe and dxcompiler.dll
Compiling Shaders

Credits

I borrowed heavily from the XNA Game Studio Bloom Postprocess sample for this lesson.

For Use

  • Universal Windows Platform apps
  • Windows desktop apps
  • Windows 11
  • Windows 10
  • Xbox One
  • Xbox Series X|S

Architecture

  • x86
  • x64
  • ARM64

For Development

  • Visual Studio 2022
  • Visual Studio 2019 (16.11)
  • clang/LLVM v12 - v18
  • MinGW 12.2, 13.2
  • CMake 3.20

Related Projects

DirectX Tool Kit for DirectX 11

DirectXMesh

DirectXTex

DirectXMath

Tools

Test Suite

Model Viewer

Content Exporter

DxCapsViewer

See also

DirectX Landing Page

Clone this wiki locally