使用着色器和着色器资源

现在,我们来了解一下如何使用着色器和着色器资源来开发适用于 Windows 8 的 Microsoft DirectX 游戏。 我们已经了解了如何设置图形设备和资源,可能你已经开始修改管道了。 现在让我们看看像素和顶点着色器。

如果你还不熟悉着色器语言,我们就要先简单阐述一下。 着色器是图形管道的特定阶段进行编译和运行的小型低级程序。 他们的专长是非常快速的浮点数学运算。 最常见的着色器程序包括:

  • 顶点着色器 - 针对场景中的每个顶点执行。 此着色器对调用应用提供给它的顶点缓冲区元素进行操作,并且至少会生成将光栅化为像素位置的 4 分量位置向量。
  • 像素着色器 - 针对呈现目标中的每个像素执行。 此着色器接收来自以前着色器阶段的光栅化坐标(在最简单的管道中是顶点着色器),并返回该像素位置的颜色(或其他 4 分量值),然后写入呈现目标。

此示例包括仅绘制几何图形的非常基本的顶点和像素着色器,以及添加基本照明计算的更复杂的着色器。

着色器程序采用 Microsoft 高级着色器语言 (HLSL) 编写。 HLSL 语法看起来很像 C 语言,但没有指针。 着色器程序必须非常简洁高效。 如果着色器编译的指令过多,则无法运行,并会返回错误。 (请注意,允许的指令的确切数量是 Direct3D 功能级别的一部分。)

在 Direct3D 中,着色器不会在运行时编译;而是在编译程序的其余部分时进行编译。 使用 Microsoft Visual Studio 2013 编译应用时,HLSL 文件将编译为 CSO (.cso) 文件,应用在绘图之前必须将此文件加载并放到 GPU 内存中。 打包应用时,请确保将这些 CSO 文件包含在应用中;它们属于资产,就像网格和纹理一样。

理解 HLSL 语义

在继续之前,有必要花点时间讨论 HLSL 语义,因为 Direct3D 新开发人员通常会对此感到困惑。 HLSL 语义是标识在应用和着色器程序之间传递的值的字符串。 尽管它们可以是各种可能的字符串中的任何一种,但最佳做法是可使用指示用法的字符串(如 POSITIONCOLOR)。 构造常量缓冲区或输入布局时,可以分配这些语义。 也可以为语义中附加一个介于 0 和 7 之间的数字,以便你可以为相似的值使用不同的寄存器。 例如:COLOR0、COLOR1、COLOR2...

前缀为“SV_”的语义是系统值语义,它们是由你的着色器程序编写的;你的游戏本身(在 CPU 上运行)无法修改它们。 通常,这些语义包含来自图形管道中另一着色器阶段的输入和输出的值,或者完全由 GPU 生成的值。

此外,SV_ 语义在用于指定着色器阶段的输入或输出时有着不同的行为。 例如,SV_POSITION(输出)包含在顶点着色器阶段转换的顶点数据,而 SV_POSITION(输入)则包含在光栅化阶段 GPU 内插的像素位置值。

下面是一些常见的 HLSL 语义:

  • POSITION(n) 表示顶点缓冲区数据。 SV_POSITION 提供像素着色器的像素位置,不能由你的游戏写入。
  • NORMAL(n) 表示顶点缓冲区提供的正常数据。
  • TEXCOORD(n) 表示提供给着色器的纹理 UV 坐标数据。
  • COLOR(n) 表示提供给着色器的 RGBA 颜色数据。 请注意,它的处理方式与坐标数据完全相同,包括在光栅化期间插值;语义只是帮助你确定它是颜色数据。
  • SV_Target[n] 用于从像素着色器写入到目标纹理或其他像素缓冲区。

在查看示例时,我们将看到 HLSL 语义的一些示例。

从常量缓冲区读取

如果常量缓冲区作为资源附加到其阶段,则任何着色器都可以从该缓冲区读取。 在此示例中,仅向顶点着色器分配了一个常量缓冲区。

常量缓冲区在两个位置声明:在 C++ 代码中以及在将访问它的相应 HLSL 文件中。

下面所示为如何在 C++ 代码中声明常量缓冲区结构。

