一、从规划到代码

上篇我把资源管理模块的设计理顺了。这周框架和模块都定下来了,技术底座是 Vulkan + Proxy DLL + procaddr 拦截。我负责的 Resource & State Manager,需要跟踪的对象变成了 VkDevice、VkQueue、VkSwapchainKHR、VkImage,以及 present 时那个关键的 imageIndex。

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

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

单例结构还是 Meyer's Singleton。之前第三篇里画过类图,这里不再重复。状态机五个状态(IDLE → WAITING_RESOURCES → READY → PROCESSING → FRAME_END)也保持不变。

不过这周开始写实际的 Vulkan 对象追踪,类里面多了些实在的东西:

class ResourceStateManager {
public:
    static ResourceStateManager& Get() {
        static ResourceStateManager instance;
        return instance;
    }

    // swapchain 注册与查询
    void RegisterSwapchain(VkSwapchainKHR swapchain, 
                           const std::vector<VkImage>& images,
                           VkFormat format, VkExtent2D extent,
                           VkImageUsageFlags usage);
    VkImage GetPresentImage(VkSwapchainKHR swapchain, uint32_t imageIndex);
    
    // queue 注册
    void RegisterQueue(VkQueue queue, uint32_t familyIndex, VkDevice device);
    QueueData* GetQueueData(VkQueue queue);
    
    // device ↔ physical device 映射
    void MapPhysicalDevice(VkDevice device, VkPhysicalDevice physical);
    VkPhysicalDevice GetPhysicalDevice(VkDevice device);

    bool CanUseTransfer(VkSwapchainKHR swapchain);
    void OnFrameEnd();

private:
    std::mutex mtx;
    FrameState state = FrameState::IDLE;

    struct SwapchainData {
        std::vector<VkImage> images;
        VkFormat format;
        VkExtent2D extent;
        VkImageUsageFlags usage;
        // 阶段 2 的中间图,后面会写到
        VkImage downscaleImage = VK_NULL_HANDLE;
    };

    struct QueueData {
        uint32_t familyIndex;
        VkDevice device;
        VkCommandPool commandPool;
        VkCommandBuffer commandBuffer;
        VkSemaphore doneSemaphore;
    };

    std::unordered_map<VkSwapchainKHR, SwapchainData> swapchains;
    std::unordered_map<VkQueue, QueueData> queues;
    std::unordered_map<VkDevice, VkPhysicalDevice> deviceToPhysical;
};

三、到底要追踪哪些对象——从注入点反推

写完骨架之后停了一下,跟框架同学对了对“最小跟踪”的边界。文档上写得很直白:不是所有 Vulkan 对象都归 Manager 管,只跟踪注入所必需的。从 `vkQueuePresentKHR` 这个注入点反推,需要的东西列出来就是这样:

Swapchain 维度。 present 时拿到的是 swapchain + imageIndex,不存下 swapchain 对应的 image 数组,根本不知道要往哪张图上写。所以 `vkCreateSwapchainKHR` 和 `vkGetSwapchainImagesKHR` 两个 hook 都要拦,前者记 format/extent/usage,后者把 image 数组存下来。

Queue 维度。 注入意味着在 present 前额外提交一次 `vkQueueSubmit`。要有 queue 可提交、有 command pool 可分配 command buffer、有 semaphore 去串同步链。这里面藏着一个容易漏的点:创建 command pool 必须填 `queueFamilyIndex`,而 `queueFamilyIndex` 只在 `vkGetDeviceQueue` 的参数里出现。如果没 hook `vkGetDeviceQueue`,这个值就丢了,command pool 就建不起来。

我当时就是在没拦 `vkGetDeviceQueue` 的情况下先写了 command pool 创建,结果 `familyIndex` 永远是 0,换了一台机器直接炸。加上 hook 之后才正常。

在 `vkGetDeviceQueue` 里把 familyIndex 绑定到 queue 上:

void ResourceStateManager::RegisterQueue(VkQueue queue, uint32_t familyIndex, VkDevice device) {
    std::lock_guard<std::mutex> lock(mtx);
    QueueData data = {};
    data.familyIndex = familyIndex;
    data.device = device;
    queues[queue] = data;
}

Device 维度。 主要是函数指针缓存。Vulkan 的函数指针不是全局唯一的,不同 layer、不同 extension 组合下 `vkGetDeviceProcAddr` 返回的可能不一样。如果每帧都做字符串查找去拿 `vkCmdBlitImage`,开销不小。缓存一次,后续直接用。

还有就是 physical device 的映射——创建中间纹理(阶段 2 的 downscale image)需要分配设备内存,`vkAllocateMemory` 需要选 `memoryTypeIndex`,而候选集合要从 `VkPhysicalDeviceMemoryProperties` 拿。所以在 `vkCreateDevice` 成功时记下 physical device:

