Jorge
Jorge Autor de Aprende Unreal Engine 4.

DirectX 12: Root Signature

DirectX 12: Root Signature

Root Signature define que tipo de recursos están vinculados a la pipeline. Si tu pipeline fuera una función, el root signature sería su declaración.

Tus shaders propios usarán una serie de recursos como texturas, constant buffer (por ejemplo matrices de transformación), samplers y otros tipos. Si tu pipeline fuera una función, sus argumentos serían precisamente esas texturas, constant buffers, samplers, etc., esa “declaración” es precisamente el root signature.

Tabla de contenidos

Parte 1. Window

Parte 2. Adapter

Parte 3. Conceptos

Parte 4. Queue

Parte 5. Resources

Parte 6. Descriptores

Parte 7. Pipeline State

Parte 8. Swapchain

Parte 9. Triángulo

» Parte 10. Root Signature

Parte 11. Texturas

Shaders

Vamos a hacer que rote el triángulo del tutorial anterior.

Primero modificamos el shader:

// shaders.hlsl
cbuffer SomeConstants : register(b0)
{
    float4x4 MVP;
}

struct VS2PS
{
    float4 position : SV_Position;
    float4 color : COLOR;
};

VS2PS VSMain(float3 position : POSITION, float4 color : COLOR)
{
    VS2PS vs2ps;
    vs2ps.position = mul(MVP, float4(position, 1.0));
    vs2ps.color = color;
    return vs2ps;
}

float4 PSMain(VS2PS vs2ps) : SV_Target
{
    return vs2ps.color;
}

Fíjate como la matrix MVP es pasada al shader usando un buffer constante (cbuffer). Es bastante habitual usar buffer constantes para pasar las matrices de transformación dado que su valor será constante a lo largo de todo el pipeline.

También fíjate como indicamos al shader que el descriptor de ese buffer está mapeado al registro b0.

En otras palabras, la GPU debe cargar en el registro b0 el descriptor asociado a ese buffer constante.

En el tutorial sobre recursos estuvimos hablando sobre los descriptores y registros.

Si ejecutas ahora verás que no se muestra nada por pantalla, lógico porque MVP aún no le hemos asignado ningún valor.

De hecho si tienes activada la capa de depuración tal y como vimos en el tutorial sobre adaptadores, la consola te dará el siguiente error:

ID3D12Device::CreateGraphicsPipelineState: Root Signature doesn't match Vertex Shader: Shader CBV descriptor range 

Es un error bastante descriptivo: nuestro pipeline toma como “argumento” un constant buffer, pero si miras el método CreateRootSignature que hicimos en el tutorial anterior, nuestra root signature está vacía. Dado que la root signature es la “declaración” de los “argumentos” de nuestro pipeline, DX12 te da ese error.

De hecho es en la root signature dónde decimos a DX12 que el descriptor del recurso constant buffer que vamos a crear lo mapeamos al registro b0.

Vamos con ello.

Constant Buffer

Vamos a crear un recurso que aloje nuestro constant buffer. En el tutorial sobre recursos vimos como hacerlo.

Añade el siguiente código:

class DemoApp
{
  /* Constant Buffer */
  ComPtr<ID3D12Resource> ConstantBufferResource;
  ComPtr<ID3D12DescriptorHeap> ConstantBufferDescriptorHeap;
  UINT8* pConstantBufferData; // puntero que obtendremos con ConstantBufferResource->Map(...)
  void CreateConstantBuffer();

  /* Constant Buffer Values */
  struct FSomeConstants
  {
    XMMATRIX MVP;
  };
  FSomeConstants SomeConstants;  
  void UpdateConstantBuffer();
};

En la implementación:

void DemoApp::CreateConstantBuffer()
{
  UINT SizeInBytes = sizeof(FSomeConstants);
  // ^^ Esta línea tiene un error que enseguida vamos a corregir
  
  ConstantBufferResource = GPUMem::Buffer(Device.Get(), SizeInBytes, D3D12_HEAP_TYPE_UPLOAD);

  D3D12_RANGE ReadRange;
  ReadRange.Begin = 0;
  ReadRange.End = 0;
  ConstantBufferResource->Map(0, &ReadRange, reinterpret_cast<void**>(&pConstantBufferData));

  /* Descriptor Heap */
  D3D12_DESCRIPTOR_HEAP_DESC DescriptorHeapDesc{};
  DescriptorHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
  DescriptorHeapDesc.NodeMask = 0;
  DescriptorHeapDesc.NumDescriptors = 1;
  DescriptorHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; // constant buffer
	
  Device->CreateDescriptorHeap(&DescriptorHeapDesc, IID_PPV_ARGS(&ConstantBufferDescriptorHeap));

  /* Create descriptor */
  D3D12_CONSTANT_BUFFER_VIEW_DESC ConstantBufferViewDesc{};
  ConstantBufferViewDesc.BufferLocation = ConstantBufferResource->GetGPUVirtualAddress();
  ConstantBufferViewDesc.SizeInBytes = SizeInBytes;
  Device->CreateConstantBufferView(&ConstantBufferViewDesc, ConstantBufferDescriptorHeap->GetCPUDescriptorHandleForHeapStart());
}

