D3D12 教程:栅栏同步 - CPU 与 GPU 的协同
在之前的教程中,我们了解了 D3D12 命令管理的基本概念:命令队列、命令列表和命令分配器。 为了实现 CPU 和 GPU 的高效协同工作,同步 (Synchronization) 是至关重要的一环。 本节教程将深入探讨 D3D12 中用于 CPU-GPU 同步的关键机制:栅栏 (Fence) 和 事件 (Event)。
1. 为什么需要同步?
现代 GPU 采用异步执行模式,CPU 提交命令后,无需等待 GPU 完成即可继续执行后续任务。 这种并行性提高了整体性能,但也带来了同步问题:
资源竞争: CPU 和 GPU 可能同时访问和修改同一份资源 (例如,纹理、缓冲区)。 如果不进行同步,可能导致数据损坏或未定义的行为。
执行顺序: 渲染的正确性依赖于命令的执行顺序。 例如,必须先完成上一帧的渲染,才能开始下一帧的渲染。 同步机制确保 CPU 和 GPU 按照预期的顺序执行操作。
2. 栅栏 (Fence):CPU 和 GPU 之间的信号
栅栏 (Fence) 是 D3D12 提供的轻量级同步对象,用于在命令队列中插入信号,并允许 CPU 查询 GPU 的执行进度。 可以将栅栏想象成 CPU 和 GPU 之间通信的“信号灯”。
栅栏值 (Fence Value): 每个栅栏都关联一个 64 位无符号整数值,称为 栅栏值。 栅栏值在每一帧渲染后 单调递增,作为帧序号和进度标识。
GPU Signal (信号): 当 GPU 执行到命令队列中的 Signal 命令 时,它会将指定的栅栏值写入到栅栏对象中,表示 GPU 完成了某个阶段的工作。
CPU Wait (等待): CPU 可以 查询栅栏的当前值,并将其与 目标栅栏值 进行比较。 如果栅栏值 小于 目标值,表示 GPU 尚未完成目标帧或更早帧的命令执行,CPU 需要 等待,直到栅栏值达到或超过目标值。
3. 事件 (Event):CPU 等待 GPU 信号的机制
为了让 CPU 高效地等待栅栏信号,D3D12 通常结合 事件 (Event) 对象一起使用。 事件是 Windows 操作系统提供的同步原语,用于线程间的同步。
创建事件: 使用
CreateEventEx
函数创建一个事件对象。设置事件关联: 使用
fence->SetEventOnCompletion(fence_value, fence_event)
将事件句柄 (fence_event
) 与栅栏 (fence
) 和目标栅栏值 (fence_value
) 关联起来。 这告诉 D3D12 运行时: 当栅栏fence
的值达到fence_value
时,触发事件fence_event
。CPU 等待事件: CPU 调用
WaitForSingleObject(fence_event, INFINITE)
进入等待状态,阻塞当前线程,直到事件fence_event
被触发 (GPU 发出信号) 或等待超时。INFINITE
表示无限等待,直到事件被触发。
4. d3d12_command
类的栅栏同步实现
在 d3d12_command
类中,栅栏同步的实现主要体现在构造函数、begin_frame()
、end_frame()
、flush()
和 command_frame::wait()
函数中。
4.1 构造函数 (d3d12_command
)
构造函数中,除了创建命令队列、命令分配器和命令列表外,还 创建了栅栏和事件对象:
C++
// 创建栅栏 (Fence)
DXCall(hr = device->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&_fence)));
if (FAILED(hr)) goto _error;
NAME_D3D12_OBJECT(_fence, L"D3D12 Fence");
// 创建栅栏事件 (Fence Event)
_fence_event = CreateEventEx(nullptr, nullptr, 0, EVENT_ALL_ACCESS);
assert(_fence_event);
device->CreateFence
: 创建栅栏对象 (_fence
)。D3D12_FENCE_FLAG_NONE
表示默认的栅栏标志。CreateEventEx
: 创建事件对象 (_fence_event
),用于 CPU 等待 GPU 信号。
4.2 command_frame::wait(fence_event, fence)
函数
command_frame::wait
函数实现了 CPU 等待 GPU 完成特定帧命令 的逻辑:
C++
void wait(HANDLE fence_event, ID3D12Fence1* fence)
{
assert(fence && fence_event);
if (fence->GetCompletedValue() < fence_value)
{
DXCall(fence->SetEventOnCompletion(fence_value, fence_event));
WaitForSingleObject(fence_event, INFINITE);
}
}
fence->GetCompletedValue()
: 获取栅栏的完成值。 这个值表示 GPU 已经完成执行的命令的最大栅栏值。if (fence->GetCompletedValue() < fence_value)
: 判断是否需要等待。 如果栅栏的完成值小于当前帧的栅栏值 (fence_value
),表示 GPU 尚未完成执行当前帧的命令,CPU 需要等待。fence->SetEventOnCompletion(fence_value, fence_event)
: 设置栅栏事件。 当栅栏值达到fence_value
时,GPU 会触发事件fence_event
。WaitForSingleObject(fence_event, INFINITE)
: CPU 等待事件。 当前线程会阻塞,直到事件fence_event
被触发,或者等待超时 (这里使用INFINITE
无限等待)。
4.3 begin_frame()
函数
begin_frame()
函数在每帧开始时调用,等待上一帧 GPU 完成,然后重置命令分配器和命令列表:
C++
void begin_frame()
{
command_frame& frame{ _cmd_frames[_frame_index] };
frame.wait(_fence_event, _fence); // 等待上一帧 GPU 完成
DXCall(frame.cmd_allocator->Reset());
DXCall(_cmd_list->Reset(frame.cmd_allocator, nullptr));
}
frame.wait(_fence_event, _fence)
: 调用command_frame::wait
函数,等待 GPU 完成执行上一帧 (或更早帧) 的命令。 确保 CPU 在开始录制新帧命令之前,GPU 已经完成了之前的渲染工作。
4.4 end_frame()
函数
end_frame()
函数在每帧结束时调用,提交命令列表到命令队列,并发出栅栏信号:
C++
void end_frame()
{
DXCall(_cmd_list->Close());
ID3D12CommandList *const cmd_lists[]{ _cmd_list };
_cmd_queue->ExecuteCommandLists(_countof(cmd_lists), &cmd_lists[0]);
u64& fence_value{ _fence_value };
++fence_value;
command_frame& frame{ _cmd_frames[_frame_index] };
frame.fence_value = fence_value;
_cmd_queue->Signal(_fence, _fence_value); // 发出栅栏信号
_frame_index = (_frame_index + 1) % frame_buffer_count;
}
_cmd_queue->Signal(_fence, _fence_value)
: 发出栅栏信号。 在命令队列中插入一个 Signal 命令,告诉 GPU: 当执行完当前命令队列中的所有命令后,将栅栏_fence
的值更新为_fence_value
。_fence_value
是递增后的当前帧的栅栏值。
4.5 flush()
函数
flush()
函数用于 等待 GPU 完成所有已提交的命令。 通常在程序退出或需要确保 GPU 完成所有工作时调用:
C++
void flush()
{
for (u32 i{ 0 }; i < frame_buffer_count; ++i)
{
_cmd_frames[i].wait(_fence_event, _fence); // 等待每一帧的栅栏信号
}
_frame_index = 0;
}
flush()
函数循环等待帧缓冲区中所有帧的栅栏信号,确保所有已提交的命令列表都被 GPU 执行完成。
4.6 release()
函数
release()
函数在程序退出时调用,释放栅栏和事件资源:
C++
void release()
{
flush(); // 先 Flush 命令队列,等待 GPU 完成所有工作
core::release(_fence); // 释放栅栏对象
_fence_value = 0; // 重置栅栏值
CloseHandle(_fence_event); // 关闭事件句柄
_fence_event = nullptr; // 置空事件句柄指针
// ... 释放其他资源 ...
}
flush()
: 重要: 在释放栅栏对象之前,必须先调用flush()
,确保 GPU 完全停止工作,并且不再访问栅栏对象。 否则,可能导致程序崩溃或其他不可预测的错误。core::release(_fence)
: 释放栅栏对象。CloseHandle(_fence_event)
: 关闭事件句柄。
5. 总结
通过栅栏和事件机制,d3d12_command
类实现了基本的 CPU-GPU 同步。 在每一帧开始前,CPU 等待 GPU 完成上一帧的渲染工作,确保资源访问的同步和命令执行的顺序。 flush()
函数则提供了等待 GPU 完成所有命令的能力,用于程序退出前的清理工作。