在之前的教程中,我们了解了 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 完成所有命令的能力,用于程序退出前的清理工作。