typedef struct _constantBufferStruct {
    DirectX::XMFLOAT4X4 world;
    DirectX::XMFLOAT4X4 view;
    DirectX::XMFLOAT4X4 projection;
} ConstantBufferStruct;

在 C++ 代码中声明常量缓冲区的结构时,请确保所有数据都与 16 字节边界正确对齐。 执行此操作的最简单方法是使用 DirectXMath 类型(如 XMFLOAT4 或 XMFLOAT4X4),如示例代码所示。 还可以通过声明静态断言来防止出现未对齐的缓冲区:

// Assert that the constant buffer remains 16-byte aligned.
static_assert((sizeof(ConstantBufferStruct) % 16) == 0, "Constant Buffer size must be 16-byte aligned");

如果 ConstantBufferStruct 不是 16 字节对齐,此行代码将在编译时导致错误。 有关常量缓冲区对齐和打包的详细信息,请参阅常量变量的打包规则

以下所示为如何在顶点着色器 HLSL 中声明常量缓冲区。

cbuffer ModelViewProjectionConstantBuffer : register(b0)
{
    matrix mWorld;      // world matrix for object
    matrix View;        // view matrix
    matrix Projection;  // projection matrix
};

所有缓冲区(常量、纹理、采样器或其他)都必须定义寄存器,以便 GPU 可以访问它们。 每个着色器阶段最多允许 15 个常量缓冲区,每个缓冲区最多可以容纳 4,096 个常量变量。 寄存器用法声明语法如下所示:

  • b*#*:常量缓冲区的寄存器 (cbuffer)。
  • t*#*:纹理缓冲区的寄存器 (tbuffer)。
  • s*#*:采样器的寄存器。 (采样器定义纹理资源中纹素的查找行为。)

例如,像素着色器的 HLSL 可能会采用纹理和采样器作为输入,其声明如下所示。

Texture2D simpleTexture : register(t0);
SamplerState simpleSampler : register(s0);

设置管道时,由你决定是否将常量缓冲区分配给寄存器,将常量缓冲区附加到在 HLSL 文件中分配的同一槽。 例如,在上一主题中,调用 VSSetConstantBuffers 指示第一个参数为“0”。 这会告知 Direct3D 将常量缓冲区资源附加到寄存器 0,这与缓冲区分配给 HLSL 文件中的 register(b0) 的内容匹配。

从顶点缓冲区读取

顶点缓冲区将场景对象的三角形数据提供给顶点着色器。 与常量缓冲区一样,顶点缓冲区结构也使用类似的打包规则在 C++ 代码中声明。

typedef struct _vertexPositionColor
{
    DirectX::XMFLOAT3 pos;
    DirectX::XMFLOAT3 color;
} VertexPositionColor;

Direct3D 11 中的顶点数据没有标准格式。 而是使用描述符定义我们自己的顶点数据布局;数据字段使用 D3D11_INPUT_ELEMENT_DESC 结构数组定义。 在这里,我们介绍了一个简单的输入布局,该布局描述与前面的结构相同的顶点格式:

D3D11_INPUT_ELEMENT_DESC iaDesc [] =
{
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },

    { "COLOR", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 },
};

hr = device->CreateInputLayout(
    iaDesc,
    ARRAYSIZE(iaDesc),
    bytes,
    bytesRead,
    &m_pInputLayout
    );

如果在修改示例代码时将数据添加到顶点格式,请确保也更新输入布局,否则着色器将无法解释它。 可以如下所示修改顶点布局:

typedef struct _vertexPositionColorTangent
{
    DirectX::XMFLOAT3 pos;
    DirectX::XMFLOAT3 normal;
    DirectX::XMFLOAT3 tangent;
} VertexPositionColorTangent;

在这种情况下,可以按如下所示修改输入布局定义。

D3D11_INPUT_ELEMENT_DESC iaDescExtended[] =
{
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },

    { "NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 },

    { "TANGENT", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 },
};

hr = device->CreateInputLayout(
    iaDesc,
    ARRAYSIZE(iaDesc),
    bytes,
    bytesRead,
    &m_pInputLayoutExtended
    );

