一、从规划到代码

上篇我把资源管理模块的设计理顺了——单例、状态机、从注入点反推的“最小跟踪”原则。这周开始动真格的,把那些画在纸上的结构变成能跑的代码。

团队最终的技术底座是 D3D12 + Proxy DLL + Detours Hook。我上篇提到过用 Vulkan 做过一版预研,但实际写的代码都是 D3D12 的。Vulkan 预研时建立的认知——显式资源状态管理、队列族概念、同步原语——在写 D3D12 时全用上了,只是把 VkImage 换成了 ID3D12Resource,把 VkQueue 换成了 ID3D12CommandQueue

资源管理不是“把所有对象都管起来”,而是从注入点反推需要什么。Present 前我们要做的事情很明确——根据 swapchain + buffer index 找到目标 back buffer,有能提交命令的 command queue 和 command list,有串联同步用的 fence。这三个需求定下来,模块的边界就清楚了。

二、搭骨架:单例 + 状态机

单例结构还是 Meyer‘s Singleton。State.h 里就是这样写的:

class State
{
  public:
    static State& Instance()
    {
        static State instance;
        return instance;
    }
    // ...
  private:
    State() = default;
};

C++11 之后静态局部变量的初始化是线程安全的,不用额外加锁。状态机五个状态(IDLE → WAITING_RESOURCES → READY → PROCESSING → FRAME_END)也保持不变。

这周开始写实际的 D3D12 对象追踪,State 里多了些实在的东西:

// D3D12 核心对象
ID3D12Device* currentD3D12Device = nullptr;
ID3D12CommandQueue* currentCommandQueue = nullptr;
IDXGISwapChain* currentSwapchain = nullptr;

// 超分与帧生成
IFeature* currentFeature = nullptr;
IFGFeature_Dx12* currentFG = nullptr;

// 帧统计
UINT64 frameCount = 0;
std::deque<double> upscaleTimes;
std::deque<double> frameTimes;

// 屏幕信息
float screenWidth = 800.0f;
float screenHeight = 450.0f;
bool isHdrActive = false;

AI 帮我确认了初始化顺序:写 State 的时候,我不确定 std::deque<double> upscaleTimes 这种成员变量在单例中的初始化顺序是否可靠。AI 确认了 C++ 会按声明顺序初始化,还提醒我如果某些成员依赖其他成员,要注意声明顺序——这个提醒让我避免了一个潜在的初始化顺序问题。

三、从注入点反推追踪对象

写完骨架之后停了一下,跟负责 Hook 层的同学对了对“最小跟踪”的边界。从 Present 这个注入点反推,需要的东西列出来就是这样:

Swapchain 维度。 Present 时拿到的是 swapchain 和 buffer index,不存下 swapchain 对应的 back buffer 数组,根本不知道要往哪张图上写。所以 CreateSwapChain 和 GetBuffer 两个 hook 都要拦,前者记 format、buffer count、swap effect,后者把 back buffer 数组存下来。

Command Queue 维度。 注入意味着在 Present 前额外提交一次命令。要有 queue 可提交、有 command allocator 可分配 command list、有 fence 去串同步链。这里面藏着一个容易漏的点:创建 command allocator 必须指定 command list 类型,而 D3D12_COMMAND_LIST_TYPE 只在 CreateCommandQueue 的参数里出现。如果没拦 CreateCommandQueue,这个值就丢了,allocator 就建不起来。

我当时就是在没拦 CreateCommandQueue 的情况下先写了 allocator 创建逻辑,结果 commandListType 永远是 D3D12_COMMAND_LIST_TYPE_DIRECT,换了一台机器(驱动版本不同)直接报错。加上 hook 之后才正常。

Device 维度。 主要是缓存原始函数指针。在 D3D12 里虽然不像 Vulkan 那样需要 vkGetDeviceProcAddr 查函数指针,但我们需要在 Hook 层调用原始函数时,确保调用的是真实的 D3D12 实现。所以在 CreateDevice 时把需要用到的原始函数指针全部缓存下来。

还有就是Resource 追踪维度。这是我花时间最多的部分。ResTrack_dx12.h 里实现了一套完整的 GPU 资源追踪系统:

