初识资源管理与状态机——理解项目的核心调度器
一、分工与起点
前两篇写完,这个中间件的技术背景和别人的轮子怎么跑起来,我算是有个底了。这篇开始角色变了——我不再是站在外面看热闹的,得自己上手造零件。
前段时间跟框架层的同学对了接口,整套系统的技术底座定下来了:Vulkan + Proxy DLL + procaddr 拦截。编译出来一个假的 `vulkan-1.dll` 塞到游戏 exe 旁边,应用去调 `vkGetInstanceProcAddr` 和 `vkGetDeviceProcAddr` 拿函数指针的时候,我们返回自己包了一层壳的函数。整套注入逻辑都跑在同一个 DLL 里面,不需要跨进程、不需要改游戏文件——这是后面所有模块能跑起来的前提。
分配到我头上的模块是资源管理与状态机(Resource & State Manager)。最开始听到这个名字,脑子里没什么具体画面,只觉得“大概就是管管纹理吧”。真正开始画调用链之后才意识到,这玩意儿卡在整个管线最中间的位置:往上对接 procaddr 拦截层,往下对接 API 适配层——拦截层把 present 调用截下来了,适配层等着往画面上写东西,但中间谁来告诉适配层“你要写的图是哪张、queue 是哪个、同步信号怎么串”?就是这个 Manager。
所以我这一阶段的重点,不是上来就敲键盘,而是先把两件事嚼碎:这块到底要解决什么问题,以及我们打算怎么设计它。
二、为什么需要专门做资源管理
一开头我也有个很朴素的想法:我们做的事情不就是把 present 前卡一个环节,往画面上多画点东西吗?那我直接在 `vkQueuePresentKHR` 的 hook 函数里拿到 swapchain 和 imageIndex,随便找个 queue 录几条命令跑一下不就完了?
项目框架里把这事儿写得很清楚:不行,因为应用不会把你要的东西都摆好在桌上等你。
同一个游戏进程里,Vulkan 对象的生命周期散落在各种地方:swapchain 是在某个初始化函数里创建然后丢给全局变量,图像数组是通过 `vkGetSwapchainImagesKHR` 一次性拿出来的,present 用的 queue 可能是另一个线程拿到自己手里保管。而我们的注入模块是个外人——它不参与游戏代码的编译链接,只能在运行时刻从旁路去“观察”和“记录”。
这就意味着,如果我们不做集中管理,每次 present hook 触发的时候,手里只有一个光秃秃的 `VkSwapchainKHR` 句柄和一个 `imageIndex` ——至于这个 swapchain 到底对应哪些 `VkImage`、哪个 queue 能往它上面提交、需不需要加 semaphore 等它渲染完成,全不知道。这个时候去做注入,不是乱写就是崩。
把这个想明白之后,资源管理模块的定位就很简单了:它是一个运行时积累信息的“记事本”。每次游戏创建 swapchain、拿到 image、获取 queue 的时候,我们在对应的 hook 里把这些信息记下来。等 present 的时候,hook 触发,我们翻开记事本一查——知道了目标图是哪个、queue 是哪个、format 和大小是什么——这时候再动手,心里就有底了。
三、架构设计:单例 + 状态机 + 最小跟踪
带着上面那个问题,我开始读模块文档里给的边界定义。文档上有句话我觉得是整个模块的指导思想:“资源管理的原则不是把所有 Vulkan 对象都管起来,而是从注入点反推需要什么。”
从 `vkQueuePresentKHR` 这个注入点反推,需要的东西就三样:
1. 知道 `presentInfo` 里的 swapchain 对应哪些 images(才能用 imageIndex 找到图)
2. 有一个能提交命令的 queue,以及绑定的 command pool / command buffer
3. 有一个能把注入完成事件塞进 present 等待链的 semaphore
这三个需求画定了 Resource & State Manager 的边界。超出边界的东西——比如 shader 管理、pipeline cache、descriptor set——现阶段一概不管,后面到了 compute/FSR2 阶段再扩。
3.1 为什么选全局单例
在 Proxy DLL 的架构下,整个注入模块就是一个被加载到目标进程里的独立 DLL。所有被我们拦截下来的 hook 函数、回调接口,全都跑在这一个 DLL 内部,彼此之间不需要走跨库调用。
这种情况下,用一个全局唯一的实例来存运行时信息是最直接的。任何一个 hook——不管是 `vkCreateSwapchainKHR` 还是 `vkGetDeviceQueue` 还是 `vkQueuePresentKHR`——都可以直接调 `ResourceStateManager::Get()` 拿到同一个实例,往里记东西或者查东西。
单例的写法用的是静态局部变量(Meyer's Singleton),C++11 之后天然线程安全,生命周期跟进程走,进程退出才析构。对我们这种需要跨帧持久存数据的 Manager 来说,刚好够用。
3.2 帧状态机的几个阶段
文档里把注入链路设计成了一个固定的“同步壳”:等应用 semaphores → 提交注入命令 → signal done semaphore → present 等 done。我的状态机就是这套壳子的前置判断——资源没到齐的时候不准往下走,帧结束了要把东西清干净。
我沿用了一个简单的五状态枚举:
IDLE:啥都没有,等新帧开始
WAITING_RESOURCES:新帧已经开始了,swapchain 信息、queue 句柄、command buffer 逐渐到位,但还没完全就绪,此时上采样或注入请求必须拦住
READY:所有必要对象都拿到了,可以安全发起注入命令
PROCESSING:注入正在跑,防止同一帧重复提交
FRAME_END:帧渲染收尾,把当前帧的追踪记录清理掉,回到 IDLE 等下一帧
这五个状态一列出来,原先在脑子里搅成一团的多钩子调用顺序,突然就清楚了。任何一个钩子里,只要看一眼现在是什么状态,就知道该干活还是该等。这种“用状态控制权限”的做法挺朴素,但在人脑追踪不过来的异步环境里,它就是块定心石。
3.3 到底要追踪哪些对象——从注入点反推
这是整个模块设计里我花时间最多的一段。不是写代码的时间长,是跟各方确认边界、翻 Vulkan spec 确认前置条件的时间长。最后定下来的追踪清单分四个维度:
Swapchain 维度。 `vkQueuePresentKHR` 的参数里只给 swapchain 和 imageIndex,不给 image 本身。所以必须在 `vkCreateSwapchainKHR` 时记下 format、extent、usage,在 `vkGetSwapchainImagesKHR` 时把 image 数组全部存下来。之后 present 时才能用 imageIndex 索引到正确的 `VkImage`。
Queue 维度。 注入意味着在 present 前要额外提交一次 `vkQueueSubmit`。所以必须有 queue 句柄、有 command pool(分配 command buffer 用)、有 done semaphore(同步串联用)。这里面有一个特别容易漏的点:创建 command pool 必须填 `queueFamilyIndex`,而这个值只在 `vkGetDeviceQueue` 的调用参数中出现。如果没 hook `vkGetDeviceQueue` 把这个 index 记下来,后面 command pool 就建不起来。这个坑我后面第四篇会细讲。
Device 维度。 主要是函数指针缓存和 physical device 映射。Vulkan 的函数指针不是全局唯一的,尤其在开了 layer 的环境下,`vkGetDeviceProcAddr` 不同调用可能返回不同的指针。每帧都去 `vkGetDeviceProcAddr(device, "vkCmdBlitImage")` 做字符串查找,开销先不提,万一哪天拿到的是另一个 layer 的包裹函数,行为就不确定了。所以缓存一份。Physical device 的映射则是因为创建中间纹理要分配设备内存,`vkAllocateMemory` 需要 `memoryTypeIndex`,而这个 index 的可选集合必须从 `VkPhysicalDeviceMemoryProperties` 拿——不记 physical device 就没办法合法地用 `vkAllocateMemory`。
Swapchain 派生资源维度。 阶段 1(清屏)需要 image view + render pass + framebuffer;阶段 2(缩放)需要一张中间的 downscale image。这两套东西都挂在 swapchain 下面,swapchain 销毁时一起释放。
把这些都理完之后,再回头看文档上那句“最小跟踪”——不是偷懒少管,是把注入要用到的路径画清楚,路径上缺什么就补什么,路径之外的东西不管多有意思都先放一放。
四、这周学到的东西
这周大部分时间在画图、读 Vulkan spec 片段、跟框架同学对齐接口。代码写得不多,真正动手的细节留到下一篇。但就这个设计阶段,已经有几点我觉得值得记下来:
1. “反推”是个很好的思维习惯。 不是从“我有什么”去顺势组织代码,而是从“最后的注入点需要什么”倒推回来,推到哪一步需要记录什么信息就记录什么。这种思路让模块边界变得非常清晰。
2. Middleware 的容错设计,核心是“知道什么东西不能假设”。 我们没办法假设游戏的 swapchain 一定开了 transfer usage,没办法假设 present queue 一定是我们拿到的那一个 queue,甚至没办法假设 swapchain 不会在中途重建。所有“应用按理应该……”的假设,到实战里都会被某款游戏打破。唯一安全的做法是:能检测就检测,检测不到的就留回退路径。
3. 状态机的价值不在“分了几种状态”,而在“排除了什么”。 一个有明确状态定义的模块,排查问题的时候能快速锁定“不是 A 状态就不是 B 问题”。对中间件这种调试手段有限的场景来说,这个排除法比什么日志都好用。
五、下一步
设计理完了,接下来就是手写代码了。下一阶段我计划做这几件事:
把 swapchain、queue、device 的追踪代码真正写出来,在几个 Vulkan 官方示例上跑通
专攻 present 前的资源就绪检查,测试 swapchain 重建、窗口 resize 这些边界路径能不能稳住
开始处理阶段 2 的中间纹理创建逻辑,把 physical device → memory type → downscale image 这条链路跑通
下一篇博客将会把开发过程中踩的坑、改的 bug、跑出来的结果逐一记录下来。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)