项目实训记录3:初识资源管理与状态机
一、分工与起点
前两篇写完,这个中间件的技术背景和别人的轮子怎么跑起来,我算是有个底了。这篇开始角色变了——我不再是站在外面看热闹的,得自己上手造零件。
前段时间跟负责 Hook 层的同学对了接口,整套系统的技术底座定下来了:D3D12 + Proxy DLL + Detours Hook。编译出来的 upscalerBridge.dll 重命名为 dxgi.dll 塞到游戏 exe 旁边,Windows 的 DLL 搜索顺序会优先加载我们的代理 DLL。整套注入逻辑都跑在同一个 DLL 里面,不需要跨进程、不需要改游戏文件——这是后面所有模块能跑起来的前提。
关于技术路线的一个说明:最终采用的技术路线是 D3D12,但我在模块设计阶段基于 Vulkan 做了一版预研。因为 Vulkan 的资源管理约束更严格——显式的布局转换、显式的队列族、显式的同步原语——如果在 Vulkan 的框架下能把资源追踪设计清楚,移植到 D3D12 反而更容易。Vulkan 预研时建立的这些认知,在写 D3D12 代码时全用上了,只是把
VkImage换成了ID3D12Resource,把VkQueue换成了ID3D12CommandQueue。
分配到我头上的模块是资源管理与状态机(Resource & State Manager)。最开始听到这个名字,脑子里没什么具体画面,真正开始画调用链之后才意识到,这玩意儿卡在整个管线最中间的位置:往上对接 Hook 拦截层,往下对接 API 适配层——拦截层把 Present 调用截下来了,适配层等着往画面上写东西,但中间谁来告诉适配层“你要写的图是哪张、queue 是哪个、同步信号怎么串”?就是这个 Manager。
所以我这一阶段的重点,不是上来就敲键盘,而是先把两件事嚼碎:这块到底要解决什么问题,以及我们打算怎么设计它。
二、为什么需要专门做资源管理
一开始我也有个很朴素的想法:我们做的事情不就是把 Present 前卡一个环节,往画面上多画点东西吗?那我直接在 Present 的 Hook 函数里拿到 swapchain 和当前 back buffer 索引,随便找个 command queue 录几条命令跑一下不就完了?
框架文档里把这事儿写得很清楚:不行。
因为应用不会把你要的东西都摆好在桌上等你。同一个游戏进程里,D3D12 对象的生命周期散落在各种地方:swapchain 是在某个初始化函数里创建然后丢给全局变量,back buffer 是通过 GetBuffer 一次性拿出来的,Present 用的 command queue 可能是另一个线程拿到自己手里保管的。而我们的注入模块是个外人——它不参与游戏代码的编译链接,只能在运行时从旁路去“观察”和“记录”。
这就意味着,如果不做集中管理,每次 Present Hook 触发的时候,手里只有一个光秃秃的 IDXGISwapChain 句柄和一个 buffer index——至于这个 swapchain 到底对应哪些 ID3D12Resource、哪个 command queue 能往它上面提交、需不需要加 fence 等它渲染完成,全不知道。这个时候去做注入,不是乱写就是崩。
把这个想明白之后,资源管理模块的定位就很清楚了:它是一个运行时积累信息的“记事本” 。每次游戏创建 swapchain、拿到 back buffer、获取 command queue 的时候,我们在对应的 Hook 里把这些信息记下来。等 Present 触发时,翻开记事本一查——目标图是哪个、queue 是哪个、format 和大小是什么——这时候再动手,心里就有底了。
三、架构设计:单例 + 状态机 + 最小跟踪
带着上面那个问题,我开始读模块文档里给的边界定义。文档上有句话我觉得是整个模块的指导思想:
“资源管理的原则不是把所有对象都管起来,而是从注入点反推需要什么。”
从 Present 这个注入点反推,需要的东西就三样:
-
知道 swapchain 对应哪些 back buffer——才能用 buffer index 找到目标图
-
有一个能提交命令的 command queue,以及绑定的 command list——才能在 Present 前插入 GPU 操作
-
有一个能把注入完成事件塞进 Present 等待链的 fence——才能避免闪烁
这三个需求画定了资源管理模块的边界。超出边界的东西——比如 shader 管理、pipeline state、descriptor heap——现阶段一概不管,后面到了需要 compute/FSR 时再扩。
3.1 为什么选全局单例
在 Proxy DLL 的架构下,整个注入模块就是一个被加载到目标进程里的独立 DLL。所有被我们拦截下来的 Hook 函数,全都跑在这一个 DLL 内部,彼此之间不需要走跨库调用。
这种情况下,用一个全局唯一的实例来存运行时信息是最直接的。任何一个 Hook——不管是 CreateSwapChain 还是 GetDeviceQueue 还是 Present——都可以直接拿到同一个实例,往里记东西或者查东西。
单例的写法我用的是 Meyer‘s Singleton(静态局部变量),C++11 之后天然线程安全,生命周期跟进程走,进程退出才析构。对我们这种需要跨帧持久存数据的 Manager 来说,刚好够用。
AI 帮我确认了这一点:设计初期我不确定静态局部变量在多线程环境下的初始化是否安全,让 AI 帮我查了 C++11 标准。它确认了
static局部变量的初始化是线程安全的,还提醒我注意如果析构函数里有跨线程操作可能会有问题——这个提醒让我在设计清理逻辑时格外小心。
3.2 帧状态机的五个阶段
我把注入链路设计成了一个固定的“同步壳”:等应用 fence → 提交注入命令 → signal done fence → Present 等 done。状态机就是这套壳子的前置判断。
我设计了一个五状态枚举:
| 状态 | 含义 | 允许的操作 |
|---|---|---|
IDLE |
啥都没有,等新帧开始 | 不执行任何注入 |
WAITING_RESOURCES |
新帧开始,资源逐渐到位但未完全就绪 | 拦住所有注入请求 |
READY |
所有必要对象都拿到了 | 可以安全发起注入命令 |
PROCESSING |
注入正在跑 | 防止同一帧重复提交 |
FRAME_END |
帧渲染收尾 | 清理当前帧的追踪记录,回到 IDLE |
这五个状态一列出来,原来在脑子里搅成一团的多 Hook 调用顺序突然就清晰了。任何一个 Hook 里,只要看一眼现在是什么状态,就知道该干活还是该等。这种“用状态控制权限”的做法挺朴素,但在人脑追踪不过来的异步环境里,它就是块定心石。
设计过程中的一次 AI 讨论:我在设计状态转换条件时,不确定
WAITING_RESOURCES到READY的转换应该由谁来触发——是 swapchain 注册完成就转,还是等 queue 也拿到再转?我跟 AI 讨论了一下,它指出应该等所有依赖项都就绪再转,否则可能出现“swapchain 有了但 queue 还没拿到”的中间状态。这个建议直接影响了状态机的转换逻辑设计。
四、从注入点反推追踪对象
这是整个模块设计里我花时间最多的一段。不是写代码的时间长,是跟团队确认边界、翻 D3D12 文档确认前置条件的时间长。
最后定下来的追踪清单分四个维度:
(1)Swapchain 维度
Present 的参数里只给 swapchain 和 buffer index,不给 back buffer 本身。所以必须在 CreateSwapChain 时记下 format、extent、buffer count,在 GetBuffer 时把 back buffer 数组全部存下来。之后 Present 时才能用 buffer index 索引到正确的 ID3D12Resource。
(2)Command Queue 维度
注入意味着在 Present 前要额外提交一次命令。所以必须有 command queue 句柄、有 command allocator(分配 command list 用)、有 done fence(同步串联用)。
这里有一个特别容易漏的点:创建 command allocator 必须指定 command list 类型,而这个类型信息只在 CreateCommandQueue 的调用参数中出现。如果没 Hook CreateCommandQueue 把这个类型记下来,后面 command allocator 就建不起来。
(3)Device 维度
主要是函数指针缓存。
在 D3D12 里,虽然不像 Vulkan 那样需要 vkGetDeviceProcAddr 查函数指针,但我们需要在 Hook 层调用原始函数时,确保调用的是真实的 D3D12 实现,而不是被我们或其他 Layer 包装过的版本。所以在 CreateDevice 时把需要用到的原始函数指针全部缓存下来,避免每帧去查表。
(4)Swapchain 派生资源维度
阶段 1(清屏/简单注入)需要 render target view;阶段 2(缩放/超分)需要额外的中间纹理。这两套东西都挂在 swapchain 下面,swapchain 销毁时一起释放。
把这些都理完之后,再回头看“最小跟踪”这四个字——不是偷懒少管,是把注入要用到的路径画清楚,路径上缺什么就补什么,路径之外的东西不管多有意思都先放一放。
五、资源状态转换策略
完整的 D3D12 资源状态追踪非常困难。应用可能在多个 command list、多个 queue 中更改同一资源的布局状态。
我的策略是:在注入点强制把 back buffer 转到注入需要的状态,注入完成后再强制转回 PRESENT 状态。
这不是最严格的做法,但对 MVP 阶段的稳定性非常有帮助。经验规律是:状态追踪要么做到“很完整”,要么就不要在 MVP 阶段碰它。半吊子的 tracking 比强制转换更危险——若误判当前状态,会导致 resource barrier 写错,bug 极难复现。
这个决策的来龙去脉:我一开始尝试做完整的资源状态追踪——在每个 command list 里记录资源的每一次状态变化。但很快发现这几乎是个无底洞:游戏的 command list 可能在多个线程里并行录制,我根本追不过来。跟 AI 讨论后,它建议我换一种思路——不在录制时追踪,而在提交时强制转换。这个建议帮我从“追不完”的困境里跳了出来。
六、这周学到的东西
这一周大部分时间在画图、读 D3D12 文档片段、跟队友对齐接口。代码写得不多,真正动手的细节留到下一篇。但就这个设计阶段,已经有几点我觉得值得记下来:
1. “反推”是个很好的思维习惯。
不是从“我有什么”去顺势组织代码,而是从“最后的注入点需要什么”倒推回来,推到哪一步需要记录什么信息就记录什么。这种思路让模块边界变得非常清晰。
2. Middleware 的容错设计,核心是“知道什么东西不能假设”。
我们没办法假设游戏的 swapchain 一定开了某个 usage,没办法假设 Present queue 一定是我们拿到的那一个 queue,甚至没办法假设 swapchain 不会在中途重建。所有“应用按理应该……”的假设,到实战里都会被某款游戏打破。唯一安全的做法是:能检测就检测,检测不到的就留回退路径。
3. 状态机的价值不在“分了几种状态”,而在“排除了什么”。
一个有明确状态定义的模块,排查问题的时候能快速锁定“不是 A 状态就不是 B 问题”。对中间件这种调试手段有限的场景来说,这个排除法比什么日志都好用。
七、AI 帮我做了什么
在设计阶段,AI 主要帮我做了三件事:
1. 确认 C++ 线程安全细节。
设计 Meyer's Singleton 时,我不确定静态局部变量在多线程环境下的初始化是否安全。AI 帮我查了 C++11 标准,确认了 static 局部变量的初始化是线程安全的,还额外提醒我注意析构函数中如果有跨线程操作可能会有问题——这个提醒让我在设计清理逻辑时格外小心。
2. 讨论状态机的转换条件。
设计 WAITING_RESOURCES → READY 的转换条件时,我不确定应该由谁来触发这个转换。AI 建议我等所有依赖项都就绪再转,避免出现“swapchain 有了但 queue 还没拿到”的中间状态。这个建议直接影响了状态机的转换逻辑设计。
3. 帮我跳出资源状态追踪的困境。
我花了很长时间试图做完整的资源状态追踪——在每个 command list 里记录资源的每一次状态变化。但很快就发现这是个无底洞:游戏的 command list 可能在多个线程里并行录制。AI 建议我换一种思路——不在录制时追踪,而在提交时强制转换。这个思路转变让我从“追不完”的困境里跳了出来,转而采用“强制转换”这种更简单、更确定的策略。
八、下一步
设计理完了,接下来就是写代码了。下一阶段我计划做这几件事:
-
把 swapchain、command queue、device 的追踪代码真正写出来
-
专攻 Present 前的资源就绪检查,测试 swapchain 重建、窗口 resize 这些边界路径能不能稳住
-
开始处理阶段 2 的中间纹理创建逻辑
下一篇博客会把开发过程中踩的坑、改的 bug、跑出来的结果逐一记录下来。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)