每个输入布局元素定义都以字符串(如“POSITION”或“NORMAL”)作为前缀,这是本主题前面讨论的语义。 它就像一个句柄,可以帮助 GPU 在处理顶点时识别该元素。 为顶点元素选择常用的有意义的名称。

与常量缓冲区一样,顶点着色器的传入顶点元素有相应的缓冲区定义。 (这就是为什么我们在创建输入布局时提供了对顶点着色器资源的引用 - Direct3D 使用着色器的输入结构验证每个顶点数据布局。)请注意输入布局定义与此 HLSL 缓冲区声明之间的语义是如何匹配的。 但是,COLOR 附加了“0”。 如果布局中只声明了一个 COLOR 元素,则无需添加 0,但以后选择添加更多颜色元素时,最好附加 0。

struct VS_INPUT
{
    float3 vPos   : POSITION;
    float3 vColor : COLOR0;
};

在着色器之间传递数据

着色器在执行时从其主函数中获取输入类型并返回输出类型。 对于上一节中定义的顶点着色器,输入类型是 VS_INPUT 结构,我们定义了匹配的输入布局和 C++ 结构。 此结构的数组用于在 CreateCube 方法中创建顶点缓冲区。

顶点着色器返回 PS_INPUT 结构,该结构必须至少包含 4 分量 (float4) 最终顶点位置。 此位置值必须具有系统值语义 SV_POSITION 并为其声明,以便 GPU 具有执行下一个绘图步骤所需的数据。 请注意,顶点着色器输出和像素着色器输入之间不是一一对应关系;顶点着色器为每个授予它的顶点返回一个结构,但像素着色器为每个像素运行一次。 这是因为每个顶点数据首先通过光栅化阶段。 此阶段确定“覆盖”要绘制的几何图形的像素,为每个像素计算每个顶点数据的内插数据,然后为每个像素调用一次像素着色器。 内插是光栅化输出值时的默认行为,尤其对于正确处理输出向量数据(光向量、每个顶点法线和切线等)至关重要。

struct PS_INPUT
{
    float4 Position : SV_POSITION;  // interpolated vertex position (system value)
    float4 Color    : COLOR0;       // interpolated diffuse color
};

检查顶点着色器

示例顶点着色器非常简单:获取顶点(位置和颜色),将模型坐标中的位置转换为透视投影坐标,并将其(以及颜色)返回到光栅器。 请注意,颜色值与位置数据一起内插,为每个像素提供不同的值,即使顶点着色器没有对颜色值执行任何计算。

VS_OUTPUT main(VS_INPUT input) // main is the default function name
{
    VS_OUTPUT Output;

    float4 pos = float4(input.vPos, 1.0f);

    // Transform the position from object space to homogeneous projection space
    pos = mul(pos, mWorld);
    pos = mul(pos, View);
    pos = mul(pos, Projection);
    Output.Position = pos;

    // Just pass through the color data
    Output.Color = float4(input.vColor, 1.0f);

    return Output;
}

更复杂的顶点着色器(例如为 Phong 着色设置对象顶点的顶点着色器)如下所示。 在这种情况下,我们利用了向量和法线内插到近似平滑表面的事实。

// A constant buffer that stores the three basic column-major matrices for composing geometry.
cbuffer ModelViewProjectionConstantBuffer : register(b0)
{
    matrix model;
    matrix view;
    matrix projection;
};

cbuffer LightConstantBuffer : register(b1)
{
    float4 lightPos;
};

struct VertexShaderInput
{
    float3 pos : POSITION;
    float3 normal : NORMAL;
};

// Per-pixel color data passed through the pixel shader.

struct PixelShaderInput
{
    float4 position : SV_POSITION; 
    float3 outVec : POSITION0;
    float3 outNormal : NORMAL0;
    float3 outLightVec : POSITION1;
};

