Skip to content

Authoring an Effect

Chuck Walbourn edited this page Sep 15, 2022 · 19 revisions
Getting Started

This lesson covers writing your own IEffect implementation, specifically a custom effect for rendering a skybox with a cubemap.

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.

Writing a skybox shader

A skybox is a shape like a sphere or a cube that surrounds the render scene giving the appearance of the distant sky, horizon, and/or surroundings. In our implementation we are going to be using a cubemap (i.e. 6-faced texture) to provide the texture information on the sky. For the geometry, we'll make use of GeometricPrimitive, and the shaders we are using for the SkyboxEffect are as follows:

SkyboxEffect_Common.hlsli

#define SkyboxRS \
"RootFlags ( ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT |" \
"            DENY_DOMAIN_SHADER_ROOT_ACCESS |" \
"            DENY_GEOMETRY_SHADER_ROOT_ACCESS |" \
"            DENY_HULL_SHADER_ROOT_ACCESS )," \
"DescriptorTable ( SRV(t0) ),"\
"DescriptorTable ( Sampler(s0) )," \
"CBV(b0)"

cbuffer SkyboxConstants : register(b0)
{
    float4x4 WorldViewProj;
}

struct VSOutput
{
    float3 TexCoord : TEXCOORD0;
    float4 PositionPS : SV_Position;
};

SkyboxEffect_VS.hlsl

[RootSignature(SkyboxRS)]
VSOutput main(float4 position : SV_Position)
{
    VSOutput vout;

    vout.PositionPS = mul(position, WorldViewProj);
    vout.PositionPS.z = vout.PositionPS.w; // Draw on far plane
    vout.TexCoord = position.xyz;

    return vout;
}

SkyboxEffect_PS.hlsl

[RootSignature(SkyboxRS)]
TextureCube<float4> CubeMap : register(t0);
SamplerState Sampler        : register(s0);

float4 main(float3 texCoord : TEXCOORD0) : SV_TARGET0
{
    return CubeMap.Sample(Sampler, normalize(texCoord));
}

Our vertex shader only makes use of the vertex position information, and generates the texture coordinates assuming the skybox is centered about the origin (0,0,0). It also sets the z value so that it's at the view far plane.

Save these three files to your new project's directory, and then from the top menu select Project / Add Existing Item.... Select "SkyboxEffect_Common.hlsli", "SkyboxEffect_VS.hlsl", and "SkyboxEffect_PS.hlsl" and add them.

View Properties on "SkyboxEffect_VS.hlsl" and for "All Configurations" and "All Platforms", set the "Shader Type" to "Vertex Shader (/vs)" and select "OK".

View Properties on "SkyboxEffect_PS.hlsl" and for "All Configurations" and "All Platforms", set the "Shader Type" to "Pixel Shader (/ps)" and select "OK".

Build and run your project. It will have the same blank scene as before, but should have produced SkyboxEffect_VS.cso and SkyboxEffect_PS.cso.

Technical note

We are using an HLSL-based root signature for our new effect. For more information see, PSOs, Shaders, and Signatures.

For this lesson, we are using a heap-based texture sampler rather than a static sampler in the root signature such as the one used in Writing custom shaders. This is consistent with most of the Effect implementations in the DirectX Tool Kit. If desired, you could change it to a static sampler as follows:

#define SkyboxRS \
"RootFlags ( ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT |" \
"            DENY_DOMAIN_SHADER_ROOT_ACCESS |" \
"            DENY_GEOMETRY_SHADER_ROOT_ACCESS |" \
"            DENY_HULL_SHADER_ROOT_ACCESS )," \
"StaticSampler(s0,"\
"           filter = FILTER_MIN_MAG_MIP_LINEAR,"\
"           addressU = TEXTURE_ADDRESS_CLAMP,"\
"           addressV = TEXTURE_ADDRESS_CLAMP,"\
"           addressW = TEXTURE_ADDRESS_CLAMP,"\
"           visibility = SHADER_VISIBILITY_PIXEL ),"\
"DescriptorTable ( Sampler(s0) )," \
"CBV(b0)"

