一、回顾:你在 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。
理解了这一点,引擎里很多看似奇怪的设计就有了原因——为什么 CreateBuffer 和 Draw 可以在不同线程调用(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 个三角形):
- 命令处理器(Command Processor) 从 Command Buffer 读取 Draw 命令
- Primitive Distributor 把三角形分发给各个 SM
- 每个 SM 拿到自己的那批三角形,启动 Warp 执行顶点着色器
- 顶点处理完后,三角形被送到光栅化单元(Raster Engine)
- 光栅化后,像素被分回各个 SM 执行像素着色器
- 结果写入 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 看起来只是改个指针。但实际上,驱动可能需要:
- 等待上一次 Draw 使用这个绑定点的 GPU 工作完成
- 创建新的描述符
- 更新根签名映射
驱动做了很多你感知不到的脏活。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); // 写纹理Acontext->PSSetShaderResources(0, 1, &srv_A); // 驱动在背后插了barriercontext->Draw(6, 0); // 读纹理A在 DX12 里,你必须显式告诉 GPU:
// 渲染到纹理AcommandList->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:
// 提交命令后,加一个 fenceUINT64 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::ImmediateFlush 和 FRHICommandListExecutor::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 管理临时资源的生命周期——用完了自动释放,下次用自动分配
// 声明式地定义一个 PassFRDGBuilder GraphBuilder(RHICmdList);
FRDGTextureRef SceneColor = GraphBuilder.CreateTexture(Desc, TEXT("SceneColor"));FRDGTextureRef GBufferA = GraphBuilder.CreateTexture(Desc, TEXT("GBufferA"));
// 添加一个 Pass:写 GBufferGraphBuilder.AddPass( RDG_EVENT_NAME("GBuffer Pass"), PassParameters, // 输入/输出声明 ERDGPassFlags::Raster, [](FRHICommandList& RHICmdList) { // 实际渲染代码 });
// 添加一个 Pass:读 GBuffer,写 SceneColorGraphBuilder.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,管线断流 |
七、总结
| 概念 | 一句话 |
|---|---|
| SIMT | GPU 以 Warp/Wavefront 为单位执行相同指令处理不同数据 |
| Command Buffer | CPU 录命令、GPU 异步执行的队列,是整个图形管线的通道 |
| DX11 的黑箱问题 | 驱动替你管理内存/Barrier/同步,代价是不可预测 + 单线程瓶颈 |
| DX12 的显式模型 | 把决策权还给引擎——手动内存、手动 Barrier、手动 Fence |
| Barrier | GPU 管线的同步点,管理状态转换和 Cache 一致性,很贵所以要合并 |
| RHI | 引擎的图形抽象层,一套代码适配所有 API |
| RDG | 声明式渲染管线——你说”画什么”,RDG 自动优化 Barrier 和资源 |
GPU 不是一台更快的 CPU——它是完全不同的计算模型。理解 GPU 怎么执行代码、命令怎么被提交和同步,是区分”会用引擎”和”理解引擎”的第一道坎。