struct ResourceInfo {
    ID3D12Resource* buffer = nullptr;
    UINT width = 0;
    UINT height = 0;
    DXGI_FORMAT format = DXGI_FORMAT_UNKNOWN;
    UINT64 lastUsedFrame = 0;
};

static ankerl::unordered_dense::map<ID3D12Resource*, std::vector<ResourceInfo*>> _trackedResources;

每个 GPU 资源,只关心三件事:它是什么(buffer 指针)、它多大(width/height)、它什么格式(format)。这三样东西足够支撑超分替换所需的参数传递。

然后通过 Hook CreateRenderTargetViewCreateShaderResourceViewCreateUnorderedAccessView 这些函数,在游戏创建描述符时把资源和描述符堆槽位绑定起来:

static void hkCreateRenderTargetView(ID3D12Device* This, ID3D12Resource* pResource,
                                     D3D12_RENDER_TARGET_VIEW_DESC* pDesc,
                                     D3D12_CPU_DESCRIPTOR_HANDLE DestDescriptor) {
    // 先调用原始函数
    o_CreateRenderTargetView(This, pResource, pDesc, DestDescriptor);
    // 然后记录绑定关系
    auto heap = GetHeapByCpuHandleRTV(DestDescriptor.ptr);
    if (heap) {
        ResourceInfo info;
        FillResourceInfo(pResource, &info);
        heap->SetByCpuHandle(DestDescriptor.ptr, info);
    }
}

这堆东西列下来,回头再看“最小跟踪”四个字,才算真明白什么意思——不是做减法做到简陋,是先把注入要用的路径画出来,路径上缺什么就补什么,路径外的一概不管。

四、锁的粒度问题

State 是全局单例,多线程访问不可避免——Present 在渲染线程跑,swapchain 创建可能在主线程,资源追踪的 Hook 可能在任意线程。不加锁肯定不行。

一开始我是进任何函数都 std::scoped_lock,图省事。但按照模块文档的建议,“在锁内只做查表与复制句柄,录制放到锁外”,我对 Present 路径的加锁方式做了调整。

这个改动是因为 Present hook 跑在每帧的高频路径上。大锁里如果包含 command list 录制甚至 ExecuteCommandLists,GPU 那边的时间是不确定的。万一应用在另一个线程里做 swapchain 重建,就会一直等在锁外面,画面直接卡死。

改法是把查询和录制拆开,用 SwapchainSnapshot 把必要信息先拷出来:

SwapchainSnapshot snap{};
{
    std::scoped_lock lock(mtx);
    auto& sc = swapchains[swapchain];
    snap.buffer = sc.buffers[bufferIndex];
    snap.width = sc.width;
    snap.height = sc.height;
    snap.format = sc.format;
}
// 锁外:resource barrier / blit / submit
RecordAndSubmit(cmdList, snap);

锁里只做查表 + 复制必要句柄,几十个 CPU 周期的事。录制 command list、提交、等 fence 全部放锁外。改了之后卡顿感明显没了。

但得额外确保 SwapchainSnapshot 里复制的 ID3D12Resource* 在录制期间不会被销毁——这个由 swapchain 销毁 hook 里清空 buffers 数组之前等所有提交结束来保证。

AI 帮我 review 了这段代码:我把改完的锁逻辑发给 AI 看,它指出我的 SwapchainSnapshot 里直接拷贝了 ID3D12Resource* 裸指针,但没有增加引用计数。它提醒我如果在 snapshot 生命周期内资源被释放,指针会悬空。我后来在 snapshot 里加了 AddRef/Release 配对,彻底解决了这个问题。

五、自旋锁与分片:更进一步

在资源追踪系统里,_trackedResources 是一个全局 map,会被多个 Hook 函数并发访问。这里我用了比 std::mutex 更激进的选择——自旋锁。

struct SpinLock {
    std::atomic<bool> _lock = { false };
    void lock() {
        // 自旋等待 + exchange 尝试
        if (!_lock.exchange(true, std::memory_order_acquire))
            return;
        int backoff = 1;
        while (true) {
            while (_lock.load(std::memory_order_relaxed)) {
                for (int i = 0; i < backoff; ++i)
                    _mm_pause();
                backoff = std::min(backoff * 2, 64);
            }
            if (!_lock.exchange(true, std::memory_order_acquire))
                return;
        }
    }
    void unlock() {
        _lock.store(false, std::memory_order_release);
    }
};

