← ALL ARTICLES
渲染架构5 MIN READ

Command Buffer与帧提交

全库目录221
本文目录13

CPU刚记录完一帧,就把上传缓冲拿去写下一帧,看似节省了内存,GPU却可能还在读旧内容。命令记录、队列提交和GPU完成发生在不同时间。我们沿这三个时刻追踪每个帧槽位的资源,完成值达到以后再复用,就能解释在途帧既影响吞吐也影响寿命。

命令记录与完成

一帧的所有权

text
游戏状态快照 → 渲染线程组织通道 → 记录命令
                                      ↓
                               队列提交并 signal
                                      ↓
                          GPU 完成 → 帧资源可复用

每个在途帧有自己的allocator、上传区间和描述符暂存。把最后提交的完成值记在槽位里,下次复用先检查这个值,就能知道旧GPU工作是否仍在使用它。交换链图像索引只指出这次取得的显示图像,不一定等于CPU帧资源槽位,两者分别管理。

D3D12 的安全复用

以下是 D3D12 调用顺序轮廓,输入为一组帧资源。完成事件已创建为自动复位事件,由当前等待者独占;交换链获取由宿主提供;RecordFrame在记录端验证输入,API记录错误还会由Close返回。同一队列由提交协调者串行执行Execute、分配完成值与Signal,nextFence在这个串行区间递增;HandleSubmissionFailure按下节规则停止正常帧循环并处理提交故障。

cpp
Frame& frame = frames[frameSlot];
UINT64 completed = fence->GetCompletedValue();
if (completed == UINT64_MAX) {
    HandleSubmissionFailure(DXGI_ERROR_DEVICE_REMOVED);
    return;
}
while (completed < frame.completionValue) {
    HRESULT eventResult = fence->SetEventOnCompletion(
        frame.completionValue, completionEvent);
    if (FAILED(eventResult)) {
        HandleSubmissionFailure(eventResult);
        return;
    }
    DWORD waitResult = WaitForSingleObject(completionEvent, INFINITE);
    if (waitResult != WAIT_OBJECT_0) {
        HRESULT error = waitResult == WAIT_FAILED
            ? HRESULT_FROM_WIN32(GetLastError()) : E_FAIL;
        HandleSubmissionFailure(error);
        return;
    }
    completed = fence->GetCompletedValue();
    if (completed == UINT64_MAX) {
        HandleSubmissionFailure(DXGI_ERROR_DEVICE_REMOVED);
        return;
    }
}
HRESULT resetResult = frame.allocator->Reset();
if (FAILED(resetResult)) {
    HandleSubmissionFailure(resetResult);
    return;
}
resetResult = commandList->Reset(frame.allocator.Get(), initialPipeline);
if (FAILED(resetResult)) {
    HandleSubmissionFailure(resetResult);
    return;
}
RecordFrame(commandList.Get(), frame);
HRESULT closeResult = commandList->Close();
if (FAILED(closeResult)) {
    HandleSubmissionFailure(closeResult);
    return; // 此列表没有提交,不能继续Execute
}
ID3D12CommandList* lists[] = {commandList.Get()};
queue->ExecuteCommandLists(1, lists);
const UINT64 submittedValue = nextFence++;
HRESULT signalResult = queue->Signal(fence.Get(), submittedValue);
if (FAILED(signalResult)) {
    HandleSubmissionFailure(signalResult); // 停止复用,进入下节故障路径
    return;
}
frame.completionValue = submittedValue;

示例在同一队列提交后signal,完成值覆盖它之前的工作。多队列共同使用同一资源时,回收还要覆盖所有相关队列的最后访问。allocator重置会回收命令内存,所以它分配的命令都完成后才能重置。上传区间和描述符回收采用相同的完成条件。命令列表对象复用与 allocator 内存重置有不同限制,不能仅以对象名称判断。

例如帧槽位 0 记录完成值 17,CPU 准备再次使用它时完成值只有 16,仍需等待。即使交换链本次返回图像索引 0,也不能把它当作完成 17 的证据。若资源又被复制队列使用到值 9,单看图形队列 17 也不够;可以保存各队列完成条件,或建立汇合依赖后使用一个覆盖全部工作的完成值。

提交失败与资源状态