If using a programmatic signature, be sure to modify it to match.

Implementing the SkyboxEffect

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

Create a new file SkyboxEffect.h in your project:

class SkyboxEffect : public DirectX::IEffect
{
public:
    SkyboxEffect(
        ID3D12Device* device,
        const DirectX::EffectPipelineStateDescription& pipelineStateDesc);

    void Apply(ID3D12GraphicsCommandList* commandList) override;

    void SetTexture(
        D3D12_GPU_DESCRIPTOR_HANDLE srvDescriptor,
        D3D12_GPU_DESCRIPTOR_HANDLE samplerDescriptor);

private:
    enum Descriptors
    {
        InputSRV,
        InputSampler,
        ConstantBuffer,
        Count
    };

    Microsoft::WRL::ComPtr<ID3D12Device> m_device;
    Microsoft::WRL::ComPtr<ID3D12RootSignature> m_rootSig;
    Microsoft::WRL::ComPtr<ID3D12PipelineState> m_pso;

    D3D12_GPU_DESCRIPTOR_HANDLE m_texture;
    D3D12_GPU_DESCRIPTOR_HANDLE m_textureSampler;
};

Create a new file SkyboxEffect.cpp in your project:

#include "pch.h"
#include "SkyboxEffect.h"
#include "ReadData.h"

SkyboxEffect::SkyboxEffect(
    ID3D12Device* device,
    const EffectPipelineStateDescription& pipelineStateDesc) :
        m_device(device),
        m_texture{},
        m_textureSampler{}
{
    // Create root signature via code (matches HLSL signature above)
    D3D12_ROOT_SIGNATURE_FLAGS rootSignatureFlags =
        D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT |
        D3D12_ROOT_SIGNATURE_FLAG_DENY_DOMAIN_SHADER_ROOT_ACCESS |
        D3D12_ROOT_SIGNATURE_FLAG_DENY_GEOMETRY_SHADER_ROOT_ACCESS |
        D3D12_ROOT_SIGNATURE_FLAG_DENY_HULL_SHADER_ROOT_ACCESS;

    CD3DX12_DESCRIPTOR_RANGE textureSRVs(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 0);
    CD3DX12_DESCRIPTOR_RANGE textureSamplers(D3D12_DESCRIPTOR_RANGE_TYPE_SAMPLER, 1, 0);

    CD3DX12_ROOT_PARAMETER rootParameters[Count] = {};
    rootParameters[InputSRV].InitAsDescriptorTable(1, &textureSRVs);
    rootParameters[InputSampler].InitAsDescriptorTable(1, &textureSamplers);
    rootParameters[ConstantBuffer].InitAsConstantBufferView(0, 0, D3D12_SHADER_VISIBILITY_ALL);

    CD3DX12_ROOT_SIGNATURE_DESC rsigDesc = {};
    rsigDesc.Init(static_cast<UINT>(std::size(rootParameters)), rootParameters,
        0, nullptr, rootSignatureFlags);

    DX::ThrowIfFailed(
        CreateRootSignature(device, &rsigDesc, m_rootSig.ReleaseAndGetAddressOf())
    );

    // Get shaders
    auto vsBlob = DX::ReadData(L"SkyboxEffect_VS.cso");
    D3D12_SHADER_BYTECODE vs = { vsBlob.data(), vsBlob.size() };

    auto psBlob = DX::ReadData(L"SkyboxEffect_PS.cso");
    D3D12_SHADER_BYTECODE ps = { psBlob.data(), psBlob.size() };

    pipelineStateDesc.CreatePipelineState(device, m_rootSig.Get(), vs, ps,
        m_pso.ReleaseAndGetAddressOf());
}