为什么不用 std::mutex?因为资源追踪的临界区极短——就是查个表、改个指针。这种场景下,std::mutex 带来的线程调度开销(系统调用、上下文切换)反而比自旋几圈更大。

为了进一步减少锁竞争,还做了分片锁(Sharded Lock)设计:

static constexpr size_t SHARD_COUNT = 16;
static CommandListShard _hudlessShards[BUFFER_COUNT][SHARD_COUNT];

static size_t GetShardIndex(ID3D12GraphicsCommandList* ptr) {
    auto addr = (UINT64) ptr;
    return (addr >> 4) % SHARD_COUNT;
}

把命令列表按地址哈希分到 16 个片里,每个片有自己的锁。不同片之间的访问完全并行,只有同一片的才需要排队。

AI 帮我设计了自旋锁的退避策略:写自旋锁的时候,我不确定 _mm_pause() 在多线程场景下的效果。AI 解释了这个指令的作用——降低自旋时的功耗、减少流水线冲突。它还建议我用指数退避(exponential backoff)来避免大量线程同时自旋导致的“惊群”效应。最终代码里的 backoff 从 1 翻倍到 64,就是这个建议的结果。

六、RAII 辅助类:让状态管理更安全

State.h 里有一组 ScopedSkip* 辅助类:

class ScopedSkipSpoofing {
  private:
    bool previousState;
  public:
    ScopedSkipSpoofing() {
        previousState = State::Instance().skipSpoofing;
        State::Instance().skipSpoofing = true;
    }
    ~ScopedSkipSpoofing() {
        State::Instance().skipSpoofing = previousState;
    }
};

为什么需要这些?因为在某些特殊场景下——比如我们自己内部创建 D3D12 Device 做前置初始化时——不能让 Hook 逻辑递归触发。

Dxgi_Hooks.cpp 里的实际用法:

static void CheckLumaAndReShade(IDXGIFactory* factory) {
    if (Config::Instance()->CreateD3D12DeviceForLuma.value_or_default() &&
        State::Instance().currentD3D12Device == nullptr) {
        ScopedSkipSpoofing skipSpoofing{};  // 进入作用域自动关闭 spoofing
        ScopedSkipDxgiLoadChecks skipDxgiLoadChecks{};
        InitD3D12DeviceForLuma(factory);
    }  // 退出作用域自动恢复
}

用 RAII 包裹一下,进入作用域自动关掉某个功能,退出时自动恢复。这比手动 set/get 安全得多——即使中间抛异常也能正确恢复。这个设计思路来自 AI 的建议,它让我把原本成对出现的 set/get 改成了 RAII 风格。

七、帧边界清理的坑:swapchain 重建

进度非常快就遇到了第一个 bug。

我在 CreateSwapChain 的 hook 里调用注册函数,把返回的 back buffers 存下来。然后在 Present 的 hook 里用 buffer index 去查。跑了几次测试,偶尔崩在 buffer 访问上,debug 打了半天日志才发现问题。

有些游戏在窗口 resize 时会重建 swapchain——先 Release 把旧的销毁,再创建一个新的。但我的 Manager 里没有处理 swapchain 销毁的通知。应用自己已经把那组 ID3D12Resource 释放了,我的 map 里还存着旧指针。等下次 Present 时(如果 resize 过程中正好有残留调用),拿到的 buffer index 可能命中旧的 vector,里面的资源已经失效了。

修法是在 Release swapchain 的 hook 里加清理。但 D3D12 的 swapchain 销毁不像 Vulkan 有显式的 vkDestroySwapchainKHR,它是通过 COM 的 Release 来管理的。所以需要在 IDXGISwapChain::Release 的 hook 里检测引用计数归零的时机:

ULONG hkRelease(IDXGISwapChain* This) {
    ULONG refCount = o_Release(This);
    if (refCount == 0) {
        // swapchain 正在被销毁,清理相关资源
        ResourceStateManager::Get().UnregisterSwapchain(This);
    }
    return refCount;
}

不是什么复杂的修法,但就是这类“应用自己做了清理、我没跟上”的同步问题,最容易出奇怪的偶发崩溃。UnregisterSwapchain 里要把相关的 back buffers、render target views、中间纹理全部释放掉。这一步不做,窗口一 resize 就崩。