PixelShaderInput main(VertexShaderInput input)
{
    // Inefficient -- doing this only for instruction. Normally, you would
 // premultiply them on the CPU and place them in the cbuffer.
    matrix mvMatrix = mul(model, view);
    matrix mvpMatrix = mul(mvMatrix, projection);

    PixelShaderInput output;

    float4 pos = float4(input.pos, 1.0f);
    float4 normal = float4(input.normal, 1.0f);
    float4 light = float4(lightPos.xyz, 1.0f);

    // 
    float4 eye = float4(0.0f, 0.0f, -2.0f, 1.0f);

    // Transform the vertex position into projected space.
    output.gl_Position = mul(pos, mvpMatrix);
    output.outNormal = mul(normal, mvMatrix).xyz;
    output.outVec = -(eye - mul(pos, mvMatrix)).xyz;
    output.outLightVec = mul(light, mvMatrix).xyz;

    return output;
}

检查像素着色器

此示例中的此像素着色器很可能是像素着色器中可以拥有的绝对最小代码量。 它采用光栅化期间生成的内插像素颜色数据,并将其作为输出返回,并写入呈现目标。 多无聊!

PS_OUTPUT main(PS_INPUT In)
{
    PS_OUTPUT Output;

    Output.RGBColor = In.Color;

    return Output;
}

返回值上的 SV_TARGET 系统值语义很重要。 它指示输出要写入主呈现目标,这是提供给交换链以供显示的纹理缓冲区。 对于像素着色器,这是必需的 - 如果没有来自像素着色器的颜色数据,Direct3D 就不会显示任何内容!

执行 Phong 着色的更复杂的像素着色器的示例如下所示。 由于向量和法线已经过内插,因此我们不必按像素计算它们。 但是,由于内插的工作原理,我们确实必须重新规范化它们;从概念上讲,我们需要逐步将向量从顶点 A 的方向“旋转”到顶点 B 的方向,并保持其长度(沿两个向量终结点之间的直线内插而不是剪切)。

cbuffer MaterialConstantBuffer : register(b2)
{
    float4 lightColor;
    float4 Ka;
    float4 Kd;
    float4 Ks;
    float4 shininess;
};

struct PixelShaderInput
{
    float4 position : SV_POSITION;
    float3 outVec : POSITION0;
    float3 normal : NORMAL0;
    float3 light : POSITION1;
};

float4 main(PixelShaderInput input) : SV_TARGET
{
    float3 L = normalize(input.light);
    float3 V = normalize(input.outVec);
    float3 R = normalize(reflect(L, input.normal));

    float4 diffuse = Ka + (lightColor * Kd * max(dot(input.normal, L), 0.0f));
    diffuse = saturate(diffuse);

    float4 specular = Ks * pow(max(dot(R, V), 0.0f), shininess.x - 50.0f);
    specular = saturate(specular);

    float4 finalColor = diffuse + specular;

    return finalColor;
}

在另一个示例中,像素着色器采用自己的常量缓冲区,其中包含光线和材料信息。 顶点着色器的输入布局将展开以包含正常数据,且该顶点着色器的输出应包括顶点、光线和顶点法线在视图坐标系中的转换向量。

如果纹理缓冲区和采样器分配有寄存器(t 和 s),则还可以在像素着色器中访问它们。

Texture2D simpleTexture : register(t0);
SamplerState simpleSampler : register(s0);

struct PixelShaderInput
{
    float4 pos : SV_POSITION;
    float3 norm : NORMAL;
    float2 tex : TEXCOORD0;
};

float4 SimplePixelShader(PixelShaderInput input) : SV_TARGET
{
    float3 lightDirection = normalize(float3(1, -1, 0));
    float4 texelColor = simpleTexture.Sample(simpleSampler, input.tex);
    float lightMagnitude = 0.8f * saturate(dot(input.norm, -lightDirection)) + 0.2f;
    return texelColor * lightMagnitude;
}

着色器是非常强大的工具,可用于生成过程资源,如阴影映射或噪音纹理。 事实上,高级技术要求你更抽象地考虑纹理,不是将其视为视觉元素,而是视为缓冲区。 它们保存高度信息等数据,或者可在最终像素着色器传递中采样或者在特定帧中采样作为多阶段效果传递的一部分的其他数据。 多重采样是一种强大的工具,是很多现代视觉效果的主干。

后续步骤

希望你熟悉 DirectX 11 这一点,并且已准备好开始处理项目。 下面是一些链接,可帮助你回答有关使用 DirectX 和 C++ 进行开发时遇到的其他问题:

使用 DirectX 设备资源

了解 Direct3D 11 呈现管道