void SkyboxEffect::Apply(
    ID3D12GraphicsCommandList* commandList)
{
    // Set the root signature & parameters
    commandList->SetGraphicsRootSignature(m_rootSig.Get());

    if (!m_texture.ptr || !m_textureSampler.ptr)
    {
        throw std::runtime_error("SkyboxEffect");
    }

    commandList->SetGraphicsRootDescriptorTable(InputSRV, m_texture);
    commandList->SetGraphicsRootDescriptorTable(InputSampler, m_textureSampler);

    // Set the pipeline state
    commandList->SetPipelineState(m_pso.Get());
}

void SkyboxEffect::SetTexture(
    D3D12_GPU_DESCRIPTOR_HANDLE srvDescriptor,
    D3D12_GPU_DESCRIPTOR_HANDLE samplerDescriptor)
{
    m_texture = srvDescriptor;
    m_textureSampler = samplerDescriptor;
}

For a DirectX Tool Kit effect, only Apply is required. We added SetTexture as a way to set the texture SRV descriptor and texture sampler descriptor which can be changed dynamically.

Build and make sure it compiles.

Many of the DirectX Tool Kit built-in effects support a number of properties which influence the selection of the VS/PS shader combination which are selected in the constructor creating the pipeline state object. Therefore, built-in effects take an EffectFlags value to provide a compact description of all properties. Since we only have one shader combination, we don't need any additional parameters in the ctor.

Note that built-in effects also generally create the root signature on-demand shared with all instances of the same effect. Since we only expect to have one 'sky' at any given time, we don't use this optimization.

Technical note

Instead of creating a programmatic root signature to match the HLSL root-signature in the shader, you can just use the vertex shader blob to create the root shader directly:

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

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

D3D12_SHADER_BYTECODE vs = { vsBlob.data(), vsBlob.size() };
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.

Adding camera settings

While we've implemented the basic effect, we are missing a key bit of information. The vertex shader makes use of a constant buffer to provide the transformation matrix. While we could just add another effect-specific method to control this, we will instead add the standard IEffectMatrices interface to our skybox effect.

In SkyboxEffect.h, modify your class's declaration to add the additional interface:

class SkyboxEffect : public DirectX::IEffect, public DirectX::IEffectMatrices

Then add to the public section:

void XM_CALLCONV SetWorld(DirectX::FXMMATRIX value) override;
void XM_CALLCONV SetView(DirectX::FXMMATRIX value) override;
void XM_CALLCONV SetProjection(DirectX::FXMMATRIX value) override;
void XM_CALLCONV SetMatrices(DirectX::FXMMATRIX world, DirectX::CXMMATRIX view, DirectX::CXMMATRIX projection) override;

And finally add to the private section (note we have a good reason for not having a m_world matrix variable which we will get to shortly):

DirectX::SimpleMath::Matrix m_view;
DirectX::SimpleMath::Matrix m_proj;
DirectX::SimpleMath::Matrix m_worldViewProj;

In SkyboxEffect.cpp, add these methods:

void SkyboxEffect::SetWorld(FXMMATRIX /*value*/)
{
    // Skybox doesn't use the world matrix by design.
}

void SkyboxEffect::SetView(FXMMATRIX value)
{
    m_view = value;
}

void SkyboxEffect::SetProjection(FXMMATRIX value)
{
    m_proj = value;
}

void SkyboxEffect::SetMatrices(FXMMATRIX /*world*/, CXMMATRIX view, CXMMATRIX projection)
{
    // Skybox doesn't use the world matrix by design.
    m_view = view;
    m_proj = projection;
}

It should be noted that we are very careful in the use of C++ multiple-inheritance only for interfaces. See C++ Core Guidelines.

Managing the constant buffer

We have all the data we need now for the constant buffer. In order to compute the worldViewProj value, we will need to do a matrix multiply. We'd like to avoid doing this more often than necessary, either keeping it from the previous frame if it's not changed or doing it only once if multiple matrices are updated in a single frame. The built-in effects all use a dirty bits design which we will use here.

In SkyboxEffect.h, add to the class's private variable declarations:

uint32_t m_dirtyFlags;

DirectX::GraphicsResource m_constantBuffer;

At the top of SkyboxEffect.cpp after the using statements, add:

namespace
{
    struct __declspec(align(16)) SkyboxEffectConstants
    {
        XMMATRIX worldViewProj;
    };

    static_assert((sizeof(SkyboxEffectConstants) % 16) == 0, "CB size alignment");

    constexpr uint32_t DirtyConstantBuffer = 0x1;
    constexpr uint32_t DirtyWVPMatrix = 0x2;
}

Update the SkyboxEffect constructor to initialize the new variables with all 'dirty bits' set:

SkyboxEffect::SkyboxEffect(
    ID3D12Device* device,
    const EffectPipelineStateDescription& pipelineStateDesc) :
        m_device(device),
        m_texture{},
        m_textureSampler{},
        m_dirtyFlags(uint32_t(-1))
{
    // Create root signature
...

In each of the camera settings methods SetView, SetProjection, and SetMatrices, set the dirty bit to indicate the matrix changed:

m_dirtyFlags |= DirtyWVPMatrix;

Now we revisit Apply to handle the constant buffer computation and updating using those 'dirty bits':

void SkyboxEffect::Apply(
    ID3D12GraphicsCommandList* commandList)
{
    if (m_dirtyFlags & DirtyWVPMatrix)
    {
        // Skybox ignores m_world matrix and the translation of m_view
        XMMATRIX view = m_view;
        view.r[3] = g_XMIdentityR3;
        m_worldViewProj = XMMatrixMultiply(view, m_proj);

        m_dirtyFlags &= ~DirtyWVPMatrix;
        m_dirtyFlags |= DirtyConstantBuffer;
    }

    if (m_dirtyFlags & DirtyConstantBuffer)
    {
        auto cb = GraphicsMemory::Get(m_device.Get()).AllocateConstant<SkyboxEffectConstants>();

        XMMATRIX transpose = XMMatrixTranspose(m_worldViewProj);
        memcpy(cb.Memory(), &transpose, cb.Size());
        std::swap(m_constantBuffer, cb);

        m_dirtyFlags &= ~DirtyConstantBuffer;
    }

    // Set the root signature & parameters
    commandList->SetGraphicsRootSignature(m_rootSig.Get());

    if (!m_texture.ptr || !m_textureSampler.ptr)
    {
        throw std::runtime_error("SkyboxEffect");
    }

    commandList->SetGraphicsRootDescriptorTable(InputSRV, m_texture);
    commandList->SetGraphicsRootDescriptorTable(InputSampler, m_textureSampler);
    commandList->SetGraphicsRootConstantBufferView(ConstantBuffer, m_constantBuffer.GpuAddress());

    // Set the pipeline state
    commandList->SetPipelineState(m_pso.Get());
}

In most effects, we use world, view, and projection matrices. Many built-in effects compute inverse matrices as well as various concatenations from these matrices. For the skybox effect, however, we want unique behavior. The skybox is 'infinitely far away', so no amount of translation of the view should move it. Also, there really is no sense it should be 'scaled' nor does the sky itself need it's own transform. Therefore, we don't use a world matrix at all in our effect, and we zero out the translation from the camera view matrix.

At this point, build to ensure everything compiles and we now have a fully implemented skybox effect.

Rendering the sky

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

Add to the Game.h file after the other includes:

#include "SkyboxEffect.h"

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):

std::unique_ptr<DirectX::GamePad> m_gamePad;
DirectX::SimpleMath::Matrix m_view;
DirectX::SimpleMath::Matrix m_proj;

float m_pitch;
float m_yaw;

std::unique_ptr<DirectX::CommonStates> m_states;
std::unique_ptr<DirectX::DescriptorHeap> m_resourceDescriptors;
std::unique_ptr<DirectX::GeometricPrimitive> m_sky;
std::unique_ptr<DX::SkyboxEffect> m_effect;

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

In the Game.cpp file, modify the constructor to initialize the new variables:

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

In the Initialize method, add:

m_gamePad = std::make_unique<GamePad>();

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

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

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

m_sky = GeometricPrimitive::CreateGeoSphere(2.f);

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