帧槽位在可复用、记录中、已提交三个状态间移动。Reset失败就退出记录,Close失败就退出提交;这时本次命令尚未进入队列,可以保留诊断并清理未提交记录。D3D12中Close失败的命令列表之后也不能Reset成功,恢复时重建该列表,而不是在下一帧原样重试。Execute之后即使后续Signal失败,也不能把槽位当成“未提交”立即覆盖,因为GPU可能已经消费命令。应用进入设备/提交故障处理,停止复用依赖该完成值的资源,确认队列或设备已终止后再销毁。示例调用序列在每一步成功的路径上成立,错误返回不产生虚假的GPU完成事实。

GetCompletedValue在设备移除时返回UINT64_MAX,这不是“全部命令已经正常完成”的证明。若只比较数值大小,设备故障会被误判为可以重置所有帧槽位;示例在等待前后都先识别这个哨兵值并转入故障路径。事件注册失败直接离开等待路径;事件唤醒后重新查询完成值,满足本槽位条件才继续。唤醒本身不充当GPU完成证据。完成值计数不分配UINT64_MAX,并在接近回绕时排空与重建该计数纪元。

正常路径可以提前给本帧预留唯一完成值,Signal成功后将它登记为槽位的回收条件;上传、描述符和allocator都挂到这个条件。只供CPU组织命令的场景快照可以在所有记录任务结束后回收,但写进命令的资源、描述符页和材质版本必须由记录结果继续持有。列表已Close、尚在协调者队列里等待提交时,GPU完成值还不能代表这份引用的寿命。

协调者把记录结果从Recorded切换到Submitting时,将整批引用交给提交记录,再释放录制持有,交接期间始终有人持有资源。提交成功后,引用等到覆盖所有消费者队列的完成条件成立才释放。取消只能取得尚未提交的记录结果;先通知记录任务停止并等待它们退出,再销毁列表及其引用。取消与提交竞争同一个状态转换,已经进入Submitting的批次由提交或故障路径处理。具体描述符、材质版本和纹理的联合持有见 GPU资源绑定。

记录与排队策略

多线程记录

不同线程可以记录相互独立的命令列表。每条列表使用满足 API 线程约束的分配器或命令池,并共享只读的场景快照。录制顺序在 CPU 上无关,提交顺序与资源依赖仍需统一组织。

渲染线程和 RHI 线程是引擎的软件分工,GPU graphics/compute/copy queue 是硬件提交接口,两者不一一对应。线程数更多可能降低记录耗时,也可能增加小任务调度与锁竞争。

记录结果与提交顺序

假设阴影列表S写深度D,主列表L读取D。CPU线程先录完L、后录完S并不构成错误,因为记录只是保存命令;协调线程仍按S、依赖转换、L提交。若按线程完成顺序提交L、S,L就可能读到上一帧的D。两个列表各自合法也不能修复跨列表依赖,协调者须根据读写关系生成顺序和屏障,具体访问规则见 GPU同步与屏障。

协调者还要处理前驱取消。若S在提交前失败,依赖S的新版本D的L一并取消并释放其录制引用;只有重新录制L并显式选择仍有效的旧D,才可继续提交。若S已经进入队列,后续L取消只停止消费,S及其资源仍按实际提交完成条件退休。提交计划同时保存资源版本与依赖批次,所以取消不会留下“命令合法但生产者不存在”的消费者。

在途帧数量

更多在途帧能让 CPU 和 GPU 重叠,也增加未呈现工作的排队量、内存和潜在输入延迟。帧资源环的大小、交换链缓冲数、present 模式和引擎限帧共同决定实际延迟,不能把“缓冲数为三”直接等同于三帧输入延迟。

CPU 读回需要确认复制完成;每帧立即等待会截断 CPU/GPU 并行。非实时决策可采用延迟几帧读取,结果附原始帧号。

引擎映射

引擎对应

Unity ScriptableRenderContext.Submit 提交已组织的渲染工作,资源释放应交给对应生命周期接口。Unreal 渲染线程和 RHI 命令列表将平台无关请求转交后端。两者的 CPU 标记时间需与 GPU 时间区分。

验证方法

  • 增加 GPU 负载,让 CPU 快速循环;检查上传数据是否跨帧串色或命令 allocator 重置时报错。
  • 改变在途帧数量,记录 CPU 等待、GPU 空闲、内存峰值和输入到显示延迟。
  • 用不同帧标记做异步回读,确认返回帧号与提交记录一致。
  • 暂时单线程录制,再对比多线程版本输出与记录开销,排除共享可变状态问题。

相关主题

示例状态:本页代码用于解释对应的数据和调用约束,完整资源创建、类型与调用环境由项目提供。除文中另列的实际实验外,本页未执行目标引擎编译、GPU捕获或性能计时。

参考资料