Direct3D 12渲染流水线
渲染流水线是现代图形处理的核心,它将3D场景转换为屏幕上的2D图像。在DirectX 12中,这个过程更加精细可控,允许开发者优化性能并实现各种高级视觉效果。本章将详细介绍DirectX 12渲染流水线的各个阶段及其工作原理。
5.1 3D 视觉即错觉?
人类视觉系统能够感知3D世界,但我们的显示设备(如显示器、手机屏幕)只能显示2D图像。3D图形学的核心挑战就是在2D平面上创造出3D深度的错觉。
这种错觉主要通过以下视觉线索实现:
- 透视投影:远处的物体看起来更小
- 光照与阴影:提供物体形状和表面细节的视觉线索
- 遮挡:前面的物体会遮挡后面的物体
- 纹理和细节:增加表面真实感
- 运动视差:观察角度变化时,物体间相对位置的变化
渲染流水线的目标就是模拟这些视觉线索,创造出令人信服的3D环境。在计算机图形学中,我们通过数学模型来表示3D世界,并通过一系列转换将其投影到2D屏幕上。
cpp
// 简单的透视投影示例
float fov = 45.0f * (XM_PI / 180.0f); // 视场角(角度转弧度)
float aspectRatio = (float)screenWidth / (float)screenHeight;
float nearPlane = 0.1f;
float farPlane = 1000.0f;
// 创建透视投影矩阵
XMMATRIX projectionMatrix = XMMatrixPerspectiveFovLH(
fov, aspectRatio, nearPlane, farPlane
);
5.2 模型的表示
在3D图形中,模型通常由以下几个基本要素组成:
-
顶点数据:定义模型的几何形状
- 位置坐标
- 法线向量
- 纹理坐标
- 切线和副切线
- 顶点颜色
- 蒙皮权重(用于动画)
-
索引数据:定义如何将顶点连接成三角形
-
材质信息:定义表面的外观特性
- 漫反射颜色
- 镜面反射颜色
- 粗糙度/光泽度
- 法线贴图
- 其他特殊贴图
以下是一个简单的顶点结构和模型数据的例子:
cpp
// 顶点结构定义
struct Vertex {
XMFLOAT3 Position; // 位置坐标
XMFLOAT3 Normal; // 法线向量
XMFLOAT2 TexCoord; // 纹理坐标
};
// 模型数据(一个简单的立方体)
std::vector<Vertex> vertices = {
// 前面
{ XMFLOAT3(-1.0f, -1.0f, -1.0f), XMFLOAT3(0.0f, 0.0f, -1.0f), XMFLOAT2(0.0f, 1.0f) },
{ XMFLOAT3(-1.0f, 1.0f, -1.0f), XMFLOAT3(0.0f, 0.0f, -1.0f), XMFLOAT2(0.0f, 0.0f) },
{ XMFLOAT3( 1.0f, 1.0f, -1.0f), XMFLOAT3(0.0f, 0.0f, -1.0f), XMFLOAT2(1.0f, 0.0f) },
{ XMFLOAT3( 1.0f, -1.0f, -1.0f), XMFLOAT3(0.0f, 0.0f, -1.0f), XMFLOAT2(1.0f, 1.0f) },
// 其余面的顶点...
};
// 索引数据
std::vector<uint16_t> indices = {
// 前面
0, 1, 2,
0, 2, 3,
// 其余面的索引...
};
在DirectX 12中,这些数据通常存储在缓冲区中,然后传递给渲染流水线:
cpp
// 创建顶点缓冲区
ComPtr<ID3D12Resource> vertexBuffer;
// ... 分配和填充顶点缓冲区 ...
// 创建顶点缓冲区视图
D3D12_VERTEX_BUFFER_VIEW vbv = {};
vbv.BufferLocation = vertexBuffer->GetGPUVirtualAddress();
vbv.SizeInBytes = vertexBufferSize;
vbv.StrideInBytes = sizeof(Vertex);
// 创建索引缓冲区和视图
// ... 类似的代码 ...
// 在绘制命令中使用
commandList->IASetVertexBuffers(0, 1, &vbv);
commandList->IASetIndexBuffer(&ibv);
commandList->DrawIndexedInstanced(indexCount, 1, 0, 0, 0);
5.3 计算机色彩基础
颜色是渲染中的核心要素,理解计算机如何表示和处理颜色对于图形编程至关重要。
5.3.1 颜色运算
在计算机图形学中,颜色通常表示为RGB(红、绿、蓝)或RGBA(加上Alpha透明度)分量的组合。每个分量的值范围通常为0到1(浮点表示)或0到255(整数表示)。
cpp
// 颜色表示(浮点)
struct Color {
float R, G, B, A;
};
// 常见颜色
Color red = { 1.0f, 0.0f, 0.0f, 1.0f };
Color green = { 0.0f, 1.0f, 0.0f, 1.0f };
Color blue = { 0.0f, 0.0f, 1.0f, 1.0f };
Color white = { 1.0f, 1.0f, 1.0f, 1.0f };
Color black = { 0.0f, 0.0f, 0.0f, 1.0f };
颜色的基本运算包括:
-
颜色混合:将两种颜色按一定比例混合
cpp
Color Lerp(Color a, Color b, float t) { return { a.R + (b.R - a.R) * t, a.G + (b.G - a.G) * t, a.B + (b.B - a.B) * t, a.A + (b.A - a.A) * t }; }
-
颜色乘法:通常用于光照计算
cpp
Color Multiply(Color a, Color b) { return { a.R * b.R, a.G * b.G, a.B * b.B, a.A * b.A }; }
-
Alpha混合:考虑透明度的颜色混合
cpp
Color AlphaBlend(Color source, Color destination) { float a = source.A; return { source.R * a + destination.R * (1 - a), source.G * a + destination.G * (1 - a), source.B * a + destination.B * (1 - a), 1.0f // 结果是完全不透明的 }; }
5.3.2 128 位颜色
在某些高精度渲染场景中,每个颜色通道可能需要32位浮点数表示,形成128位颜色格式:
- 每个通道使用32位IEEE浮点数
- 提供极高的精度和动态范围
- 适用于HDR渲染、色彩校正和图像处理
- 在DirectX中对应
DXGI_FORMAT_R32G32B32A32_FLOAT
格式
cpp
// 128位浮点颜色
struct Color128 {
float R, G, B, A; // 每个分量是32位浮点数
};
// 在DirectX中使用128位颜色格式
D3D12_RESOURCE_DESC textureDesc = {};
textureDesc.Format = DXGI_FORMAT_R32G32B32A32_FLOAT;
// ... 其他参数 ...
5.3.3 32 位颜色
32位颜色是最常用的颜色格式,通常分配为RGBA,每个通道8位:
- 红色、绿色、蓝色和alpha各占8位
- 可表示约1670万种颜色
- 在DirectX中对应
DXGI_FORMAT_R8G8B8A8_UNORM
格式
cpp
// 32位整数颜色
struct Color32 {
uint8_t R, G, B, A; // 每个分量是8位整数
};
// 或使用单个32位值
uint32_t PackColor(uint8_t r, uint8_t g, uint8_t b, uint8_t a) {
return (a << 24) | (b << 16) | (g << 8) | r;
}
// 在DirectX中使用32位颜色格式
D3D12_RESOURCE_DESC textureDesc = {};
textureDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
// ... 其他参数 ...
5.4 渲染流水线概述
DirectX 12的渲染流水线由以下几个主要阶段组成:
- 输入装配器阶段:将顶点和索引数据组装成基本图元(如点、线、三角形)
- 顶点着色器阶段:处理每个顶点的位置、颜色等属性
- 曲面细分阶段(可选):增加模型细节
- 几何着色器阶段(可选):创建或修改图元
- 光栅化阶段:将3D图元转换为2D像素
- 像素着色器阶段:计算每个像素的颜色
- 输出合并阶段:执行深度测试、模板测试和混合操作
以下是一个简化的DirectX 12渲染流水线示例:
cpp
// 1. 设置输入布局
D3D12_INPUT_ELEMENT_DESC inputElementDescs[] = {
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
{ "NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }
};
// 2. 创建图形管线状态对象
D3D12_GRAPHICS_PIPELINE_STATE_DESC psoDesc = {};
psoDesc.InputLayout = { inputElementDescs, _countof(inputElementDescs) };
psoDesc.pRootSignature = rootSignature.Get();
psoDesc.VS = { vertexShaderBlob->GetBufferPointer(), vertexShaderBlob->GetBufferSize() };
psoDesc.PS = { pixelShaderBlob->GetBufferPointer(), pixelShaderBlob->GetBufferSize() };
psoDesc.RasterizerState = CD3DX12_RASTERIZER_DESC(D3D12_DEFAULT);
psoDesc.BlendState = CD3DX12_BLEND_DESC(D3D12_DEFAULT);
psoDesc.DepthStencilState = CD3DX12_DEPTH_STENCIL_DESC(D3D12_DEFAULT);
psoDesc.SampleMask = UINT_MAX;
psoDesc.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE;
psoDesc.NumRenderTargets = 1;
psoDesc.RTVFormats[0] = DXGI_FORMAT_R8G8B8A8_UNORM;
psoDesc.DSVFormat = DXGI_FORMAT_D24_UNORM_S8_UINT;
psoDesc.SampleDesc.Count = 1;
// 3. 在绘制命令中设置渲染管线
commandList->SetPipelineState(pipelineState.Get());
commandList->SetGraphicsRootSignature(rootSignature.Get());
commandList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
commandList->IASetVertexBuffers(0, 1, &vertexBufferView);
commandList->IASetIndexBuffer(&indexBufferView);
commandList->DrawIndexedInstanced(indexCount, 1, 0, 0, 0);
5.5 输入装配器阶段
输入装配器(Input Assembler,IA)阶段是渲染流水线的第一个阶段,负责从内存中读取顶点数据并将其组装成基本图元。
5.5.1 顶点
顶点是3D模型的基本构建单元,通常包含以下属性:
- 位置:通常是三维空间中的XYZ坐标
- 法线:用于光照计算的表面法向量
- 纹理坐标:用于从纹理中采样的UV坐标
- 颜色:顶点的颜色信息
- 切线和副切线:用于法线映射
- 骨骼索引和权重:用于蒙皮动画
在DirectX 12中,通过定义输入布局来指定顶点结构:
cpp
// 定义顶点结构
struct Vertex {
XMFLOAT3 position;
XMFLOAT3 normal;
XMFLOAT2 texCoord;
XMFLOAT4 color;
};
// 定义输入布局
D3D12_INPUT_ELEMENT_DESC inputLayout[] = {
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
{ "NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
{ "COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 32, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }
};
5.5.2 图元拓扑
图元拓扑定义了如何将顶点连接成几何图元。DirectX 12支持以下主要拓扑类型:
- 点列表(
D3D_PRIMITIVE_TOPOLOGY_POINTLIST
):每个顶点表示一个点 - 线列表(
D3D_PRIMITIVE_TOPOLOGY_LINELIST
):每两个顶点构成一条线 - 线带(
D3D_PRIMITIVE_TOPOLOGY_LINESTRIP
):连续的顶点形成连接的线段 - 三角形列表(
D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST
):每三个顶点构成一个三角形 - 三角形带(
D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP
):连续的顶点形成相邻的三角形
cpp
// 设置图元拓扑
commandList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
三角形列表是最常用的拓扑类型,其工作原理如下:
- 每三个顶点形成一个三角形
- 顶点0, 1, 2形成第一个三角形
- 顶点3, 4, 5形成第二个三角形
- 以此类推
gherkin
顶点索引: 0----1 3----4
| /| | /|
| / | | / |
| / | | / |
2 / 5 /
三角形带则更节省顶点:
- 第一个三角形使用顶点0, 1, 2
- 第二个三角形使用顶点2, 1, 3(注意顺序)
- 第三个三角形使用顶点2, 3, 4
- 以此类推
顶点索引: 1----3----5
|\ /|\ /|
| X | X |
|/ \|/ \|
0----2----4
5.5.3 索引
索引允许多个图元共享顶点,节省内存并提高性能。索引缓冲区存储顶点数组的索引,而不是重复存储顶点数据。
cpp
// 定义索引数据(三角形列表)
uint16_t indices[] = {
0, 1, 2, // 三角形1
0, 2, 3, // 三角形2
4, 5, 6, // 三角形3
4, 6, 7 // 三角形4
};
// 创建索引缓冲区
ComPtr<ID3D12Resource> indexBuffer;
// ... 分配和填充索引缓冲区 ...
// 创建索引缓冲区视图
D3D12_INDEX_BUFFER_VIEW indexBufferView = {};
indexBufferView.BufferLocation = indexBuffer->GetGPUVirtualAddress();
indexBufferView.Format = DXGI_FORMAT_R16_UINT; // 16位索引
indexBufferView.SizeInBytes = sizeof(indices);
// 设置索引缓冲区并绘制
commandList->IASetIndexBuffer(&indexBufferView);
commandList->DrawIndexedInstanced(12, 1, 0, 0, 0); // 绘制12个索引(4个三角形)
索引的优势:
- 内存效率:共享顶点减少数据重复
- 性能提升:减少顶点着色器的处理量
- 顶点缓存优化:GPU可以缓存最近处理的顶点
5.6 顶点着色器阶段
顶点着色器(Vertex Shader)是完全可编程的阶段,负责处理每个顶点的计算。它的主要任务包括:
- 坐标变换:将顶点从模型空间转换到裁剪空间
- 顶点属性计算:计算或传递颜色、纹理坐标等属性
- 特殊效果准备:如顶点动画、变形等
典型的顶点着色器HLSL代码示例:
hlsl
// 常量缓冲区定义
cbuffer ModelViewProjectionConstantBuffer : register(b0) {
matrix model;
matrix view;
matrix projection;
};
// 顶点着色器输入结构
struct VertexInput {
float3 position : POSITION;
float3 normal : NORMAL;
float2 texCoord : TEXCOORD0;
float4 color : COLOR;
};
// 顶点着色器输出结构(传递给像素着色器)
struct VertexOutput {
float4 position : SV_POSITION;
float3 normal : NORMAL;
float2 texCoord : TEXCOORD0;
float4 color : COLOR;
float3 worldPosition : TEXCOORD1;
};
// 顶点着色器主函数
VertexOutput main(VertexInput input) {
VertexOutput output;
// 坐标变换:模型空间 -> 世界空间 -> 观察空间 -> 裁剪空间
float4 modelPosition = float4(input.position, 1.0f);
float4 worldPosition = mul(modelPosition, model);
float4 viewPosition = mul(worldPosition, view);
output.position = mul(viewPosition, projection);
// 法线变换:从模型空间到世界空间
// 注意:法线需要使用模型矩阵的逆转置
output.normal = normalize(mul(input.normal, (float3x3)model));
// 传递纹理坐标和颜色
output.texCoord = input.texCoord;
output.color = input.color;
// 保存世界空间位置(用于光照计算)
output.worldPosition = worldPosition.xyz;
return output;
}
5.6.1 局部空间和世界空间
在3D图形中,坐标转换是通过一系列空间变换完成的:
-
局部空间(模型空间):
- 模型的原始坐标系
- 以模型自身为中心
- 独立于场景中的位置和方向
-
世界空间:
- 整个3D场景的共同坐标系
- 所有模型都放置在这个统一空间中
- 通过模型矩阵(Model Matrix)将模型从局部空间变换到世界空间
cpp
// 创建模型矩阵(局部空间->世界空间)
XMMATRIX modelMatrix = XMMatrixIdentity();
modelMatrix *= XMMatrixScaling(scale.x, scale.y, scale.z); // 缩放
modelMatrix *= XMMatrixRotationQuaternion(XMLoadFloat4(&rotation)); // 旋转
modelMatrix *= XMMatrixTranslation(position.x, position.y, position.z); // 平移
变换顺序很重要:
- 先缩放
- 再旋转
- 最后平移
在顶点着色器中应用模型矩阵:
hlsl
// 将顶点从局部空间变换到世界空间
float4 worldPosition = mul(float4(localPosition, 1.0f), modelMatrix);
5.6.2 观察空间
观察空间(又称摄像机空间或视图空间)是以摄像机为中心的坐标系:
- 摄像机位于原点(0,0,0)
- 摄像机沿负Z轴观察
- Y轴通常指向上方
通过视图矩阵(View Matrix)将世界空间转换为观察空间:
cpp
// 创建视图矩阵(世界空间->观察空间)
XMVECTOR eyePosition = XMLoadFloat3(&cameraPosition);
XMVECTOR focusPoint = XMLoadFloat3(&cameraTarget);
XMVECTOR upDirection = XMLoadFloat3(&cameraUp);
XMMATRIX viewMatrix = XMMatrixLookAtLH(eyePosition, focusPoint, upDirection);
在顶点着色器中应用视图矩阵:
hlsl
// 将顶点从世界空间变换到观察空间
float4 viewPosition = mul(worldPosition, viewMatrix);
5.6.3 投影和齐次裁剪空间
投影变换将观察空间中的3D坐标转换为标准化设备坐标(NDC)空间,这个过程包含两个步骤:
- 投影变换:通过投影矩阵将观察空间转换为齐次裁剪空间
- 透视除法:将齐次坐标(x,y,z,w)转换为NDC坐标(x/w, y/w, z/w)
DirectX中使用两种主要的投影方式:
-
透视投影:
- 模拟现实中的透视效果(远小近大)
- 适用于大多数3D场景
cpp
// 创建透视投影矩阵 float fovY = 45.0f * XM_PI / 180.0f; // 垂直视场角(度转弧度) float aspectRatio = (float)width / height; float nearZ = 0.1f; float farZ = 1000.0f; XMMATRIX projectionMatrix = XMMatrixPerspectiveFovLH( fovY, aspectRatio, nearZ, farZ );
-
正交投影:
- 不考虑透视效果(远近物体大小相同)
- 适用于2D UI、工程图纸等
cpp
// 创建正交投影矩阵 float width = 10.0f; float height = 8.0f; float nearZ = 0.1f; float farZ = 100.0f; XMMATRIX projectionMatrix = XMMatrixOrthographicLH( width, height, nearZ, farZ );
在顶点着色器中应用投影矩阵:
hlsl
// 将顶点从观察空间变换到裁剪空间
float4 clipPosition = mul(viewPosition, projectionMatrix);
// 整个变换链可以合并为一行
output.position = mul(mul(mul(float4(input.position, 1.0f), model), view), projection);
完成投影变换后,顶点位于裁剪空间中。位于单位立方体(-w ≤ x,y,z ≤ w)外的顶点将被裁剪。经过透视除法后,顶点将位于[-1,1]³的标准化设备坐标系中。
5.7 曲面细分阶段
曲面细分(Tessellation)是一个可选的流水线阶段,允许动态增加模型的几何细节。它由三个子阶段组成:
- 外壳着色器(Hull Shader):准备控制点和细分因子
- 曲面细分器(Tessellator):固定功能,执行细分操作
- 域着色器(Domain Shader):计算细分后的新顶点属性
曲面细分的主要应用:
- 随距离动态调整网格细节(LOD)
- 曲面位移映射
- 水面和地形渲染
- 程序化几何生成
简化的曲面细分HLSL示例:
hlsl
// 补丁常量函数(决定细分因子)
struct PatchConstantOutput {
float edges[3] : SV_TessFactor;
float inside : SV_InsideTessFactor;
};
PatchConstantOutput PatchConstantFunction(InputPatch<VertexOutput, 3> patch) {
PatchConstantOutput output;
// 基于摄像机距离计算细分因子
float distance = length(CameraPosition - GetPatchCenter(patch));
float tessAmount = saturate((MaxDistance - distance) / MaxDistance);
// 设置细分因子(1到MaxTessFactor)
float factor = lerp(1.0f, MaxTessFactor, tessAmount);
output.edges[0]