3031 字
15 分钟
现代 GPU 架构与命令缓冲区

一、回顾:你在 DX11 里做了什么#

在之前的 DX11 笔记中,你调用 context->Draw(3, 0) 画了一个三角形。这行代码在 CPU 侧瞬间返回了,但 GPU 并没有立刻执行它。实际上发生的事情是:

CPU GPU
│ │
├─ Draw(3, 0) │
│ └→ 写命令到 Command Buffer │
├─ Draw(6, 0) │
│ └→ 追加命令 │
├─ ...更多 Draw... │
├─ Present() │
│ └→ 提交 Command Buffer │
│ ├─ 收到命令,开始执行
│ ├─ 顶点着色器
│ ├─ 光栅化
│ ├─ 像素着色器
│ ├─ 输出合并
│ └─ 呈现到屏幕

CPU 和 GPU 是异步并行的。CPU 往一个队列里塞命令,GPU 从队列里取命令。这个队列就是 Command Buffer。

理解了这一点,引擎里很多看似奇怪的设计就有了原因——为什么 CreateBufferDraw 可以在不同线程调用(DX11 的 Device/Context 分离)、为什么 GPU 性能瓶颈可能不在 Draw 调用本身而在”等待 GPU 完成上一帧”。


二、GPU 如何并行:SIMT 执行模型#

1 CPU 的多线程 vs GPU 的 SIMT#

CPU 设计目标是低延迟——分支预测、乱序执行、大缓存,一切为了让单个指令流尽快完成。8 个物理核心跑 16 个线程已经算多了。

GPU 设计目标是高吞吐——几千个核心,每个核心比较简单,没有复杂的分支预测。它们被组织成单指令多线程(SIMT,Single Instruction Multiple Threads)

一个 GPU 有 N 个 SM (Streaming Multiprocessor)
每个 SM 有 M 个 CUDA Core / Shader Core
每个 SM 同时运行多个 Warp(NVIDIA,32 线程)或 Wavefront(AMD,64 线程)

Warp 内部的所有线程同时执行同一条指令,但操作不同的数据。这就是 float4 对齐如此重要的根本原因——每个寄存器的宽度天然是 128 位(4 个 float),一次 SIMD 操作处理 4 个分量。

2 一个 Draw Call 在 GPU 上的旅程#

当你调用 Draw(3000, 0)(画 1000 个三角形):

  1. 命令处理器(Command Processor) 从 Command Buffer 读取 Draw 命令
  2. Primitive Distributor 把三角形分发给各个 SM
  3. 每个 SM 拿到自己的那批三角形,启动 Warp 执行顶点着色器
  4. 顶点处理完后,三角形被送到光栅化单元(Raster Engine)
  5. 光栅化后,像素被分回各个 SM 执行像素着色器
  6. 结果写入 ROP(Render Output Unit),做深度测试和混合

关键洞察:一个 Draw Call 的工作被拆散到几十个 SM 上并行执行。GPU 不在乎你画 1 个三角形还是 1000 个——它都会用上所有 SM。这就是为什么减少 Draw Call 数量如此重要——100 个 Draw Call 各画 10 个三角形,比 1 个 Draw Call 画 1000 个三角形慢得多,不是因为 GPU 画不动,而是因为 CPU 提交命令和 GPU 切换状态的开销。

3 延迟隐藏(Latency Hiding)#

GPU 的核心武器。当一个 Warp 在等纹理采样结果(几百个时钟周期),SM 会立刻切换到另一个早已准备好的 Warp 执行:

Warp 0: VS执行 → 纹理采样(等待中)...
Warp 1: VS执行 → 纹理采样(等待中)...
Warp 2: PS执行 → 写入结果 → PS执行(下一个像素)
Warp 3: VS执行 → ...

SM 上有几十个 Warp 在轮转。如果活跃 Warp 数量不够(Occupancy 低),GPU 就无法有效隐藏延迟,性能会下降。Shader 里用太多寄存器、或者频繁的分支导致 Wave 发散,都会降低 Occupancy。


三、DX11 的”黑箱”模型及其问题#

1 驱动帮你做了什么#

DX11 里你调用 context->Draw(),驱动在背后做了大量工作:

  • 内存管理:你创建了一个 D3D11_USAGE_DEFAULT 的 Buffer,驱动决定它到底放在显存还是共享内存、什么时候移动数据、什么时候释放
  • 资源状态转换(Resource State Transitions):你设了一个 Render Target,又立刻把它当纹理读——驱动自动插入 barrier,隐式做状态转换
  • 命令缓冲管理:你的 Draw 被追加到驱动的内部 Command Buffer,驱动决定什么时候提交
  • 描述符(Descriptors)分配:Shader Resource View 的底层描述符由驱动管理

这套机制的代价是:

  • 不可预测——你不知道驱动什么时候在做重活。有时候一个看起来便宜的 API 调用(比如 PSSetShaderResources),内部可能触发一整条 Command Buffer 的提交和 flush。
  • 单线程瓶颈——所有命令最终被驱动的一个内部线程序列化。Deferred Context 可以帮你录命令,但最终提交到 Immediate Context 时仍然是单线程的。
  • CPU 开销——驱动的验证、转换、内存管理都是 CPU 时间。在 CPU-bounded 的游戏里,这些开销是纯浪费。