if (r == VK_SUCCESS && device && *device) {
    deviceToPhysical[*device] = physicalDevice;
}

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

四、锁的粒度问题

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

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

改法是把查询和录制拆开:

SwapchainSnapshot snap{};
{
    std::scoped_lock lock(mtx);
    auto& sc = swapchains[swapchain];
    snap.image = sc.images[imageIndex];
    snap.extent = sc.extent;
    snap.format = sc.format;
    snap.usage = sc.usage;
}
// 锁外:barrier / blit / submit
InjectionLayer::RecordAndSubmit(cmdBuf, snap);

锁里只做查表 + 复制必要句柄,几十个 CPU 周期的事。录制 command buffer、提交、等 fence 全部放锁外。改了之后卡顿感明显没了,但得额外确保 `SwapchainSnapshot` 里复制的 VkImage 句柄在录制期间不会被销毁——这个由 swapchain 销毁 hook 里清空 images 数组之前等所有提交结束来保证。

五、帧边界清理的坑

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

我在 `vkCreateSwapchainKHR` 的 hook 里调用 `RegisterSwapchain`,把返回的 images 存下来。然后在 `vkQueuePresentKHR` 的 hook 里用 imageIndex 去查。跑了几次 Vulkan 官方示例,偶尔崩在 image 访问上,debug 打了半天日志才发现问题。

有些 Vulkan 应用在窗口 resize 时会重建 swapchain——先 `vkDestroySwapchainKHR` 把旧的销毁,再创建一个新的。但我的 Manager 里没有处理 swapchain 销毁的通知。应用自己已经把那组 VkImage 回收了,我的 map 里还存着旧指针。等下下次 present 时(如果 resize 过程中正好有残留调用),拿到的 imageIndex 可能命中旧的 vector,里面的 VkImage 已经失效了。

修法是在 `vkDestroySwapchainKHR` 的 hook 里加清理:

void ResourceStateManager::UnregisterSwapchain(VkSwapchainKHR swapchain) {
    std::lock_guard<std::mutex> lock(mtx);
    auto it = swapchains.find(swapchain);
    if (it != swapchains.end()) {
        Log("Swapchain %p destroyed, %zu images unreferenced", 
            swapchain, it->second.images.size());
        swapchains.erase(it);
    }
}

不是什么复杂的修法,但就是这类"应用自己做了清理、我没跟上"的同步问题,最容易出奇怪的偶发崩溃。`vkDestroySwapchainKHR` 里主动把他相关的 image views、framebuffers、render pass、downscale image 全释放掉。这一步不做,窗口一 resize 就崩。

六、为什么不做完整 layout tracking

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

反正 Vulkan 的 barrier 本来就要源布局和目标布局,如果我能知道当前 image 到底在什么 layout 下,就可以只转必要的那一步,而不是每次都“转过去再转回来”。

但实际翻了翻 Vulkan spec,很快就放弃了。同个 image 可能在多个 queue、多个 command buffer、多个 render pass 里被反复转 layout,甚至 external memory 和平台扩展都会影响 layout 状态机。要在 MVP 阶段做到“完整追踪”,代码量不会比整个注入层少。

而且项目文档里一句话说得很对:半吊子 tracking 比强制转换更危险。 layout 误判会导致 barrier 写错,错误极难复现,在 RenderDoc 里看也可能只是一帧闪烁,根本排查不到。

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

七、当前状态

Resource & State Manager 模块在 Vulkan 路径下已经稳定跑了几个官方示例:

做了的事 状态
单例 + 状态机 + 锁粒度优化 跑通
Swapchain / Image 注册与销毁清理 稳定
PhysicalDevice 映射 已加
QueueFamilyIndex 追踪 已补,command pool 创建正常
Transfer usage 检测与回退 几个官方示例测过
锁粒度优化(查表复制、录制放锁外) 完成
中间纹理(downscale image) 设计已明确,代码跟进中
Layout 强制转换 已加,验证层通过
多 swapchain 同时 present 没碰

八、体会

这周写下来,最大的感受是 Vulkan 的显式控制让中间件好写也难写。好写在状态都是看得见的——barrier、layout、usage 全在明面上;难写在必须自己处理同步,漏一个 semaphore 或者 barrier 就崩。

九、接下来

downscale image 的创建和 resize 重建,这周把代码补上
继续拿更多 Vulkan 示例跑,尤其是有复杂渲染管线的
开始看 compute shader 做缩放的基础设施——descriptor set 管理、pipeline 缓存这些

Logo

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

更多推荐