No olvides llamar a esta función en el constructor:

DemoApp::DemoApp(HWND hWnd, UINT Width, UINT Height)
{
	// ...

	CreateConstantBuffer();
}

Si ejecutas el código verás el siguiente error:

ID3D12Device::CreateConstantBufferView: Size of 64 is invalid.  Device requires SizeInBytes be a multiple of 256. [ STATE_CREATION ERROR #650: CREATE_CONSTANT_BUFFER_VIEW_INVALID_DESC]
D3D12: Removing Device.

Efectivamente, la GPU requiere que el tamaño de los constant buffer sean múltiplo de 256.

Para ello podemos hacer un truco con operaciones bitwise. Modifica la línea por esta otra:

UINT SizeInBytes = (sizeof(FSomeConstants) + 256) & 256;

Si haces una operación AND a nivel de bit con el valor 256 están creando un múltiplo de 256 (pones a 0 los primeros 8 bits, 2 elevado a 8 es 256).

Ya solo nos queda copiar los datos al buffer:

void DemoApp::UpdateConstantBuffer()
{
  static float Angle = 0.0f;

  Angle += 0.1f;
	
  SomeConstants.MVP = XMMatrixRotationZ(Angle);

  memcpy(pConstantBufferData, &SomeConstants, sizeof(FSomeConstants));
}

No olvides llamar a este método en el método Tick:

void DemoApp::Tick()
{
  UpdateConstantBuffer();
  
  RecordCommandList();
  ID3D12CommandList* ppCommandLists[] = { CommandList.Get() };
  CommandQueue->ExecuteCommandLists(1, ppCommandLists);
  Swapchain->Present(1, 0);
  FlushAndWait();
}

Root Signature

El estado actual es el siguiente:

  • Por un lado tenemos al shader listo para recibir en el registro b0 un descriptor de constant buffer.
  • Por otro lado hemos reservado memoria en la GPU y copiado la matriz. Además hemos creado un descriptor listo para cargar en un registro.

¿Qué nos falta?

Nos falta el “pegamento”. En concreto nos faltan dos elementos:

  • un primer elemento que indique que el registro b0 será cargado con un descriptor de constant buffer (declaración)

  • y un segundo elemento que diga que ese constant buffer es precisamente el que hemos creado.

El primer elemento es el root signature. El segundo elemento se hace mediante comandos.

En anteriores tutoriales vimos como crear un root signature vacío:

void DemoApp::CreateRootSignature()
{
  /* RootSignature vacío, no le pasamos decriptores
	  (ni texturas, ni constant buffer, etc.,) a los shaders */

  D3D12_ROOT_SIGNATURE_DESC SignatureDesc{};
  SignatureDesc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT;
  SignatureDesc.NumParameters = 0;
  SignatureDesc.NumStaticSamplers = 0;

  ComPtr<ID3DBlob> Signature;
  ComPtr<ID3DBlob> Error;

  D3D12SerializeRootSignature(&SignatureDesc, D3D_ROOT_SIGNATURE_VERSION_1_0, &Signature, &Error);
  Device->CreateRootSignature(0, Signature->GetBufferPointer(), Signature->GetBufferSize(), IID_PPV_ARGS(&RootSignature));
}

Vamos a añadir un parámetro:

D3D12_ROOT_PARAMETER RootParameters[1];
/* ... */

D3D12_ROOT_SIGNATURE_DESC SignatureDesc{};
SignatureDesc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT;
SignatureDesc.NumParameters = _countof(RootParameters);
SignatureDesc.pParameters = RootParameters;

Recuerda que el parámetro tiene que decir “en el registro b0 se cargará un constant buffer”:

D3D12_ROOT_PARAMETER RootParameters[1];

// este parametro es visible para todos los shaders
RootParameters[0].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL;
// otros valores podrian ser D3D12_SHADER_VISIBILITY_VERTEX, D3D12_SHADER_VISIBILITY_PIXEL, etc.,

// este parámetro es de tipo constant buffer
RootParameters[0].ParameterType = D3D12_ROOT_PARAMETER_TYPE_CBV;
// otros valores posibles serian D3D12_ROOT_PARAMETER_TYPE_SRV, D3D12_ROOT_PARAMETER_TYPE_32BIT_CONSTANTS, etc.,

RootParameters[0].Descriptor.RegisterSpace = 0; // principalmente para temas de retrocompatibilidad con shaders antiguos

// como es de tipo CBV se cargaran en los registros bX
// en el campo ShaderRegister se indica ese X
RootParameters[0].Descriptor.ShaderRegister = 0; // b0
// para 1 sería b1, para 2 sería b2, etc.,

El código completo sería:

void DemoApp::CreateRootSignature()
{
  D3D12_ROOT_PARAMETER RootParameters[1];
  RootParameters[0].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL;
  RootParameters[0].ParameterType = D3D12_ROOT_PARAMETER_TYPE_CBV;
  RootParameters[0].Descriptor.RegisterSpace = 0;
  RootParameters[0].Descriptor.ShaderRegister = 0;

  D3D12_ROOT_SIGNATURE_DESC SignatureDesc{};
  SignatureDesc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT;
  SignatureDesc.NumParameters = _countof(RootParameters);
  SignatureDesc.pParameters = RootParameters;
  SignatureDesc.NumStaticSamplers = 0;

  ComPtr<ID3DBlob> Signature;
  ComPtr<ID3DBlob> Error;

  D3D12SerializeRootSignature(&SignatureDesc, D3D_ROOT_SIGNATURE_VERSION_1_0, &Signature, &Error);
  Device->CreateRootSignature(0, Signature->GetBufferPointer(), Signature->GetBufferSize(), IID_PPV_ARGS(&RootSignature));
}

Si ejecutas el código verás que funciona sin errores, sin embargo, ¡aún no le hemos indicado el valor que debe cargarse en el registro b0!

Envía un comando indicando que valor debe ser cargado en el registro b0.

void DemoApp::RecordCommandList()
{
/* ... */
  CommandList->SetGraphicsRootConstantBufferView(
    0, /* RootParameterIndex */
    ConstantBufferResource->GetGPUVirtualAddress() /* BufferLocation */
  );

  CommandList->DrawInstanced(3, 1, 0, 0);
/* ... */
}

Si ejecutas verás el triángulo rotando:

Triangle Rotated

Root Signature al detalle

¿Te has fijado en la llamada para asignar el valor?

CommandList->SetGraphicsRootConstantBufferView(
    0, /* Root Param Index */
    ConstantBufferResource->GetGPUVirtualAddress()
);

¿Notas algo raro? ¡No estamos pasándole el descriptor! En vez de eso, le estamos pasando directamente la dirección en memoria del recurso.

Este hecho suscita algunas preguntas:

  • Si no estamos usando nuestro descriptor (alojado en ConstantBufferDescriptorHeap), ¿para qué hemos creado un descriptor heap?
  • ¿Para qué hemos creado un descriptor si no lo vamos a usar?
  • Siempre hemos dicho que en los registros se cargan descriptores, por eso los necesitábamos crear, ¿por qué en este caso no le estamos pasando un descriptor?

La respuesta es la siguiente: un Root Signature tiene espacio reservado para funcionar como un descriptor heap de tipo D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV. ¡Boom!

Es decir, la llamada del código anterior en realidad está creando un descriptor directamente dentro del propio Root Signature.

Estamos usando Root Signature como un descriptor heap.

¿Entonces para qué hemos creado un descriptor heap aparte? Para no introducir todos los conceptos de golpe y porque lo vamos a usar enseguida para ilustrar otro uso del root signature. Pero efectivamente en este caso no hubiera sido necesario.

Un root signature funciona como un descriptor heap para CBV, SVR y UAV. Además de ello también reserva espacio para almacenar static samplers (para las texturas) y también tiene espacio para almacenar constantes.

Además de contener descriptores, static samplers y constantes, puede almacenar descriptor table. Enseguida veremos que son.

La siguiente imagen ilustra un root signature.

hlsl-root-signature

Fuente https://docs.microsoft.com/en-us/windows/win32/direct3d12/specifying-root-signatures-in-hlsl#root-signature-version-11

Este root signature ilustrada en la imagen contiene siete parámetros (de 0 a 6). Podemos encontrar parámetros que almacenan descriptores (parámetros con índices 0, 1 y 2), tres parámetros que almacenan descriptor tables (parámetros con índices 3, 4 y 6) y un parámetro que almacena una constante (parámetro 5).

En el caso de nuestro proyecto, DemoApp, tenemos una root signature con un sólo parámetro que almacena un descriptor de tipo CBV.

Vamos a ilustrar en código como podemos definir la root signature de la imagen. De este modo podemos tener una visión más global de cómo funciona una root signature.

Empezamos definiendo siete parámetros.

D3D12_ROOT_PARAMETER RootParameters[7];

Como primer parámetro del root signature un descriptor CBV. ¡El propio root signature está funcionando como un descriptor heap!. Este descriptor se carga en el registro b0.

RootParameters[0].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL;
RootParameters[0].ParameterType = D3D12_ROOT_PARAMETER_TYPE_CBV; // se cargará en algún registro b, ¿cuál? el que indique shader register
RootParameters[0].Descriptor.RegisterSpace = 0; // [*]
RootParameters[0].Descriptor.ShaderRegister = 0; // b0

[*] En la ilustración indica espacio 1 porque se supone que está usando un shader antiguo por temas de retrocompatibilidad, en la doc oficial de DX12 encontraras más información del problema de compatibilidad que intenta resolver el campo RegisterSpace.

Nosotros usaremos siempre RegisterSpace = 0.

El segundo argumento es un descriptor de tipo SRV. Se carga en el registro t0.

RootParameters[1].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL;
RootParameters[1].ParameterType = D3D12_ROOT_PARAMETER_TYPE_SRV; // register t
RootParameters[1].Descriptor.RegisterSpace = 0;
RootParameters[1].Descriptor.ShaderRegister = 0; // t0

El tercer argumento es un descriptor de tipo UAV (unordered access view). Se carga en el registro u0.

RootParameters[2].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL;
RootParameters[2].ParameterType = D3D12_ROOT_PARAMETER_TYPE_UAV; // // register u
RootParameters[2].Descriptor.RegisterSpace = 0;
RootParameters[2].Descriptor.ShaderRegister = 0; // u0

Los recursos de tipo UNORDERED_ACCESSS_VIEW (UAV) son para escribir en texturas desde el propio shader. No son habituales.

El cuarto, quinto y séptimo argumento son descriptor tables. Lo veremos enseguida.

El sexto argumento es una contante que se carga en el registro b9.

RootParameters[5].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL; // register b
RootParameters[5].ParameterType = D3D12_ROOT_PARAMETER_TYPE_32BIT_CONSTANTS;
RootParameters[5].Descriptor.RegisterSpace = 0;
RootParameters[5].Descriptor.ShaderRegister = 9; // b9

Obviando los parámetros asociados a los descriptor tables, el shader quedaría así:

// HLSL

cbuffer SomeConstants : register(b0)
{
    float4x4 MVP;
    float3 LightDir;
    float3 AlbedoColor;
};

Texture2D foo : register(t0);
RWBuffer dataLog : register(u0);
int boo : register(b9);

float4 VSMain(...)
{
    // ...
}

float4 PSMain(...) : SV_Target
{
    // ...
}

Descriptor Tables

Una de las preguntas que puedes estar haciéndote es, si el root signature ya funciona como un descriptor heap para CBV, SRV y UAV, ¿para qué querríamos crear nuestro propio descriptor heap de tipo D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV? ¡Usemos directamente el root signature como descriptor heap!

Bueno no es tan fácil. Un Root Signature solo puede contener un número muy limitado de parámetros.

En concreto, un Root Signature está limitado a un tamaño de 64 DWORDs (1 DWORD = 32 bits).

Almacenar un descriptor cuesta 2 DWORD por cada descriptor.

Almacenar una constante cuesta 1 DWORD por cada constante.

¿Y si queremos usar más descriptores? Entonces usamos un descriptor table.

Un descriptor table hace de puente entre el root signature y los descriptores de un descriptor heap. Cada descriptor table ocupa 1 DWORD en el root signature.

Un descriptor table puede referenciar varios descriptores de un descriptor heap.

Repasa la ilustración anterior y definamos el cuarto argumento:

D3D12_DESCRIPTOR_RANGE DescriptorRanges[3];

DescriptorRanges[0].BaseShaderRegister = 0; // b0
DescriptorRanges[0].NumDescriptors = 1;
DescriptorRanges[0].OffsetInDescriptorsFromTableStart = 0;
DescriptorRanges[0].RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_CBV;
DescriptorRanges[0].RegisterSpace = 0;

DescriptorRanges[1].BaseShaderRegister = 1; // u1, u2
DescriptorRanges[1].NumDescriptors = 2;
DescriptorRanges[1].OffsetInDescriptorsFromTableStart = D3D12_DESCRIPTOR_RANGE_OFFSET_APPEND;
DescriptorRanges[1].RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_UAV;
DescriptorRanges[1].RegisterSpace = 0;

DescriptorRanges[2].BaseShaderRegister = 1; // t1+
DescriptorRanges[2].NumDescriptors = UINT_MAX;
DescriptorRanges[2].OffsetInDescriptorsFromTableStart = D3D12_DESCRIPTOR_RANGE_OFFSET_APPEND;
DescriptorRanges[2].RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_SRV;
DescriptorRanges[2].RegisterSpace = 0;

D3D12_ROOT_PARAMETER RootParameters[7];
/* ... */
RootParameters[3].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL;
RootParameters[3].ParameterType = D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE;
RootParameters[3].DescriptorTable.NumDescriptorRanges = 3;
RootParameters[3].DescriptorTable.pDescriptorRanges = DescriptorRanges;
/* ... */

En nuestro ejemplo con DemoApp podemos usar el descriptor heap que creamos anteriormente usando un descriptor table.

Para ello modifica el método CreateRootSignature tal que así:

void DemoApp::CreateRootSignature()
{
  D3D12_DESCRIPTOR_RANGE DescriptorRanges[1];
  DescriptorRanges[0].BaseShaderRegister = 0;
  DescriptorRanges[0].NumDescriptors = 1;
  DescriptorRanges[0].OffsetInDescriptorsFromTableStart = 0;
  DescriptorRanges[0].RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_CBV;
  DescriptorRanges[0].RegisterSpace = 0;

  D3D12_ROOT_PARAMETER RootParameters[1];
  RootParameters[0].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL;
  RootParameters[0].ParameterType = D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE;
  RootParameters[0].DescriptorTable.NumDescriptorRanges = 1;
  RootParameters[0].DescriptorTable.pDescriptorRanges = DescriptorRanges;

  D3D12_ROOT_SIGNATURE_DESC SignatureDesc{};
  SignatureDesc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT;
  SignatureDesc.NumParameters = _countof(RootParameters);
  SignatureDesc.pParameters = RootParameters;
  SignatureDesc.NumStaticSamplers = 0;

  ComPtr<ID3DBlob> Signature;
  ComPtr<ID3DBlob> Error;

  D3D12SerializeRootSignature(&SignatureDesc, D3D_ROOT_SIGNATURE_VERSION_1_0, &Signature, &Error);
  Device->CreateRootSignature(0, Signature->GetBufferPointer(), Signature->GetBufferSize(), IID_PPV_ARGS(&RootSignature));
}

Para asignar un valor al descriptor table debemos usar el siguiente comando:

CommandList->SetGraphicsRootDescriptorTable(
  0, /* RootParameterIndex */,
  ConstantBufferDescriptorHeap->GetGPUDescriptorHandleForHeapStart() /* BaseDescriptor */
);

Sin embargo, previamente debemos indicarle a la GPU que descriptors heaps vamos a usar:

ID3D12DescriptorHeap* Heaps[] { ConstantBufferDescriptorHeap.Get() };
CommandList->SetDescriptorHeaps(
  1 /* NumDescriptorHeaps */, 
  Heaps /* DescriptorHeaps */
);	

El código final quedaría:

void DemoApp::RecordCommandList()
{
  /* ... */

  ID3D12DescriptorHeap* Heaps[] { ConstantBufferDescriptorHeap.Get() };
  CommandList->SetDescriptorHeaps(
    1 /* NumDescriptorHeaps */, 
    Heaps /* DescriptorHeaps */
  );	
  CommandList->SetGraphicsRootDescriptorTable(
    0, /* RootParameterIndex */,
    ConstantBufferDescriptorHeap->GetGPUDescriptorHandleForHeapStart() /* BaseDescriptor */
  );
	
  CommandList->DrawInstanced(3, 1, 0, 0);

  /* ... */
}

Si ejecutas volverías a ver el triángulo rotando.

Pipeline con Root Signature

Con todo lo visto hasta ahora esta imagen es muy ilustrativa y da una visión global y completa del pipeline y la root signature:

Pipeline

Fuente https://docs.microsoft.com/en-us/windows/win32/direct3d12/pipelines-and-shaders-with-directx-12

En siguientes tutoriales texturizaremos el triángulo y hablaremos de los samplers.

Código fuente

Puedes descargar el proyecto con el código fuente completo aquí.

comments powered by Disqus