2 “Bind”操作的隐藏成本#

DX11 的 API 设计鼓励”每帧绑定、即时切换”:

context->PSSetShaderResources(0, 1, &srv); // 绑定纹理
context->Draw(6, 0); // 画
context->PSSetShaderResources(0, 1, &srv2); // 换一张纹理
context->Draw(6, 0); // 再画

每次 PSSetShaderResources 看起来只是改个指针。但实际上,驱动可能需要:

  1. 等待上一次 Draw 使用这个绑定点的 GPU 工作完成
  2. 创建新的描述符
  3. 更新根签名映射

驱动做了很多你感知不到的脏活。DX12 的设计动机就是把这些决策从驱动手里拿回来,交给引擎开发者。


四、DX12 / Vulkan:显式 API 的设计哲学#

1 引擎接管了驱动的职责#

职责DX11(驱动做)DX12 / Vulkan(你或 RHI 做)
内存分配驱动决定你手动创建 Heap,手动做 Sub-allocation
资源状态跟踪驱动自动插入 barrier你手动写 Resource Barrier
命令缓冲驱动内部管理你手动录 Command List,手动提交
描述符驱动分配你手动创建 Descriptor Heap / Descriptor Set
同步驱动隐式同步你手动创建 Fence,手动 Wait

这不是 API 的倒退——这是把控制权还给引擎。引擎知道自己的资源生命周期和访问模式,可以做出比驱动更优的决策。

2 Command Allocator 与 Command List#

DX12 里提交渲染命令的标准流程:

// 1. 重置分配器——回收上一帧命令占用的内存
commandAllocator->Reset();
// 2. 开始录制命令
commandList->Reset(commandAllocator, nullptr);
// 3. 录制各种命令
commandList->ResourceBarrier(1, &barrier); // 手动状态转换
commandList->SetPipelineState(pso);
commandList->SetGraphicsRootSignature(rootSig);
commandList->IASetVertexBuffers(...);
commandList->DrawInstanced(3, 1, 0, 0);
// 4. 结束录制
commandList->Close();
// 5. 提交到命令队列
ID3D12CommandList* lists[] = { commandList };
commandQueue->ExecuteCommandLists(1, lists);

关键设计

  • Command Allocator 是一块线性内存,所有命令追加写入。Reset 只是把写指针归零,不释放内存——类似你之前 DX11 笔记里提到的 Frame Allocator。
  • Command List 可以被预录制并复用。比如整个 Shadow Pass 的 Command List 可以录一次,之后每帧直接提交。
  • 录制是线程安全的——不同的线程可以录不同的 Command List,最后一起提交。这解决了 DX11 的多线程瓶颈。

3 Resource Barrier:渲染管线的红绿灯#

这是从 DX11 到 DX12 最大的思维转变。在 DX11 里你可以:

// DX11: 先渲染到纹理A,然后立刻把A当纹理采样
context->OMSetRenderTargets(1, &rtv, nullptr);
context->Draw(6, 0); // 写纹理A
context->PSSetShaderResources(0, 1, &srv_A); // 驱动在背后插了barrier
context->Draw(6, 0); // 读纹理A

在 DX12 里,你必须显式告诉 GPU:

// 渲染到纹理A
commandList->OMSetRenderTargets(1, &rtv, ...);
commandList->DrawInstanced(6, 1, 0, 0);
// 转换状态:渲染目标 → 着色器资源
D3D12_RESOURCE_BARRIER barrier = {};
barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
barrier.Transition.pResource = &textureA;
barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET;
barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
commandList->ResourceBarrier(1, &barrier);
// 现在可以安全地作为纹理采样
commandList->SetGraphicsRootDescriptorTable(0, srvHandle);
commandList->DrawInstanced(6, 1, 0, 0);

Barrier 的本质:告诉 GPU”这里所有人都要停下来,等前面的工作(写纹理A)全部完成,后面的人(采样纹理A)才能开始”。这是 GPU 内部流水线的同步原语。

Barrier 有三个作用:

  • 保证数据一致性:确保纹理写入完成后再被读取
  • 允许 GPU 做布局优化:渲染目标和纹理采样的内存布局可能不同,Barrier 触发 layout transition
  • 指导 Cache 刷新:写入端的 L2 Cache 数据需要刷到显存,读取端才能看到

但是 Barrier 很贵。每多加一个 Barrier,GPU 内部流水线就要断一次。这就是为什么现代引擎(包括 UE)用 Render Dependency Graph (RDG) 来合并和优化 Barrier——把”手动挡”升级为”自动挡但能看到变速箱”。

4 Fence:CPU 与 GPU 的握手#

DX12 提供了 Fence 机制来同步 CPU 和 GPU:

// 提交命令后,加一个 fence
UINT64 fenceValue = 1;
commandQueue->Signal(fence, fenceValue);
// CPU 在这里等待 GPU 完成 fence=1 之前的所有命令
if (fence->GetCompletedValue() < fenceValue) {
HANDLE event = CreateEvent(nullptr, FALSE, FALSE, nullptr);
fence->SetEventOnCompletion(fenceValue, event);
WaitForSingleObject(event, INFINITE);
}

这对应 UE 里的 FRHICommandListImmediate::ImmediateFlushFRHICommandListExecutor::WaitForTasks。引擎通过 Fence 确保渲染线程提交的命令被 GPU 执行完,再让游戏线程安全地更新下一帧的资源。


五、RHI:引擎的图形抽象层#

1 为什么需要 RHI#

UE 同时支持 DX11、DX12、Vulkan、Metal。每个 API 的语义差异巨大。引擎不可能在每个子系统(材质、后处理、粒子)里分别写四套代码。

RHI(Render Hardware Interface)就是这层胶水。它定义了一套统一的 API,背后的实现各自翻译到对应的图形 API。

// 业务代码(材质/粒子/后处理)
RHICmdList.SetShaderResourceViewParameter(...)
RHICmdList.DrawPrimitive(...)
// ↓ RHI 层 ↓
// ├── FD3D11DynamicRHI → 翻译为 DX11 调用
// ├── FD3D12DynamicRHI → 翻译为 DX12 调用(含 Barrier 管理)
// ├── FVulkanDynamicRHI → 翻译为 Vulkan 调用
// └── FMetalDynamicRHI → 翻译为 Metal 调用

2 RDG:在 RHI 之上再抽象一层#

UE 在 RHI 之上又建了一层 RDG (Render Dependency Graph)。它的核心思想是:

  • 你声明每个 Pass 的输入(读哪些纹理)和输出(写哪些纹理)
  • RDG 自动分析依赖关系,计算出最优的 Barrier 位置
  • RDG 管理临时资源的生命周期——用完了自动释放,下次用自动分配
// 声明式地定义一个 Pass
FRDGBuilder GraphBuilder(RHICmdList);
FRDGTextureRef SceneColor = GraphBuilder.CreateTexture(Desc, TEXT("SceneColor"));
FRDGTextureRef GBufferA = GraphBuilder.CreateTexture(Desc, TEXT("GBufferA"));
// 添加一个 Pass:写 GBuffer
GraphBuilder.AddPass(
RDG_EVENT_NAME("GBuffer Pass"),
PassParameters, // 输入/输出声明
ERDGPassFlags::Raster,
[](FRHICommandList& RHICmdList) {
// 实际渲染代码
});
// 添加一个 Pass:读 GBuffer,写 SceneColor
GraphBuilder.AddPass(
RDG_EVENT_NAME("Lighting Pass"),
LightingParameters, // 声明: 读 GBufferA, GBufferB, GBufferC;
// 写 SceneColor
ERDGPassFlags::Raster,
[](FRHICommandList& RHICmdList) {
// 延迟光照计算
});
GraphBuilder.Execute(); // RDG 自动插入 Barrier、分配/释放资源

这就是 Frame Graph 的 UE 实现。你声明”我要做什么”,系统自动处理”怎么做”——Barrier 优化、内存别名分析、Pass 合并。


六、回到你的项目:这些知识在哪起作用#

无论你用 UE 还是将来用自研引擎,这些概念都会直接出现:

你在做的事情背后的 GPU 现实
材质参数太多,帧率下降Shader 寄存器压力 → Occupancy 下降 → GPU 隐藏不了延迟
开箱演出很多半透明叠加每个半透明层 = 多个 Draw Call → Barrier + Render Target 切换开销
场景里有 500 个静态网格体如果不做合批(Batching),每个网格 = 一个 Draw Call = CPU 提交瓶颈
打开 RenderDoc 看到一条一条的 Pass每个 Pass 切换 = 至少一次 Barrier
帧率突然下降但 Profiler 显示 GPU “Idle”可能是 CPU 侧的命令提交线程在等 GPU Fence,管线断流

七、总结#

概念一句话
SIMTGPU 以 Warp/Wavefront 为单位执行相同指令处理不同数据
Command BufferCPU 录命令、GPU 异步执行的队列,是整个图形管线的通道
DX11 的黑箱问题驱动替你管理内存/Barrier/同步,代价是不可预测 + 单线程瓶颈
DX12 的显式模型把决策权还给引擎——手动内存、手动 Barrier、手动 Fence
BarrierGPU 管线的同步点,管理状态转换和 Cache 一致性,很贵所以要合并
RHI引擎的图形抽象层,一套代码适配所有 API
RDG声明式渲染管线——你说”画什么”,RDG 自动优化 Barrier 和资源

GPU 不是一台更快的 CPU——它是完全不同的计算模型。理解 GPU 怎么执行代码、命令怎么被提交和同步,是区分”会用引擎”和”理解引擎”的第一道坎。

现代 GPU 架构与命令缓冲区
https://www.m4doka.xyz/posts/engine/engine-2-gpu-architecture/
作者
m4doka
发布于
2026-06-25
许可协议
CC BY-NC-SA 4.0