前言
由于在Direct3D 11中取消了固定管线,要想绘制图形必须要了解可编程渲染管线的流程,一个能绘制出图形的渲染管线最少需要有这两个可编程着色器:顶点着色器和像素着色器。
本章会直接跳过渲染管线的工作原理,直接来到编程实战。
接下来的目标如下:
- 通过Visual Studio自带的HLSL编译器生成顶点着色器和像素着色器
- (可选)通过D3DComplier在运行期编译/生成着色器二进制文件
- 将着色器绑定到渲染管线上
- 了解顶点缓冲区,并将它绑定到输入装配阶段
- 完成上面的操作后就可以渲染出第一个三角形了
这里将直接从一个已经编写好的HLSL代码入手。
在此之前你还需要知道如何编译着色器:
章节 |
---|
HLSL编译着色器的三种方法 |
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。
第一份HLSL代码
现在我们在项目中创建HLSL文件夹,将所有的着色器代码放到这里。
在里面创建一个Triangle.hlsli
的文件,内容如下:
// Triangle.hlsli
struct VertexIn
{
float3 pos : POSITION;
float4 color : COLOR;
};
struct VertexOut
{
float4 posH : SV_POSITION;
float4 color : COLOR;
};
接下来创建Triangle_VS.hlsl
文件,用于存放顶点着色器代码:
#include "Triangle.hlsli"
// 顶点着色器
VertexOut VS(VertexIn vIn)
{
VertexOut vOut;
vOut.posH = float4(vIn.pos, 1.0f);
vOut.color = vIn.color; // 这里alpha通道的值默认为1.0
return vOut;
}
最后创建Triangle_PS.hlsl
文件,用于存放像素着色器代码:
#include "Triangle.hlsli"
// 像素着色器
float4 PS(VertexOut pIn) : SV_Target
{
return pIn.color;
}
HLSL代码的语法和C/C++的语法非常相似,也许后面会开坑描述一下HLSL语言,不过现在先把注意力放在这份代码中比较特别的地方。
float3
和float4
都是内置的变量类型,可以看作是C++的struct
类型,支持多种构造方式和成员访问。除此之外,还有float
和float2
两种类型。对于float4
,它的四个成员分别为x
,y
,z
和w
然后具体讲述一下变量名后面的语义:
语义名 | 具体含义 |
---|---|
POSITION | 描述该变量是一个坐标点 |
SV_POSITION | 说明该顶点的位置在从顶点着色器输出后,后续的着色器都不能改变它的值,作为光栅化时最终确定的像素位置 |
COLOR | 描述该变量是一个颜色 |
SV_Target | 说明输出的颜色值将会直接保存到渲染目标视图的后备缓冲区对应位置 |
输入布局(Input Layout)
ID3D11Device::CreateInputLayout方法--创建输入布局
在HLSL中,用于输入的结构体为:
struct VertexIn
{
float3 pos : POSITION;
float4 color : COLOR;
};
该项目与之对应的C++结构体为:
struct VertexPosColor
{
DirectX::XMFLOAT3 pos;
DirectX::XMFLOAT4 color;
static const D3D11_INPUT_ELEMENT_DESC inputLayout[2];
};
注意:DX SDK中的
xnamath.h
在Windows SDK中已经被抛弃,取而代之的则是要包含头文件directxmath.h
,XNA相关的数学库基本上都移植到这里了,除此之外,他们都已经被放入到名称空间DirectX中。
为了能够建立C++结构体与HLSL结构体的对应关系,需要使用ID3D11InputLayout
输入布局来描述每一个成员的用途、语义、大小等信息。
还要留意的是,其中inputLayout
并不是结构体VertexPosColor
的内部成员,而是静态成员,不占用该结构体的空间。我们使用D3D11_INPUT_ELEMENT_DESC
结构体来描述待传入结构体中每个成员的具体信息,定义如下:
typedef struct D3D11_INPUT_ELEMENT_DESC
{
LPCSTR SemanticName; // 语义名
UINT SemanticIndex; // 语义索引
DXGI_FORMAT Format; // 数据格式
UINT InputSlot; // 输入槽索引(0-15)
UINT AlignedByteOffset; // 初始位置(字节偏移量)
D3D11_INPUT_CLASSIFICATION InputSlotClass; // 输入类型
UINT InstanceDataStepRate; // 忽略
} D3D11_INPUT_ELEMENT_DESC;
inputLayout
的初始化信息如下,描述了C++对应到HLSL的两个成员的信息:
const D3D11_INPUT_ELEMENT_DESC VertexPosColor::inputLayout[2] = {
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },
{ "COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0}
};
其中,语义名要与HLSL结构体中的语义名相同,若有多个相同的语义名,则语义索引就是另外一种区分。相同的语义按从上到下所以分别为0,1,2...
然后,DXGI_FORMAT
在这里通常描述数据的存储方式、大小。用DXGI_FORMAT_R32G32B32_FLOAT
仅仅是解释为3个float类型的值;而用DXGI_FORMAT_R32G32B32A32_FLOAT
在这里是说明颜色按RGBA存储,并且为4个float类型的值
输入槽这里只使用1个,即索引为0的输入槽。
初始位置则指的是该成员的位置与起始成员所在的字节偏移量。
输入类型有两种:D3D11_INPUT_PER_VERTEX_DATA
为按每个顶点数据输入,D3D11_INPUT_PER_INSTANCE_DATA
则是按每个实例数据输入。
接下来就可以使用ID3D11Device::CreateInputLayout
方法创建一个输入布局:
HRESULT ID3D11Device::CreateInputLayout(
const D3D11_INPUT_ELEMENT_DESC *pInputElementDescs, // [In]输入布局描述
UINT NumElements, // [In]上述数组元素个数
const void *pShaderBytecodeWithInputSignature, // [In]顶点着色器字节码
SIZE_T BytecodeLength, // [In]顶点着色器字节码长度
ID3D11InputLayout **ppInputLayout); // [Out]获取的输入布局
ID3D11DeviceContext::IASetInputLayout方法--输入装配阶段设置输入布局
下面的方法可以让我们使用刚创建好的输入布局:
void ID3D11DeviceContext::IASetInputLayout(
ID3D11InputLayout *pInputLayout); // [In]输入布局
顶点/像素着色器的创建
ID3D11Device::CraeteXXXXShader方法--创建着色器
从D3D设备可以创建出6种着色器:
方法 | 着色器类型 | 描述 |
---|---|---|
ID3D11Device::CreateVertexShader | ID3D11VertexShader | 顶点着色器 |
ID3D11Device::CreateHullShader | ID3D11HullShader | 外壳着色器 |
ID3D11Device::CreateDomainShader | ID3D11DomainShader | 域着色器 |
ID3D11Device::CreateComputeShader | ID3D11ComputeShader | 计算着色器 |
ID3D11Device::CreateGeometryShader | ID3D11GeometryShader | 几何着色器 |
ID3D11Device::CreatePixelShader | ID3D11PixelShader | 像素着色器 |
这些方法的输入形参都是一致的,只是输出的是不同的着色器,以创建顶点着色器的方法为例:
HRESULT ID3D11Device::CreateVertexShader(
const void *pShaderBytecode, // [In]着色器字节码
SIZE_T BytecodeLength, // [In]字节码长度
ID3D11ClassLinkage *pClassLinkage, // [In_Opt]忽略
ID3D11VertexShader **ppVertexShader); // [Out]获取顶点着色器
GameApp::InitEffect方法--着色器或特效相关的初始化
下面展示了GameApp::InitEffect
方法的实现,其中输入布局的创建也需要放到这里。
CreateShaderFromFile
函数请到文章开头的 HLSL编译着色器的三种方法 查看。
// 这里使用了filesystem头文件,除此之外还需要添加
// using namespace std::experimental;
bool GameApp::InitEffect()
{
ComPtr<ID3DBlob> blob;
// 创建顶点着色器
HR(CreateShaderFromFile(L"HLSL\\Triangle_VS.cso", L"HLSL\\Triangle_VS.hlsl", "VS", "vs_5_0", blob.ReleaseAndGetAddressOf()));
HR(m_pd3dDevice->CreateVertexShader(blob->GetBufferPointer(), blob->GetBufferSize(), nullptr, m_pVertexShader.GetAddressOf()));
// 创建并绑定顶点布局
HR(m_pd3dDevice->CreateInputLayout(VertexPosColor::inputLayout, ARRAYSIZE(VertexPosColor::inputLayout),
blob->GetBufferPointer(), blob->GetBufferSize(), m_pVertexLayout.GetAddressOf()));
// 创建像素着色器
HR(CreateShaderFromFile(L"HLSL\\Triangle_PS.cso", L"HLSL\\Triangle_PS.hlsl", "PS", "ps_5_0", blob.ReleaseAndGetAddressOf()));
HR(m_pd3dDevice->CreatePixelShader(blob->GetBufferPointer(), blob->GetBufferSize(), nullptr, m_pPixelShader.GetAddressOf()));
return true;
}
GameApp::InitResource方法
顶点缓冲区(Vertex Buffer)
顶点缓冲区的作用是,将顶点数组以缓冲区ID3D11Buffer
的形式提供给输入装配阶段。
ID3D11Device::CreateBuffer方法--创建一个缓冲区
要创建顶点缓冲区,首先需要填充好缓冲区描述D3D11_BUFFER_DESC
:
typedef struct D3D11_BUFFER_DESC
{
UINT ByteWidth; // 数据字节数
D3D11_USAGE Usage; // CPU和GPU的读写权限相关
UINT BindFlags; // 缓冲区类型的标志
UINT CPUAccessFlags; // CPU读写权限的指定
UINT MiscFlags; // 忽略
UINT StructureByteStride; // 忽略
} D3D11_BUFFER_DESC;
在这里需要详细讲述一下D3D11_USAGE
枚举类型对应的读写关系:
CPU读 | CPU写 | GPU读 | GPU写 | |
---|---|---|---|---|
D3D11_USAGE_DEFAULT | √ | √ | ||
D3D11_USAGE_IMMUTABLE | √ | |||
D3D11_USAGE_DYNAMIC | √ | √ | ||
D3D11_USAGE_STAGING | √ | √ | √ | √ |
对于D3D11_USAGE_DEFAULT
类型的缓冲区,应当使用 ID3D11DeviceContext::UpdateSubresource
方法来更新缓冲区资源,它的原理是将内存中的某段数据传递到显存中,然后再将该显存中的数据复制到在显存中的缓冲区。这种更新方式我们是无法直接访问缓冲区的内容的。在绘制完成/开始前调用可以比较快地更新显存中的数据。
而对于D3D11_USAGE_DYNAMIC
类型的缓冲区,则应当使用ID3D11DeviceContext::Map
和ID3D11DeviceContext::Unmap
方法,将显存中的数据映射到内存中,然后修改该片内存的数据,最后将修改好的数据映射回显存中。这种更新方式我们是可以直接获取来自显存的数据的,但代价就是更新的效率会比上面的方式更低一些。
由于目前的教程所涉及到的顶点缓冲区在创建后通常是不会修改的,因此将其设为D3D11_USAGE_IMMUTABLE
。
这里将创建包含三个顶点数据的缓冲区:
// 设置三角形顶点
// 注意三个顶点的给出顺序应当按顺时针排布
VertexPosColor vertices[] =
{
{ XMFLOAT3(0.0f, 0.5f, 0.5f), XMFLOAT4(0.0f, 1.0f, 0.0f, 1.0f) },
{ XMFLOAT3(0.5f, -0.5f, 0.5f), XMFLOAT4(0.0f, 0.0f, 1.0f, 1.0f) },
{ XMFLOAT3(-0.5f, -0.5f, 0.5f), XMFLOAT4(1.0f, 0.0f, 0.0f, 1.0f) }
};
// 设置顶点缓冲区描述
D3D11_BUFFER_DESC vbd;
ZeroMemory(&vbd, sizeof(vbd));
vbd.Usage = D3D11_USAGE_IMMUTABLE;
vbd.ByteWidth = sizeof vertices;
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
vbd.CPUAccessFlags = 0;
有了缓冲区描述,还需要使用D3D11_SUBRESOURCE_DATA
结构体来指定要用来初始化的数据:
typedef struct D3D11_SUBRESOURCE_DATA
{
const void *pSysMem; // 用于初始化的数据
UINT SysMemPitch; // 忽略
UINT SysMemSlicePitch; // 忽略
} D3D11_SUBRESOURCE_DATA;
子资源数据结构体的填充也很简单:
// 新建顶点缓冲区
D3D11_SUBRESOURCE_DATA InitData;
ZeroMemory(&InitData, sizeof(InitData));
InitData.pSysMem = vertices;
最后通过ID3D11Device::CreateBuffer
来创建一个顶点缓冲区:
HRESULT ID3D11Device::CreateBuffer(
const D3D11_BUFFER_DESC *pDesc, // [In]顶点缓冲区描述
const D3D11_SUBRESOURCE_DATA *pInitialData, // [In]子资源数据
ID3D11Buffer **ppBuffer); // [Out] 获取缓冲区
演示如下:
ComPtr<ID3D11Buffer> m_pVertexBuffer = nullptr;
HR(m_pd3dDevice->CreateBuffer(&vbd, &InitData, m_pVertexBuffer.GetAddressOf()));
ID3D11DeviceContext::IASetVertexBuffers方法--渲染管线输入装配阶段设置顶点缓冲区
创建好顶点缓冲区后,就可以在渲染管线输入装配阶段设置该顶点缓冲区了:
void ID3D11DeviceContext::IASetVertexBuffers(
UINT StartSlot, // [In]输入槽索引
UINT NumBuffers, // [In]缓冲区数目
ID3D11Buffer *const *ppVertexBuffers, // [In]指向缓冲区数组的指针
const UINT *pStrides, // [In]一个数组,规定了对所有缓冲区每次读取的字节数分别是多少
const UINT *pOffsets); // [In]一个数组,规定了对所有缓冲区的初始字节偏移量
// 输入装配阶段的顶点缓冲区设置
UINT stride = sizeof(VertexPosColor); // 跨越字节数
UINT offset = 0; // 起始偏移量
m_pd3dImmediateContext->IASetVertexBuffers(0, 1, m_pVertexBuffer.GetAddressOf(), &stride, &offset);
只要绘制的内容不变,该部分的设置则只需要进行一次即可,因为渲染管线中各个部分的设置方法一经调用就会立即生效。然而如果需要绘制不同的内容或者效果,则需要在绘制前给渲染管线绑定好各种所需的资源。
图元类型
D3D_PRIMITIVE_TOPOLOGY
枚举定义了许多种图元类型,通常会根据顶点缓冲区的顶点索引(如果有索引缓冲区则是根据这些索引的值)和装配方式进行解释,其中:
图元类型 | 含义 |
---|---|
D3D11_PRIMITIVE_TOPOLOGY_POINTLIST | 按一系列点进行装配 |
D3D11_PRIMITIVE_TOPOLOGY_LINESTRIP | 按一系列线段进行装配,每相邻两个顶点(或索引数组相邻的两个索引对应的顶点)构成一条线段 |
D3D11_PRIMITIVE_TOPOLOGY_LINELIST | 按一系列线段进行装配,每两个顶点(或索引数组每两个索引对应的顶点)构成一条线段 |
D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP | 按一系列三角形进行装配,每相邻三个顶点(或索引数组相邻的三个索引对应的顶点)构成一个三角形 |
D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST | 按一系列三角形进行装配,每三个顶点(或索引数组每三个索引对应的顶点)构成一个三角形 |
D3D11_PRIMITIVE_TOPOLOGY_LINELIST_ADJ | 每4个顶点为一组,只绘制第2个顶点与第3个顶点的连线(或索引数组每4个索引为一组,只绘制索引模4余数为2和3的连线) |
D3D11_PRIMITIVE_TOPOLOGY_LINESTRIP_ADJ | 绘制除了最开始和结尾的所有线段(或者索引数组不绘制索引0和1的连线,以及n-2和n-1的连线) |
D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST_ADJ | 每6个顶点为一组,只绘制第1、3、5个顶点构成的三角形(或索引数组每6个索引为一组,只绘制索引模6余数为0, 2, 4的三角形) |
D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP_ADJ | 抛弃所有索引模2为奇数的顶点或索引,剩余的进行Triangle Strip的绘制 |
Point List
Line list(左) or line strip(右)
Triangle list(左) or triangle strip(右)
Line list with adjacency(左) or line strip with adjacency(右)
Triangle list with adjacency(v6, v8, v10也构成一个三角形)
Triangle strip with adjacency缺图
通常绝大多数情况下,我们都会使用D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST
ID3D11DeviceContext::IASetPrimitiveTopology方法--渲染管线输入装配阶段设置图元类型
void ID3D11DeviceContext::IASetPrimitiveTopology(
D3D11_PRIMITIVE_TOPOLOGY Topology); // [In]图元类型
该操作只需要设置一次即可。
ID3D11DeviceContext::*SSetShader方法--给渲染管线某一着色阶段设置对应的着色器
这里的*
可以是V, H, D, C, G, P,对应六个可编程渲染管线阶段,除了第一个参数提供的着色器类型不同外,其余参数一致。
以ID3D11DeviceContext::VSSetShader
为例:
void ID3D11DeviceContext::VSSetShader(
ID3D11VertexShader *pVertexShader, // [In]顶点着色器
ID3D11ClassInstance *const *ppClassInstances, // [In_Opt]忽略
UINT NumClassInstances); // [In]忽略
注意: 类似给渲染管线绑定资源的一切方法,在绑定之后就会一直生效,而不是说仅能够使用一次。所以,以后如果你需要用别的特效去绘制当前物体,就要重新绑定好渲染管线所需要的一切资源。
最后给出GameApp::InitResource
方法的实现
bool GameApp::InitResource()
{
// 设置三角形顶点
VertexPosColor vertices[] =
{
{ XMFLOAT3(0.0f, 0.5f, 0.5f), XMFLOAT4(0.0f, 1.0f, 0.0f, 1.0f) },
{ XMFLOAT3(0.5f, -0.5f, 0.5f), XMFLOAT4(0.0f, 0.0f, 1.0f, 1.0f) },
{ XMFLOAT3(-0.5f, -0.5f, 0.5f), XMFLOAT4(1.0f, 0.0f, 0.0f, 1.0f) }
};
// 设置顶点缓冲区描述
D3D11_BUFFER_DESC vbd;
ZeroMemory(&vbd, sizeof(vbd));
vbd.Usage = D3D11_USAGE_IMMUTABLE;
vbd.ByteWidth = sizeof vertices;
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
vbd.CPUAccessFlags = 0;
// 新建顶点缓冲区
D3D11_SUBRESOURCE_DATA InitData;
ZeroMemory(&InitData, sizeof(InitData));
InitData.pSysMem = vertices;
HR(m_pd3dDevice->CreateBuffer(&vbd, &InitData, m_pVertexBuffer.GetAddressOf()));
// ******************
// 给渲染管线各个阶段绑定好所需资源
// 输入装配阶段的顶点缓冲区设置
UINT stride = sizeof(VertexPosColor); // 跨越字节数
UINT offset = 0; // 起始偏移量
m_pd3dImmediateContext->IASetVertexBuffers(0, 1, m_pVertexBuffer.GetAddressOf(), &stride, &offset);
// 设置图元类型,设定输入布局
m_pd3dImmediateContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
m_pd3dImmediateContext->IASetInputLayout(m_pVertexLayout.Get());
// 将着色器绑定到渲染管线
m_pd3dImmediateContext->VSSetShader(m_pVertexShader.Get(), nullptr, 0);
m_pd3dImmediateContext->PSSetShader(m_pPixelShader.Get(), nullptr, 0);
return true;
}
GameApp::DrawScene方法
ID3D11DeviceContext::Draw方法--根据已经绑定的顶点缓冲区进行绘制
该方法不需要提供索引缓冲区:
void ID3D11DeviceContext::Draw(
UINT VertexCount, // [In]需要绘制的顶点数目
UINT StartVertexLocation); // [In]起始顶点索引
调用该方法后,从输入装配阶段开始,该绘制的进行将会经历一次完整的渲染管线阶段,直到输出合并阶段为止。
通过指定VertexCount
和StartVertexLocation
的值我们可以按顺序绘制从索引VertexCount
到VertexCount + StartVertexLocation - 1
的顶点
GameApp::DrawScene
方法的实现如下:
void GameApp::DrawScene()
{
assert(m_pd3dImmediateContext);
assert(m_pSwapChain);
static float black[4] = { 0.0f, 0.0f, 0.0f, 1.0f }; // RGBA = (0,0,0,255)
m_pd3dImmediateContext->ClearRenderTargetView(m_pRenderTargetView.Get(), black);
m_pd3dImmediateContext->ClearDepthStencilView(m_pDepthStencilView.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
// 绘制三角形
m_pd3dImmediateContext->Draw(3, 0);
HR(m_pSwapChain->Present(0, 0));
}
最终的效果如下:
练习题
粗体字为自定义题目
- 尝试交换三角形第一个和第三个顶点的数据,屏幕将显示什么?为什么?
- 尝试用6个顶点绘制矩形表面
- 尝试用4个顶点绘制矩形表面(提示:
D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP
)
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。