八、为什么不做完整资源状态追踪

写到资源生命周期这块,我确实冒出过一个念头:要不要把 D3D12 的资源状态(D3D12_RESOURCE_STATES)也完整追踪起来?

D3D12 的 resource barrier 需要指定转换前后的状态。如果我能知道当前 back buffer 到底在什么状态下,就可以只转必要的那一步,而不是每次都“转过去再转回来”。

但实际翻了翻 D3D12 文档和示例代码,很快就放弃了。同一个资源可能在多个 command list、多个 queue 里被反复转状态,甚至不同的 command list 可能在多线程里并行录制。要在 MVP 阶段做到“完整追踪”,代码量不会比整个注入层少。

而且项目文档里一句话说得很对:半吊子 tracking 比强制转换更危险。 状态误判会导致 resource barrier 写错,错误极难复现——在 PIX 或 RenderDoc 里看可能只是一帧画面异常,根本排查不到。

所以现在用的是 “强制转换”策略:在 present 前先 barrier 转到 D3D12_RESOURCE_STATE_RENDER_TARGET(或 D3D12_RESOURCE_STATE_COMMON),注入完成后 barrier 转回 D3D12_RESOURCE_STATE_PRESENT。确实有点笨,但行为是确定的,debug 层能验,出问题也容易定位。后面如果上 compute/FSR,再考虑要不要扩状态追踪的范围。

ResTrack_dx12.h 里预留了资源状态转换的辅助函数:

static void ResourceBarrier(ID3D12GraphicsCommandList* InCommandList, 
                            ID3D12Resource* InResource,
                            D3D12_RESOURCE_STATES InBeforeState, 
                            D3D12_RESOURCE_STATES InAfterState);

目前这个函数做的事情很简单——就是插入一个 resource barrier,把资源从 A 状态转到 B 状态。以后如果需要做更精细的状态追踪,可以在这里扩展。

九、当前状态

资源管理模块在 D3D12 路径下已经能够稳定跑通基本注入流程:

做了的事 状态
State 单例 + 状态机 跑通
Swapchain / Back buffer 注册与销毁清理 稳定
Command queue 追踪 + command list type 缓存 已加
Device 函数指针缓存 已完成
GPU 资源追踪(ResTrack_Dx12) 基础框架完成
锁粒度优化(查表复制、录制放锁外) 完成
RAII 辅助类(ScopedSkip*) 已加
自旋锁 + 分片锁 已实现
资源状态强制转换 已加,debug 层通过
中间纹理(downscale image) 设计已明确,代码跟进中
多 swapchain 同时 present 没碰

十、代码量统计

这几周实际落地的代码:

文件 职责 行数
State.h 全局状态管理单例 ~280
ResTrack_dx12.h GPU 资源追踪系统 ~380
OwnedMutex.h 带所有权的互斥锁 ~60
ResTrack_dx12.cpp 资源追踪实现细节 ~200
Dxgi_Hooks.cpp(资源管理相关部分) Hook 与资源管理的交互 ~150
调试辅助与日志 状态打印、错误输出 ~100

合计约 1170 行

十一、这几周写下来的体会

这几周写下来,最大的感受是 D3D12 的显式控制让中间件好写也难写

好写在状态都是看得见的——resource barrier、heap、command list 全在明面上,Hook 住关键函数就能拿到想要的信息。难写在必须自己处理所有同步细节,漏一个 barrier 或者 fence 就崩,而且崩的地方往往离 bug 源头很远。

另一个感受是 “最小跟踪”这个原则在实战中非常管用。如果没有这个原则兜着,我很可能会在资源追踪系统里越写越多——追踪所有资源、追踪所有状态、追踪所有生命周期——最后变成一个庞大而脆弱的系统。有了“从注入点反推”这个边界,每次想加新功能的时候都会先问自己一句:这个信息 Present 的时候真的需要吗? 不需要就不加。

十二、接下来

  • 把 downscale image 的创建和 resize 重建代码补上

  • 继续用更多游戏(GTA 5)做实测,尤其是有复杂渲染管线的场景

  • 开始看 compute shader 做缩放的基础设施——descriptor heap 管理、pipeline state 缓存这些

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