EffectPipelineStateDescription pd(&GeometricPrimitive::VertexType::InputLayout,
    CommonStates::Opaque,
    CommonStates::DepthRead,
    CommonStates::CullClockwise,
    rtState,
    D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE);

m_effect = std::make_unique<DX::SkyboxEffect>(device, pd);

ResourceUploadBatch resourceUpload(device);

resourceUpload.Begin();

m_sky->LoadStaticBuffers(device, resourceUpload);

DX::ThrowIfFailed(
    CreateDDSTextureFromFile(device, resourceUpload, L"lobbycube.dds",
        m_cubemap.ReleaseAndGetAddressOf()));

CreateShaderResourceView(device, m_cubemap.Get(),
    m_resourceDescriptors->GetFirstCpuHandle(), true);

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

uploadResourcesFinished.wait();

m_effect->SetTexture(m_resourceDescriptors->GetFirstGpuHandle(), m_states->LinearWrap());

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

auto size = m_deviceResources->GetOutputSize();

m_proj = Matrix::CreatePerspectiveFieldOfView(XM_PI / 4.f,
    float(size.right) / float(size.bottom), 0.1f, 10.f);

m_effect->SetProjection(m_proj);

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

auto pad = m_gamePad->GetState(0);

if (pad.IsConnected())
{
    if (pad.IsViewPressed())
    {
        ExitGame();
    }

    if (pad.IsLeftStickPressed())
    {
        m_yaw = m_pitch = 0.f;
    }
    else
    {
        constexpr float ROTATION_GAIN = 0.1f;
        m_yaw += -pad.thumbSticks.leftX * ROTATION_GAIN;
        m_pitch += pad.thumbSticks.leftY * ROTATION_GAIN;
    }
}

// limit pitch to straight up or straight down
constexpr float limit = XM_PIDIV2 - 0.01f;
m_pitch = std::max(-limit, m_pitch);
m_pitch = std::min(+limit, m_pitch);

// keep longitude in sane range by wrapping
if (m_yaw > XM_PI)
{
    m_yaw -= XM_2PI;
}
else if (m_yaw < -XM_PI)
{
    m_yaw += XM_2PI;
}

float y = sinf(m_pitch);
float r = cosf(m_pitch);
float z = r * cosf(m_yaw);
float x = r * sinf(m_yaw);

XMVECTORF32 lookAt = { x, y, z, 0.f };
m_view = XMMatrixLookAtRH(g_XMZero, lookAt, Vector3::Up);

In Game.cpp, add to the TODO of both OnActivated and OnResuming:

m_gamePad->Resume();

In Game.cpp, add to the TODO of both OnDeactivated and OnSuspending:

m_gamePad->Suspend();

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

m_states.reset();
m_resourceDescriptors.reset();
m_sky.reset();
m_effect.reset();
m_cubemap.Reset();

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_effect->SetView(m_view);
m_effect->Apply(commandList);
m_sky->Draw(commandList);

Build and run to see the skybox. Plug in a Xbox game controller and use the left stick to adjust the view to look around.

If you prefer to use mouse & keyboard controls, use the control scheme in Mouse and keyboard input.

You may notice that the text in the lobby-cube image appears backwards. The environment mapping in the shader assumes the cubemap 'surrounds' the scene, which means we are looking 'through' it which is what makes it seem backwards. If you want to flip it, just edit the vertex shader code in SkyboxEffect_VS.hlsl:

[RootSignature(SkyboxRS)]
VSOutput main(float4 position : SV_Position)
{
    VSOutput vout;

    vout.PositionPS = mul(position, WorldViewProj);
    vout.PositionPS.z = vout.PositionPS.w; // Draw on far plane

    vout.TexCoord.x = -position.x;
    vout.TexCoord.yz = position.yz;

    return vout;
}

More to explore

  • Usable versions of the SkyboxEffect class can be downloaded from here: .cpp / .h, and you will need the three shader files above as well.

Next lessons: Using HDR rendering

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